diff --git a/.changeset/ce-recover-stale-sessions.md b/.changeset/ce-recover-stale-sessions.md deleted file mode 100644 index a76727d30e..0000000000 --- a/.changeset/ce-recover-stale-sessions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@runfusion/fusion": patch ---- - -Recover stale Compound Engineering sessions on plugin load and session reads so persisted active rows without live agent handles no longer leave the dashboard stuck waiting for work that is not running. diff --git a/.changeset/fix-appimage-local-runtime-root.md b/.changeset/fix-appimage-local-runtime-root.md deleted file mode 100644 index 01bb88be47..0000000000 --- a/.changeset/fix-appimage-local-runtime-root.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@runfusion/fusion": patch ---- - -Fix "Couldn't start local Fusion" on the Linux AppImage (and any packaged build launched from a desktop launcher). The embedded local runtime now roots its data at the user's home directory (`~/.fusion`) instead of `process.cwd()`, which was `/` or the read-only AppImage mount point and caused database creation to fail with EACCES/EROFS. Set `FUSION_HOME` to override the location. diff --git a/.changeset/fn-6712-agent-set-instructions-tool.md b/.changeset/fn-6712-agent-set-instructions-tool.md new file mode 100644 index 0000000000..28835efc9b --- /dev/null +++ b/.changeset/fn-6712-agent-set-instructions-tool.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add the `fn_agent_set_instructions` extension tool so managing agents can update direct or indirect reports' inline or file-backed instructions with org-hierarchy authorization. diff --git a/.github/workflows/full-suite.yml b/.github/workflows/full-suite.yml index 1859ef490e..ccfc63704d 100644 --- a/.github/workflows/full-suite.yml +++ b/.github/workflows/full-suite.yml @@ -33,6 +33,13 @@ jobs: test-shards: name: Test shard ${{ matrix.shard }}/4 runs-on: ubuntu-latest + # Backstop for a wedged shard. The per-invocation watchdog (L2, + # scripts/lib/run-vitest-watchdog.mjs) kills any single hung invocation at + # its budget ceiling (<=30min), so this job budget only fires if L2 itself + # wedges — and it must sit strictly above that ceiling so L2 fires first. + # Without this, a hang ran to GitHub's 6h default (the silent-black-hole bug + # this plan closes). + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -119,6 +126,9 @@ jobs: test-inventory-guard: name: Dashboard curated-gate guard runs-on: ubuntu-latest + # Runs only `vitest list` (no tests execute), so this is generous headroom, + # not a tight bound — but no CI job should be able to hang to the 6h ceiling. + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v4 @@ -163,6 +173,9 @@ jobs: test-slow: name: Engine slow tier runs-on: ubuntu-latest + # Real-git slow suites; same backstop rationale as test-shards. Sits above + # the L2 per-invocation ceiling so the watchdog fires first on a single hang. + timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index fc21833572..88141f36ac 100644 --- a/.gitignore +++ b/.gitignore @@ -65,7 +65,7 @@ homebrew-tap/ # never meant to be committed. .DONE improvements.md -docs/screenshots/ +# FNXC:RepoHygiene 2026-06-17-00:38: Published Markdown docs embed `docs/screenshots/*.png`; keep that directory trackable so GitHub, npm-rendered docs, and fresh clones do not show broken images. .claude/ # Local kb state and backups @@ -73,6 +73,10 @@ docs/screenshots/ .fusion-backup/ .fusion-backup-*/ +# FNXC:RepoHygiene 2026-06-14-13:34: `paseo.json` is local config generated by the external Paseo git-worktree manager. +# It can reappear whenever a developer provisions this repo with Paseo, so it must stay untracked repo-root noise. +paseo.json + # Stray runtime databases fusion.db fusion.db-wal @@ -81,6 +85,8 @@ fusion.db-shm # Capacitor mobile platform directories (generated by `cap add`) packages/dashboard/ios/ packages/dashboard/android/ +packages/mobile/ios/ +packages/mobile/android/ # Per-shard vitest JSON timing reporter outputs (raw; merged into # scripts/test-timings.json via `ci-test-shard.mjs --write-timings`). diff --git a/AGENTS.md b/AGENTS.md index f2427f324f..56ead95f11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ The merge gate is thin and trusted: CI blocks PRs on exactly Lint, Typecheck, Bu 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:velocity # weekly report-only test velocity baseline; use -- --measure --write-report to refresh pnpm test:full # full workspace suite — explicit opt-in only pnpm lint pnpm build @@ -195,7 +196,8 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme ## Reference docs (deeper detail) - `./docs/architecture.md` — lifecycle invariants, self-healing rules, reliability interaction backstops, run-audit internals. -- `./docs/testing.md` — full testing lanes, worker fanout guidance, test taxonomy, and file organization. +- `./docs/testing.md` — full testing lanes, worker fanout guidance, test taxonomy, weekly velocity baseline, and file organization. +- `./docs/test-velocity-baseline.md` — weekly #leads-ready test feedback-loop velocity report generated by `scripts/test-velocity-baseline.mjs`. - `./docs/dashboard-guide.md` — dashboard behavior and **Styling Guide** details. User-facing docs for Merge Advance Notice and Smart Pull live here. - `./docs/PLUGIN_AUTHORING.md` — plugin authoring guide, lifecycle hooks, routes, tools, and dashboard-extension surfaces. - `./docs/agents.md` — pi extension scope, coordination tools, checkout leasing, runtime config. @@ -215,8 +217,8 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme ### Lazy-Loaded Heavy Views -These 20 views are lazy-loaded via `React.lazy()` with ``. -Keep this AGENTS inventory in sync with App lazy imports and `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts`. +These 22 views are lazy-loaded via `React.lazy()` with ``. +Keep this AGENTS inventory in sync with App lazy imports, AppModals lazy modal imports (`SettingsModal`, `WorkflowNodeEditor`, `SetupWizardModal`), and `packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts`. - `AgentsView` - `NodesView` @@ -228,13 +230,37 @@ Keep this AGENTS inventory in sync with App lazy imports and `packages/dashboard - `DocumentsView` - `SkillsView` - `ResearchView` -- `ReliabilityView` +- `CommandCenter` - `EvalsView` - `TodoView` - `GoalsView` - `StashRecoveryView` - `PullRequestView` - `SetupWizardModal` +- `SettingsModal` +- `WorkflowNodeEditor` - `PluginManager` - `PiExtensionsManager` - `AgentDetailView` + +## FNXC_LOG comments: + - Please whenever you're working on a codebase. I want you to add comments describing the date of the change (must be in this format yyyy-MM-dd-hh:mm) and describing the requirements or the change in requirements that made you implement certain functionality. + - I want you to write FNXC:Area-of-product in front of all your comments so they can be grepped. + - Most of this should be written as jsdocs but you can add short comments around for the important variables and more complex parts of the codebase. + - The idea is to encode the requiements of the system (especially software behavior, UX, and important technical decisions) into the code so it's clearer later why a certain piece of code was written. + - Always make sure to keep these comments updated as you work in the codebase and requirements change. + - Use technical writing principles to write non-verbose comments that convey the important info without fluff. + - Keep in mind that ALL of the important user facing requirements sent by the user must be written as comments somewhere in the codebase. + - There's no need to add line breaks in FNXC comments to stay under a certain character width. Just add line breaks normally at the ened of sentences. + + Good Example for a FNXC Comment: + ``` + /* + FNXC:SettingsNavigation 2026-05-13-08:05: + The Settings dialog needs enough horizontal room for a main-tab section sidebar while Ghostty settings live in their own second tab. + Use scoped CSS so the native modal host and Storybook share the same width without relying on newly generated utilities. + + FNXC:SettingsNavigation 2026-05-13-08:11: + The modal should be 20% wider than the first section-sidebar layout and use a taller viewport so more settings remain visible without scrolling. + */ + ``` \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4951d537a1..633012536b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,433 @@ User-facing release notes aggregated across all packages. This file is auto-synced from each `packages/*/CHANGELOG.md` by `scripts/release.mjs` — do not edit by hand. +## 0.44.0 + +### @fusion/dashboard + +#### Patch Changes + +- @fusion/core@0.44.0 +- @fusion/engine@0.44.0 +- @fusion/i18n@0.39.7 +- @fusion-plugin-examples/cli-printing-press@0.1.24 +- @fusion-plugin-examples/compound-engineering@0.1.7 +- @fusion-plugin-examples/dependency-graph@0.1.38 +- @fusion-plugin-examples/roadmap@0.1.26 +- @fusion-plugin-examples/cursor-runtime@0.1.26 +- @fusion-plugin-examples/droid-runtime@0.1.33 +- @fusion-plugin-examples/hermes-runtime@0.2.57 +- @fusion-plugin-examples/openclaw-runtime@0.2.57 +- @fusion-plugin-examples/paperclip-runtime@0.2.57 + +### @fusion/desktop + +#### Patch Changes + +- @fusion/core@0.44.0 +- @fusion/dashboard@0.44.0 + +### @fusion/engine + +#### Patch Changes + +- @fusion/core@0.44.0 +- @fusion/pi-claude-cli@0.44.0 + +### @fusion/plugin-sdk + +#### Patch Changes + +- @fusion/core@0.44.0 + +### @runfusion/fusion + +#### Minor Changes + +- 6427802: Route Fusion's Claude CLI path through the ACP bridge (`claude-code-cli-acp`) instead of `claude -p` (Route A, dormant behind an OFF-by-default kill-switch). + + - **U10** — forward `mcpServers` on ACP `session/new` through the runtime contract (`AgentRuntimeOptions.mcpServers` + the plugin's `newAcpSession`); defaults to `[]` so existing read-only ACP "ask" turns are unchanged. + - **U11** — `streamViaAcp`: the `pi-claude-cli` provider can drive Claude through the bundled ACP bridge, returning the same `AssistantMessageEventStream` as the `-p` path. Dispatched only when `FUSION_CLAUDE_ACP=1` and a bridge path are present, so the live `-p` path is byte-for-byte untouched by default. Full-history prompting, schema-only MCP forwarding with break-early on pi-known tools, control-char/size sanitization, env allow-list, process-registry registration, and inactivity timeout. + - **KTD10** — the ACP runtime plugin publishes its identity-pinned bundled bridge path on load so the kill-switch needs no manual path; it does not enable the transport. + - **OQ2** — opt-in connection reuse (`FUSION_CLAUDE_ACP_REUSE=1`, default OFF): a warm bridge connection + ACP session is kept across turns of one conversation (keyed by `sessionId`), so multi-turn lanes skip the cold bridge/`claude` spawn and `session/new` round-trip and send only the latest-turn delta (`buildResumePrompt`). A stable `router` indirection serves each turn's handlers; a warm-child death routes failure to the current owner turn (no 30-min inactivity hang), eviction is cache-identity-aware (a concurrent cold turn can't kill a newer entry's child), an empty resume cold-starts instead of issuing an empty prompt, and a per-turn token drops cross-turn stray updates. The idle reaper is `unref`'d. Default OFF → the cold path is functionally unchanged. + + The Claude-via-pi OAuth path is unchanged. Live verification confirmed the bridge gates tool execution behind `session/request_permission` (forwarded MCP tools and native tools do not execute when cancelled). Remaining for a follow-up: picker/auth/status surface (U12), workflow `model`-node verification (U13), and production rollout. + +- c1b581e: Add the **Command Center** dashboard — a combined analytics/observability and live Mission-Control view (`?view=command-center`). + + - **Telemetry** — a queryable `usage_events` SQLite table populated via a dedicated `emitUsageEvent` capture seam (tool calls, messages, session lifecycle), feeding date-range aggregators for tokens, tool usage + autonomy ratio, activity (sessions/messages/active-nodes/stickiness), productivity (files/commits/PRs/LOC), and ecosystem breadth — all in `packages/core` and reusable by CLI/engine. + - **Cost** — derived from token counts via a hand-maintained `model-pricing` map carrying `pricingAsOf` + a staleness flag; unknown models report unavailable rather than guessing. + - **View** — a new lazy-loaded, ARIA-tabbed Command Center with hand-rolled CSS-bar chart primitives, a date-range picker, per-area panels, a live Mission-Control panel (SSE push + idle-aware polling), and an SDLC funnel. + - **API** — `GET /api/command-center/{tokens,tools,activity,productivity,live}` (agent-usable), each under session auth and project scoping, with `?format=csv` export and an opt-in OpenTelemetry (OTLP) metrics exporter. + +- 898ac1e: Add the Command Center signals analytics endpoint backed by local incidents data and document honest empty-state sentinels for signal metrics. +- 863ebfa: Add a CLI session relaunch route and enable the dashboard's resume-exhausted "Relaunch fresh" action to re-enqueue the owning task for a fresh CLI-agent run. +- 21c4d3e: Dashboard agent chat sessions now load the same agent-declared and enabled plugin-contributed skills as task execution sessions, so plugin skills such as `ce-debug` are available in chat. +- a453716: Render assistant question tool calls as shared in-chat response cards in full Chat and Quick Chat. +- a998f63: Enable creating workflow node connections from the mobile workflow editor. +- e2a3a37: Add a project setting for configuring the auto-merge conflict retry cap before Fusion parks or bounces tasks for recovery. +- 05fe6e5: Compound Engineering now treats stage launch settings as an explicit `disabledStages` opt-out list so newly bundled stages, including `ce-debug`, remain launchable on existing installs with stale settings snapshots. +- b6ac5f2: Add bounded-by-default verification guardrails: project `verificationCommandTimeoutMs`, marathon command detection, and an explicit `allowFullSuite` escape hatch for full verification runs. +- 0453a65: Request agent and enabled plugin skills across planning, mission interview, workflow design, memory insight, and scheduled automation agent sessions. +- cdadac1: Load selected Fusion and enabled plugin skills in milestone/slice interview and agent-onboarding dashboard sessions. +- f41732d: Add a live-updating, animated Command Center token-usage-over-time view with hour/day/week granularity and bounded polling for token totals. +- 504305e: Add a Command Center GitHub issue analytics endpoint and dashboard area showing issues filed by Fusion, issues fixed by Fusion, net flow, daily trends, and by-repository breakdowns from the local project task store. +- 64092ca: Add Command Center agent-run sheets that show total, active, completed, and failed heartbeat runs in the Activity area and Overview, plus agent-run daily activity and CSV export rows. +- 36f1fee: Add the Command Center Team tab and `/api/command-center/team` endpoint for project-scoped per-agent token, cost, files-changed, task-completion, and live-status analytics. +- af31f7d: Move System Stats into the Command Center as a redesigned graph-rich System area with gauges, trend sparklines, task/agent bars, and relocated Vitest controls; remove the standalone System Stats modal plus its Header and mobile More affordances. +- 94a081f: Persist GitHub source issue closure timestamps and use them for exact Command Center "Fixed by Fusion" date bucketing, falling back to task `updatedAt` only when the real close time has not been observed. +- 9b396b6: Add an optional project-scoped GitHub source-issue closed-at backfill endpoint that fills historical imported tasks with real GitHub `closed_at` values for more accurate Fixed by Fusion analytics. +- 2059790: Add a Command Center GitHub affordance for operators to run the historical source-issue closed-at backfill and review accumulated scanned, filled, skipped, and error counts. +- d6e2f92: Add `recharts` and shared Command Center PieChart/LineChart wrappers for downstream graphical chart migrations. The wrappers are token-themed, responsive, reduced-motion aware, and safe for empty, zero, negative, NaN, and Infinity inputs; the current production build shows no observable Command Center chunk-size increase yet because no Command Center surface imports the new wrappers until the dependent migration tasks land (CommandCenter chunk remains 74.68 kB / 16.46 kB gzip in this task's build output). +- 99d799c: Add Command Center pie and line chart affordances to the Overview, Tokens, Tools, Activity, and Productivity analytics surfaces using existing analytics data. +- 5e1a4ff: Add Command Center pie and line charts to Team, Ecosystem, GitHub, Signals, and System surfaces using existing analytics data. +- 47e7b4a: Populate Command Center Productivity Lines changed from merge-time commit association diff stats when available. +- c1b581e: Add the **Monitor stage** (U13) — deployment and incident tracking that closes the SDLC loop. + + - **Schema** — new `deployments` and `incidents` SQLite tables (`packages/core/src/db.ts`, `SCHEMA_VERSION` 119 → 120, migration added in the same change; fingerprint auto-covers SCHEMA_SQL tables). + - **Metrics** — real MTTR (incident-open → resolved) plus deploy/incident counts in `activity-analytics`, replacing the prior unavailable seam. + - **Ingestion** — `POST /api/monitor/{deployments,incidents}` self-authenticate via a shared ingest secret (constant-time bearer check, fail-closed) with SSRF-untrusted payload links; `GET /api/monitor/metrics` exposes the aggregates. + - **Loop closure** — a `monitor` workflow trait can auto-open a single fix task on a regression signal, guarded by `groupingKey` grouping, a threshold/sustained gate, cooldown absorption, a per-window circuit breaker, and a self-loop guard. + +- 168dc2f: Export Command Center analytics over OpenTelemetry (OTLP) so teams can ship token / cost / activity metrics to Datadog / Grafana / etc. **Disabled by default** (U10, R4). + + - New pure mapping `mapAnalyticsToOtlp` in `@fusion/core` (`otel-metrics.ts`) turns the token/cost/activity aggregator outputs into the OTLP/HTTP JSON wire shape (`resourceMetrics`) — counters for token/cost, gauges for activity — with `model` / `provider` / `node.id` / `agent.id` attributes per data point. Fully testable without a live collector; no SDK dependency in core. + - Dashboard exporter (`otel-exporter.ts`) periodically maps current analytics and POSTs them to a configured collector, wired into `server.ts` startup/shutdown. + + **SDK choice:** ships a **minimal OTLP/HTTP JSON exporter rather than the official `@opentelemetry/*` SDK** — and therefore adds **no new runtime dependency**. The OTLP/HTTP JSON protocol is a single, stable `POST /v1/metrics` of a well-defined JSON envelope (built in core), so for a default-disabled feature we avoid pulling the multi-package SDK (sdk-metrics + exporter-metrics-otlp-http + resources + api). The wire shape is collector-compatible; swapping in the official SDK later is mechanical. (If maintainers prefer the real SDK, that is a follow-up changeset + dependency add.) + + **Enabled only via env** (none set ⇒ nothing starts): `FUSION_OTEL_METRICS_ENDPOINT` (full `/v1/metrics` URL, required to enable), `FUSION_OTEL_METRICS_HEADERS` (`k=v,k2=v2` auth headers), `FUSION_OTEL_METRICS_INTERVAL_MS`, `FUSION_OTEL_METRICS_TIMEOUT_MS`, `FUSION_OTEL_RESOURCE_ATTRIBUTES`. + + **Security:** endpoint validated on write — `http://` is rejected in production (exporter does not start) and warns loudly otherwise; auth header (Datadog/Grafana token) VALUES are never logged and are masked in diagnostics; a collector-unreachable failure logs (redacted) and backs off exponentially without crashing the server or blocking requests. + +- 951c6ef: Ingest external signals (Sentry / Datadog / PagerDuty / generic webhook) into triage tasks via a common `SignalSource` adapter seam (U11, KTD8). + + - New `POST /api/signals/:provider` endpoints, mirroring the GitHub ingestion path. Verified, normalized signals create a task in the `triage` column via the existing task store. + - Generic webhook is the must-work path; Sentry/Datadog/PagerDuty are thin adapters with provider-specific HMAC verification + payload normalization. Each normalized `Signal` carries a `groupingKey` (Sentry `issue.id`, PagerDuty `incident.id`, Datadog monitor key; the generic webhook requires a caller-supplied key or falls back to `source + normalized-title`) for the downstream storm guard. + - Security (mandatory): per-provider HMAC against an env-sourced secret (never source-controlled) with 401 on missing/invalid secret or signature — the generic webhook is never an unauthenticated task-creation endpoint; ±5 min replay window + delivery-id nonce dedup; persistent external-id dedup; ~1 MB body cap; per-source rate limit; field-length + meta-byte caps; SSRF-untrusted handling of payload URLs; `meta` stored as data, never rendered as raw HTML. + +- 0a87890: Add a persistent, incrementally-refreshed knowledge index (U14) downstream agents can query. + + - **Schema** — new `knowledge_pages` SQLite table (`packages/core/src/db.ts`) with `SCHEMA_VERSION` bumped 118 → 119 (added in the same change as the migration; the fingerprint auto-covers SCHEMA_SQL tables). Keyword search uses a denormalized lowercased `searchText` column with AND-of-terms `LIKE` matching, deliberately avoiding SQLite FTS5 (not available on every build) and any external embedding API. + - **Index module** (`packages/dashboard/src/knowledge-index.ts`) — upsert-by-source-key pages, a model-free keyword query API, and `refreshKnowledgeForTask` that re-indexes a single completed task (one upsert, never a full re-index, so unaffected pages keep their timestamps). This is the delta over the existing `insights`/`memoryView` surfaces, which are LLM-extracted learnings, not a deterministic searchable index of concrete task/PR history. + - **Refresh hook** — `KnowledgeIndexRefreshService` listens for `task:moved → done` (mirroring `GitHubSourceIssueCloseService`) and is wired alongside the other completion listeners; fail-soft so it can never disrupt task completion. + - **Query API** (`register-knowledge-routes.ts`) — `GET /api/knowledge/query` and `POST /api/knowledge/refresh`, registered as an `ApiRouteRegistrar` so they inherit the dashboard's standard session/auth middleware (401 when unauthenticated) and apply `getScopedStore(req)` (no cross-project reads), exactly like U9. + +#### Patch Changes + +- c8788d8: Align the workflow editor's client-side column trait validation details with the server validator so conflicting trait compositions identify the same source traits before save. +- 265d9ec: Fix task workflow selection so successful workflow changes and clears notify dashboard clients to refresh board workflow lanes. +- def4bd9: Add dashboard controls for renaming regular Chat and Quick Chat sessions. +- 62335f8: Fix two post-merge Full Suite test failures. Sync the roadmap store's schema-version assertion to core's `SCHEMA_VERSION` (116 → 117). Stop `useCeSessions` background refreshes (poll fallback and push events) from clearing an error a `cancel`/`remove` just surfaced — an in-flight session kept the poll running, which silently erased the action error before the user could see it. +- cd2da10: Wire dashboard CLI session banner actions so needs-attention sessions surface, supported actions call existing routes/settings flows, and unsupported actions render disabled instead of silently doing nothing. +- fee0178: Guard no-commits-expected tasks from being finalized as done by no-op merge/self-healing lanes when skipped or incomplete steps outweigh completed work. +- bc6dfd3: Surface paused workflow graph exits that occur outside `in-progress` as operator-actionable failures instead of leaving tasks stranded. +- 0093678: Block release and publish-class tasks during triage unless they were explicitly authorized by a user-authored source. +- 3158e9c: Fix the dashboard TUI Agents view so pressing `s` starts the selected agent without also switching back to Main. +- 0db8134: Bound `fn_task_list` text output across CLI, dashboard, and engine tool surfaces so oversized board listings remain plain text with an explicit truncation marker instead of overflowing host response budgets. +- a15b4ca: Keep the chat sidebar visible at a compact bounded width when a tablet software keyboard opens, then restore the previous width when the keyboard closes. +- 198fb17: Keep prior chat thread messages visible while reconnecting to an in-flight streamed assistant response. +- 98cb80d: Fix the tablet task detail modal sizing so the action footer remains on-screen and the modal uses more viewport width. +- 4a9fe99: Allow chat attachments to be sent without accompanying text in Quick Chat and Main Chat while still rejecting fully empty sends. +- d35f93e: Refresh dashboard mobile and PWA home-screen icons from the canonical Fusion logo and bump the service-worker cache for installed app updates. +- 550715d: Bringing up Quick Chat now focuses the composer input on desktop (matching existing mobile behavior). +- 89171e0: Polish the bundled Compound Engineering dashboard view so its spacing, radii, and controls align with Fusion dashboard design tokens and shared component classes. +- 914842f: Make Chat the first tab and default active view in the task detail modal while preserving explicit initial tab requests. +- 6ced5d7: Fix workflow graph merge-node failures so merge-seam aborts are not misclassified as pause/resume aborts. Non-paused merge failures now route to the bounded auto-merge retry path instead of being parked failed with no merge retry count. +- a84a8e1: Fix `fn_task_list` crashes when the runtime `@fusion/core` formatter export is unavailable by resolving defensively and returning bounded fallback text. +- 593ebac: Resolve task-list text formatting defensively when an installed core package is missing the `formatTaskListText` runtime export, preserving `fn_task_list` output with a bounded inline fallback. +- 403bd9d: Prevent custom workflows from reaching terminal success when declared task-document artifacts are missing, and keep malformed blocking gate verdicts from being treated as successful workflow-step passes. +- 01b80db: Add a Fusion-native `fn_ask_question` tool for dashboard chat agents so structured questions render in the existing chat response card and answers return through the next chat message. +- 5b9ff04: Keep previously persisted main-chat conversation messages visible while reconnecting to an in-flight assistant response. +- 1bd8f6d: Fix mobile terminal cell measurement by making xterm font stacks use real monospace text faces before the Nerd Font symbols fallback. +- 19aac38: Load dashboard chat skills requested with `/skill:{name}` and strip the command token from model prompts. +- a013bc0: Fix the perpetual step off-by-one: `fn_task_update` and `fn_review_step` now treat `step` as 0-based, matching the `### Step N:` numbering in PROMPT.md (Step 0 = Preflight) and `TaskStore.updateStep`. Previously the tools were 1-indexed while everything agent-facing was 0-based, so agents could not mark Step 0 done and reviews/progress landed one step early. +- 4c3186d: Prefer fresher TypeScript plugin source over stale gitignored dist output in dev/worktree plugin resolution when no `bundled.js` exists. Production bundled installs remain unaffected because `bundled.js` still always wins. +- 0767d1b: Generalize bundled plugin freshness checks across staged CLI plugin artifacts. +- 29b27a7: Improve Command Center tool analytics by categorizing Fusion tool families and re-bucketing historical `other` rows. +- 98ccf8a: Completed no-commit executions that finalize to in-review are no longer re-parked as failed "engine abort during pause/resume" operator-action graph failures; genuine pause and hard-cancel semantics are preserved. +- 4dd5337: Close cached CLI extension TaskStore instances on session shutdown so task-tool runs do not leave SQLite handles behind. +- 4929198: Lower the shared `fn_task_list` plain-text budget and cover filtered column listings with realistic regression cases so large todo, planning, and done outputs stay host-safe. +- 673a8a6: Fix `fn_task_list` column filters so empty target columns return explicit text instead of an empty content block. +- 58a34e9: Clamp Command Center SDLC completion analytics to cohort-based conversion rates and add the radial completion gauge plus animated live activity signals. +- 3d28b3b: Preserve already-streamed chat text, thinking, and tool-call state when the dashboard reattaches to an in-flight assistant response. +- dae0bde: Encourage dashboard chat agents to use structured `fn_ask_question` cards when offering choices or alternatives. +- ab8ecb2: Stop the engine from registering merge-trait hooks that collided with core's in-review field-effects adapter and could crash workflow-column moves. +- b1a2aee: Expose task document read/write tools to dashboard chat agents with explicit `task_id` targeting. +- 16b6e5d: Fix mobile iOS terminal cell measurement by making xterm font remeasure resilient to strict FontFaceSet shorthand rejection and pinning text-size adjustment on terminal viewports. +- 0ed46d9: Expose task document read/write tools to planning agents with explicit task IDs, matching chat session behavior. +- 3b32b53: Completed/no-commit executions that finalize to review no longer get re-parked failed when later teardown overwrites completion-finalize abort provenance with a hard-cancel marker. Genuine user/global pauses, merge-seam retry routing, and active-execution hard-cancel behavior are preserved. +- b6823af: Fix completed tasks being parked failed in in-review with a spurious "engine abort during pause/resume — operator action required" error (FN-6648; recurrence of FN-6478/FN-6568/FN-6625/FN-6644/FN-6647). The paused-after-completion graceful-exit path finalizes a fully completed task to in-review while leaving a non-user `paused` flag set; `handleGraphFailure`'s completion-finalized guards required `paused !== true`, so the trailing graph failure was misclassified as an operator-action pause abort once the volatile completion markers were lost. The classifier now recognizes finalized completions regardless of a lingering non-user pause flag, while genuine user/global pauses and in-progress tasks are unaffected. +- 2367918: Add attractive Command Center Overview charts for tokens by model, tool categories, and daily activity using existing analytics data. +- 662a09b: Add live animated Command Center Activity line charts for messages, active agents, active nodes, and combined throughput, backed by a reusable zero/NaN-safe LineChart primitive. +- 11c4120: Fix mobile terminal font measurement by keeping the symbols-only Nerd Font out of xterm's measured ASCII font stack while retaining a scoped DOM glyph fallback. +- 21d8076: Fix Command Center mobile chart rendering so chart primitives shrink inside the tabpanel without scroll-stealing overflow, zero-height collapse, or stretch artifacts, and normalize chart/card border and spacing rhythm across the combined analytics surfaces. +- ef54459: Fix Command Center token analytics so Tokens by model and the per-model table group tasks by the actually-used runtime model instead of collapsing resolved-via-settings usage into `(unknown)`. +- 317b08b: Command Center token-cost analytics now price resolved-via-settings task usage from the actually-used model snapshot, with legacy own-model fallback, instead of showing those costs as unavailable. +- fe207ca: Fix Command Center mobile chart rendering by bounding chart label/track layouts in real mobile engines and normalizing chart/card/table border spacing across the dashboard bundle. +- 0f021ae: Fix Command Center charts and shell styling to use the canonical `--accent` and `--text` dashboard tokens instead of undefined `--color-accent` and `--text-primary` aliases, so chart accents and primary text render with the intended colors. +- 282b069: Replace non-Command-Center dashboard CSS references to the undefined `--text-primary` alias with the canonical `--text` token so primary text uses the intended theme-aware color. +- 9d07e85: Fix served dashboard lazy-view preloads so persisted Command Center and other lazy views include their extracted CSS chunks. +- cfddde5: Fix Command Center activity chart rendering so plotted extrema stay visible and chart wrappers keep a measurable default height. +- cc02286: Inline direct and room chat attachments into agent prompts so agents can read text files and receive supported image attachments. +- 84cf3ff: Guard task detail activity-log rendering against legacy/operator log entries that use text/detail instead of action/outcome. +- 283f689: Repair mission autopilot reconciliation so stale triaged/in-progress features without live task cards are retriaged, while generated fix-loop debris is blocked instead of recreating duplicate tasks. + +### runfusion.ai + +#### Patch Changes + +- Updated dependencies [c8788d8] +- Updated dependencies [265d9ec] +- Updated dependencies [6427802] +- Updated dependencies [def4bd9] +- Updated dependencies [c1b581e] +- Updated dependencies [898ac1e] +- Updated dependencies [62335f8] +- Updated dependencies [cd2da10] +- Updated dependencies [fee0178] +- Updated dependencies [863ebfa] +- Updated dependencies [bc6dfd3] +- Updated dependencies [0093678] +- Updated dependencies [3158e9c] +- Updated dependencies [0db8134] +- Updated dependencies [a15b4ca] +- Updated dependencies [21c4d3e] +- Updated dependencies [198fb17] +- Updated dependencies [98cb80d] +- Updated dependencies [a453716] +- Updated dependencies [4a9fe99] +- Updated dependencies [d35f93e] +- Updated dependencies [550715d] +- Updated dependencies [a998f63] +- Updated dependencies [89171e0] +- Updated dependencies [914842f] +- Updated dependencies [6ced5d7] +- Updated dependencies [e2a3a37] +- Updated dependencies [a84a8e1] +- Updated dependencies [593ebac] +- Updated dependencies [05fe6e5] +- Updated dependencies [403bd9d] +- Updated dependencies [01b80db] +- Updated dependencies [5b9ff04] +- Updated dependencies [1bd8f6d] +- Updated dependencies [19aac38] +- Updated dependencies [a013bc0] +- Updated dependencies [b6ac5f2] +- Updated dependencies [0453a65] +- Updated dependencies [4c3186d] +- Updated dependencies [0767d1b] +- Updated dependencies [29b27a7] +- Updated dependencies [cdadac1] +- Updated dependencies [98ccf8a] +- Updated dependencies [4dd5337] +- Updated dependencies [4929198] +- Updated dependencies [673a8a6] +- Updated dependencies [58a34e9] +- Updated dependencies [3d28b3b] +- Updated dependencies [dae0bde] +- Updated dependencies [ab8ecb2] +- Updated dependencies [b1a2aee] +- Updated dependencies [16b6e5d] +- Updated dependencies [0ed46d9] +- Updated dependencies [3b32b53] +- Updated dependencies [b6823af] +- Updated dependencies [2367918] +- Updated dependencies [f41732d] +- Updated dependencies [504305e] +- Updated dependencies [64092ca] +- Updated dependencies [36f1fee] +- Updated dependencies [662a09b] +- Updated dependencies [af31f7d] +- Updated dependencies [11c4120] +- Updated dependencies [21d8076] +- Updated dependencies [ef54459] +- Updated dependencies [94a081f] +- Updated dependencies [317b08b] +- Updated dependencies [9b396b6] +- Updated dependencies [2059790] +- Updated dependencies [fe207ca] +- Updated dependencies [d6e2f92] +- Updated dependencies [99d799c] +- Updated dependencies [5e1a4ff] +- Updated dependencies [0f021ae] +- Updated dependencies [282b069] +- Updated dependencies [9d07e85] +- Updated dependencies [cfddde5] +- Updated dependencies [47e7b4a] +- Updated dependencies [cc02286] +- Updated dependencies [84cf3ff] +- Updated dependencies [c1b581e] +- Updated dependencies [283f689] +- Updated dependencies [168dc2f] +- Updated dependencies [951c6ef] +- Updated dependencies [0a87890] + - @runfusion/fusion@0.44.0 + +## 0.43.1 + +### @fusion/dashboard + +#### Patch Changes + +- @fusion/core@0.43.1 +- @fusion/engine@0.43.1 +- @fusion/i18n@0.39.6 +- @fusion-plugin-examples/cli-printing-press@0.1.23 +- @fusion-plugin-examples/compound-engineering@0.1.6 +- @fusion-plugin-examples/dependency-graph@0.1.37 +- @fusion-plugin-examples/roadmap@0.1.25 +- @fusion-plugin-examples/cursor-runtime@0.1.25 +- @fusion-plugin-examples/droid-runtime@0.1.32 +- @fusion-plugin-examples/hermes-runtime@0.2.56 +- @fusion-plugin-examples/openclaw-runtime@0.2.56 +- @fusion-plugin-examples/paperclip-runtime@0.2.56 + +### @fusion/desktop + +#### Patch Changes + +- @fusion/core@0.43.1 +- @fusion/dashboard@0.43.1 + +### @fusion/engine + +#### Patch Changes + +- @fusion/core@0.43.1 +- @fusion/pi-claude-cli@0.43.1 + +### @fusion/plugin-sdk + +#### Patch Changes + +- @fusion/core@0.43.1 + +### @runfusion/fusion + +#### Patch Changes + +- 59f2596: Fix the standalone `fn plugin new` scaffold so generated plugins include the required `state: "installed"` field and build unedited with `pnpm build`. This also lets the documented `fn plugin dev . --once` path complete its pre-load build step instead of failing TypeScript validation for a missing `FusionPlugin.state`. + + Manual end-to-end spot-check for release validation: `npx @runfusion/fusion@ plugin new proof-point-plugin && cd proof-point-plugin && pnpm install && pnpm build && npx @runfusion/fusion@ plugin dev . --once`. + + Registry evidence captured for the original failing release: `npm view @runfusion/fusion@0.43.0 dist.integrity` returned `sha512-kvxicT+e8ulc7FDhBVP9NsgaioZv6NDW81N8cXNS/X8M32Eo3Y33xT6JFW2DrSiFXsJmAaib/GnpQE0nYQYApQ==`. + +- 1f540b2: Persist planning-session response history before agent continuation so retry/replay and SQLite session recovery retain answered turns when generation errors or transitions complete. +- 19eca3d: Park incomplete tasks that exhaust stuck-loop recovery instead of making them scheduler-runnable again. + +### runfusion.ai + +#### Patch Changes + +- Updated dependencies [59f2596] +- Updated dependencies [1f540b2] +- Updated dependencies [19eca3d] + - @runfusion/fusion@0.43.1 + +## 0.43.0 + +### @fusion/dashboard + +#### Patch Changes + +- @fusion/core@0.43.0 +- @fusion/engine@0.43.0 +- @fusion/i18n@0.39.5 +- @fusion-plugin-examples/cli-printing-press@0.1.22 +- @fusion-plugin-examples/compound-engineering@0.1.5 +- @fusion-plugin-examples/dependency-graph@0.1.36 +- @fusion-plugin-examples/roadmap@0.1.24 +- @fusion-plugin-examples/cursor-runtime@0.1.24 +- @fusion-plugin-examples/droid-runtime@0.1.31 +- @fusion-plugin-examples/hermes-runtime@0.2.55 +- @fusion-plugin-examples/openclaw-runtime@0.2.55 +- @fusion-plugin-examples/paperclip-runtime@0.2.55 + +### @fusion/desktop + +#### Patch Changes + +- @fusion/core@0.43.0 +- @fusion/dashboard@0.43.0 + +### @fusion/engine + +#### Patch Changes + +- @fusion/core@0.43.0 +- @fusion/pi-claude-cli@0.43.0 + +### @fusion/plugin-sdk + +#### Patch Changes + +- @fusion/core@0.43.0 + +### @runfusion/fusion + +#### Minor Changes + +- 9149121: Enable Z.ai GLM-5.2 model selection. +- 64de883: Make the built-in compound-engineering workflow run the CE way end-to-end: + + - **Execute** stage invokes the `compound-engineering:ce-work` skill in coding mode instead of the generic executor prompt. + - **Merge** stage adds `ce-commit-push-pr` and `ce-resolve-pr-feedback` skill steps (CE owns commit/push/PR + feedback; Fusion's merge seam still owns the board-state merge). The plugin now bundles `ce-commit`, `ce-commit-push-pr`, and `ce-resolve-pr-feedback`. + - **Planning questions reach a human:** workflow-step sessions carry a `FUSION_WORKFLOW_STEP` signal; in that mode the CE skills emit an await-input sentinel instead of calling a blocking tool with no listener. The executor parks the task `awaiting-user-input` with the question, and a new task-card **"Answer questions"** button opens the workflow tab where the existing input banner captures the answer and resumes the step. + - **Subagents work in workflow steps:** `fn_spawn_agent` gains an optional `systemPromptOverride`; the plugin installs the 43 `ce-*` persona definitions plugin-locally and exposes their directory via `FUSION_CE_AGENTS_DIR`, so the CE skills read a persona def and spawn it as a real subagent (falling back to inline single-agent work when unavailable). + +- e8c2d51: Add a one-click dashboard Update now action for installing available Fusion updates. + +#### Patch Changes + +- 740c712: Inline the private `@fusion/core` types into the published `@runfusion/fusion/plugin-sdk` declaration entry so standalone external plugins created with `fn plugin new` can typecheck and `pnpm build` cleanly against released Fusion. Human spot-check: `npx @runfusion/fusion@0.42.0 plugin new proof-point-plugin && cd proof-point-plugin && pnpm install && pnpm build`. +- b1ba87e: Ensure `zai/glm-5.2` reliably appears in the model list after user Z.ai provider extensions load. +- 65a4c51: Recover stale Compound Engineering sessions on plugin load and session reads so persisted active rows without live agent handles no longer leave the dashboard stuck waiting for work that is not running. +- 20aad56: Fix "Couldn't start local Fusion" on the Linux AppImage (and any packaged build launched from a desktop launcher). The embedded local runtime now roots its data at the user's home directory (`~/.fusion`) instead of `process.cwd()`, which was `/` or the read-only AppImage mount point and caused database creation to fail with EACCES/EROFS. Set `FUSION_HOME` to override the location. +- 066c919: Preserve the original corrupt project database at `fusion.db` when startup recovery fails after moving it aside. +- 0d75725: Fixed unreliable horizontal scrolling when swiping across task cards on the mobile board. Native HTML5 drag is now disabled on touch-primary devices (where it never worked anyway), so the browser no longer hijacks swipe-to-scroll gestures that start on a card. +- 67ae2be: Fix two mobile chat send failures. The regular chat send button was dead to touch because the action only ran on `onClick`, which iOS suppresses after `preventDefault()` in the touch sequence — it now fires from pointerdown/touchstart with a dedupe latch. Quick chat messages could strand in the composer (shown locally but never sent to the agent or persisted) when a queued message's delivery trigger bailed — a dropped stream leaving the streaming flag stuck `true`, or a stream that looked healthy when queued but then stalled. A queued send now detects a stale flag at send time via the stream's connection state and the server's generation status, and a delivery watchdog re-confirms any message that stays pending and force-delivers it once no generation is actually in flight. +- fd6caaa: Fix sporadic quick chat send failures on mobile (notably the first message after a response). A real touch tap dispatches both `pointerdown` and `touchstart`, and the quick chat send button ran its action on each — firing `handleSendMessage` twice per tap. Because React had not yet flushed the composer clear between the two events, both reads saw the same text and sent, and the hook's second send closed the first's freshly-opened stream and re-POSTed, which could drop the response. The send and stop buttons now claim a single action per tap so only the first of the paired events fires. +- 9eeaaa7: Fix the quick chat stop button rendering too narrow. It borrowed ChatView's `.chat-input-stop` styling, which sizes itself with `--chat-input-control-size` — a variable scoped to ChatView's composer and undefined in the quick chat DOM — collapsing the button toward its icon width. It is now pinned to the send button's square dimensions. +- ee6d7ac: Workflow step execution now surfaces task attachment locations in the context-recovery prompt path and no longer tells autonomous agents to ask for context. +- 14ed177: Restored horizontal swiping on mobile kanban board columns while preserving page-level horizontal pan containment. +- df01ab7: Fix Create Pull Request conflict preflight to derive `conflictsWithBase` from `git merge-tree --write-tree` exit codes instead of non-empty output, and treat no-op PR conflict resolution merges as successful without attempting an empty commit. +- 96773dd: Fix standalone installs of the published CLI crashing with `ERR_MODULE_NOT_FOUND` for `@earendil-works/pi-coding-agent`. `@earendil-works/pi-coding-agent` and `@earendil-works/pi-ai` are now plain required dependencies instead of also being optional peers, so clean npm and pnpm installs resolve the pi runtime packages. +- 67d4d51: Move task-card timing badges from the top metadata cluster into the bottom-right footer chip cluster so timers align with retry and GitHub footer badges. +- 7b83906: Run the configured or inferred dependency install inside temporary standalone AI-merge clean-room worktrees before merge/review verification. +- be2773b: Fix scheduler concurrency diagnostics and semaphore slot accounting so queued tasks are not held behind contradictory or negative capacity readings. +- 3cc82bd: Fix mobile board horizontal overflow that caused iOS Safari to zoom-out/cut-off the board and let the whole page pan off-screen. Screen-reader-only `.visually-hidden` spans were `position: absolute` with no offsets, so inside the horizontally-scrolled kanban columns they rendered off-screen-right and ballooned the document's scroll width. Pinning the utility to its containing block's origin keeps the document locked to the viewport on mobile. +- aa71ace: Refresh expired Claude OAuth access tokens from Fusion auth storage instead of requiring repeated manual re-login. +- 417183d: Send task-detail Chat composer messages on plain Enter while preserving Shift+Enter newlines and Cmd/Ctrl+Enter sending. + +### runfusion.ai + +#### Patch Changes + +- Updated dependencies [9149121] +- Updated dependencies [740c712] +- Updated dependencies [b1ba87e] +- Updated dependencies [65a4c51] +- Updated dependencies [64de883] +- Updated dependencies [20aad56] +- Updated dependencies [066c919] +- Updated dependencies [0d75725] +- Updated dependencies [67ae2be] +- Updated dependencies [fd6caaa] +- Updated dependencies [9eeaaa7] +- Updated dependencies [ee6d7ac] +- Updated dependencies [e8c2d51] +- Updated dependencies [14ed177] +- Updated dependencies [df01ab7] +- Updated dependencies [96773dd] +- Updated dependencies [67d4d51] +- Updated dependencies [7b83906] +- Updated dependencies [be2773b] +- Updated dependencies [3cc82bd] +- Updated dependencies [aa71ace] +- Updated dependencies [417183d] + - @runfusion/fusion@0.43.0 + ## 0.42.0 ### @fusion/dashboard @@ -8895,6 +9322,30 @@ for reference. - Updated dependencies [a2ed6d0] - @runfusion/fusion@0.1.0 +## 0.39.7 + +### @fusion/i18n + +#### Patch Changes + +- @fusion/core@0.44.0 + +## 0.39.6 + +### @fusion/i18n + +#### Patch Changes + +- @fusion/core@0.43.1 + +## 0.39.5 + +### @fusion/i18n + +#### Patch Changes + +- @fusion/core@0.43.0 + ## 0.39.4 ### @fusion/i18n @@ -8927,6 +9378,30 @@ for reference. - @fusion/core@0.40.0 +## 0.11.33 + +### @fusion/droid-cli + +#### Patch Changes + +- @fusion-plugin-examples/droid-runtime@0.1.33 + +## 0.11.32 + +### @fusion/droid-cli + +#### Patch Changes + +- @fusion-plugin-examples/droid-runtime@0.1.32 + +## 0.11.31 + +### @fusion/droid-cli + +#### Patch Changes + +- @fusion-plugin-examples/droid-runtime@0.1.31 + ## 0.11.30 ### @fusion/droid-cli diff --git a/CONCEPTS.md b/CONCEPTS.md index 3f615356ca..552a60a9a6 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -82,6 +82,12 @@ The core board entity: a unit of work that moves through columns (triage, todo, ### Workflow Runtime The authoritative task lifecycle runtime. It resolves a Task to workflow IR, walks the graph, routes node outcomes, and invokes runtime primitives for side effects. The engine substrate still owns scheduling, routing claims, persistence, concurrency, process supervision, storage, and audit plumbing; lifecycle policy lives in workflow nodes and built-in workflow IR. +### ACP Ask Path +A one-turn read-only model ask routed through the ACP runtime rather than a CLI print mode. The runner accumulates streamed prose, may recover a trailing JSON object for structured seams, and treats abnormal ACP stop reasons as incomplete answers for validator use. + +### Claude Bridge +The pinned `claude-code-cli-acp` subprocess bundled with the ACP runtime plugin. It speaks ACP over stdio to Fusion while driving the real interactive `claude` through a PTY, and is resolved from the plugin-owned `node_modules` tree rather than PATH. + ### Runtime Primitive A named, injected operation a workflow node can call to perform side effects without depending on `executor.ts` lifecycle branches. Examples include planning session, coding session, step execution/reset, review, verification, workflow step, transition, merge request, abort, and audit. Primitives are the boundary between workflow policy and engine substrate. diff --git a/README.es.md b/README.es.md index a69739fc7c..e2a2a88a29 100644 --- a/README.es.md +++ b/README.es.md @@ -72,14 +72,14 @@ Cada tarea muestra su plan, sus revisiones, sus diffs y sus cambios de archivos | | | |---|---| | 🧠 **Planificación con IA** | Describe una tarea en lenguaje natural. Los agentes de planificación la convierten en un plan `PROMPT.md` con pasos, alcance de archivos y criterios de aceptación. | -| 🔁 **Puertas de flujo** | Plan → Revisión → Ejecución → Revisión en cada paso. Las puertas previas al merge bloquean código deficiente; las posteriores ejecutan verificaciones informativas. | +| 🔁 **Workflows seleccionables** | Los integrados cubren codificación, arreglos rápidos, trabajo con revisión intensa, ejecución paso a paso, Compound Engineering con plugin y fragmentos de ciclo de vida de PR. Elige un workflow por tarea o crea personalizados en el [Editor de workflows](./docs/workflow-editor.md). | | 🌳 **Aislamiento con worktrees** | Cada tarea corre en su propia rama y worktree (`fusion/{task-id}`). Tareas en paralelo. Cero conflictos. Delegación opcional a [worktrunk](https://github.com/max-sixty/worktrunk) mediante [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) (ver [abstracción WorktreeBackend](./docs/architecture.md#worktreebackend-abstraction)). | -| ⚡ **Merge inteligente** | ¿Pasa todas las puertas? Fusion hace squash-merge y avanza. Habilita aprobación manual en cualquier punto. | +| ⚡ **Controles de merge inteligente** | ¿Pasa todas las puertas? Fusion hace squash-merge y avanza. Puedes exigir aprobación manual, heredar el valor global de auto-merge o definir sobreescrituras por tarea. | | 🛰️ **Malla multinodo** | Laptop, Mac mini, servidor Linux, VM en la nube, teléfono — todos sincronizados. Escritorio, móvil, web. | -| 🧩 **Cualquier modelo** | Anthropic, OpenAI, Ollama y más. Local y en la nube coexisten. | +| 🧩 **Cualquier modelo** | Anthropic, OpenAI, Ollama, Google Generative AI, Z.ai, runtimes locales y [proveedores personalizados](./docs/dashboard-guide.md#custom-providers). Local y nube coexisten, con canales de modelo/fallback configurables por workflow. | | 🏢 **Empresas de agentes** | Importa equipos predefinidos — más de 440 agentes en 16 empresas — y ejecútalos de forma autónoma durante semanas. | | 📬 **Mensajería entre agentes** | Buzón incorporado entre agentes. Delega, aclara, coordina. | -| 🗨️ **Salas de chat multiagente** | Conversaciones grupales con alcance de proyecto donde varios miembros de la sala pueden responder: los miembros mencionados son respondedores directos, y miembros ambientales adicionales pueden responder hasta un límite. Actualmente **experimental** — habilita `chatRooms` en **Configuración → Funciones experimentales → Salas de chat**. ([Documentación de salas de chat](./docs/dashboard-guide.md#chat-rooms)) | +| 🗨️ **Chat de agentes** | Chat directo, chat de tareas, adjuntos, tarjetas de preguntas en chat, streams reanudables y salas multiagente experimentales donde los miembros mencionados responden directamente y miembros ambientales pueden sumarse hasta un límite. ([Documentación de chat](./docs/dashboard-guide.md#chat-view)) | | 🗺️ **Misiones** | Planificación jerárquica (Misión → Hito → Slice → Característica → Tarea) con piloto automático y contratos de validación. | | 🔬 **Investigación** | Ejecuciones de investigación delimitadas con búsqueda web, GitHub, documentación local y síntesis con LLM (además de soporte integrado en tiempo de ejecución para WebSearch/WebFetch en flujos de planificación y síntesis cuando está disponible). Convierte los hallazgos en tareas. ([Documentación](./docs/research.md)) | | 🧪 **Automejora** | Los agentes reflexionan sobre su propio resultado y actualizan sus prompts a medida que aprenden tu base de código. | @@ -127,6 +127,18 @@ Las tareas con dependencias se procesan secuencialmente. Las tareas independient --- +## Resumen del flujo de trabajo + +Fusion workflows definen cómo una tarea pasa de una idea a una entrega. La ruta de codificación predeterminada sigue siendo el ciclo **Planificación/triage → Ejecución → Pasos del flujo → Revisión → Merge**, pero ahora la política vive en un workflow seleccionable en lugar de estar solo codificada en el motor. + +- **Selecciona por tarea:** elige un workflow desde los controles de workflow de la tarea/tablero, o asígnalo con `fn_workflow_select` / `workflow_id` al crear tareas. +- **Catálogo integrado:** Coding (`builtin:coding`), Quick fix (`builtin:quick-fix`), Review-heavy (`builtin:review-heavy`), Compound engineering (`builtin:compound-engineering`, requiere plugin), Stepwise coding (`builtin:stepwise-coding`) y PR lifecycle (`builtin:pr-workflow`, un fragmento reutilizable de grafo de PR). +- **Personaliza con seguridad:** inspecciona los workflows integrados, duplícalos o crea workflows personalizados en el [Editor de workflows](./docs/workflow-editor.md). Los ajustes específicos de workflow cubren canales de modelo, revisión/aprobación, ejecución de pasos, campos de tarea y columnas. + +Lee [Pasos del flujo](./docs/workflow-steps.md) para la semántica de ejecución y [Editor de workflows](./docs/workflow-editor.md) para la guía de autoría en el panel. + +--- + ## Multinodo. Un tablero. Todas las plataformas.
@@ -283,32 +295,41 @@ Para el flujo de trabajo con Capacitor + PWA, consulta [MOBILE.md](./MOBILE.md). | Guía | Qué cubre | |---|---| -| [Primeros pasos](./docs/getting-started.md) | Instalación e incorporación | -| [Guía del panel](./docs/dashboard-guide.md) | Vistas de tablero/lista, terminal, gestor de git | -| [Gestión de tareas](./docs/task-management.md) | Ciclo de vida de la tarea y comandos CLI | -| [Referencia CLI](./docs/cli-reference.md) | Referencia completa de comandos y daemon | -| [Referencia de configuración](./docs/settings-reference.md) | Opciones de configuración | -| [Arquitectura](./docs/architecture.md) | Funcionamiento interno del sistema | -| [Agentes](./docs/agents.md) | Gestión de agentes, creación, latido | -| [Pasos del flujo](./docs/workflow-steps.md) | Puertas de calidad, plantillas, fases | -| [Misiones](./docs/missions.md) | Jerarquía de misiones, planificación, piloto automático | -| [Multiproyecto](./docs/multi-project.md) | Registro central, modos de aislamiento | +| [Primeros pasos](./docs/getting-started.md) | Instalación, incorporación, primera tarea y selección básica de workflows | +| [Guía del panel](./docs/dashboard-guide.md) | Vistas de tablero/lista, chat, editor de workflows, gestor de git, configuración y herramientas UI | +| [Gestión de tareas](./docs/task-management.md) | Ciclo de vida, especificaciones de prompts, comentarios, archivado e integración con GitHub | +| [Referencia CLI](./docs/cli-reference.md) | Referencia completa de comandos `fn` y daemon | +| [Referencia de configuración](./docs/settings-reference.md) | Configuración global/proyecto, jerarquía de modelos, configuración de workflows y proveedores personalizados | +| [Pasos del flujo](./docs/workflow-steps.md) | Runtime de workflows, integrados, puertas, plantillas y fases | +| [Editor de workflows](./docs/workflow-editor.md) | Autoría visual, importación/exportación, campos/columnas/configuración y editor móvil | +| [Investigación](./docs/research.md) | Ejecuciones de investigación, hallazgos, exportaciones e integración con tareas | +| [Agentes](./docs/agents.md) | Gestión de agentes, spawning, latidos y buzones | +| [Misiones](./docs/missions.md) | Jerarquía, planificación, piloto automático y contratos de validación | +| [Gestión de plugins](./docs/plugin-management.md) | Descubrir, instalar, habilitar, configurar y solucionar plugins | +| [Autoría de plugins](./docs/PLUGIN_AUTHORING.md) | Crear plugins con hooks, rutas, herramientas, runtimes y superficies de panel | +| [Acceso remoto](./docs/remote-access.md) | Acceso remoto con token, Tailscale/Cloudflare y solución de problemas | +| [Multiproyecto](./docs/multi-project.md) | Registro central, aislamiento y migraciones | | [Docker](./docs/docker.md) | Despliegue en contenedores | --- ## Características principales -- **Planificación con IA** — El agente de planificación genera un `PROMPT.md` detallado con pasos, alcance de archivos y criterios de aceptación -- **Ejecución paso a paso** — Ciclo Plan → Revisión → Ejecución → Revisión para cada paso de la tarea -- **Aislamiento con worktrees de git** — Cada tarea corre en su propio worktree (rama `fusion/{task-id}`) -- **Pasos del flujo** — Puertas de calidad configurables (previas al merge: bloquean el merge; posteriores al merge: informativas) -- **Integración con GitHub** — Importar issues, crear PRs, insignias en tiempo real de PR/issue -- **Panel** — Tablero kanban en tiempo real, gestión de agentes, terminal, gestor de git, planificador de misiones -- **Misiones** — Planificación jerárquica (Misión → Hito → Slice → Característica → Tarea) con piloto automático, contratos de validación, reintentos de corrección de características y semántica de entrega bloqueada -- **Multiproyecto** — Gestiona múltiples proyectos desde una sola instalación con aislamiento de proyectos -- **Mensajería entre agentes** — Sistema de mensajería integrado para la coordinación entre agentes y usuarios -- **Salas de chat (experimental)** — Chat grupal con alcance de proyecto donde los miembros mencionados se enrutan como respondedores directos y miembros ambientales adicionales pueden responder hasta un límite (habilitar en **Configuración → Funciones experimentales → Salas de chat**; detalles en [Guía del panel → Salas de chat](./docs/dashboard-guide.md#chat-rooms)) +- **AI Planning** — Planning agent generates detailed `PROMPT.md` with steps, file scope, and acceptance criteria +- **Step-by-step Execution** — Plan → Review → Execute → Review cycle for each task step, with graph-mode workflows able to model per-step parse/execute/review/rework explicitly +- **Git Worktree Isolation** — Each task runs in its own worktree (`fusion/{task-id}` branch) +- **Selectable workflows** — Pick Coding, Quick fix, Review-heavy, Stepwise coding, plugin-gated Compound Engineering, custom workflows, or PR lifecycle fragments where appropriate ([overview](#resumen-del-flujo-de-trabajo); [Workflow Steps](./docs/workflow-steps.md#resumen-del-flujo-de-trabajo)) +- **Visual Workflow Editor** — Inspect read-only built-ins, duplicate/customize workflows, and edit graph nodes, columns, task fields, typed settings, and per-project values ([Workflow Editor](./docs/workflow-editor.md)) +- **Workflow Steps** — Configurable quality gates (pre-merge blocks merge; post-merge informational), plus opt-in [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) +- **Workflow-native policy** — Fast-mode planning, typed triage thresholds, review/approval, step execution, and model/fallback lanes are workflow settings ([Settings Reference](./docs/settings-reference.md#workflow-settings)) +- **GitHub + PR lifecycle** — Import issues, create PRs, display live PR/issue badges, and use workflow-mode PR lifecycle graph fragments where enabled +- **Dashboard** — Real-time kanban/list/graph views, agent management, terminal, git manager, missions, chat, workflow editor, custom providers, and one-click updates +- **Missions** — Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot, validation contracts, fix-feature retries, mission-goal linking, and blocked handoffs +- **Multi-Project** — Manage multiple projects from one installation with project isolation +- **Custom Providers** — Add OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI providers; saved models appear in project and workflow model dropdowns ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers)) +- **Smart merge controls** — Global auto-merge stays live for default tasks, while explicit per-task overrides can force auto/manual behavior +- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users; engineer-role agents can opt into backlog auto-claim +- **Agent Chat + Chat Rooms** — Direct/task chat supports attachments, resumable streams, question response cards, and renameable conversations; experimental rooms route mentioned members as direct responders ([Dashboard Guide → Chat View](./docs/dashboard-guide.md#chat-view)) ### Autenticación de proveedores @@ -332,6 +353,8 @@ Fusion usa una jerarquía de modelos de doble alcance con cinco canales independ | Title Summarization | Generación automática de títulos | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` | | Workflow Step Refinement | Refinamiento de prompts con IA | (usa `defaultProvider`/`defaultModelId`) | (usa `modelProvider`/`modelId` en WorkflowStep) | +**Canales de workflow:** El workflow predeterminado expone canales de modelo Plan/Triage, Executor, Reviewer y fallback en **Configuración → Modelos de proyecto**, y los workflows avanzados pueden declarar valores tipados adicionales ([Referencia de configuración](./docs/settings-reference.md#workflow-settings)). + **Sobreescrituras por tarea:** Las tareas pueden sobreescribir los canales de executor, validator y planning con campos de modelo por tarea (`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`). **Precedencia:** Por tarea → Sobreescritura de proyecto → Canal global → `defaultProvider`/`defaultModelId` → Resolución automática. diff --git a/README.fr.md b/README.fr.md index ed18bbd973..1cb4a030a4 100644 --- a/README.fr.md +++ b/README.fr.md @@ -78,7 +78,7 @@ Chaque tâche affiche son plan, ses révisions, ses diffs et ses modifications d | 🧩 **N'importe quel modèle** | Anthropic, OpenAI, Ollama et plus encore. Local et cloud coexistent. | | 🏢 **Entreprises d'agents** | Importez des équipes prédéfinies — plus de 440 agents répartis dans 16 entreprises — et faites-les fonctionner de façon autonome pendant des semaines. | | 📬 **Messagerie inter-agents** | Boîte aux lettres intégrée entre agents. Déléguer, clarifier, coordonner. | -| 🗨️ **Salles de discussion multi-agents** | Conversations de groupe à portée de projet où plusieurs membres peuvent répondre : les membres mentionnés sont des répondants directs, et des membres ambiants supplémentaires peuvent répondre jusqu'à un certain plafond. Actuellement **expérimental** — activez `chatRooms` dans **Paramètres → Fonctionnalités expérimentales → Salles de discussion**. ([Documentation des salles de discussion](./docs/dashboard-guide.md#chat-rooms)) | +| 🗨️ **Chat d’agents** | Chat direct, chat de tâche, pièces jointes, cartes de questions, flux reprenables et salles multi-agents expérimentales où les membres mentionnés répondent directement et les membres ambiants peuvent participer jusqu’à un plafond. ([Docs Chat](./docs/dashboard-guide.md#chat-view)) | | 🗺️ **Missions** | Planification hiérarchique (Mission → Jalon → Tranche → Fonctionnalité → Tâche) avec pilotage automatique et contrats de validation. | | 🔬 **Recherche** | Exécutions de recherche délimitées avec recherche web, GitHub, docs locaux et synthèse LLM (plus prise en charge intégrée de WebSearch/WebFetch dans les flux de planification et de synthèse lorsque disponible). Transformez les résultats en tâches. ([Docs](./docs/research.md)) | | 🧪 **Auto-amélioration** | Les agents réfléchissent à leurs propres résultats et mettent à jour leurs prompts au fur et à mesure qu'ils apprennent votre base de code. | @@ -126,6 +126,18 @@ Les tâches avec dépendances sont traitées séquentiellement. Les tâches ind --- +## Aperçu des workflows + +Les workflows Fusion définissent comment une tâche passe de l’idée à la livraison. Le parcours de codage par défaut reste **Plan/Triage → Exécution → Étapes de workflow → Revue → Merge**, mais cette politique vit désormais dans un workflow sélectionnable plutôt que seulement dans le moteur. + +- **Sélection par tâche :** choisissez un workflow dans les contrôles de tâche/tableau, ou assignez-le avec `fn_workflow_select` / `workflow_id` lors de la création. +- **Catalogue intégré :** Coding (`builtin:coding`), Quick fix (`builtin:quick-fix`), Review-heavy (`builtin:review-heavy`), Compound engineering (`builtin:compound-engineering`, avec plugin), Stepwise coding (`builtin:stepwise-coding`) et PR lifecycle (`builtin:pr-workflow`, fragment PR réutilisable). +- **Personnalisation sûre :** inspectez les workflows intégrés, dupliquez-les ou créez des workflows personnalisés dans l’[Éditeur de workflows](./docs/workflow-editor.md). Les réglages de workflow couvrent les voies de modèles, revue/approbation, exécution des étapes, champs de tâche et colonnes. + +Consultez [Workflow Steps](./docs/workflow-steps.md) pour la sémantique d’exécution et [Workflow Editor](./docs/workflow-editor.md) pour le guide d’édition dans le tableau de bord. + +--- + ## Multi-nœuds. Un tableau. Toutes les plateformes.
@@ -283,32 +295,41 @@ Pour le workflow Capacitor + PWA, voir [MOBILE.md](./MOBILE.md). | Guide | Ce qu'il couvre | |---|---| -| [Premiers pas](./docs/getting-started.md) | Installation et intégration | -| [Guide du tableau de bord](./docs/dashboard-guide.md) | Vues tableau/liste, terminal, gestionnaire git | -| [Gestion des tâches](./docs/task-management.md) | Cycle de vie des tâches et commandes CLI | -| [Référence CLI](./docs/cli-reference.md) | Référence complète des commandes et du démon | -| [Référence des paramètres](./docs/settings-reference.md) | Options de configuration | -| [Architecture](./docs/architecture.md) | Internals du système | -| [Agents](./docs/agents.md) | Gestion des agents, instanciation, heartbeat | -| [Étapes de workflow](./docs/workflow-steps.md) | Portes de qualité, modèles, phases | -| [Missions](./docs/missions.md) | Hiérarchie de missions, planification, pilotage automatique | -| [Multi-projet](./docs/multi-project.md) | Registre central, modes d'isolation | -| [Docker](./docs/docker.md) | Déploiement en conteneur | +| [Démarrage](./docs/getting-started.md) | Installation, onboarding, première tâche et bases de sélection des workflows | +| [Guide du tableau de bord](./docs/dashboard-guide.md) | Vues tableau/liste, chat, éditeur de workflows, gestionnaire git, paramètres et outils UI | +| [Gestion des tâches](./docs/task-management.md) | Cycle de vie, spécifications de prompts, commentaires, archivage et intégration GitHub | +| [Référence CLI](./docs/cli-reference.md) | Référence complète des commandes `fn` et du daemon | +| [Référence des paramètres](./docs/settings-reference.md) | Paramètres globaux/projet, hiérarchie des modèles, paramètres de workflow et fournisseurs personnalisés | +| [Workflow Steps](./docs/workflow-steps.md) | Runtime de workflow, workflows intégrés, portes, modèles et phases | +| [Workflow Editor](./docs/workflow-editor.md) | Édition visuelle, import/export, champs/colonnes/paramètres et éditeur mobile | +| [Recherche](./docs/research.md) | Exécutions de recherche, résultats, exports et intégration aux tâches | +| [Agents](./docs/agents.md) | Gestion des agents, spawning, heartbeat et boîtes aux lettres | +| [Missions](./docs/missions.md) | Hiérarchie, planification, autopilotage et contrats de validation | +| [Gestion des plugins](./docs/plugin-management.md) | Découvrir, installer, activer, configurer et dépanner les plugins | +| [Création de plugins](./docs/PLUGIN_AUTHORING.md) | Construire des plugins avec hooks, routes, outils, runtimes et surfaces tableau de bord | +| [Accès distant](./docs/remote-access.md) | Accès distant tokenisé, Tailscale/Cloudflare et dépannage | +| [Multi-projet](./docs/multi-project.md) | Registre central, modes d’isolation et migrations | +| [Docker](./docs/docker.md) | Déploiement conteneurisé | --- ## Fonctionnalités principales -- **Planification IA** — L'agent de planification génère un `PROMPT.md` détaillé avec étapes, périmètre des fichiers et critères d'acceptation -- **Exécution pas à pas** — Cycle Plan → Révision → Exécution → Révision pour chaque étape de tâche -- **Isolation par worktree git** — Chaque tâche s'exécute dans son propre worktree (branche `fusion/{task-id}`) -- **Étapes de workflow** — Portes de qualité configurables (pré-fusion : bloque la fusion ; post-fusion : informatif) -- **Intégration GitHub** — Import de tickets, création de PR, badges PR/ticket en temps réel -- **Tableau de bord** — Tableau kanban en temps réel, gestion des agents, terminal, gestionnaire git, planificateur de missions -- **Missions** — Planification hiérarchique (Mission → Jalon → Tranche → Fonctionnalité → Tâche) avec pilotage automatique, contrats de validation, nouvelles tentatives sur correctifs/fonctionnalités et sémantique de transfert en cas de blocage -- **Multi-projet** — Gérez plusieurs projets depuis une installation unique avec isolation des projets -- **Messagerie inter-agents** — Messagerie intégrée pour la coordination entre agents et utilisateurs -- **Salles de discussion (Expérimental)** — Discussion de groupe à portée de projet où les membres mentionnés sont routés comme répondants directs et des membres ambiants supplémentaires peuvent répondre jusqu'à un certain plafond (activer via **Paramètres → Fonctionnalités expérimentales → Salles de discussion** ; détails dans [Guide du tableau de bord → Salles de discussion](./docs/dashboard-guide.md#chat-rooms)) +- **AI Planning** — Planning agent generates detailed `PROMPT.md` with steps, file scope, and acceptance criteria +- **Step-by-step Execution** — Plan → Review → Execute → Review cycle for each task step, with graph-mode workflows able to model per-step parse/execute/review/rework explicitly +- **Git Worktree Isolation** — Each task runs in its own worktree (`fusion/{task-id}` branch) +- **Selectable workflows** — Pick Coding, Quick fix, Review-heavy, Stepwise coding, plugin-gated Compound Engineering, custom workflows, or PR lifecycle fragments where appropriate ([overview](#aperçu-des-workflows); [Workflow Steps](./docs/workflow-steps.md#aperçu-des-workflows)) +- **Visual Workflow Editor** — Inspect read-only built-ins, duplicate/customize workflows, and edit graph nodes, columns, task fields, typed settings, and per-project values ([Workflow Editor](./docs/workflow-editor.md)) +- **Workflow Steps** — Configurable quality gates (pre-merge blocks merge; post-merge informational), plus opt-in [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) +- **Workflow-native policy** — Fast-mode planning, typed triage thresholds, review/approval, step execution, and model/fallback lanes are workflow settings ([Settings Reference](./docs/settings-reference.md#workflow-settings)) +- **GitHub + PR lifecycle** — Import issues, create PRs, display live PR/issue badges, and use workflow-mode PR lifecycle graph fragments where enabled +- **Dashboard** — Real-time kanban/list/graph views, agent management, terminal, git manager, missions, chat, workflow editor, custom providers, and one-click updates +- **Missions** — Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot, validation contracts, fix-feature retries, mission-goal linking, and blocked handoffs +- **Multi-Project** — Manage multiple projects from one installation with project isolation +- **Custom Providers** — Add OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI providers; saved models appear in project and workflow model dropdowns ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers)) +- **Smart merge controls** — Global auto-merge stays live for default tasks, while explicit per-task overrides can force auto/manual behavior +- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users; engineer-role agents can opt into backlog auto-claim +- **Agent Chat + Chat Rooms** — Direct/task chat supports attachments, resumable streams, question response cards, and renameable conversations; experimental rooms route mentioned members as direct responders ([Dashboard Guide → Chat View](./docs/dashboard-guide.md#chat-view)) ### Authentification des fournisseurs @@ -332,6 +353,8 @@ Fusion utilise une hiérarchie de modèles à double portée avec cinq voies ind | Résumé de titre | Génération automatique de titre | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` | | Raffinement des étapes de workflow | Raffinement de prompt IA | (utilise `defaultProvider`/`defaultModelId`) | (utilise `modelProvider`/`modelId` sur WorkflowStep) | +**Voies de workflow :** Le workflow par défaut expose les voies Plan/Triage, Executor, Reviewer et fallback dans **Paramètres → Modèles du projet**, et les workflows avancés peuvent déclarer d’autres valeurs typées ([Référence des paramètres](./docs/settings-reference.md#workflow-settings)). + **Remplacements par tâche :** Les tâches peuvent remplacer les voies exécuteur, validateur et planification avec des champs de modèle par tâche (`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`). **Précédence :** Par tâche → Remplacement projet → Voie globale → `defaultProvider`/`defaultModelId` → Résolution automatique. diff --git a/README.ko.md b/README.ko.md index beead4c0bc..9b8026f4d6 100644 --- a/README.ko.md +++ b/README.ko.md @@ -71,14 +71,14 @@ | | | |---|---| | 🧠 **AI 계획** | 평문으로 태스크를 설명하면, 계획 에이전트가 단계, 파일 범위, 완료 기준이 포함된 `PROMPT.md` 계획서로 변환합니다. | -| 🔁 **워크플로우 게이트** | 모든 단계마다 계획 → 검토 → 실행 → 검토 주기를 거칩니다. 사전 머지 게이트는 불량 코드를 차단하고, 사후 머지 게이트는 정보성 검사를 실행합니다. | +| 🔁 **선택 가능한 워크플로** | 내장 워크플로는 코딩, 빠른 수정, 검토 강화, 단계별 실행, 플러그인 기반 Compound Engineering, PR lifecycle 조각을 지원합니다. 태스크별로 선택하거나 [워크플로 편집기](./docs/workflow-editor.md)에서 커스텀 워크플로를 작성하세요. | | 🌳 **워크트리 격리** | 각 태스크는 자체 브랜치와 워크트리(`fusion/{task-id}`)에서 실행됩니다. 병렬 태스크. 충돌 없음. [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings)를 통한 선택적 [worktrunk](https://github.com/max-sixty/worktrunk) 위임 지원([WorktreeBackend 추상화](./docs/architecture.md#worktreebackend-abstraction) 참조). | -| ⚡ **스마트 머지** | 모든 게이트 통과 시 Fusion이 스쿼시 머지하고 다음으로 넘어갑니다. 어디서든 수동 승인을 선택할 수 있습니다. | +| ⚡ **스마트 머지 제어** | 모든 게이트 통과 시 Fusion이 스쿼시 머지하고 진행합니다. 수동 승인 요구, 전역 auto-merge 기본값 상속, 태스크별 auto/manual 재정의를 선택할 수 있습니다. | | 🛰️ **멀티 노드 메시** | 노트북, Mac mini, Linux 서버, 클라우드 VM, 휴대폰 — 모두 동기화됩니다. 데스크톱, 모바일, 웹. | -| 🧩 **모든 모델** | Anthropic, OpenAI, Ollama 등 다양한 모델을 지원합니다. 로컬과 클라우드가 공존합니다. | +| 🧩 **모든 모델** | Anthropic, OpenAI, Ollama, Google Generative AI, Z.ai, 로컬 런타임, [커스텀 공급자](./docs/dashboard-guide.md#custom-providers)를 지원합니다. 로컬과 클라우드가 공존하며 워크플로 모델/fallback 레인을 프로젝트별로 구성할 수 있습니다. | | 🏢 **에이전트 컴퍼니** | 사전 구축된 팀 — 16개 컴퍼니에 걸쳐 440개 이상의 에이전트 — 을 임포트하여 몇 주 동안 자율적으로 실행합니다. | | 📬 **에이전트 간 메시징** | 에이전트 간 내장 메일박스. 위임, 확인, 조율이 가능합니다. | -| 🗨️ **멀티 에이전트 채팅 룸** | 여러 룸 구성원이 답할 수 있는 프로젝트 범위 그룹 대화: 언급된 구성원은 직접 응답자로, 추가 주변 구성원은 최대 한도까지 응답할 수 있습니다. 현재 **실험적** — **설정 → 실험적 기능 → 채팅 룸**에서 `chatRooms`를 활성화하세요. ([채팅 룸 문서](./docs/dashboard-guide.md#chat-rooms)) | +| 🗨️ **에이전트 채팅** | 직접 채팅, 태스크 채팅, 첨부파일, 인채팅 질문 카드, 재개 가능한 스트림, 언급된 구성원이 직접 응답하고 주변 구성원도 제한 내 참여할 수 있는 실험적 채팅 룸을 지원합니다. ([채팅 문서](./docs/dashboard-guide.md#chat-view)) | | 🗺️ **미션** | 계층적 계획(미션 → 마일스톤 → 슬라이스 → 기능 → 태스크), 자동 조종, 검증 계약 포함. | | 🔬 **리서치** | 웹 검색, GitHub, 로컬 문서, LLM 합성을 활용한 경계 있는 리서치 실행(계획 및 합성 흐름에서 런타임 내장 WebSearch/WebFetch 지원 포함). 결과를 태스크로 전환합니다. ([문서](./docs/research.md)) | | 🧪 **자기 개선** | 에이전트가 자신의 출력물을 돌아보고 코드베이스를 학습하면서 프롬프트를 업데이트합니다. | @@ -126,6 +126,18 @@ graph TD --- +## 워크플로 개요 + +Fusion 워크플로는 태스크가 아이디어에서 전달까지 이동하는 방식을 정의합니다. 기본 코딩 경로는 여전히 **Plan/Triage → Execute → Workflow steps → Review → Merge** 루프이지만, 이제 정책은 엔진에만 고정되지 않고 선택 가능한 워크플로에 있습니다. + +- **태스크별 선택:** 대시보드의 태스크/보드 워크플로 컨트롤에서 선택하거나, 태스크 생성 시 `fn_workflow_select` / `workflow_id`로 지정합니다. +- **내장 카탈로그:** Coding(`builtin:coding`), Quick fix(`builtin:quick-fix`), Review-heavy(`builtin:review-heavy`), Compound engineering(`builtin:compound-engineering`, 플러그인 필요), Stepwise coding(`builtin:stepwise-coding`), PR lifecycle(`builtin:pr-workflow`, 재사용 가능한 PR 그래프 조각). +- **안전한 커스터마이징:** 내장 워크플로를 살펴보고 복제하거나 [워크플로 편집기](./docs/workflow-editor.md)에서 커스텀 워크플로를 작성합니다. 워크플로별 설정은 모델 레인, 검토/승인, 단계 실행, 태스크 필드, 컬럼을 다룹니다. + +실행 의미는 [Workflow Steps](./docs/workflow-steps.md), 대시보드 작성 방법은 [Workflow Editor](./docs/workflow-editor.md)를 참조하세요. + +--- + ## 멀티 노드. 하나의 보드. 모든 플랫폼.
@@ -282,32 +294,41 @@ Capacitor + PWA 워크플로우는 [MOBILE.md](./MOBILE.md)를 참조하세요. | 가이드 | 내용 | |---|---| -| [시작하기](./docs/getting-started.md) | 설치 및 온보딩 | -| [대시보드 가이드](./docs/dashboard-guide.md) | 보드/목록 뷰, 터미널, git 관리자 | -| [태스크 관리](./docs/task-management.md) | 태스크 수명 주기 및 CLI 명령 | -| [CLI 참조](./docs/cli-reference.md) | 전체 명령 및 데몬 참조 | -| [설정 참조](./docs/settings-reference.md) | 구성 옵션 | -| [아키텍처](./docs/architecture.md) | 시스템 내부 구조 | -| [에이전트](./docs/agents.md) | 에이전트 관리, 스폰, 하트비트 | -| [워크플로우 단계](./docs/workflow-steps.md) | 품질 게이트, 템플릿, 단계 | -| [미션](./docs/missions.md) | 미션 계층 구조, 계획, 자동 조종 | -| [멀티 프로젝트](./docs/multi-project.md) | 중앙 레지스트리, 격리 모드 | +| [시작하기](./docs/getting-started.md) | 설치, 온보딩, 첫 태스크, 워크플로 선택 기본 | +| [대시보드 가이드](./docs/dashboard-guide.md) | 보드/목록 보기, 채팅, 워크플로 편집기, git 관리자, 설정, UI 도구 | +| [태스크 관리](./docs/task-management.md) | 수명주기, 프롬프트 사양, 댓글, 보관, GitHub 통합 | +| [CLI 참조](./docs/cli-reference.md) | 전체 `fn` 명령과 데몬 참조 | +| [설정 참조](./docs/settings-reference.md) | 전역/프로젝트 설정, 모델 계층, 워크플로 설정, 커스텀 공급자 | +| [Workflow Steps](./docs/workflow-steps.md) | 워크플로 런타임, 내장 워크플로, 게이트, 템플릿, 단계 | +| [Workflow Editor](./docs/workflow-editor.md) | 시각적 작성, 가져오기/내보내기, 필드/컬럼/설정, 모바일 편집기 | +| [리서치](./docs/research.md) | 리서치 실행, 결과, 내보내기, 태스크 통합 | +| [에이전트](./docs/agents.md) | 에이전트 관리, spawning, heartbeat, 메일박스 | +| [미션](./docs/missions.md) | 계층 구조, 계획, 자동 조종, 검증 계약 | +| [플러그인 관리](./docs/plugin-management.md) | 플러그인 검색, 설치, 활성화, 구성, 문제 해결 | +| [플러그인 작성](./docs/PLUGIN_AUTHORING.md) | hooks, routes, tools, runtimes, 대시보드 surface가 있는 플러그인 구축 | +| [원격 액세스](./docs/remote-access.md) | 토큰 기반 원격 대시보드, Tailscale/Cloudflare, 문제 해결 | +| [멀티 프로젝트](./docs/multi-project.md) | 중앙 레지스트리, 격리 모드, 마이그레이션 | | [Docker](./docs/docker.md) | 컨테이너 배포 | --- ## 핵심 기능 -- **AI 계획** — 계획 에이전트가 단계, 파일 범위, 완료 기준이 담긴 상세한 `PROMPT.md`를 생성합니다 -- **단계별 실행** — 각 태스크 단계마다 계획 → 검토 → 실행 → 검토 주기를 진행합니다 -- **Git 워크트리 격리** — 각 태스크는 자체 워크트리(`fusion/{task-id}` 브랜치)에서 실행됩니다 -- **워크플로우 단계** — 구성 가능한 품질 게이트(사전 머지: 머지 차단; 사후 머지: 정보 제공) -- **GitHub 연동** — 이슈 임포트, PR 생성, 실시간 PR/이슈 배지 -- **대시보드** — 실시간 칸반 보드, 에이전트 관리, 터미널, git 관리자, 미션 플래너 -- **미션** — 계층적 계획(미션 → 마일스톤 → 슬라이스 → 기능 → 태스크), 자동 조종, 검증 계약, 수정-기능 재시도, 차단 핸드오프 시맨틱 포함 -- **멀티 프로젝트** — 단일 설치에서 여러 프로젝트를 프로젝트 격리로 관리 -- **에이전트 간 메시징** — 에이전트와 사용자 간 조율을 위한 내장 메시징 -- **채팅 룸 (실험적)** — 언급된 구성원이 직접 응답자로 라우팅되고 추가 주변 구성원이 최대 한도까지 답할 수 있는 프로젝트 범위 그룹 채팅(**설정 → 실험적 기능 → 채팅 룸**에서 활성화; [대시보드 가이드 → 채팅 룸](./docs/dashboard-guide.md#chat-rooms)에서 자세히 확인) +- **AI Planning** — Planning agent generates detailed `PROMPT.md` with steps, file scope, and acceptance criteria +- **Step-by-step Execution** — Plan → Review → Execute → Review cycle for each task step, with graph-mode workflows able to model per-step parse/execute/review/rework explicitly +- **Git Worktree Isolation** — Each task runs in its own worktree (`fusion/{task-id}` branch) +- **Selectable workflows** — Pick Coding, Quick fix, Review-heavy, Stepwise coding, plugin-gated Compound Engineering, custom workflows, or PR lifecycle fragments where appropriate ([overview](#워크플로-개요); [Workflow Steps](./docs/workflow-steps.md#워크플로-개요)) +- **Visual Workflow Editor** — Inspect read-only built-ins, duplicate/customize workflows, and edit graph nodes, columns, task fields, typed settings, and per-project values ([Workflow Editor](./docs/workflow-editor.md)) +- **Workflow Steps** — Configurable quality gates (pre-merge blocks merge; post-merge informational), plus opt-in [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) +- **Workflow-native policy** — Fast-mode planning, typed triage thresholds, review/approval, step execution, and model/fallback lanes are workflow settings ([Settings Reference](./docs/settings-reference.md#workflow-settings)) +- **GitHub + PR lifecycle** — Import issues, create PRs, display live PR/issue badges, and use workflow-mode PR lifecycle graph fragments where enabled +- **Dashboard** — Real-time kanban/list/graph views, agent management, terminal, git manager, missions, chat, workflow editor, custom providers, and one-click updates +- **Missions** — Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot, validation contracts, fix-feature retries, mission-goal linking, and blocked handoffs +- **Multi-Project** — Manage multiple projects from one installation with project isolation +- **Custom Providers** — Add OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI providers; saved models appear in project and workflow model dropdowns ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers)) +- **Smart merge controls** — Global auto-merge stays live for default tasks, while explicit per-task overrides can force auto/manual behavior +- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users; engineer-role agents can opt into backlog auto-claim +- **Agent Chat + Chat Rooms** — Direct/task chat supports attachments, resumable streams, question response cards, and renameable conversations; experimental rooms route mentioned members as direct responders ([Dashboard Guide → Chat View](./docs/dashboard-guide.md#chat-view)) ### 공급자 인증 @@ -331,6 +352,8 @@ Fusion은 다섯 개의 독립적인 레인을 가진 이중 범위 모델 계 | Title Summarization | 자동 제목 생성 | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` | | Workflow Step Refinement | AI 프롬프트 개선 | (`defaultProvider`/`defaultModelId` 사용) | (WorkflowStep의 `modelProvider`/`modelId` 사용) | +**워크플로 레인:** 기본 워크플로는 **설정 → 프로젝트 모델**에서 Plan/Triage, Executor, Reviewer, fallback 모델 레인을 노출하며, 고급 워크플로 설정은 추가 타입 값을 선언할 수 있습니다([설정 참조](./docs/settings-reference.md#workflow-settings)). + **태스크별 재정의:** 태스크는 태스크별 모델 필드(`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`)로 executor, validator, planning 레인을 재정의할 수 있습니다. **우선순위:** 태스크별 → 프로젝트 재정의 → 전역 레인 → `defaultProvider`/`defaultModelId` → 자동 해결. diff --git a/README.md b/README.md index fd66b7f911..aee08bbc91 100644 --- a/README.md +++ b/README.md @@ -69,14 +69,14 @@ Every task shows its plan, its reviews, its diffs, and its file changes in real | | | |---|---| | 🧠 **AI planning** | Describe a task in plain language. Planning agents turn it into a `PROMPT.md` plan with steps, file scope, and acceptance criteria. | -| 🔁 **Workflow gates** | Plan → Review → Execute → Review on every step. Pre-merge gates block bad code; post-merge gates run informational checks; workflow-declared optional steps such as [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) can be enabled per task. | +| 🔁 **Selectable workflows** | Built-ins cover coding, quick fixes, review-heavy work, stepwise execution, plugin-gated Compound Engineering, and PR lifecycle fragments. Pick a workflow per task or author custom ones in the [Workflow Editor](./docs/workflow-editor.md). | | 🌳 **Worktree isolation** | Each task runs in its own branch and worktree (`fusion/{task-id}`). Parallel tasks. Zero conflicts. Optional [worktrunk](https://github.com/max-sixty/worktrunk) delegation via [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) (see [WorktreeBackend abstraction](./docs/architecture.md#worktreebackend-abstraction)). | -| ⚡ **Smart merge** | Passing every gate? Fusion squash-merges and moves on. Opt into manual approval anywhere, or let tasks follow the live global auto-merge default unless they have an explicit per-task override. | +| ⚡ **Smart merge controls** | Passing every gate? Fusion squash-merges and moves on. Opt into manual approval anywhere, inherit the live global auto-merge default, or set explicit per-task auto/manual overrides. | | 🛰️ **Multi-node mesh** | Laptop, Mac mini, Linux server, cloud VM, phone — all synced. Desktop, mobile, web. | -| 🧩 **Any model** | Anthropic, OpenAI, Ollama, Google Generative AI, and user-defined [custom providers](./docs/dashboard-guide.md#custom-providers). Local and cloud coexist, with workflow model lanes configurable per project. | +| 🧩 **Any model** | Anthropic, OpenAI, Ollama, Google Generative AI, Z.ai, local runtimes, and user-defined [custom providers](./docs/dashboard-guide.md#custom-providers). Local and cloud coexist, with workflow model/fallback lanes configurable per project. | | 🏢 **Agent companies** | Import pre-built teams — 440+ agents across 16 companies — and run them autonomously for weeks. | | 📬 **Inter-agent messaging** | Built-in mailbox between agents. Delegate, clarify, coordinate; engineer-role agents can opt into backlog auto-claim when you want implementation help beyond executor-only pickup. | -| 🗨️ **Multi-agent Chat Rooms** | Project-scoped group conversations where multiple room members can reply: mentioned members are direct responders, and additional ambient members may respond up to a cap. Currently **experimental** — enable `chatRooms` in **Settings → Experimental Features → Chat Rooms**. ([Chat Rooms docs](./docs/dashboard-guide.md#chat-rooms)) | +| 🗨️ **Agent chat** | Direct chat, task chat, attachments, in-chat question cards, resumable streams, and experimental multi-agent Chat Rooms where mentioned members respond directly and ambient members can join up to a cap. ([Chat docs](./docs/dashboard-guide.md#chat-view)) | | 🗺️ **Missions** | Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot and validation contracts. | | 🔬 **Research** | Bounded research runs with web search, GitHub, local docs, and LLM synthesis (plus runtime builtin WebSearch/WebFetch support in planning + synthesis flows when available). Turn findings into tasks. ([Docs](./docs/research.md)) | | 🧪 **Self-improvement** | Agents reflect on their own output and update their prompts as they learn your codebase. | @@ -124,6 +124,23 @@ Tasks with dependencies are processed sequentially. Independent tasks run in par --- +## Workflow overview + + + +Fusion workflows define how a task moves from idea to delivery. The default coding path is still the familiar **Plan/Triage → Execute → Workflow steps → Review → Merge** loop, but the policy now lives in a selectable workflow rather than being only hard-coded engine behavior. + +- **Select per task:** choose a workflow from the dashboard task/board workflow controls, or assign one through `fn_workflow_select` / `workflow_id` when creating tasks. +- **Built-in catalog:** Coding (`builtin:coding`), Quick fix (`builtin:quick-fix`), Review-heavy (`builtin:review-heavy`), Compound engineering (`builtin:compound-engineering`, plugin-gated), Stepwise coding (`builtin:stepwise-coding`), and the PR lifecycle (`builtin:pr-workflow`, a reusable PR graph fragment). +- **Customize safely:** inspect built-ins, duplicate them, or author custom workflows in the visual [Workflow Editor](./docs/workflow-editor.md). Workflow-specific settings cover model lanes, review/approval policy, step execution knobs, task fields, and columns. + +Read [Workflow Steps](./docs/workflow-steps.md) for runtime semantics, built-in workflow behavior, and workflow-step templates; read [Workflow Editor](./docs/workflow-editor.md) for the dashboard authoring guide. + +--- + ## Multi-node. One board. Every platform.
@@ -280,16 +297,20 @@ For Capacitor + PWA workflow, see [MOBILE.md](./MOBILE.md). | Guide | What it covers | |---|---| -| [Getting Started](./docs/getting-started.md) | Installation and onboarding | -| [Dashboard Guide](./docs/dashboard-guide.md) | Board/list views, terminal, git manager | -| [Task Management](./docs/task-management.md) | Task lifecycle and CLI commands | -| [CLI Reference](./docs/cli-reference.md) | Full command and daemon reference | -| [Settings Reference](./docs/settings-reference.md) | Configuration options | -| [Architecture](./docs/architecture.md) | System internals | -| [Agents](./docs/agents.md) | Agent management, spawning, heartbeat | -| [Workflow Steps](./docs/workflow-steps.md) | Quality gates, templates, phases | -| [Missions](./docs/missions.md) | Mission hierarchy, planning, autopilot | -| [Multi-Project](./docs/multi-project.md) | Central registry, isolation modes | +| [Getting Started](./docs/getting-started.md) | Installation, onboarding, first task, and workflow-selection basics | +| [Dashboard Guide](./docs/dashboard-guide.md) | Board/list views, chat, workflow editor, git manager, settings, and UI tools | +| [Task Management](./docs/task-management.md) | Task lifecycle, prompt specs, comments, archiving, and GitHub integration | +| [CLI Reference](./docs/cli-reference.md) | Full `fn` command and daemon reference | +| [Settings Reference](./docs/settings-reference.md) | Global/project settings, model hierarchy, workflow settings, and custom providers | +| [Workflow Steps](./docs/workflow-steps.md) | Workflow runtime, built-in workflows, gates, templates, and phases | +| [Workflow Editor](./docs/workflow-editor.md) | Visual authoring, importing/exporting, custom fields/columns/settings, and mobile editor | +| [Research](./docs/research.md) | Bounded research runs, findings, exports, and task integration | +| [Agents](./docs/agents.md) | Agent management, spawning, heartbeat, and mailbox workflows | +| [Missions](./docs/missions.md) | Mission hierarchy, planning, autopilot, and validation contracts | +| [Plugin Management](./docs/plugin-management.md) | Discovering, installing, enabling, configuring, and troubleshooting plugins | +| [Plugin Authoring](./docs/PLUGIN_AUTHORING.md) | Building plugins with lifecycle hooks, routes, tools, runtimes, and dashboard surfaces | +| [Remote Access](./docs/remote-access.md) | Tokenized remote dashboard access, Tailscale/Cloudflare setup, and troubleshooting | +| [Multi-Project](./docs/multi-project.md) | Central registry, isolation modes, and migration paths | | [Docker](./docs/docker.md) | Container deployment | --- @@ -297,18 +318,20 @@ For Capacitor + PWA workflow, see [MOBILE.md](./MOBILE.md). ## Core features - **AI Planning** — Planning agent generates detailed `PROMPT.md` with steps, file scope, and acceptance criteria -- **Step-by-step Execution** — Plan → Review → Execute → Review cycle for each task step +- **Step-by-step Execution** — Plan → Review → Execute → Review cycle for each task step, with graph-mode workflows able to model per-step parse/execute/review/rework explicitly - **Git Worktree Isolation** — Each task runs in its own worktree (`fusion/{task-id}` branch) +- **Selectable workflows** — Pick Coding, Quick fix, Review-heavy, Stepwise coding, plugin-gated Compound Engineering, custom workflows, or PR lifecycle fragments where appropriate ([overview](#workflow-overview); [Workflow Steps](./docs/workflow-steps.md#workflow-overview)) +- **Visual Workflow Editor** — Inspect read-only built-ins, duplicate/customize workflows, and edit graph nodes, columns, task fields, typed settings, and per-project values ([Workflow Editor](./docs/workflow-editor.md)) - **Workflow Steps** — Configurable quality gates (pre-merge: blocks merge; post-merge: informational), plus workflow-declared optional steps such as opt-in [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) -- **Workflow-native policy** — Fast-mode planning (`leanPlanning` / `autoApproveSpec`) and typed triage thresholds are workflow settings, not hard-coded engine constants ([Settings Reference](./docs/settings-reference.md#workflow-native-triage-policy-settings); [fast-mode step behavior](./docs/workflow-steps.md#execution-modes)) -- **GitHub Integration** — Import issues, create PRs, real-time PR/issue badges -- **Dashboard** — Real-time kanban board, agent management, terminal, git manager, mission planner, custom provider setup, and workflow model lanes -- **Missions** — Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot, validation contracts, fix-feature retries, and blocked-handoff semantics +- **Workflow-native policy** — Fast-mode planning (`leanPlanning` / `autoApproveSpec`), typed triage thresholds, review/approval, step execution, and model/fallback lanes are workflow settings, not hard-coded engine constants ([Settings Reference](./docs/settings-reference.md#workflow-native-triage-policy-settings); [workflow settings](./docs/settings-reference.md#workflow-settings)) +- **GitHub + PR lifecycle** — Import issues, create PRs, display real-time PR/issue badges, and use workflow-mode PR lifecycle graph fragments where enabled +- **Dashboard** — Real-time kanban/list/graph views, agent management, terminal, git manager, mission planner, chat, workflow editor, custom provider setup, and one-click update action +- **Missions** — Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot, validation contracts, fix-feature retries, mission-goal linking, and blocked-handoff semantics - **Multi-Project** — Manage multiple projects from a single installation with project isolation - **Custom Providers** — Add OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI providers; saved models appear in Project Models and workflow model dropdowns ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers); [settings shape](./docs/settings-reference.md#customproviders)) - **Smart merge controls** — Global auto-merge stays live for default tasks, while explicit per-task overrides can force auto/manual behavior ([Settings Reference](./docs/settings-reference.md#project-settings)) - **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users; engineer-role agents can opt into backlog auto-claim for implementation tasks ([Settings Reference](./docs/settings-reference.md#project-settings)) -- **Chat Rooms (Experimental)** — Project-scoped group chat where mentioned members are routed as direct responders and additional ambient members may reply up to a cap (enable via **Settings → Experimental Features → Chat Rooms**; details in [Dashboard Guide → Chat Rooms](./docs/dashboard-guide.md#chat-rooms)) +- **Agent Chat + Chat Rooms** — Direct/task chat supports attachments, resumable streams, question response cards, and renameable conversations; experimental rooms route mentioned members as direct responders with optional ambient replies ([Dashboard Guide → Chat View](./docs/dashboard-guide.md#chat-view)) ### Provider authentication @@ -333,7 +356,7 @@ Fusion uses a dual-scope model hierarchy with five independent lanes. Global set | Title Summarization | Auto-title generation | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` | | Workflow Step Refinement | AI prompt refinement | (uses `defaultProvider`/`defaultModelId`) | (uses `modelProvider`/`modelId` on WorkflowStep) | -**Workflow lanes:** The default workflow exposes Plan/Triage, Executor, and Reviewer model lanes in **Settings → Project Models**, and advanced workflow settings can declare additional typed model/policy values ([Settings Reference](./docs/settings-reference.md#workflow-settings)). +**Workflow lanes:** The default workflow exposes Plan/Triage, Executor, Reviewer, and fallback model lanes in **Settings → Project Models**, and advanced workflow settings can declare additional typed model/policy values ([Settings Reference](./docs/settings-reference.md#workflow-settings)). **Per-Task Overrides:** Tasks can override the executor, validator, and planning lanes with per-task model fields (`modelProvider`/`modelId`, `validatorModelProvider`/`validatorModelId`, `planningModelProvider`/`planningModelId`). diff --git a/README.zh-CN.md b/README.zh-CN.md index 0f95341ee1..54ffc03ec5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -71,14 +71,14 @@ | | | |---|---| | 🧠 **AI 规划** | 用自然语言描述任务。规划智能体将其转化为包含步骤、文件范围和验收标准的 `PROMPT.md` 计划。 | -| 🔁 **工作流门控** | 每个步骤均经历:规划 → 审核 → 执行 → 审核。合并前门控阻止劣质代码,合并后门控执行信息性检查。 | +| 🔁 **可选工作流** | 内置工作流覆盖编码、快速修复、强化审核、逐步执行、插件化 Compound Engineering 与 PR lifecycle 片段。可按任务选择,或在[工作流编辑器](./docs/workflow-editor.md)中编写自定义工作流。 | | 🌳 **工作树隔离** | 每个任务在独立分支和工作树(`fusion/{task-id}`)中运行,支持并行任务,零冲突。可通过 [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) 选择性启用 [worktrunk](https://github.com/max-sixty/worktrunk) 委托(参见 [WorktreeBackend 抽象](./docs/architecture.md#worktreebackend-abstraction))。 | -| ⚡ **智能合并** | 通过所有门控后,Fusion 自动压缩合并并继续推进。你也可以在任意环节开启手动审批。 | +| ⚡ **智能合并控制** | 通过所有门控后,Fusion 自动压缩合并并继续推进。你可以要求人工审批、继承全局 auto-merge 默认值,或设置任务级自动/手动覆盖。 | | 🛰️ **多节点网格** | 笔记本、Mac mini、Linux 服务器、云虚拟机、手机——全部同步。桌面端、移动端、Web 端均支持。 | -| 🧩 **任意模型** | 支持 Anthropic、OpenAI、Ollama 等,本地与云端并存。 | +| 🧩 **任意模型** | 支持 Anthropic、OpenAI、Ollama、Google Generative AI、Z.ai、本地运行时与[自定义提供方](./docs/dashboard-guide.md#custom-providers)。本地与云端并存,并可按项目配置工作流模型/回退通道。 | | 🏢 **智能体公司** | 导入预构建团队——16 家公司共 440+ 个智能体——自主运行数周。 | | 📬 **智能体间消息** | 内置智能体间邮箱,支持委派、澄清与协调。 | -| 🗨️ **多智能体聊天室** | 项目范围内的群组会话,多位成员可以回复:被提及成员为直接响应者,其他旁听成员在上限内也可参与回复。当前为**实验性**功能——在**设置 → 实验性功能 → 聊天室**中启用 `chatRooms`。([聊天室文档](./docs/dashboard-guide.md#chat-rooms)) | +| 🗨️ **智能体聊天** | 支持直接聊天、任务聊天、附件、聊天内问题卡、可恢复流,以及实验性的多智能体聊天室;被提及成员直接回复,旁听成员可在上限内参与。([聊天文档](./docs/dashboard-guide.md#chat-view)) | | 🗺️ **任务群** | 层级式规划(任务群 → 里程碑 → 切片 → 功能 → 任务),支持自动驾驶和验证契约。 | | 🔬 **调研** | 有边界的调研运行,集成网络搜索、GitHub、本地文档和 LLM 综合分析(规划与综合流程中还支持运行时内置 WebSearch/WebFetch)。将调研发现直接转化为任务。([文档](./docs/research.md)) | | 🧪 **自我改进** | 智能体反思自身输出,并在熟悉你的代码库后持续更新其提示词。 | @@ -126,6 +126,18 @@ graph TD --- +## 工作流概览 + +Fusion 工作流定义任务如何从想法走向交付。默认编码路径仍是 **Plan/Triage → Execute → Workflow steps → Review → Merge** 循环,但策略现在属于可选择的工作流,而不只是写死在引擎里。 + +- **按任务选择:** 在仪表板的任务/看板工作流控件中选择,或创建任务时通过 `fn_workflow_select` / `workflow_id` 指定。 +- **内置目录:** Coding(`builtin:coding`)、Quick fix(`builtin:quick-fix`)、Review-heavy(`builtin:review-heavy`)、Compound engineering(`builtin:compound-engineering`,需插件)、Stepwise coding(`builtin:stepwise-coding`)以及 PR lifecycle(`builtin:pr-workflow`,可复用的 PR 图形片段)。 +- **安全定制:** 在可视化[工作流编辑器](./docs/workflow-editor.md)中查看内置工作流、复制它们或编写自定义工作流。工作流专属设置涵盖模型通道、审核/审批策略、步骤执行、任务字段与列。 + +阅读 [Workflow Steps](./docs/workflow-steps.md) 了解运行语义;阅读 [Workflow Editor](./docs/workflow-editor.md) 了解仪表板编辑指南。 + +--- + ## 多节点。一块看板。全平台覆盖。
@@ -280,32 +292,41 @@ Capacitor + PWA 工作流,请参见 [MOBILE.md](./MOBILE.md)。 | 指南 | 内容 | |---|---| -| [入门指南](./docs/getting-started.md) | 安装与引导 | -| [仪表板指南](./docs/dashboard-guide.md) | 看板/列表视图、终端、Git 管理器 | -| [任务管理](./docs/task-management.md) | 任务生命周期与 CLI 命令 | -| [CLI 参考](./docs/cli-reference.md) | 完整命令与守护进程参考 | -| [设置参考](./docs/settings-reference.md) | 配置选项 | -| [架构](./docs/architecture.md) | 系统内部机制 | -| [智能体](./docs/agents.md) | 智能体管理、生成与心跳 | -| [工作流步骤](./docs/workflow-steps.md) | 质量门控、模板与阶段 | -| [任务群](./docs/missions.md) | 任务群层级、规划与自动驾驶 | -| [多项目](./docs/multi-project.md) | 中央注册表与隔离模式 | +| [入门指南](./docs/getting-started.md) | 安装、引导、首个任务与工作流选择基础 | +| [仪表板指南](./docs/dashboard-guide.md) | 看板/列表视图、聊天、工作流编辑器、Git 管理器、设置与 UI 工具 | +| [任务管理](./docs/task-management.md) | 生命周期、提示规范、评论、归档与 GitHub 集成 | +| [CLI 参考](./docs/cli-reference.md) | 完整 `fn` 命令与守护进程参考 | +| [设置参考](./docs/settings-reference.md) | 全局/项目设置、模型层级、工作流设置与自定义提供方 | +| [Workflow Steps](./docs/workflow-steps.md) | 工作流运行时、内置工作流、门控、模板与阶段 | +| [Workflow Editor](./docs/workflow-editor.md) | 可视化编排、导入/导出、字段/列/设置与移动端编辑器 | +| [调研](./docs/research.md) | 调研运行、发现、导出与任务集成 | +| [智能体](./docs/agents.md) | 智能体管理、派生、心跳与邮箱流程 | +| [任务群](./docs/missions.md) | 层级、规划、自动驾驶与验证契约 | +| [插件管理](./docs/plugin-management.md) | 发现、安装、启用、配置与排查插件 | +| [插件开发](./docs/PLUGIN_AUTHORING.md) | 使用 hooks、routes、tools、runtimes 与仪表板表面构建插件 | +| [远程访问](./docs/remote-access.md) | 带令牌的远程仪表板、Tailscale/Cloudflare 与故障排查 | +| [多项目](./docs/multi-project.md) | 中央注册表、隔离模式与迁移 | | [Docker](./docs/docker.md) | 容器部署 | --- ## 核心功能 -- **AI 规划** — 规划智能体生成包含步骤、文件范围和验收标准的详细 `PROMPT.md` -- **逐步执行** — 每个任务步骤均经历规划 → 审核 → 执行 → 审核循环 -- **Git 工作树隔离** — 每个任务在独立工作树(`fusion/{task-id}` 分支)中运行 -- **工作流步骤** — 可配置的质量门控(合并前:阻止合并;合并后:信息性检查) -- **GitHub 集成** — 导入 Issue、创建 PR、实时 PR/Issue 徽章 -- **仪表板** — 实时看板、智能体管理、终端、Git 管理器、任务群规划器 -- **任务群** — 层级式规划(任务群 → 里程碑 → 切片 → 功能 → 任务),支持自动驾驶、验证契约、修复功能重试和阻塞移交语义 -- **多项目** — 从单一安装管理多个项目,项目间相互隔离 -- **智能体间消息** — 内置消息机制,用于智能体与用户之间的协调 -- **聊天室(实验性)** — 项目范围内的群组聊天,被提及成员作为直接响应者路由,其他旁听成员在上限内可回复(通过**设置 → 实验性功能 → 聊天室**启用;详情见[仪表板指南 → 聊天室](./docs/dashboard-guide.md#chat-rooms)) +- **AI Planning** — Planning agent generates detailed `PROMPT.md` with steps, file scope, and acceptance criteria +- **Step-by-step Execution** — Plan → Review → Execute → Review cycle for each task step, with graph-mode workflows able to model per-step parse/execute/review/rework explicitly +- **Git Worktree Isolation** — Each task runs in its own worktree (`fusion/{task-id}` branch) +- **Selectable workflows** — Pick Coding, Quick fix, Review-heavy, Stepwise coding, plugin-gated Compound Engineering, custom workflows, or PR lifecycle fragments where appropriate ([overview](#工作流概览); [Workflow Steps](./docs/workflow-steps.md#工作流概览)) +- **Visual Workflow Editor** — Inspect read-only built-ins, duplicate/customize workflows, and edit graph nodes, columns, task fields, typed settings, and per-project values ([Workflow Editor](./docs/workflow-editor.md)) +- **Workflow Steps** — Configurable quality gates (pre-merge blocks merge; post-merge informational), plus opt-in [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) +- **Workflow-native policy** — Fast-mode planning, typed triage thresholds, review/approval, step execution, and model/fallback lanes are workflow settings ([Settings Reference](./docs/settings-reference.md#workflow-settings)) +- **GitHub + PR lifecycle** — Import issues, create PRs, display live PR/issue badges, and use workflow-mode PR lifecycle graph fragments where enabled +- **Dashboard** — Real-time kanban/list/graph views, agent management, terminal, git manager, missions, chat, workflow editor, custom providers, and one-click updates +- **Missions** — Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot, validation contracts, fix-feature retries, mission-goal linking, and blocked handoffs +- **Multi-Project** — Manage multiple projects from one installation with project isolation +- **Custom Providers** — Add OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI providers; saved models appear in project and workflow model dropdowns ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers)) +- **Smart merge controls** — Global auto-merge stays live for default tasks, while explicit per-task overrides can force auto/manual behavior +- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users; engineer-role agents can opt into backlog auto-claim +- **Agent Chat + Chat Rooms** — Direct/task chat supports attachments, resumable streams, question response cards, and renameable conversations; experimental rooms route mentioned members as direct responders ([Dashboard Guide → Chat View](./docs/dashboard-guide.md#chat-view)) ### 提供商身份验证 @@ -329,6 +350,8 @@ Fusion 使用双作用域模型层级,包含五条独立通道。全局设置 | 标题摘要 | 自动标题生成 | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` | | 工作流步骤优化 | AI 提示词优化 | (使用 `defaultProvider`/`defaultModelId`) | (使用 WorkflowStep 上的 `modelProvider`/`modelId`) | +**工作流通道:** 默认工作流在**设置 → 项目模型**中暴露 Plan/Triage、Executor、Reviewer 与 fallback 模型通道,高级工作流设置还可声明额外类型化值([设置参考](./docs/settings-reference.md#workflow-settings))。 + **任务级覆盖:** 任务可通过任务级模型字段(`modelProvider`/`modelId`、`validatorModelProvider`/`validatorModelId`、`planningModelProvider`/`planningModelId`)覆盖执行器、验证器和规划器通道。 **优先级:** 任务级 → 项目覆盖 → 全局通道 → `defaultProvider`/`defaultModelId` → 自动解析。 diff --git a/README.zh-TW.md b/README.zh-TW.md index d4d15d287c..d8173ebb66 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -71,14 +71,14 @@ | | | |---|---| | 🧠 **AI 規劃** | 用白話文描述任務。規劃代理人將其轉換為含步驟、檔案範圍與驗收條件的 `PROMPT.md` 計畫。 | -| 🔁 **工作流程關卡** | 每個步驟皆執行:規劃 → 審閱 → 執行 → 審閱。合併前關卡阻擋劣質程式碼;合併後關卡執行資訊性檢查。 | +| 🔁 **可選工作流程** | 內建工作流程涵蓋編碼、快速修復、強化審閱、逐步執行、外掛化 Compound Engineering 與 PR lifecycle 片段。可依任務選取,或在[工作流程編輯器](./docs/workflow-editor.md)中撰寫自訂工作流程。 | | 🌳 **工作樹隔離** | 每個任務在各自的分支與工作樹(`fusion/{task-id}`)中執行。任務並行執行,零衝突。可選用 [worktrunk](https://github.com/max-sixty/worktrunk) 委派,透過 [`worktrunk.enabled`](./docs/settings-reference.md#worktree-backend-settings) 設定(詳見 [WorktreeBackend 抽象層](./docs/architecture.md#worktreebackend-abstraction))。 | -| ⚡ **智慧合併** | 通過所有關卡後,Fusion 自動壓縮合併並繼續執行。可在任何環節選擇手動核准。 | +| ⚡ **智慧合併控制** | 通過所有關卡後,Fusion 自動壓縮合併並繼續執行。你可以要求人工核准、繼承全域 auto-merge 預設值,或設定任務層級自動/手動覆蓋。 | | 🛰️ **多節點網狀架構** | 筆電、Mac mini、Linux 伺服器、雲端虛擬機、手機——全部同步。桌面、行動裝置、網頁皆支援。 | -| 🧩 **任意模型** | 支援 Anthropic、OpenAI、Ollama 等。本地與雲端模型共存。 | +| 🧩 **任意模型** | 支援 Anthropic、OpenAI、Ollama、Google Generative AI、Z.ai、本地執行環境與[自訂提供者](./docs/dashboard-guide.md#custom-providers)。本地與雲端共存,並可依專案設定工作流程模型/備援通道。 | | 🏢 **代理人公司** | 匯入預建團隊——橫跨 16 家公司的 440+ 個代理人——自主運行數週。 | | 📬 **代理人間訊息傳遞** | 代理人之間內建郵件信箱。委派、釐清、協調。 | -| 🗨️ **多代理人聊天室** | 專案範圍的群組對話,多位成員可回覆:被提及的成員為直接回應者,其餘環境成員最多可回應至上限。目前為**實驗性功能**——在**設定 → 實驗性功能 → 聊天室**中啟用 `chatRooms`。([聊天室文件](./docs/dashboard-guide.md#chat-rooms)) | +| 🗨️ **代理人聊天** | 支援直接聊天、任務聊天、附件、聊天內問題卡、可恢復串流,以及實驗性多代理人聊天室;被提及成員直接回覆,環境成員可在上限內參與。([聊天文件](./docs/dashboard-guide.md#chat-view)) | | 🗺️ **任務群組** | 層級式規劃(任務群組 → 里程碑 → 切片 → 功能 → 任務),具備自動駕駛模式與驗證合約。 | | 🔬 **研究** | 有界研究執行,整合網頁搜尋、GitHub、本地文件與 LLM 合成(規劃與合成流程中亦支援執行時內建的 WebSearch/WebFetch)。將研究結果轉換為任務。([文件](./docs/research.md)) | | 🧪 **自我改善** | 代理人反思自身輸出,並隨著對你的程式碼庫的了解更新自身提示詞。 | @@ -126,6 +126,18 @@ graph TD --- +## 工作流程概覽 + +Fusion 工作流程定義任務如何從想法走到交付。預設編碼路徑仍是 **Plan/Triage → Execute → Workflow steps → Review → Merge** 迴圈,但政策現在位於可選取的工作流程中,而不只是硬編碼在引擎裡。 + +- **依任務選取:** 從儀表板的任務/看板工作流程控制項選取,或建立任務時用 `fn_workflow_select` / `workflow_id` 指定。 +- **內建目錄:** Coding(`builtin:coding`)、Quick fix(`builtin:quick-fix`)、Review-heavy(`builtin:review-heavy`)、Compound engineering(`builtin:compound-engineering`,需外掛)、Stepwise coding(`builtin:stepwise-coding`)與 PR lifecycle(`builtin:pr-workflow`,可重用的 PR 圖形片段)。 +- **安全客製:** 在視覺化[工作流程編輯器](./docs/workflow-editor.md)中檢視內建工作流程、複製它們或撰寫自訂工作流程。工作流程專屬設定涵蓋模型通道、審閱/核准、步驟執行、任務欄位與欄。 + +閱讀 [Workflow Steps](./docs/workflow-steps.md) 了解執行語義;閱讀 [Workflow Editor](./docs/workflow-editor.md) 了解儀表板編輯指南。 + +--- + ## 多節點。一個看板。全平台支援。
@@ -281,32 +293,41 @@ Capacitor + PWA 工作流程,請參閱 [MOBILE.md](./MOBILE.md)。 | 指南 | 涵蓋內容 | |---|---| -| [入門指南](./docs/getting-started.md) | 安裝與引導 | -| [儀表板指南](./docs/dashboard-guide.md) | 看板/清單檢視、終端機、git 管理器 | -| [任務管理](./docs/task-management.md) | 任務生命週期與命令列指令 | -| [命令列參考](./docs/cli-reference.md) | 完整指令與背景程式參考 | -| [設定參考](./docs/settings-reference.md) | 組態選項 | -| [系統架構](./docs/architecture.md) | 系統內部運作 | -| [代理人](./docs/agents.md) | 代理人管理、生成與心跳 | -| [工作流程步驟](./docs/workflow-steps.md) | 品質關卡、範本、階段 | -| [任務群組](./docs/missions.md) | 任務群組層級、規劃、自動駕駛模式 | -| [多專案](./docs/multi-project.md) | 中央登錄表、隔離模式 | +| [入門指南](./docs/getting-started.md) | 安裝、導引、第一個任務與工作流程選取基礎 | +| [儀表板指南](./docs/dashboard-guide.md) | 看板/清單檢視、聊天、工作流程編輯器、Git 管理器、設定與 UI 工具 | +| [任務管理](./docs/task-management.md) | 生命週期、提示規格、留言、封存與 GitHub 整合 | +| [CLI 參考](./docs/cli-reference.md) | 完整 `fn` 命令與守護程式參考 | +| [設定參考](./docs/settings-reference.md) | 全域/專案設定、模型層級、工作流程設定與自訂提供者 | +| [Workflow Steps](./docs/workflow-steps.md) | 工作流程執行時、內建工作流程、門控、範本與階段 | +| [Workflow Editor](./docs/workflow-editor.md) | 視覺化編排、匯入/匯出、欄位/欄/設定與行動編輯器 | +| [研究](./docs/research.md) | 研究執行、發現、匯出與任務整合 | +| [代理人](./docs/agents.md) | 代理人管理、spawning、heartbeat 與信箱流程 | +| [任務群組](./docs/missions.md) | 階層、規劃、自動駕駛與驗證合約 | +| [外掛管理](./docs/plugin-management.md) | 探索、安裝、啟用、設定與疑難排解外掛 | +| [外掛開發](./docs/PLUGIN_AUTHORING.md) | 使用 hooks、routes、tools、runtimes 與儀表板表面建置外掛 | +| [遠端存取](./docs/remote-access.md) | 權杖化遠端儀表板、Tailscale/Cloudflare 與疑難排解 | +| [多專案](./docs/multi-project.md) | 中央登錄、隔離模式與遷移 | | [Docker](./docs/docker.md) | 容器部署 | --- ## 核心功能 -- **AI 規劃** — 規劃代理人產生詳細的 `PROMPT.md`,包含步驟、檔案範圍與驗收條件 -- **逐步執行** — 每個任務步驟執行「規劃 → 審閱 → 執行 → 審閱」循環 -- **Git 工作樹隔離** — 每個任務在各自的工作樹(`fusion/{task-id}` 分支)中執行 -- **工作流程步驟** — 可設定的品質關卡(合併前:阻擋合併;合併後:資訊性) -- **GitHub 整合** — 匯入議題、建立 PR、即時 PR/議題徽章 -- **儀表板** — 即時看板、代理人管理、終端機、git 管理器、任務群組規劃器 -- **任務群組** — 層級式規劃(任務群組 → 里程碑 → 切片 → 功能 → 任務),具備自動駕駛模式、驗證合約、修復功能重試與封鎖交接語意 -- **多專案** — 從單一安裝管理多個專案,具備專案隔離 -- **代理人間訊息傳遞** — 代理人與使用者之間協調用的內建訊息傳遞 -- **聊天室(實驗性)** — 專案範圍的群組對話,被提及的成員為直接回應者,其餘環境成員最多可回覆至上限(在**設定 → 實驗性功能 → 聊天室**中啟用;詳見[儀表板指南 → 聊天室](./docs/dashboard-guide.md#chat-rooms)) +- **AI Planning** — Planning agent generates detailed `PROMPT.md` with steps, file scope, and acceptance criteria +- **Step-by-step Execution** — Plan → Review → Execute → Review cycle for each task step, with graph-mode workflows able to model per-step parse/execute/review/rework explicitly +- **Git Worktree Isolation** — Each task runs in its own worktree (`fusion/{task-id}` branch) +- **Selectable workflows** — Pick Coding, Quick fix, Review-heavy, Stepwise coding, plugin-gated Compound Engineering, custom workflows, or PR lifecycle fragments where appropriate ([overview](#工作流程概覽); [Workflow Steps](./docs/workflow-steps.md#工作流程概覽)) +- **Visual Workflow Editor** — Inspect read-only built-ins, duplicate/customize workflows, and edit graph nodes, columns, task fields, typed settings, and per-project values ([Workflow Editor](./docs/workflow-editor.md)) +- **Workflow Steps** — Configurable quality gates (pre-merge blocks merge; post-merge informational), plus opt-in [Browser Verification](./docs/workflow-steps.md#workflow-declared-optional-steps) +- **Workflow-native policy** — Fast-mode planning, typed triage thresholds, review/approval, step execution, and model/fallback lanes are workflow settings ([Settings Reference](./docs/settings-reference.md#workflow-settings)) +- **GitHub + PR lifecycle** — Import issues, create PRs, display live PR/issue badges, and use workflow-mode PR lifecycle graph fragments where enabled +- **Dashboard** — Real-time kanban/list/graph views, agent management, terminal, git manager, missions, chat, workflow editor, custom providers, and one-click updates +- **Missions** — Hierarchical planning (Mission → Milestone → Slice → Feature → Task) with autopilot, validation contracts, fix-feature retries, mission-goal linking, and blocked handoffs +- **Multi-Project** — Manage multiple projects from one installation with project isolation +- **Custom Providers** — Add OpenAI-compatible, OpenAI Responses, Anthropic-compatible, or Google Generative AI providers; saved models appear in project and workflow model dropdowns ([Dashboard Guide](./docs/dashboard-guide.md#custom-providers)) +- **Smart merge controls** — Global auto-merge stays live for default tasks, while explicit per-task overrides can force auto/manual behavior +- **Inter-Agent Messaging** — Built-in messaging for coordination between agents and users; engineer-role agents can opt into backlog auto-claim +- **Agent Chat + Chat Rooms** — Direct/task chat supports attachments, resumable streams, question response cards, and renameable conversations; experimental rooms route mentioned members as direct responders ([Dashboard Guide → Chat View](./docs/dashboard-guide.md#chat-view)) ### 供應商驗證 @@ -330,6 +351,8 @@ Fusion 使用具備五條獨立通道的雙範圍模型層級。全域設定定 | Title Summarization | 自動標題產生 | `titleSummarizerGlobalProvider` + `titleSummarizerGlobalModelId` | `titleSummarizerProvider` + `titleSummarizerModelId` | | Workflow Step Refinement | AI 提示詞精煉 | (使用 `defaultProvider`/`defaultModelId`) | (使用 WorkflowStep 上的 `modelProvider`/`modelId`) | +**工作流程通道:** 預設工作流程會在**設定 → 專案模型**中顯示 Plan/Triage、Executor、Reviewer 與 fallback 模型通道,進階工作流程設定可宣告額外型別值([設定參考](./docs/settings-reference.md#workflow-settings))。 + **每任務覆蓋:** 任務可透過每任務模型欄位(`modelProvider`/`modelId`、`validatorModelProvider`/`validatorModelId`、`planningModelProvider`/`planningModelId`)覆蓋執行器、驗證器與規劃通道。 **優先順序:** 每任務 → 專案覆蓋 → 全域通道 → `defaultProvider`/`defaultModelId` → 自動解析。 diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 9d578e203d..65cb2c3370 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -767,6 +767,14 @@ Bundled workspace plugin pattern: - Register the lazy dashboard component in host code (currently `packages/dashboard/app/plugins/registerBundledPluginViews.ts`) - CLI bundling inlines backend plugin code from workspace packages; dashboard view modules are imported by the dashboard build via the host registry +### Bundled plugin build-freshness guard + + + +Bundled plugins shipped in `@runfusion/fusion` are tracked by the staged bundled-plugin set in `packages/cli/src/plugins/staged-bundled-plugin-ids.ts`; the default auto-install subset remains `BUNDLED_PLUGIN_IDS` in `packages/cli/src/plugins/bundled-plugin-install.ts`. The CLI build asserts every staged bundled plugin has a loadable entry under `packages/cli/dist/plugins//`, and the freshness test checks any per-plugin `plugins//dist/index.js` that exists against the newest `src/**` mtime. + +This catches stale `dist/` drift: `resolvePluginEntryPath` prefers `bundled.js` and compiled `dist/index.js` before falling back to `src/index.ts`, while per-plugin `dist/` is gitignored and can lag behind source edits. If `bundled-plugin-freshness` reports `dist is stale relative to src`, run `pnpm build` from the workspace root to regenerate plugin `dist/` outputs and staged CLI plugin artifacts before rerunning tests. + Runtime host context contract: - Registered views receive a `context` object from the dashboard host (`PluginDashboardViewContext`). - Context includes the active `projectId`, current visible `tasks`, optional `workflowSteps`, `openTaskDetail` for launching the native task detail flow, and `openFile(path, options?)` for opening project-relative files in the dashboard's built-in file viewer. @@ -1547,6 +1555,7 @@ const traits: PluginTraitContribution[] = [ export default definePlugin({ manifest: { id: "my-plugin", name: "My Plugin", version: "1.0.0" }, + state: "installed", hooks: {}, traits, }); @@ -1666,6 +1675,7 @@ const workflowExtensions: WorkflowExtensionContribution[] = [ export default definePlugin({ manifest: { id: "my-plugin", name: "My Plugin", version: "1.0.0" }, + state: "installed", workflowExtensions, }); ``` diff --git a/docs/README.md b/docs/README.md index 7896ab59ad..c68dc6a74b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow | Guide | Description | |---|---| | [Getting Started](./getting-started.md) | Installation, first-run, first task, and daily workflow basics | -| [Dashboard Guide](./dashboard-guide.md) | Board/list views, terminal, git manager, files, planning, and UI tools | +| [Dashboard Guide](./dashboard-guide.md) | Board/list views, chat, workflow selection/editor, terminal, git manager, files, planning, and UI tools | | [CLI Reference](./cli-reference.md) | Complete `fn` command reference with subcommands, flags, and examples | | [Remote Access](./remote-access.md) | Operator runbook for Tailscale/Cloudflare setup, tokenized login links, security caveats, and troubleshooting | | [Native Shell Connection Guide](./native-shell.md) | Canonical mobile/desktop shell onboarding, profile management, QR/manual setup, and remote handoff behavior | @@ -35,15 +35,17 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow | [Goals Refinement Evidence Pack](./goals-refinement-evidence-pack.md) | Structured observation template and two-observation threshold for conditional Slice 4 activation requests | | [Research](./research.md) | Research runs, provider setup, dashboard/CLI usage, findings, exports, and task integration | | [Research View UX Spec](./research-view-ux-spec.md) | Canonical layout and capability-state messaging spec for the Research dashboard view (FN-4138, informs FN-4134/FN-4135) | -| [Workflow Steps](./workflow-steps.md) | Reusable quality gates, templates, pre/post-merge phases, and workflow execution results | -| [Custom Non-Coding Workflows MVP Spec](./custom-workflows-mvp-spec.md) | Decision-ready MVP spec for user-authored non-coding workflow definitions, lifecycle mapping, metrics, and risk checklist | +| [Workflow Steps](./workflow-steps.md) | Workflow overview, built-in workflow catalog, per-task selection, runtime semantics, reusable quality gates, templates, phases, and execution results | +| [Workflow Editor](./workflow-editor.md) | Visual workflow editor guide for opening, viewing, authoring, validating, importing/exporting, custom fields/columns/settings, and tuning workflows | +| [Custom Workflow Reliability Acceptance Map](./custom-workflow-reliability-acceptance-map.md) | End-to-end reliability acceptance criteria for custom workflow authoring, selection, execution, recovery, restart durability, and deferred journeys | +| [Custom Non-Coding Workflows MVP Spec](./custom-workflows-mvp-spec.md) | MVP framing for user-authored non-coding workflows, lifecycle mapping, metrics, and risk checklist | | [Task Evaluations](./evals.md) | Eval scoring contract, evidence persistence, score categories, and evaluation pipeline | | [Multi-Project](./multi-project.md) | Central registry architecture, project management, isolation modes, and migration paths | ### Configuration & Agents | Guide | Description | |---|---| -| [Settings Reference](./settings-reference.md) | Global and project settings, defaults, API endpoints, and model selection hierarchy | +| [Settings Reference](./settings-reference.md) | Global/project settings, workflow setting values, model/fallback lane hierarchy, defaults, and API endpoints | | [Agents](./agents.md) | Agent management, presets, prompts, heartbeat behavior, spawning, and mailbox workflows | ### Architecture & Development @@ -64,6 +66,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow | [Sandbox Backends](./sandbox.md) | Pluggable sandbox backends for executor command isolation (bubblewrap, spawn-based) | | [Secrets](./secrets.md) | Encrypted secrets storage, per-secret access policies, scopes, and agent tool wiring | | [Testing](./testing.md) | Full testing lanes, worker fanout guidance, test taxonomy, and file organization | +| [Real iOS Safari Acceptance Surface](./ios-acceptance.md) | Provisioning runbook and harness usage for terminal verification gates on physical or cloud real-iOS Safari | | [Solutions Catalog](./solutions/) | Documented solutions to past problems (bugs, architecture patterns, best practices) organized by category | | [Localization Contributing Guide](./i18n-contributing.md) | Conventions for contributing translations, locale file structure, and i18n tooling | | [Mobile](../MOBILE.md) | Capacitor/PWA mobile development setup and workflow | @@ -79,8 +82,13 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow | [Memory Plugin Contract](./memory-plugin-contract.md) | Pluggable memory backend architecture, interface contract, and migration strategy | | [Compound Engineering Plugin](./plugins/compound-engineering.md) | CE workflow dashboard surface: artifact hub, interactive sessions, work→board bridge, and bidirectional sync | | [External Plugin Authoring](./plugins/external-authoring.md) | Step-by-step guide for authoring plugins using an installed `fn` CLI (no monorepo access needed) | +| [External Plugin Proof-Point Runbook](./plugins/external-proof-point-runbook.md) | Repeatable release-validation runbook for proving an external plugin runs against a published Fusion CLI build | ### Audit Reports + | Report | Description | |---|---| | [UX Audit Report](./ux-audit-report.md) | Comprehensive UX audit with prioritized recommendations for dashboard improvements | @@ -118,6 +126,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow | [Workflow Policy Ownership Map](./workflow-policy-ownership-map.md) | U1 characterization map classifying production merge, retry, scheduling, and recovery policy branches before workflow-policy migration cutover | | [Test-Speed Baseline (2026-06-03)](./test-speed-baseline-2026-06-03.md) | Measured per-file test timing baseline and optimization targets (successor to FN-5048 audit) | | [ACP Runtime Contract](./acp-contract.md) | Agent Client Protocol plugin launch/readiness contract and failure taxonomy | +| [ACP MCP Passthrough & Permission Forwarding Upstream Sponsorship (FN-6475)](./upstream/claude-code-cli-acp-mcp-permission-forwarding.md) | Ready-to-file upstream sponsorship for `claude-code-cli-acp` ACP `session/new.mcpServers` passthrough and permission-gate traversal; Route A remains NOT GO until proven | | [Mission Completion Gate Contract](./missions-completion-contract.md) | Decision record for mission completion gate invariants and acceptance flow | | [Lost-Work Tasks Incident (2026-05-23)](./incidents/2026-05-23-lost-work-tasks.md) | Incident catalog of 9 lost-work tasks from no-op finalize and reuse-handoff bugs | @@ -131,5 +140,6 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow ## Suggested Reading Paths - **New user:** Getting Started → Dashboard Guide → Task Management +- **Workflow author:** Dashboard Guide → Workflow Editor → Workflow Steps → Settings Reference - **Power user / automation owner:** Settings Reference → Workflow Steps → Agents - **Maintainer / contributor:** Architecture → Multi-Project → Contributing diff --git a/docs/acp-contract.md b/docs/acp-contract.md index 2e40f2cdfa..70df18f4e9 100644 --- a/docs/acp-contract.md +++ b/docs/acp-contract.md @@ -23,6 +23,42 @@ agent over JSON-RPC/stdio. Mirrors the shape of `docs/cursor-cli-contract.md`. - The subprocess environment is built from the `acpEnvAllowList` allow-list only (inherited `process.env` is **not** forwarded — the agent is untrusted). +## Claude bridge ask profile (Route B) + +Route-B planning and validator asks use the `acp` runtime with the bundled +`claude-code-cli-acp` bridge instead of `claude -p`: + +- `claude-code-cli-acp@0.1.1` is pinned under the ACP runtime plugin and the + sentinel binary name resolves to this plugin's own `node_modules/.bin` shim, + not to a PATH-selected substitute. +- The read-only ask posture uses `tools: "readonly"`, `acpArgs: []`, and leaves + `acpFsRead` / `acpFsWrite` off. Route A's tool-bearing provider path remains + deferred and is not implied by this profile. +- The Claude bridge env allow-list is intentionally narrow: `HOME` is forwarded + so the underlying `claude` can read `~/.claude` auth/session state, and `PATH` + is forwarded for sub-executable resolution. `ANTHROPIC_API_KEY`, + `ANTHROPIC_AUTH_TOKEN`, and inherited `process.env` are not forwarded. +- `checkSetup` treats the bridge as installed only when the resolved binary is + plugin-owned, the ACP handshake succeeds, and no Claude auth hint is returned. + Auth-needed statuses tell the operator to run `claude` once to authenticate. + +## `askAcpOnce` prose → JSON recovery contract + +The engine-side `askAcpOnce` runner creates one readonly ACP session, accumulates +all `onText` deltas into `text`, runs one `promptWithFallback` turn, optionally +recovers the trailing JSON object via `extractJsonObjects`, and disposes the +session in `finally`. Its shape is deliberately close to the old one-shot result: + +- Success: `{ ok: true, text, parsed?, stopReason? }`. +- Failure: `{ ok: false, reason, message, text?, stopReason? }` for session + creation errors, turn errors, timeouts, and abnormal stops. +- `promptWithFallback` surfaces ACP `stopReason` to the runner. Planning tolerates + an absent stop reason, but validation treats abnormal/truncated stops such as + `max_tokens` and `cancelled` as `error` regardless of any recovered JSON. +- Validator prose fallback is constrained: prose can infer `fail` or `blocked`, + but never `pass`. A pass requires clean structured JSON (`verdict:"pass"` or + `passed:true`) from a clean turn. + ## Readiness = the `initialize` handshake There is no `--version` probe. Readiness is the protocol handshake itself: @@ -64,3 +100,137 @@ enabled (writes default OFF). - `@agentclientprotocol/sdk` v0.24.0 — https://www.npmjs.com/package/@agentclientprotocol/sdk - Validation: the SDK example echo agent (CI) + an in-repo controllable fixture (`src/__tests__/fixtures/echo-agent.mjs`); Gemini CLI / Claude-adapter for manual e2e. + +## Open Questions + + + +### OQ1 — Route A MCP-over-ACP forwarding and permission-gate traversal + +**Status:** UNRESOLVED / BLOCKED as of FN-6476 (2026-06-15). **Gate traversal:** UNRESOLVED — no forwarded tool invocation reached the point where it could be classified as GATED or BYPASSED. **Combined Route A verdict: NOT GO** until this OQ records both required U9 answers as GO. + +**Recovery status:** NOT-RECOVERED. `fn_task_show FN-6459` retained only archived task metadata plus an archive log entry, `.fusion/tasks/FN-6459/` is absent in the FN-6465 worktree, and `fn_task_document_read(key="research")` returned not found from FN-6465's execution context. No surviving authoritative FN-6459 U9 verdict was available to transcribe. + +**U9 answers required before Route A implementation:** + +1. Whether `claude-code-cli-acp` can forward the real Fusion MCP server(s) supplied through ACP `session/new.mcpServers` to the underlying interactive `claude`, using the actual `packages/pi-claude-cli/src/mcp-config.ts` stdio shape (`{ command: "node", args: [serverPath, schemaFilePath] }`), not a stub. +2. Whether a forwarded Fusion tool invocation surfaces back to Fusion as ACP `session/request_permission` and therefore traverses the existing permission gate, or whether the bridge lets `claude` invoke the MCP tool autonomously inside the bridge, bypassing the gate. + +**FN-6465 result:** these answers remain unproven. Local binaries were present during recovery (`claude` 2.1.177 and pinned `claude-code-cli-acp` 0.1.1), but FN-6465 did not complete an authenticated, instrumented spike against the real Fusion MCP config with ACP permission telemetry. Do not infer a GO from binary presence. + +**FN-6466 result (real bridge run, still blocked):** The follow-up spike opened ACP `session/new` **directly** with a non-empty Route-A MCP payload so it did not reuse the plugin helper that still hardcodes `mcpServers: []`. The payload matched the real `mcp-config.ts` stdio shape: one server named `custom-tools`, `command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`, and a temp schema file containing **62** captured Fusion custom tools sourced from `packages/cli/src/extension.ts`. The bridge accepted `initialize` and `session/new` with that payload, so the transport did **not** reject the forwarded MCP declaration outright. The first prompt turn explicitly instructed Claude to call `fn_task_list`, but the turn ended with assistant text **`Not logged in · Please run /login`**, **zero** tool-call updates, and **zero** ACP `session/request_permission` callbacks. + +**FN-6467 result (second real bridge run, still blocked):** The rerun verified `claude` **2.1.177** on PATH and the pinned `claude-code-cli-acp` **0.1.1** shim under `plugins/fusion-plugin-acp-runtime/node_modules/.bin`; the lockfile records integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`. The harness again opened ACP directly with one non-empty stdio MCP server named `custom-tools`, `command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`, containing **62** Fusion custom-tool names confirmed from `packages/cli/src/extension.ts` and matching FN-6466's payload source. `initialize` returned `agentInfo.name="claude-code-cli-acp"`, `version="0.1.1"`, and `authMethods=["claude-code-login"]`; `session/new` accepted the non-empty `mcpServers` payload and returned a session. The prompt explicitly instructed Claude to call `fn_task_list`, but the turn ended with assistant text **`Not logged in · Please run /login`**, stopReason `end_turn`, **zero** tool-call updates, and **zero** ACP `session/request_permission` callbacks. + +**Recorded OQ1 state after FN-6467:** +1. **Can Claude invoke a real forwarded Fusion tool through the bridge?** **UNPROVEN / BLOCKED.** The bridge accepts the non-empty `mcpServers` declaration, but the underlying `claude` session is still unauthenticated from the bridge's perspective and no forwarded MCP tool was invoked. +2. **Do forwarded tool calls traverse ACP `session/request_permission`?** **UNPROVEN / BLOCKED (neither GATED nor BYPASSED observed).** No forwarded tool call occurred, so the rerun observed no permission callback and cannot classify the security-critical gate path. + +**FN-6473 result (escalation rerun, still blocked):** The escalation re-verified the local prerequisites and an actual bridge turn: `claude` **2.1.177** resolved at `/Users/eclipxe/.local/bin/claude`; the plugin-local pinned bridge shim resolved at `plugins/fusion-plugin-acp-runtime/node_modules/.bin/claude-code-cli-acp` and reported **0.1.1**; the lockfile still records integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`. The instrumented harness opened ACP directly with one non-empty stdio MCP server named `custom-tools`, `command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`, containing **62** Fusion custom-tool names confirmed from `packages/cli/src/extension.ts` and matching `mcp-config.ts`'s `writeMcpConfig` shape. `initialize` returned `agentInfo.name="claude-code-cli-acp"`, `version="0.1.1"`, and `authMethods=["claude-code-login"]`; `session/new` accepted the non-empty `mcpServers` payload and returned a session. The prompt explicitly instructed Claude to invoke `fn_task_list`, but the turn ended with assistant text **`Not logged in · Please run /login`**, stopReason `end_turn`, **zero** tool-call updates, and **zero** ACP `session/request_permission` callbacks. + +**Recorded OQ1 state after FN-6473:** +1. **Can Claude invoke a real forwarded Fusion tool through the bridge?** **UNPROVEN / BLOCKED.** The bridge still accepts the non-empty `mcpServers` declaration, but the underlying `claude` session remains unauthenticated from the bridge's perspective and no forwarded MCP tool was invoked. +2. **Do forwarded tool calls traverse ACP `session/request_permission`?** **UNPROVEN / BLOCKED (neither GATED nor BYPASSED observed).** The explicit request-permission instrumentation recorded zero callbacks because no forwarded tool call occurred. + +**FN-6476 result (genuinely-authenticated rerun attempt, still blocked):** This rerun first re-verified the local prerequisites: `claude` **2.1.177** resolved at `/Users/eclipxe/.local/bin/claude`; the plugin-local bridge shim resolved at `plugins/fusion-plugin-acp-runtime/node_modules/.bin/claude-code-cli-acp` and reported **0.1.1**; the lockfile still records integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`. The payload source was the committed FN-6473/FN-6475 OQ1 record plus a rebuild from the real `mcp-config.ts` shape and `packages/cli/src/extension.ts`: one stdio MCP server named `custom-tools`, `command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`, carrying **62** Fusion custom-tool names. The authenticated-readiness proof opened ACP directly against the pinned bridge and drove a no-MCP prompt turn before attempting any forwarded-tool verdict; `initialize` returned `agentInfo.name="claude-code-cli-acp"`, `version="0.1.1"`, `authMethods=["claude-code-login"]`, and the turn returned assistant text **`Not logged in · Please run /login`** with stopReason `end_turn`, **zero** tool-like updates, and **zero** ACP `session/request_permission` callbacks. Because the readiness proof failed, the harness did **not** proceed to the MCP-forwarding prompt; no forwarded Fusion tool was invoked and gate traversal could not be classified as GATED or BYPASSED. + +**Recorded OQ1 state after FN-6476:** +1. **Can Claude invoke a real forwarded Fusion tool through the bridge?** **UNPROVEN / BLOCKED.** The bridge binary and real 62-tool payload are present, but this environment still cannot exercise an authenticated bridge session; no forwarded MCP tool was invoked. +2. **Do forwarded tool calls traverse ACP `session/request_permission`?** **UNPROVEN / BLOCKED (neither GATED nor BYPASSED observed).** The explicit client-side `requestPermission` instrumentation recorded zero callbacks because the auth-readiness gate failed before a forwarded tool call. + +**Escalation path:** rerun U9 with an environment where the pinned bridge can reach an authenticated `claude`, the same non-empty `session/new.mcpServers` shape, and explicit `session/request_permission` instrumentation. Sponsor the missing bridge/ACP MCP permission-forwarding capability upstream: the bridge/ACP layer must forward `session/new.mcpServers` to the underlying Claude session and surface forwarded tool calls through ACP `session/request_permission` or an MCP-layer permission hook. If an authenticated rerun still ignores `mcpServers`, cannot invoke the forwarded tools, or bypasses the ACP permission gate without an MCP-layer permission hook or sensitive-tool exclusion, Route A remains blocked. A `claude -p` fallback is not an acceptable Route-A completion path. + +**FN-6475 sponsorship record (2026-06-15):** upstream sponsorship was authored in [`docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md`](upstream/claude-code-cli-acp-mcp-permission-forwarding.md) and filed as https://github.com/moabualruz/claude-code-cli-acp/issues/2. This records the requested MCP passthrough plus permission-gate traversal / MCP-layer hook contract only; OQ1 remains **UNRESOLVED / BLOCKED** and the combined Route A verdict remains **NOT GO** until a later authenticated rerun proves both required U9 answers. + +**U14 internal mechanisms:** GO for design, subject to U9. Route A should use a second `acp-claude` runtime posture rather than mutating the generic `acp` runtime; inject the ACP bridge client from the engine `registerExtensionProviders` seam into the vendored `@fusion/pi-claude-cli` provider options; and add `AgentRuntimeOptions.mcpServers` to both the engine runtime contract and the ACP plugin-local structural copy, with `newAcpSession` defaulting to `[]` for Route-B compatibility. +## U9 verdict — MCP-over-ACP through the Claude bridge (2026-06-15) + +**U9 MECHANICS = GO** (overturns the prior headless NOT-GO chain; see plan OQ1). +Spike: pinned `claude-code-cli-acp` 0.1.1 driven directly over ACP (SDK 0.24.0) +in an **interactive TTY with `claude` logged in**, non-empty `session/new.mcpServers` += one stdio `custom-tools` server exposing `fn_task_list`. + +- **Auth:** bridged `claude` authenticated via the interactive login session — no `/login` wall. +- **(1) MCP forwarding:** PROVEN — Claude invoked `mcp__custom-tools__fn_task_list`; + the MCP server's `tools/call` executed; result returned via a `tool_call` `session/update`. +- **(2) Permission gate:** GATED — `session/request_permission` (`allow_once`/`allow_always`/`reject`) + fired *before* execution. The ACP permission floor holds; forwarded MCP calls are NOT bypassed. + +**Operational precondition (R17):** auth works only where the bridged `claude` can reach +the login/keychain session. The Fusion daemon/worker context is detached from that session +→ `Not logged in` (this is why FN-6466/6467/6473/6476 failed). Route-A mechanics are +unblocked (U10–U13 buildable); **shipping requires the provider's runtime to host an +authenticated `claude`** (keychain/login access, or file-based creds the daemon can read). + +### R17 resolution (2026-06-15): daemon-auth is the HARD ship-gate — creds are Keychain-only + +`claude` here stores OAuth creds in the **macOS Keychain** (`genp` / `svce="Claude Code-credentials"`), +NOT in a file: `~/.claude/.credentials.json` is an empty directory. Therefore forwarding `HOME` +to the bridge does **not** give the daemon-hosted `claude` its credentials. A detached Fusion +daemon/worker runs in a different security session with no login-Keychain access → `Not logged in` +(the root cause of the FN-6466/6467/6473/6476 failures). + +**Implication:** U9 mechanics are GO, but **Route A cannot ship to the daemon-hosted `pi-claude-cli` +provider until daemon→Keychain auth is solved.** Candidate resolutions (each its own follow-up): +1. Host the provider's bridge in a process with login-Keychain access (run within the user's Aqua + session, not a detached launchd daemon). +2. Provide the bridge's `claude` a file/API-key credential the daemon CAN read — but the user's auth + is claude.ai OAuth, and the env allow-list deliberately excludes `ANTHROPIC_API_KEY` from the + untrusted bridge; changing that is a security-posture decision. +3. Grant the daemon explicit Keychain access (`security unlock-keychain` / ACL) — fragile, security-sensitive. + +Until one lands, Route A is mechanically proven but operationally blocked on macOS. + +### R17 CLOSED (2026-06-15): confirmed by user — Claude CLI works in the live `fn` daemon today + +The user confirmed the existing `pi-claude-cli` (`claude -p`) provider authenticates in their +running Fusion daemon. Since the daemon is launched from their login session, it has macOS +Keychain access; the ACP bridge's `claude` inherits the same session and authenticates identically. +**R17 is satisfied for the supported (login-session) daemon.** Residual (documented, not blocking): +detached/headless launchd daemons would still need a credential-delivery solution — out of scope +for the supported setup. **Route A (U10–U13) is cleared to build.** + +### U11 tool-flow verification PASSED (2026-06-15): enablement gate cleared + +Live run against pinned `claude-code-cli-acp` 0.1.1 in an authenticated session, +returning `cancelled` to every `session/request_permission` (what `streamViaAcp` +does). Two tests, fresh session each: + +- **Forwarded MCP tool (`fn_task_list`):** Claude fired `ToolSearch` first + (internal, completed), then `mcp__custom-tools__fn_task_list` — a permission + request fired, we cancelled, the call went to `failed`, and the schema server's + `tools/call` was NEVER reached (no execution marker). Forwarded tools do not + execute when cancelled. +- **Native Bash:** permission request fired, we cancelled, `Bash` went to + `failed`, the side-effect file was never created. Native tools do not execute + when cancelled. + +Conclusion: the bridge gates tool execution BEHIND `session/request_permission` +(no TOCTOU window); `streamViaAcp`'s deny-by-default handler + break-early on +pi-known tools is SAFE. Also validated: the bridged `claude` authenticates only +with the richer env allow-list (HOME/PATH + USER/SHELL/LANG/XDG_*) that +`streamViaAcp` forwards — a thin {HOME,PATH} env fails with "Not logged in". +The Route A enablement gate is CLEARED. Harness: /tmp/acp-toolflow/verify2.mjs. + +### Route A architecture notes (2026-06-15, from review) + +- **Two parallel MCP-forwarding paths, by design.** U10 wires `mcpServers` through the engine `AgentRuntimeOptions` → ACP plugin adapter `newAcpSession` (for the engine-driven `acp` runtime). U11's `pi-claude-cli` provider does NOT consume that field — `streamViaAcp` drives its OWN inline ACP client and builds `mcpServers` locally via `buildAcpMcpServers` (KTD10: the provider speaks ACP directly via the published bridge path, never through the plugin adapter). The two intersect only at the shared schema-only MCP server shape. Do not "wire U10 into U11" — that would double-forward. +- **Known residual: ACP-path token usage/cost reads zero.** `streamViaAcp` synthesizes pi events via `createEventBridge` from ACP `session/update`s, which carry no token-usage frames, so `output.usage` stays zero on the ACP path. Cost telemetry undercounts when the kill-switch is enabled. The U12 status surface should not treat zero-usage as a bug; wiring usage (if the bridge ever exposes it) is deferred. diff --git a/docs/agents.md b/docs/agents.md index bd61143c97..e506d82beb 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -4,6 +4,10 @@ Fusion uses multiple agent roles for planning, execution, review, and merge workflows. +## CLI session actions + +The dashboard's CLI session banner uses authenticated `POST /api/cli-sessions/:id/*` routes for task-bound CLI sessions. `POST /api/cli-sessions/:id/relaunch` is project-scoped, rejects sessions that do not have a `taskId`, records a relaunch intent, and lets the engine listener clear resume linkage before moving the owning task back to `todo` for a fresh executor launch. This route backs the `resume-exhausted` banner's **Relaunch fresh** action; when a session summary has no `cliSessionId`, the client does not call the route. + ## Interactive CLI Chat Use `fn chat` to message an agent from your terminal. @@ -19,6 +23,10 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - `fn chat ` opens an interactive REPL. - Each message is stored as a `user-to-agent` MessageStore message from `cli` with `metadata.wakeRecipient=true`. - Agent replies are polled from your inbox and printed as they arrive. +- Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills. +- Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them. +- In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command. +- Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`. ### Flags @@ -41,6 +49,18 @@ printf "deploy report" | fn chat agent-abc123 --once --non-interactive > Replies require a running engine for the same project (for example `fn` dashboard or `fn serve`). +## Agent instruction updates from agents + +The `fn_agent_set_instructions` extension tool lets a managing agent update a report's operating instructions without opening the dashboard. It accepts: + +- `agent_id` — target agent ID or resolvable agent name. +- `instructions_text` — optional inline instructions; pass an explicit empty string to clear `instructionsText`. +- `instructions_path` — optional markdown file path; pass an explicit empty string to clear `instructionsPath`. + +At least one instruction field must be provided. The tool persists changes through `AgentStore.updateAgent`, so instruction edits are captured as normal agent config revisions. + +Authorization is scoped to the org hierarchy. When the caller is an agent (`ctx.agentId` is present), the target must be one of that caller's direct or indirect reports; self-targeting, peer/unrelated targets, and ancestors are rejected. Direct CLI/user calls that do not carry `ctx.agentId` are treated as privileged operator actions and may update any agent. + ## Agent Field Parity Matrix Every first-class editable agent field has a defined create/edit/import/template behavior. This ensures consistent round-tripping across all surfaces. @@ -561,7 +581,7 @@ When an identity-bearing, non-ephemeral agent wakes with no assigned task and `r Guardrails: - Only unpaused, unassigned, unchecked-out todo tasks with satisfied dependencies are considered - Claims are rejected for terminal/paused/owned/conflicting tasks -- Implementation-task backlog pickup is executor-only by default. Engineer-role agents may opt in through project setting `engineerBacklogAutoClaim` or per-agent `runtimeConfig.engineerBacklogAutoClaim`; the per-agent value overrides the project default in both directions. +- Implementation-task backlog pickup is executor-only by default. Engineer-role agents may opt in through **Settings → Scheduling & Capacity → "Let engineer agents auto-claim backlog tasks"** (`settings.engineerBacklogAutoClaim`) or **Agents → Agent Detail → Settings → Heartbeat Settings → "Engineer Backlog Auto-Claim"** (`runtimeConfig.engineerBacklogAutoClaim`); the per-agent value overrides the project default in both directions. If a no-task engineer wake shows compatible backlog while this is disabled, delegate the work or create a coordination follow-up instead of treating the board as empty. - Explicit task routing/delegation is not affected by the backlog auto-claim opt-in gate. - Checkout safety is preserved (`checkout_conflict` paths are non-fatal skips) - On successful claim, the same heartbeat run switches into task-scoped execution (no nested run re-entry) diff --git a/docs/architecture.md b/docs/architecture.md index 5b89610817..cf6892c863 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -312,8 +312,9 @@ Intentional exclusions from shared snapshots: - Main `useChat` session restore/recovery must not reset the active thread during session-list refresh or `chat:session:updated` metadata churn while a response is in flight. - `chat_sessions.inFlightGeneration` stores a durable JSON snapshot while generation is active: latest streamed text/thinking, tool-call state, and `replayFromEventId` for SSE resume. - `ChatManager.sendMessage()` updates that snapshot during streaming (debounced) and clears it on done/error/cancel so stale partial state does not survive completion. -- When the active session is still generating after reload/reconnect (`isGenerating: true`), `useChat`/`useQuickChat` hydrate the UI from `inFlightGeneration` immediately, then reconnect `/api/chat/sessions/:id/stream` with `Last-Event-ID = replayFromEventId` to avoid re-appending already-known deltas. +- When the active session is still generating after reload/reconnect (`isGenerating: true`), `useChat`/`useQuickChat` hydrate the UI from `inFlightGeneration` immediately, seed the shared stream handlers with that same text/thinking/tool-call snapshot, then reconnect `/api/chat/sessions/:id/stream` with `Last-Event-ID = replayFromEventId` so newly replayed deltas append to the restored bubble instead of replacing it or re-appending already-known deltas. - Hooks also auto-reattach if a stale cached session is selected and a later refresh (or session re-fetch) flips `isGenerating` to true with an `inFlightGeneration` snapshot; dedupe is guarded by a last-attached `(sessionId, replayFromEventId)` ref so snapshot checkpoint bumps do not open duplicate SSE streams. +- Attach-triggered message loads may commit the persisted transcript when they match the last attached generation even if React has not yet settled the active-session state/ref. Cache misses during that attach path must preserve the already visible thread so prior user/assistant messages remain visible beside the live streaming assistant response. - Chat message submission uses SSE streaming responses from dashboard chat routes. - Direct-chat terminal failures now persist as a distinct assistant message with `metadata.failureInfo` (`summary`, optional `errorClass`, optional `code`, optional `detail`, optional reference metadata) so the chat thread remains the durable primary failure surface after reload/reconnect. - `ChatManager.sendMessage()` preserves any interrupted partial assistant output as its own message, then appends a separate persisted failure bubble instead of overwriting the partial reply. @@ -672,7 +673,7 @@ Runtime action-gate flow (v1): - `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification - `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions - Batch 1 maintenance now includes one `fts-maintenance` step for both search indexes. The live `tasks_fts` branch still runs `merge` every tick, `optimize` every 4th tick, and `rebuild` above `32 MiB` or `1 MiB × live task count`. The archive `archived_tasks_fts` branch is lighter because archive writes are mostly append-only: `merge` every 8th tick, `optimize` every 24th tick, and `rebuild` above `64 MiB` or `512 KiB × archived row count`. Each branch is independently guarded by `fts5Available` and emits `task:fts-maintenance` run-audit telemetry with distinct `target` values (`tasks_fts` vs `archived_tasks_fts`). - - AI merge clean-room worktrees are created under the configured worktrees directory's hidden container, `/.ai-merge/`, as `fusion-ai-merge-fn--` detached worktrees. When that container is repo-local, its relative path is added to the repo's local git exclude when possible (alongside the legacy `.fusion/ai-merge/` entry) so an in-flight clean room does not dirty the integration checkout. Inline cleanup runs from `runAiMerge`'s clean-room `finally` for successful lands, empty/no-op finalization, concurrent-advance retries, and thrown/aborted merges. Cleanup canonicalizes the path, attempts `git worktree remove --force`, always falls back to filesystem removal, then runs `git worktree prune` so stale or partial registrations (including `git worktree add` failures) do not dangle. Cleanup emits `merge:ai-worktree-cleanup` audit events for git-remove, fs-rm, and prune phases; benign already-absent/de-registered paths are treated as idempotent success, while genuine filesystem-removal failures are logged/audited with `success: false` rather than silently swallowed. + - AI merge clean-room worktrees are created under the configured worktrees directory's hidden container, `/.ai-merge/`, as `fusion-ai-merge-fn--` detached worktrees. When that container is repo-local, its relative path is added to the repo's local git exclude when possible (alongside the legacy `.fusion/ai-merge/` entry) so an in-flight clean room does not dirty the integration checkout. After `git worktree add` and before the merge/review loop, `runAiMerge` bootstraps the clean room with the shared merge dependency-sync helper: a configured `worktreeInitCommand` is authoritative and always runs, while unset settings infer `pnpm`/`npm`/`yarn`/`bun` installs from lockfiles and can skip only when the `node_modules/.fusion-install-marker` hash still matches. Failures and aborts hard-stop the AI merge before merge agents or verification run, and `merge:ai-deps-sync` records the command, skip state, and duration. Inline cleanup runs from `runAiMerge`'s clean-room `finally` for successful lands, empty/no-op finalization, concurrent-advance retries, and thrown/aborted merges. Cleanup canonicalizes the path, attempts `git worktree remove --force`, always falls back to filesystem removal, then runs `git worktree prune` so stale or partial registrations (including `git worktree add` failures) do not dangle. Cleanup emits `merge:ai-worktree-cleanup` audit events for git-remove, fs-rm, and prune phases; benign already-absent/de-registered paths are treated as idempotent success, while genuine filesystem-removal failures are logged/audited with `success: false` rather than silently swallowed. - Worktrees-dir sweeps that list direct children of `` (pool idle scan, orphan cleanup/reap, self-healing unregistered-orphan reap, and cap enforcement) must exclude the `.ai-merge` container by name; those one-level sweeps never inspect or recycle clean rooms beneath it. Batch 1 sweeps stale AI merge clean-room worktrees under the new `/.ai-merge/` root and still scans legacy `.fusion/ai-merge/` plus legacy `tmpdir()` locations for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so the dedicated periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example SQLite busy/parse errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force ` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle. - `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`. @@ -682,6 +683,8 @@ Runtime action-gate flow (v1): #### Stuck-loop exhaustion terminal contract When stuck-kill retries are exhausted, `checkStuckBudget()` marks the task `status: "failed"`, moves it to `in-review`, and writes an error that starts with `STUCK_LOOP_EXHAUSTED:`. The error and final task-log line both include the kill count/max and last stuck reason (`loop` or `inactivity`). `StuckTaskDetector` also untracks the task and refuses to re-track it while that failed terminal error remains, preventing further automatic kill/requeue churn. The final log line explicitly states that no further automatic retries will run and directs operators to manually retry, pause, or move the task back to triage to resume work. +Active `fn_run_verification` subprocesses are a bounded progress signal (FN-6598). `createRunVerificationTool()` brackets each command with `StuckTaskDetector.beginVerification()` / `endVerification()`; while the command is active and still inside its own timeout plus cleanup grace, the detector suppresses `loop` and `no-progress-churn` classification so healthy marathon verification output cannot consume stuck-kill budget. `inactivity` is not suppressed: the verification runner must continue emitting line output or synthetic heartbeats, and if the process overruns its recorded deadline or never sends an end signal, normal detection resumes. + If loop recovery times out during compact-and-resume and the executor does not unwind within the bounded force-requeue grace window, `TaskExecutor.markStuckAborted()` now hard-cancels the hung task before clearing execution guards: spawned child agents are terminated, `awaitAbortInFlightTaskWork()` reaps API/step/workflow/configured-command/subagent/CLI surfaces, the task worktree is removed with `RemovalReason.ExecutorStuckKilled`, stale in-memory worktree/loop/paused/stuck state is cleared, and then the task is moved back to `todo` with the configured `preserveProgressOnStuckRequeue` semantics. The path preserves the concurrent-recovery guard: if the latest task column is no longer `in-progress`, it only clears the execution guard and does not reap/remove resources that a self-healing recovery now owns. Task logs distinguish loop detection, compaction timeout, force-kill cleanup start, force-requeue, and cleanup completion/failure. - `recoverMissingWorktreeReviewFailures()` is a narrow failed-review recovery: only `status: "failed"` `in-review` tasks with the explicit session-start signature `Refusing to start coding agent in missing worktree:` (from `assertValidWorktreeSession()`) are requeued. Recovery clears stale session metadata (`worktree`, `branch`, `sessionFile`, transient failure state), preserves valid step progress/retry counters, logs the auto-recovery reason, and moves the task back to `todo` for a clean retry. - `recoverMergeableReviewTasks()` only re-enqueues truly eligible tasks; retry-exhausted review tasks are skipped to avoid re-enqueue/no-op loops that keep refreshing `updatedAt`. @@ -851,7 +854,8 @@ Operator setup + troubleshooting guide: **[Remote Access runbook](./remote-acces Key server capabilities: - REST APIs for tasks, git, GitHub, agents, missions, planning, automations/routines, settings -- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values with visual usage bars in the System Stats modal), task/agent aggregates, and manual vitest process termination +- System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination +- Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/live`) are project-scoped dashboard routes. `/productivity` reads Lines changed from nullable `task_commit_associations.additions`/`deletions` merge-time diff stats and keeps the unavailable sentinel when no in-range association has stats. `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume. - Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation - Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md) - `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup. @@ -1075,6 +1079,7 @@ The run-audit system records every mutation performed by the engine across four - **Git / `merge:no-op-attribution-mismatch`** — emitted by the rebase landed-files attribution guard (FN-5304) when `..HEAD` has zero attributable own commits but the source `fusion/` tip still carries attributable own commits. `target` is the task ID; metadata includes `recordedSha`, `rebaseMergeBaseSha`, `sourceBranchRef`, `sourceBranchOwnCommitCount`, and `sourceBranchOwnCommitShas`. - **Git / `merge:no-op-attribution-mismatch-skipped`** — emitted when the FN-5304 source-tip guard cannot run because the source branch ref is unavailable (for example already pruned). `target` is the task ID; metadata includes `reason` (`"source-ref-unavailable"`). - **Database / `task:auto-recover-misrouted-foreign-commit`** — emitted per dropped misrouted commit during FN-4948 contamination recovery. `target` is the recovering task; metadata carries `{ droppedSha, foreignTaskId, paths }`. +- **Database / `task:no-commits-finalize-blocked-incomplete-steps`** — emitted by no-op finalize lanes when a `noCommitsExpected` task has no net branch changes but incomplete/skipped steps outweigh done steps. Metadata includes `{ reason, doneCount, incompleteCount, lane, classification?, baseRef? }`; the accompanying task log explains that the task was demoted to `todo` with progress preserved instead of finalized as done. - **Database / `task:orphan-detected-no-action`** — emitted by `recoverOrphanedExecutions` (FN-5337) when row metadata looks orphaned after grace windows; annotation-only event with no lifecycle mutation (`in-progress` task stays put). - **Database / `task:reattach-orphaned-execution`** — emitted by `reattachOrphanedAssignedExecutions` (FN-6336) when self-healing re-dispatches an idle assigned `in-progress` task forward via `executor.resumeTaskForAgent(agentId)` after proving the assigned agent has no active heartbeat run or active execution. - **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`. @@ -1275,6 +1280,7 @@ The columns/traits track moved *board* policy (transitions, capacity, hold, merg - A `parse-steps` node reads a workflow-declared **artifact** (PROMPT.md is just the default workflow's declared `step-source` artifact) and runs a registry **parser** (`step-headings`, `json-steps`, or a plugin-contributed parser) to write `Task.steps[]`. It is the only graph-side step-list writer and must dominate any `foreach`. Parsers fail closed to a routable `outcome:parse-error`. - A `foreach(source:"task-steps")` node instantiates an inline template subgraph once per planned step, with `mode` (sequential/parallel) and `isolation` (shared/worktree) as explicit axes and per-instance run-state pinned + persisted for crash-safe resume. - Resume-limbo graph failures are retried only through a narrow persisted counter (`Task.graphResumeRetryCount`, max 2). The executor classifies a failure as transient only when it happens immediately after the engine restart/unpause resume log marker, reports no graph `reason`, has no completed step progress, and the task has no durable `lastError`/`failureReason`; it clears transient `status`/`error`, logs the auto-retry, and schedules one more graph execution. Any explicit graph reason, completed step progress, durable task error, missing resume marker, or exhausted counter remains a genuine `status:"failed"` disposition and goes to review handoff, preserving the FN-5704 anti-loop contract. +- Paused graph exits are benign only while the task is still in `in-progress`; that is the user-pause/engine-pause state where preserving the pause without requeueing is intentional. If the graph reports a pause/abort exit after the task has already advanced to another live column (for example `in-review` after an unpause/resume race), `TaskExecutor.handleGraphFailure()` surfaces the boundary as operator-actionable failure evidence (`status:"failed"`/`error` when no failure is already present, plus a task-log entry) and does **not** move, rewind, or auto-merge the task. The exception is completed/no-commit finalize-to-review teardown (FN-6625/FN-6644/FN-6647): once the persisted task row proves a completed finalize handoff (non-`in-progress`, all steps done/skipped, no live pause/status/error, and the finalize-to-review log entry), a trailing graph abort resolves as an already-advanced benign graph exit even if volatile completion markers were cleared by teardown/restart and later abort provenance was re-marked from `completion-finalize` to `hard-cancel`. Genuine `userPaused`/global-pause exits and active-execution hard-cancels still use the operator-action path. `done` and `archived` remain terminal and keep their column/status, while existing failure details are preserved. - A `step-review` node surfaces reviewer verdicts (APPROVE/REVISE/RETHINK/UNAVAILABLE) as outcome edges; `rework` edges (the only legal graph cycles, bounded per instance) route REVISE/RETHINK back to `step-execute`, with RETHINK traversal triggering the reset seam. - A `code` node runs sandboxed TypeScript (esbuild + child process, clamped timeout, no store handle) for arbitrary computed routing/field logic — the same trust tier as project-local script steps. @@ -1452,6 +1458,8 @@ Dashboard session-diff route registration (`packages/dashboard/src/routes/regist - `legacy` = recovered via legacy task-id/subject matching - `ambiguous` = manual reconciliation where historical task-id attribution could be misleading +Commit associations also carry optional `additions`/`deletions` shortstat counts captured by merge paths. These nullable fields are the Command Center Productivity LOC source: analytics sum additions + deletions only when at least one in-range row has stats, and preserve the `—` unavailable sentinel when all matching rows are `NULL` so unknown historical data is never rendered as `0`. + ### Done-task files-changed sources of truth Done-task file-count surfaces intentionally distinguish three data sources: @@ -1642,6 +1650,7 @@ The GitHub tracking state listener now attaches to every registered project stor #### Finalize integrity gate - Finalize-to-done now runs an ownership classifier with three outcomes: `owned-commit` (task trailer/subject commit proven landed on merge target), `proven-no-op` (zero-ahead branch plus start point reachable from target), and `unproven` (missing ownership evidence, including foreign start-point inheritance). - `owned-commit` and `proven-no-op` can finalize. `proven-no-op` explicitly reconciles metadata by clearing stale `task.modifiedFiles` and stamping `mergeDetails.noOpMerge=true` with `landedFiles: []`. +- `noCommitsExpected === true` tasks have an additional no-op finalize guard (FN-6461): if a zero-net-change lane reaches finalize with step evidence showing incomplete/skipped work outweighing completed work (`incompleteCount >= doneCount`, with at least one step), the task must not move to `done`. Merger and self-healing write `task.error`, log an operator-visible reason, emit `task:no-commits-finalize-blocked-incomplete-steps`, and move the task back to `todo` with `preserveProgress: true`. All-done no-commits tasks, mostly-done tasks with only a minor skipped tail, zero-step tasks, ordinary tasks, and no-commits tasks with real landed changes keep the existing finalize behavior. - `unproven` no longer silently completes as done; merger/self-healing emit `task:finalize-unproven-blocked` audit events and auto-retry by requeuing to `todo` for a fresh execution pass. - Historical cleanup is additive: `reconcileDoneTaskIntegrity()` scans done tasks missing `mergeDetails.commitSha` but still carrying `modifiedFiles`, then either recovers owned commit metadata, clears no-op stale files, or emits `task:integrity-warning` without regressing done tasks back to review. `task:integrity-warning` is transition-only on the persisted warning reason: first warning emits once, repeated sweeps with the same `mergeDetails.integrityWarning.reason` stay silent, and a new warning reason emits again. - This integrity gate complements FN-4646 landed-file capture (metadata truth source) and FN-4647 dashboard labeling (UI presentation); gate enforcement is in merger/self-healing, while display semantics remain UI-owned. @@ -1785,6 +1794,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Worktrunk-managed lifecycles**: when `worktrunk.enabled`, self-healing defers prune/idle/worktree-cap sweeps to the worktrunk backend; branch-level stale/ conflict reclaim stays native. Orphan `fusion/*` branches are operator-managed via standard git tooling (no auto-rescue task filing). - **Post-finalize verification no-op (FN-4944)**: when auto-merge receives a delayed `VerificationError` after a task is already `done` with `mergeDetails.mergeConfirmed === true` (already-on-main fast-path), it must log one `[verification] ... no action` diagnostic and must not bounce the task back to `in-progress` / `merging-fix`. Defense-in-depth now re-checks the done+mergeConfirmed condition immediately before each verification-failure status write site, and emits `task:post-finalize-verification-no-op` database audit events with failure metadata for forensics. - **Transient auto-merge retry classification (FN-5697)**: non-conflict auto-merge errors now run through `isTransientError(...)` before terminal parking. Transient provider/network failures (for example `This operation was aborted`, `socket hang up`, and `server_error` payloads) are retried with bounded exponential backoff (`5s/10s/20s`) and `status=null` for both direct and pull-request merge strategies; once `MAX_AUTO_MERGE_TRANSIENT_RETRIES` is exhausted, tasks are parked `in-review/failed` with explicit transient-exhaustion logs. +- **Merge-seam abort provenance (FN-6568)**: workflow graph merge-node failures must not be classified as pause/resume aborts merely because the merge seam hard-canceled an in-flight session. `TaskExecutor` tracks paused-abort provenance separately (`global-pause`, `merge-seam`, `hard-cancel`); genuine user/global pauses still preserve FN-6478/FN-5147 parking, while non-paused `merge`/`requestMerge` graph failures route back into the bounded auto-merge retry path instead of being parked `status:"failed"` with `mergeRetries=NULL`. - **Worktree pool exclusivity (FN-4954)**: `WorktreePool.acquire(taskId)` / `release(path, taskId?)` track a `leased` map so every pooled path is either idle or leased, never both. Cross-task double-lease detection throws `PoolDoubleLeaseError` and emits `worktree:pool-double-lease-detected`; merger Step 8 now detaches HEAD and clears `task.worktree` / `task.branch` before releasing paths back to the pool. - **Stale registration recovery (FN-5056)**: `NativeWorktreeBackend.create` and `executor.tryCreateWorktree` detect `missing but already registered worktree` failures, run `git worktree prune` (plus `remove --force` / `add -f` fallbacks) before retrying, and emit `worktree:stale-registration-{detected,recovered,recovery-failed}` audit events. - **Raw worktree deletion must be paired with prune (FN-5058)**: any direct filesystem deletion of a worktree directory (`rm -rf` / `rmSync`) must be followed by best-effort `git worktree prune` via `pruneWorktreeAdminEntries` so `.git/worktrees/*` admin entries are not stranded in a missing-but-registered state (FN-5056 class). @@ -1793,7 +1803,7 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Scheduler overlap priority/age guard (FN-5325)**: with `groupOverlappingFiles=true`, scheduler now defers a lower-priority (or younger same-priority) candidate when an overlapping queued todo task exists, preserving priority→age→task-id order for overlap serialization without preempting in-progress work. If the inversion is against an already-running lower-priority blocker, scheduler still defers the candidate; the per-pairing audit event was removed in FN-6174 due to zero consumers and table bloat. - **Empty-commit refusal + early empty-own-diff finalize (FN-5345/FN-5377)**: Fusion task worktrees install a `prepare-commit-msg` hook that refuses `git commit --allow-empty` and other zero-staged-diff commits, preventing verification-only tasks from manufacturing empty handoff commits that defeat the merger's no-op classifier. The hook allows legitimate empty-tree paths (amend, merge, squash, cherry-pick, revert, rebase). Amend detection tokenizes the parent process command line (`ps -o args=` with `/proc/$PPID/cmdline` fallback for Alpine/busybox) and stops at the first message-supplying flag (`-m`/`-F`/`--message`/`--file`) so a commit message containing the substring `--amend` cannot bypass the guard. In `aiMergeTask`, an early empty-own-diff fast-path runs BEFORE any reuse-handoff acquisition: when integration mode is `reuse-task-worktree`, the branch exists, `git rev-list --count ..` is > 0, and `git diff --quiet ..` exits 0, the task auto-finalizes as no-op with `mergeDetails.noOpMerge: true` and emits `task:auto-recover-finalize-already-on-main` with `reason: "empty-own-diff-early-fast-path"`. The fast-path best-effort removes the stranded worktree (FN-4811 same-task/foreign-owner guard) and deletes the `fusion/` branch so empty-own-diff residuals do not accumulate. This unsticks tasks where a stale empty handoff commit combined with drifted worktree↔branch mapping would otherwise wedge the handoff gate with `registered-branch-mismatch`. The explicit `cwd-integration-branch` mode is unchanged (`cwd-main` remains a deprecated alias normalized to it). `classifyOwnedLandedEvidence` also detects empty-own-diff (aheadCount > 0, zero net diff) and returns `proven-no-op` so downstream self-healing and post-handoff finalize paths benefit too. Additionally, merger's reuse-fallback path now consults `git worktree list --porcelain` before creating a new worktree: extant usable registrations of `fusion/` are reused directly (rather than blindly `git worktree add -f` producing a duplicate registration), and stale registrations are pruned first. The direct-reuse shortcut is guarded by FN-4811 (refuses paths owned by a different task in `activeSessionRegistry`) and FN-4954 (skipped when `recycleWorktrees=true` with a pool attached, so `WorktreePool.acquire` lease bookkeeping stays consistent). Two audit subtypes — `merge:reuse-fallback-pruned-stale-registration` and `merge:reuse-fallback-reused-existing-registration` — replace the prior overloading of `merge:reuse-fallback-new-worktree` for these cases. - **Verified no-op/duplicate executor completion (FN-6275)**: explicit `fn_task_done` may complete with zero branch commits only when the summary starts with a recognized sentinel (`PREMISE STALE:`, `NO-OP:`, `NOOP:`, `DUPLICATE: FN-NNNN ...`, or `REDUNDANT:`) or the task already carries a no-commit contract. The sentinel only relaxes the `no_commits` invariant; `wrong_toplevel`, `wrong_branch`, pending-step/review refusals, and scope-leak guards still run. Accepted sentinel completions persist `noCommitsExpected: true`, write task-log audit details with marker kind/reason/raw summary/run/agent IDs, and add a task timeline activity so the no-code terminal path remains explainable. Ordinary zero-commit implementation completions without a leading sentinel are still refused. -- **In-review branch-binding self-heal (FN-5083)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`. +- **In-review branch-binding self-heal (FN-5083/FN-6695)**: `reconcile-in-review-branch-rebind` runs after `reconcile-task-worktree-metadata` and before `reclaim-stale-active-branches`. It restores `task.branch` (and clears `task.worktree` for fresh acquisition) for `in-review` tasks when exactly one case-insensitive `fusion/` candidate branch has unique commits versus the integration base. Ambiguous candidates emit `task:auto-rebind-skipped` (`reason: "ambiguous-candidates"`) and are never auto-resolved. Unsafe metadata repair is also skipped with `task:auto-rebind-skipped`: `userPaused` preserves authoritative user intent, and `checkedOutBy` preserves live agent checkout ownership. Branch construction across executor/worktree-pool/worktree-acquisition/merger/self-healing canonicalizes to lowercase via `canonicalFusionBranchName`; `fn_task_done` wrong-branch checks now auto-canonicalize case-only mismatches and emit `branch:auto-canonicalize-case`. - **In-review is terminal-until-merged under `autoMerge: false` (FN-5147)**: when a project sets `settings.autoMerge: false`, `in-review` is the intended resting state until a human merges the PR. No lifecycle-mutating self-healing sweep (`reclaimSelfOwnedBranchConflicts`, `recoverGhostReviewTasks`, `recoverStaleIncompleteReviewTasks`, `recoverInterruptedMergingTasks`, `recoverStuckMergeDeadlocks`, `recoverMissingWorktreeReviewFailures`, `recoverPartialProgressNoTaskDoneFailures`, `recoverCompletionHandoffLimbo`, `recoverPostDoneNonContinuableWedge`, `recoverMergeableReviewTasks`, `recoverMergedReviewTasks`, `recoverAlreadyMergedReviewTasks`, `recoverOrphanOnlyScopeViolations`, `recoverForeignOnlyContaminatedInReviewTasks`, `recoverReviewTasksWithFailedPreMergeSteps`, `finalizeNoOpReviewTasks`, `surfaceInReviewStalls`, `surfaceInReviewStalled`) may move the task out of `in-review`, mark it `paused`/`failed`, or re-enqueue it for execution. Explicit per-task overrides are distinguished by `task.autoMergeProvenance: "user"`; ambiguous legacy rows stamped `autoMerge: true` by the pre-FN-6245 review-entry path are marked `"legacy-stamp"` once and surfaced in run-audit/logs, but are only cleared by the operator-driven `reconcileLegacyAutoMergeStamps({ apply: true })` action. Scoped FN-5819 exception: shared-group members (`branchContext.assignmentMode === "shared"`) are still allowed through the member→`branch_groups.branchName` integration step while `autoMerge` is off; this is a soft pre-integration only and does not permit shared-branch → default-branch promotion. RECONCILE-ONLY sweeps (branch rebind, blocker fan-out, stale-status clears, contamination metadata cleanup, attribution restore, PR refresh, misclassified-failure error clearing) continue to run. - **Auto-merge integration-root default (FN-5279)**: direct auto-merge now defaults `mergeIntegrationWorktree` to `reuse-task-worktree`; merger must pass the reuse handoff gates or emit `merge:reuse-handoff-refused` and leave the task in `in-review` without silently falling back to `cwd-integration-branch` (`cwd-main` remains a deprecated alias normalized to that mode). - **Orphaned execution sweep is observation-only (FN-5337)**: `recoverOrphanedExecutions` only annotates stale in-progress candidates with `task:orphan-detected-no-action` and `[orphan-detected] ... no action (operator-decides)` logs. It must never move `in-progress`/`in-review` backward to `todo` or mutate lease/worktree metadata. Proof-based backward recovery remains exclusively in `recoverInProgressLimbo` (FN-5219), `RestartRecoveryCoordinator`, `recoverMissingWorktreeReviewFailures`, and explicit executor/merger failure paths. Reintroducing lifecycle mutation here requires hard git/session proof gating plus CEO+CTO+PM sign-off. @@ -1802,13 +1812,14 @@ This section preserves the detailed lifecycle/self-healing contracts that were f - **Dual-observe parity seam (FN-5742 Phase 2)**: with the same flag ON, legacy remains authoritative while shadow reads compute/emit parity telemetry only. Scheduler emits `merge:dependency-parity-diff` when `in-review|done|archived` dependency satisfaction diverges from completion-handoff marker satisfaction, and `merge:lease-parity-diff` when legacy in-review overlap leasing diverges from shadow lease decomposition. Merger emits `merge:request-dequeued-shadow` (agree/disagree metadata) by comparing legacy dequeue selection to shadow merge-request selection while explicitly skipping `manual-required` rows. Phase 3 dequeue cutover is gated on sustained parity (low disagreement rate) from these additive events; no lifecycle authority changes in Phase 2. - **Authoritative cutover seam (FN-5743 Phase 3)**: with the flag ON, merge-request records and `completion_handoff_accepted` markers become authoritative enforcement signals for dequeue/retry ownership and dependency/lease gates. Accepted handoffs stop stamping `in-review` executor overlap leases, transient merge retries stay in merge-request state (`running → retrying → queued`, terminal `exhausted|succeeded|cancelled`) without `todo` rebounds, and user hard-cancel (`in-review → todo`) deterministically cancels pending merge-request records while keeping FN-5147/FN-5704 behavior unchanged. - **No-progress churn terminalization (FN-5168)**: `StuckTaskDetector` now tracks ignored `fn_task_update` rebuffs via `recordIgnoredStepUpdate(taskId)` and, after one loop/compact-and-resume recovery has already fired in the same `execute()` lifecycle, escalates `ignoredStepUpdateCount >= 25` to the terminal reason `no-progress-churn`. `SelfHealingManager.checkStuckBudget()` maps that reason directly to `STUCK_NO_PROGRESS_CHURN`, emits `task:stuck-no-progress-churn-terminalized` with `{ taskId, ignoredStepUpdateCount, stuckKillStreak, lastReason }`, and parks the task in `in-review` without consuming the normal stuck-kill budget. Under FN-5147 `autoMerge: false`, that failed in-review task remains terminal-until-merged just like `STUCK_LOOP_EXHAUSTED`; the new class adds an earlier bounded exit, not a re-execution path. +- **Verification-active stuck-loop suppression (FN-6598)**: `fn_run_verification` registers a per-task active verification window with `StuckTaskDetector`. Within the command's own timeout budget, subprocess output/heartbeats are treated as forward progress and suppress only `loop` / `no-progress-churn`; the deadline restores normal classification if the command or end callback wedges, and `inactivity` remains governed by heartbeat flow. - **Todo↔in-progress flapping convergence (FN-5941)**: live backward-recovery paths now share a `getFalsePositiveRequeueSignal(...)` guard that suppresses `in-progress → todo` recovery when any hard liveness proof exists (`getExecutingTaskIds`, recent active-heartbeat run, checked-out lease, live worktree+branch binding, or recent `executionStartedAt` inside the relevant grace window). Suppressed candidates emit observation-only `task:*no-action` audits instead of silently mutating lifecycle state. Scheduler adds a short `recentEngineTodoRequeues` settle window so engine-sourced requeues cannot be re-dispatched immediately on the same `task:moved → todo` tick. The durable convergence backstop is the dispatch-oscillation breaker: scheduler reuses `task.dispatchStormCount` + `task.lastDispatchAt` as a sliding-window counter (`dispatchOscillationThreshold`, `dispatchOscillationWindowMs`) and, when the threshold is exceeded, leaves the task parked in `todo`, sets `paused: true` with `pausedReason: "dispatch-oscillation"`, records `task:dispatch-oscillation-terminalized`, and requires an operator unpause or forward move to reset the counter. - **Landed-files attribution (FN-5103)**: Rebase-strategy `mergeDetails.landedFiles` / `filesChanged` / `insertions` / `deletions` are captured from task-attributable commits only via `filterFilesToOwnTaskCommits` (subject-prefix + trailer + bracket-prefix evidence), tagged `landedFilesAttributionRestricted: true`. Zero own commits → `landedFiles: []` and `noOpVerifiedShortCircuit: true`. FN-5304 guard: when `..HEAD` reports zero own commits, merger must also validate the source `fusion/` tip; if that source tip still has attributable own commits relative to `rebaseBaseSha`, throw `SilentNoOpAttributionMismatchError`, refuse writing `mergeConfirmed: true`, park the task in `in-review` with `status: "failed"`, and emit `merge:no-op-attribution-mismatch`. If source ref is unavailable, skip with diagnostic + `merge:no-op-attribution-mismatch-skipped` (`reason: "source-ref-unavailable"`). Attribution-helper failures fall back to the unrestricted `rebaseBaseSha..sha` walk and set `landedFilesCaptureFallback: 'attribution-failed'`. Self-healing `recoverDoneTaskMergeMetadata` skips reconcile when `landedFilesAttributionRestricted` or `noOpVerifiedShortCircuit` is set so the narrower set is not overwritten with the full range. Squash-strategy capture is unchanged. - **Soft-delete scheduler invalidation (FN-5137)**: `task:deleted` events must invalidate `AutoClaimSnapshotManager` and clear scheduler bookkeeping (`pausedTaskIds`, `failedTaskIds`, `wasNodeDispatchValidationBlocked`, `wasNodeBlocked`); `executor.execute()` / `resumeOrphaned()` / `resumeTaskForAgent()` refuse any task with `deletedAt` set. - **Soft-delete in-flight abort (FN-5142)**: `task:deleted` must immediately abort/dispose active executor work (`activeSessions`, `activeStepExecutors`, `activeWorkflowStepSessions`, reviewer subagents), interrupt active merge state (`mergeAbortController`, `activeMergeSession`, `activeMergeTaskId`, `mergeActive`, `mergeQueue`, `pausedReviewTaskIds`), and abort triage specify/subagent sessions for that id. Handlers are per-task and idempotent. - **Soft-delete audit + column reconcile (FN-5175)**: `TaskStore.deleteTask` records a `runAuditEvents` row (`mutationType: "task:deleted"`, `domain: "database"`) inside the same transaction that sets `deletedAt`, and sets `"column" = 'archived'` on the row. Callers without a heartbeat run context (`fn task delete`, pi extension, dashboard delete route) pass an `auditContext` with `agentId: "system"` and a synthetic `runId`. The watcher cross-instance emit path does NOT re-record the audit event. The row stays in `tasks` (not `archivedTasks`); `archiveTask` is unchanged. - **Soft-delete resurrection guard (FN-5208)**: `TaskStore.readTaskJson()` must never fall back to `.fusion/tasks//task.json` when the DB row exists with `deletedAt` set — it throws `TaskDeletedError`. `atomicCreateTaskJson` / `atomicWriteTaskJson` / `atomicWriteTaskJsonWithAudit` refuse to upsert a task whose row is currently soft-deleted (unless the in-memory task carries `deletedAt` itself, for soft-delete maintenance paths), emit a `[soft-delete-resurrection-blocked]` log line, and record a `task:resurrection-blocked` run-audit event. Stale in-flight planner/triage writes for a soft-deleted ID surface `TaskDeletedError` and abort cleanly without emitting `task:created`. -- **Exhausted in-review visibility surfaces (FN-5513)**: retry-exhausted merge failures (`column='in-review'`, `status='failed'`, `mergeRetries >= 3`) can remain soft-deleted for lifecycle safety, but are now intentionally discoverable through opt-in read paths: `TaskStore.listExhaustedInReviewTasks({ includeDeleted })`, `GET /api/tasks/exhausted-in-review`, `GET /api/tasks/:id?includeDeleted=true`, CLI `fn_task_show` soft-delete fallback marker, CLI `fn_task_list({ includeDeleted: true })`, and the dashboard ReliabilityView "Exhausted in-review (hidden blockers)" panel. This complements FN-5488/FN-5496 downstream blocker healing by surfacing the upstream blocker without mutating lifecycle state. +- **Exhausted in-review visibility surfaces (FN-5513/FN-6569)**: retry-exhausted merge failures (`column='in-review'`, `status='failed'`, `mergeRetries >= maxAutoMergeRetries`, default `3`) can remain soft-deleted for lifecycle safety, but are now intentionally discoverable through opt-in read paths: `TaskStore.listExhaustedInReviewTasks({ includeDeleted })`, `GET /api/tasks/exhausted-in-review`, `GET /api/tasks/:id?includeDeleted=true`, CLI `fn_task_show` soft-delete fallback marker, CLI `fn_task_list({ includeDeleted: true })`, and the dashboard ReliabilityView "Exhausted in-review (hidden blockers)" panel. This complements FN-5488/FN-5496 downstream blocker healing by surfacing the upstream blocker without mutating lifecycle state. - **Soft-delete stream verification gate (FN-5153)**: `docs/soft-delete-verification-matrix.md` is the authoritative checklist for the FN-5105 → FN-5143 soft-delete stream. Every scenario × layer cell must be GREEN (or have a linked follow-up FN) before the stream is closed; `packages/engine/src/__tests__/reliability-interactions/soft-delete-end-to-end.test.ts` is the cross-layer regression backstop. ## Reliability interaction backstops @@ -1827,7 +1838,7 @@ Reliability-layer changes are in scope. Interaction regression backstops live in - FN-5093 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-stalled-detector.test.ts` covers composition between quiet-window in-review stalled surfacing and adjacent reason-driven/paused/ghost-recovery/auto-merge gating paths. - FN-5103 backstop: `packages/engine/src/__tests__/reliability-interactions/landed-files-attribution.test.ts` covers attribution-restricted rebase landed-files capture, verified-short-circuit zero-own-commit capture, and attribution-failure fallback composition. - FN-5147 backstop: `packages/engine/src/__tests__/reliability-interactions/in-review-automerge-off.test.ts` covers `autoMerge: false` + long-quiet in-review + maintenance/startup sweep cycles, asserting no column move / no paused / no status mutation / no requeue, plus explicit regression guards for `surfaceInReviewStalls` and `surfaceInReviewStalled`. -- FN-5168 backstop: `packages/engine/src/__tests__/reliability-interactions/non-progress-churn.test.ts` covers loop→compact recovery followed by ignored-step-update churn escalation, terminal `beforeRequeue(false)` behavior, audit/log payloads, and FN-5147 autoMerge-off composition. +- FN-5168/FN-6598 backstop: `packages/engine/src/__tests__/reliability-interactions/non-progress-churn.test.ts` covers loop→compact recovery followed by ignored-step-update churn escalation, terminal `beforeRequeue(false)` behavior, audit/log payloads, FN-5147 autoMerge-off composition, and verification-active suppression so healthy `fn_run_verification` runs do not reach `onLoopDetected` / stuck-budget handling while the no-verification control still trips. - FN-5219 backstop: `packages/engine/src/__tests__/reliability-interactions/in-progress-limbo-recovery.test.ts` covers `recoverInProgressLimbo` composition with `recoverOrphanedExecutions` (no double-recovery), `reconcile-task-worktree-metadata` (live rebindable worktree wins), `recoverMissingWorktreeReviewFailures` (in-review vs in-progress disjoint), and executor task-id claim skip, plus an explicit FN-5149 reproduction case. - FN-5704 backstop: `packages/engine/src/__tests__/reliability-interactions/reclaim-self-owned-resume-limbo-escalation.test.ts` covers bounded no-progress reclaim/resume detection, preserve-work escalation to `todo`, `task:resume-limbo-escalated` audit metadata, progress-signal reset behavior, and user-paused/autoMerge-off non-escalation guards. - FN-5715 backstop: `packages/engine/src/__tests__/reliability-interactions/mission-validation-trigger-gap.test.ts` locks the mission-validation trigger invariant so done mission-linked tasks still start validation when the mission loop was stopped, startup recovery replays done implementing features with unpassed assertions, and recovery remains idempotent for already-passed features. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a13999123f..5987657139 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -485,7 +485,7 @@ Use planning mode to turn a rough idea into a triage task through an interactive When supported by your configured runtime/model provider, planning sessions can also use builtin `WebSearch` and `WebFetch` tools for live context gathering. -Planning sessions also have read-only board tools: `fn_task_list` (list active backlog tasks) and `fn_task_get` (read full task details, including PROMPT.md) so interviews can avoid duplicate in-flight plans and anchor questions to existing work. `fn_task_list` also accepts `includeDeleted: true` to surface soft-deleted blockers when diagnosing stalled dependency chains, and `fn_task_show` now auto-falls back to include soft-deleted tasks with a `[SOFT-DELETED at ...]` marker. +Planning sessions also have read-only board tools: `fn_task_list` (list active backlog tasks) and `fn_task_get` (read full task details, including PROMPT.md) so interviews can avoid duplicate in-flight plans and anchor questions to existing work. `fn_task_list` output is bounded and falls back to a defensive formatter if the runtime task-list clamp helper is unavailable, so board reads return text instead of failing during ambient planning or heartbeat checks. `fn_task_list` also accepts `includeDeleted: true` to surface soft-deleted blockers when diagnosing stalled dependency chains, and `fn_task_show` now auto-falls back to include soft-deleted tasks with a `[SOFT-DELETED at ...]` marker. ```bash fn task plan [description] @@ -1025,6 +1025,7 @@ fn plugin dev [--once] [--ai-scan] Subcommands: `list|ls`, `install`, `rescan`, `trust`, `untrust`, `verify`, `uninstall`, `enable`, `disable`, `create`, `new`, `dev`. Scope semantics: +- `fn plugin install ` accepts a built plugin directory or installed package name, not a packed `.tgz` tarball; extract tarballs before installing. - `fn plugin install` / `fn plugin uninstall` are **global** operations - `fn plugin enable` / `fn plugin disable` are **project-scoped** operations (`--project` selects the project context) - `fn plugin list` shows globally installed plugins plus enabled/disabled state for the current project context diff --git a/docs/custom-workflow-reliability-acceptance-map.md b/docs/custom-workflow-reliability-acceptance-map.md new file mode 100644 index 0000000000..150f09be71 --- /dev/null +++ b/docs/custom-workflow-reliability-acceptance-map.md @@ -0,0 +1,142 @@ +# Custom Workflow Reliability Acceptance Map + +[← Docs index](./README.md) + + + +## Purpose + +This map defines the minimum reliability bar for landing the custom workflow system reliably for goal **G-MPW67VQR-0001-97S3**. It translates the MVP framing in [Custom Non-Coding Workflows MVP Spec](./custom-workflows-mvp-spec.md), runtime contracts in [Workflow Steps](./workflow-steps.md), visual authoring behavior in [Workflow Editor](./workflow-editor.md), policy boundaries in [Workflow Policy Ownership Map](./workflow-policy-ownership-map.md), and lifecycle/recovery invariants in [Architecture](./architecture.md) into end-to-end acceptance criteria. + +Use this document to write engineering tasks, QA plans, and release checks. It is not a product implementation plan; when a criterion is not met, file or link a focused follow-up task and keep code changes out of this artifact. + +## Priority split + +| Priority | Acceptance area | Why it blocks or waits | +|---|---|---| +| MVP/blocking | Valid custom workflow creation/import/update, read-only built-in protection, and persisted workflow IDs discoverable through `fn_workflow_list` | Operators cannot run or select a workflow until authoring is durable and validation fails closed. | +| MVP/blocking | Task workflow assignment through dashboard selectors, `fn_workflow_select`, and `workflow_id` on `fn_task_create` / delegation tools | Runtime reliability depends on explicit selections resolving predictably and unselected tasks falling back only to `builtin:coding`. | +| MVP/blocking | Workflow graph execution through `WorkflowGraphExecutor` / workflow runtime primitives with lifecycle invariants preserved | The graph runtime is the authoritative lifecycle path; it must preserve file-scope guards, hard-cancel, merge, and recovery semantics. | +| MVP/blocking | `toolMode: readonly`, `gateMode`, structured verdict, `REVISE`, and required-artifact completion gating | These are the MVP safety and completion contracts from the custom-workflows MVP spec. | +| MVP/blocking | Recovery/restart behavior emits observable facts and never silently moves workflow work backward | Reliability requires durable state, bounded recovery, and auditability across scheduler/engine restarts. | +| Nice-to-have/enhancement | Workflow settings cross-node sync | Settings export includes workflow values, but settings sync explicitly does not sync workflow values yet. | +| Nice-to-have/enhancement | Dedicated workflow run telemetry events and adoption dashboards | The MVP spec identifies telemetry gaps (`workflow_definition_registered`, `workflow_run_started`, run-level status, definition-ID tagging) as instrumentation improvements; existing acceptance can use task state, task documents, workflow results, and run-audit until those land. | +| Nice-to-have/enhancement | Rich marketplace/templates, cross-workflow orchestration, external write connectors, migration/versioning | Explicitly deferred by the MVP cut list and not needed for first reliable custom workflow runs. | + +## Critical journey catalog + +### 1. Author, import, duplicate, and save a custom workflow + +- **Actor / need:** A workflow author needs to create or copy a workflow that can be reviewed, saved, and selected without corrupting built-in definitions. +- **Trigger:** Open the [Workflow Editor](./workflow-editor.md) from the dashboard, duplicate a built-in with **Duplicate to customize**, start from Blank, import a JSON envelope, or use workflow tools such as `fn_workflow_create` / `fn_workflow_update`. +- **Expected happy path + lifecycle transitions + feedback:** The editor serializes graph nodes/edges, columns, fields, and setting declarations into Workflow IR, saves the custom definition, and keeps built-ins read-only. The saved workflow appears in the editor picker and `fn_workflow_list`; no task lifecycle transition occurs until a task selects the workflow. The editor reports whether the workflow can run on the linear engine or must run on the graph interpreter. +- **Failure / recovery expectation:** Invalid JSON, dangling edges, illegal cycles, unplaced nodes, blocking column-trait violations, invalid setting/field declarations, and attempts to mutate built-ins are rejected before partial persistence. Import errors and server validation errors render in a persistent inline error region; built-ins show read-only hints and disable mutation controls. +- **Measurable success signal:** A stable workflow ID is returned/listed by `fn_workflow_list`; `fn_workflow_get` or the editor reload shows the saved IR; invalid saves return a typed validation failure without changing the prior persisted definition. +- **Priority:** MVP/blocking for save/validation/discovery; enhancement for AI-assisted design quality and richer telemetry around definition registration. + +### 2. Edit graph routing, columns, custom fields, and workflow settings safely + +- **Actor / need:** A workflow author needs to evolve a workflow's routing policy, board columns, task fields, and per-project values without losing existing task data. +- **Trigger:** Edit nodes/edges in the graph inspector, modify Columns/Fields/Settings panels, save setting **Definitions**, save per-project **Values**, or call `fn_workflow_settings`. +- **Expected happy path + lifecycle transitions + feedback:** Graph edits persist as Workflow IR; column changes update workflow-defined lanes/traits; field declarations validate and render dynamic task fields; setting values resolve per `(workflow, project)` as `stored value ?? declaration default`. No active task should change lifecycle state merely because an author opens or saves settings; tasks consume effective settings on execution/resume. +- **Failure / recovery expectation:** Invalid field values, incompatible enum defaults, unknown settings, orphaned setting values, and invalid workflow setting writes are rejected or dropped from effective settings without corrupting stored declarations. Editing or switching a workflow must orphan removed/incompatible task field values rather than destroy them. +- **Measurable success signal:** The editor reloads the saved graph/schema; `fn_workflow_settings(action="get")` returns stored and effective values; invalid `fn_workflow_settings(action="set")` writes reject atomically; orphaned task custom fields remain visible under the task detail disclosure. +- **Priority:** MVP/blocking for validation and non-destructive persistence; nice-to-have for cross-node workflow setting sync. + +### 3. Select a workflow for a task, board, or mission-derived feature task + +- **Actor / need:** An operator or triage agent needs to route work through the intended workflow at task creation or before execution, including tasks that originate from mission features. +- **Trigger:** Use the dashboard task/board workflow selector, task detail **Workflow** tab, `fn_workflow_select`, `workflow_id` on `fn_task_create` / delegation tools, or mission feature triage/linking surfaces such as `fn_feature_link_task` where the created/linked task carries a workflow selection. +- **Expected happy path + lifecycle transitions + feedback:** Unselected tasks resolve to `builtin:coding`; explicitly selected workflows persist on the task before scheduler pickup; newly created tasks enter the normal planning/todo path for their selected workflow; mission goal provenance remains derived through the mission/feature hierarchy rather than copied onto the task row. The UI shows the selected workflow and offers **Edit workflow** in the task workflow context. +- **Failure / recovery expectation:** A missing or corrupt explicit custom workflow fails closed as a workflow-resolution failure instead of silently falling back to `builtin:coding`. Invalid workflow IDs supplied through tools reject with a clear validation error. Mission links must preserve their own linked-task guards; deleting mission hierarchy cannot silently drop live linked tasks. +- **Measurable success signal:** The task record/tool output shows the selected workflow ID; task detail shows the workflow context; runtime starts with the selected workflow; workflow-resolution failures park the task with an explicit error rather than executing the wrong workflow. +- **Priority:** MVP/blocking for per-task selection and fail-closed resolution; nice-to-have for first-class mission-feature workflow defaults if not already supported by a triage entry point. + +### 4. Execute the selected workflow through the graph runtime + +- **Actor / need:** The scheduler/executor needs to run the selected workflow deterministically while preserving Fusion's observable task lifecycle. +- **Trigger:** A schedulable task with a selected or default workflow is picked up for execution. +- **Expected happy path + lifecycle transitions + feedback:** `TaskExecutor.execute()` resolves the workflow, pins graph execution for the run, and `WorkflowGraphExecutor` traverses nodes through workflow runtime primitives such as planning, execute, workflow-step, review, merge, schedule, and step-execute. Standard coding work continues to show `todo → in-progress → in-review → done` (or equivalent workflow-defined columns/holds where enabled), workflow checks appear on task cards/list/detail, and task documents/artifacts are persisted as produced. +- **Failure / recovery expectation:** Unsupported edge conditions throw `WorkflowIrError`; explicit custom workflow resolution failures fail closed; interpreter failures park as workflow failures rather than re-running a legacy imperative path. File-scope guards (`FileScopeViolationError`), squash overlap enforcement, `autoMerge:false` terminal-until-human behavior, and `moveTask(in-progress → todo)` hard-cancel semantics remain non-bypassable. +- **Measurable success signal:** Workflow results are visible in task card/list/detail surfaces; node outcomes route according to `success`, `failure`, or `outcome:` edges; relevant run-audit records exist for lifecycle/git/database mutations; parity instrumentation emits `workflow:parity-observed` or `workflow:parity-drift` when dual-observe is enabled. +- **Priority:** MVP/blocking. + +### 5. Enforce gate, revision, readonly, and required-artifact contracts + +- **Actor / need:** A reviewer, workflow-step agent, or non-coding operator needs gates to prevent false success while advisory checks remain non-blocking. +- **Trigger:** A prompt/script/gate/step-review node runs; a workflow step emits `APPROVE`, `APPROVE_WITH_NOTES`, `REVISE`, malformed output, or a readonly tool attempt; terminal success is evaluated against declared artifacts. +- **Expected happy path + lifecycle transitions + feedback:** `gateMode: gate` blocks merge/completion on failure; `gateMode: advisory` records `advisory_failure` without blocking. Structured verdicts persist; `REVISE` follows the existing revision-loop behavior by appending in-scope feedback to Workflow Revision Instructions and reopening the appropriate implementation step/session. Required artifact keys must exist before terminal success; otherwise the run is incomplete rather than falsely done. +- **Failure / recovery expectation:** `toolMode: readonly` is enforced as a hard allowlist; denied mutation tools fail closed with `READONLY_VIOLATION` / `[readonly-violation]`. Out-of-scope revision feedback becomes a dependent follow-up task rather than mutating unrelated files. Malformed verdict output is recorded as `malformed` with no inferable verdict. Bounded rework edges prevent infinite loops and route `outcome:rework-exhausted`. +- **Measurable success signal:** `WorkflowStepResult` stores verdict/notes/output; task logs or prompt revisions show retained in-scope feedback; created follow-up task IDs capture out-of-scope feedback; required task-document keys exist at terminal success; missing artifacts leave an incomplete/failure state visible in workflow results. +- **Priority:** MVP/blocking. + +### 6. Recover failed, blocked, or parked workflow runs without silent backward moves + +- **Actor / need:** The scheduler/self-healing system needs to recover eligible workflow work without erasing operator intent or hiding unrecovered failures. +- **Trigger:** A task is failed/blocked/parked after a workflow node failure, retry exhaustion, stale worktree metadata, dependency-blocking lease, failed pre-merge workflow result, or manual `moveTask(in-progress → todo)` cancel. +- **Expected happy path + lifecycle transitions + feedback:** Eligible recoveries are bounded and explicit: failed pre-merge workflow results can auto-revive only within configured budgets, stale metadata is reconciled with audit evidence, dependency/lease circular waits are unwound only when proof gates pass, and terminal/actionable `in-review` failures remain visible. Human-paused or `autoMerge:false` in-review work stays terminal-until-human merge unless a documented scoped exception applies. +- **Failure / recovery expectation:** Self-healing must publish typed recovery facts and reconcile metadata; it must not silently requeue, pause, fail, unpause, or move merge/retry tasks outside guarded workflow primitives. When proof is insufficient, it emits annotation-only `task:*-no-action` run-audit events rather than mutating lifecycle state. +- **Measurable success signal:** Run-audit includes recovery mutation events such as `task:reconcile-dependency-blocking-lease`, no-action events from the backward-move family, or workflow recovery events; task logs explain auto-recovery; task state remains stable when recovery is not proven. +- **Priority:** MVP/blocking. + +### 7. Preserve workflow run state across scheduler/engine restarts + +- **Actor / need:** Operators need in-flight custom workflow runs to survive process restarts without duplicating work, losing progress, or running the wrong workflow. +- **Trigger:** The engine or scheduler restarts while a task is planning, executing graph nodes, waiting in review/hold, blocked, or recovering. +- **Expected happy path + lifecycle transitions + feedback:** Persisted task state, workflow selection, workflow setting values, task steps, documents, workflow results, custom fields, and run-audit history are enough for startup recovery to reattach or resume forward when safe. Orphaned assigned executions can re-dispatch in place after grace windows; stranded `in-progress` rows without runnable context can move back to `todo` only through audited recovery paths. +- **Failure / recovery expectation:** Restart recovery must not reset selected workflows to `builtin:coding` when an explicit custom workflow was chosen, must not duplicate terminal actions, and must preserve `autoMerge:false` in-review terminal semantics. Missing/corrupt explicit workflow definitions continue to fail closed after restart. +- **Measurable success signal:** After restart, task detail/tool state still shows the workflow ID and node/step progress; run-audit has startup/self-healing records for any repair; no duplicate workflow results or duplicated task documents are produced; failed explicit workflow resolution remains visible as an error. +- **Priority:** MVP/blocking. + +## MVP gap → follow-up ledger + +This task is a documentation-only map and did not perform a source-code audit. The ledger therefore records only gaps confirmed by the source documents, not speculative product defects. + +| Gap / criterion | Status | Follow-up task | +|---|---|---| +| Dedicated workflow run telemetry for `workflow_definition_registered`, `workflow_run_started`, run-level status keyed by workflow definition ID, and definition-ID adoption metrics | Nice-to-have/enhancement per MVP spec instrumentation notes; not blocking this acceptance map because existing success signals can be task state, workflow results, task documents, and run-audit | Not filed here as an MVP/blocking gap | +| Cross-node workflow setting value sync | Nice-to-have/enhancement; Settings Reference explicitly says workflow settings are not synced across nodes yet | Not filed here as an MVP/blocking gap | +| First-class mission-feature workflow defaults at triage time | Deferred/conditional; MVP spec lists this as an open decision, while current supported surfaces include task creation/selection and feature-to-task linkage | Not filed here without a confirmed current-behavior defect | +| Confirmed unmet MVP/blocking implementation criterion | None confirmed during this docs-only analysis | None | + +## Non-goals / deferred journeys + +The following journeys are intentionally out of scope for the MVP reliability bar and should not block the first reliable custom workflow launch: + +- Drag-and-drop marketplace-grade workflow builder beyond the shipped visual editor mechanics. +- Cross-workflow triggers, event buses, or orchestration/dependencies between separate workflows. +- Arbitrary external write connectors such as Slack/Jira/Zendesk/CRM actions beyond existing Fusion tools. +- Custom per-step RBAC or secret-scope models beyond existing `toolMode`, sandbox, and action-gate controls. +- Template marketplace, workflow version marketplace, and runtime migration/versioning of workflow definitions. +- Organization-level approval policy engines beyond existing review/approval settings and workflow gates. + +## Release-check checklist + + + +Before claiming the custom workflow system is reliable for goal **G-MPW67VQR-0001-97S3**, QA or engineering should run the executable release-check harness: + +```bash +pnpm test:workflow-release-check # run the targeted manifest-listed seams and emit text PASS/FAIL evidence +pnpm test:workflow-release-check --json # emit the same item/seam evidence as machine-readable JSON +pnpm test:workflow-release-check --dry-run # validate the manifest and print planned commands without running Vitest +``` + +The source of truth for the checklist-to-seam mapping is [`scripts/lib/workflow-reliability-release-check.json`](../scripts/lib/workflow-reliability-release-check.json). The runner validates that every referenced file exists, groups the seams into targeted package-scoped Vitest commands, and exits non-zero if the manifest is invalid or any required item fails. It is intentionally an on-demand QA/release lane, not a merge-gate expansion. + +| Release-check item | Manifest ID | Automated evidence seams | +|---|---|---| +| A custom workflow can be authored/imported, rejected on invalid IR, saved, discovered, selected, and reloaded. | `author-import-save-discover-reload` | `packages/core/src/__tests__/workflow-definition-store.test.ts`; `packages/dashboard/src/routes/__tests__/workflow-import-export.test.ts`; `packages/dashboard/src/routes/__tests__/workflow-design-route.test.ts`; `packages/core/src/__tests__/workflow-selection-store.test.ts` | +| A task can execute the selected workflow through runtime primitives, and explicit missing custom workflow IDs fail closed. | `selected-workflow-execution-fail-closed` | `packages/core/src/__tests__/workflow-selection-store.test.ts`; `packages/engine/src/__tests__/workflow-task-runtime.test.ts` | +| Gate/advisory/readonly/`REVISE`/required-artifact behavior is observable in task state, workflow results, task documents, and logs. | `gate-advisory-readonly-revise-required-artifact` | `packages/engine/src/__tests__/workflow-malformed-verdict-gate.test.ts`; `packages/engine/src/__tests__/workflow-required-artifact-gate.test.ts`; `packages/engine/src/__tests__/workflow-step-readonly-allowlist.test.ts`; `packages/engine/src/__tests__/executor-workflow-revision-scope.test.ts` | +| `autoMerge:false`, hard-cancel, file-scope, and recovery invariants are preserved under custom workflow execution. | `automerge-hard-cancel-file-scope-recovery` | `packages/engine/src/__tests__/reliability-interactions/workflow-and-file-scope.test.ts`; `packages/engine/src/__tests__/reliability-interactions/workflow-interpreter-cutover.test.ts`; `packages/engine/src/__tests__/self-healing-custom-workflow-recovery.test.ts` | +| Engine/scheduler restart preserves workflow selection and progress, and any recovery emits typed run-audit evidence instead of silent lifecycle mutation. | `restart-selection-progress-run-audit` | `packages/core/src/__tests__/workflow-restart-durability.test.ts`; `packages/engine/src/__tests__/self-healing-custom-workflow-recovery.test.ts` | + +Manual-only checks: **none currently deferred**. If a future release-check item cannot be automated, add it to the manifest's `manual` array with a non-empty `automationDeferredReason`, label it here, and file/link a focused follow-up after confirming there is no duplicate task. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1c3e8743ca..06930ea8ea 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -4,6 +4,14 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, settings, logs, and repository operations. +## Dashboard Updates + +When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, restart Fusion to apply the new version because the already-running dashboard server is unchanged until restart. + +## Mobile/PWA app icons + +The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images. + ## Browser Navigation The dashboard now handles browser back navigation consistently on desktop and mobile. @@ -53,10 +61,12 @@ Features: - Working-branch and base-branch filter selections are persisted per project and restored across refresh/navigation - Column visibility controls - Inline quick entry creation +- The quick-entry GitHub icon is a per-task tracking override: leave it untouched to use the project default, turn it on to opt the next task into tracking when the default is off, or turn it off to opt the next task out when the default is on. - PR/issue badges with live updates -- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown alongside existing footer metadata like timers -- Agent-created provenance badge in task card headers for agent-originated tasks (`sourceType: agent_heartbeat` or `sourceType: automation`, or legacy tasks with `sourceAgentId`), with labels preferring `sourceMetadata.agentName` over raw agent IDs +- GitHub provenance marker on task cards imported from GitHub (`sourceType: github_import`), shown in the footer with other external-source metadata +- Task card header meta badges group priority, fast mode, agent-created provenance, and elapsed/created-time chips into one wrapping row; agent labels prefer `sourceMetadata.agentName` over raw agent IDs - Column ordering semantics: `todo` mirrors scheduler pickup order (priority descending, then oldest `createdAt`, then task ID); `triage`, `in-progress`, `in-review`, and `archived` remain priority-first with task-ID tie-breaks; `done` is ordered by most recent completion first (`columnMovedAt`, then `updatedAt`, then `createdAt` fallback) +- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll. ![Board view](./screenshots/dashboard-overview.png) @@ -98,22 +108,27 @@ Behavior: - Nodes support manual drag repositioning with a 4px movement threshold to separate click from drag, using pointer capture and zoom-aware delta scaling for reliable tracking - Custom node positions persist per project in browser localStorage (`kb:${projectId}:fusion-plugin-dependency-graph:positions`) across refresh/project switches, and **Fit to graph** clears saved positions and restores auto-layout -## Workflow Editor +## Workflow Selection and Editor -The workflow editor opens as a full-screen modal editor for authoring custom workflows from the board's workflow selector. +Workflows define how a task moves through planning, execution, review, workflow steps, merge, and any custom graph policy. Most coding tasks can stay on the default Coding workflow, but task and board workflow controls can select a different built-in or custom workflow per task. For the built-in catalog and runtime semantics, see [Workflow Steps → Workflow overview](./workflow-steps.md#workflow-overview). + +The workflow editor opens as a full-screen modal editor for inspecting built-ins and authoring custom workflows. Navigation: -- Open a task or board surface that shows the workflow selector, then choose **Manage…** +- Open a task or board surface that shows the workflow selector, then choose **Manage…**. - From the board workflow toolbar, use the edit workflow button beside the selector to open the currently selected workflow directly when one is selected. +- Use the global **Workflow** / **Workflows** entry point from desktop header, compact header overflow, or mobile **More** navigation to browse definitions. +- From Settings moved-setting stubs, choose **Open workflow settings** to jump to the default workflow's settings values. Behavior: - Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels - Read-only built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology. +- Custom workflows can be created from blank, duplicated from built-ins/custom definitions, imported/exported, AI-designed, validated, and saved from the editor. - The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring. - The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow. - On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs. - On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the board workflow toolbar edit button opens directly to the selected workflow editor when that selected workflow is available. -- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. +- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. The structural **start** node opens an inspector for the workflow entry column when the workflow defines columns; the **Name** field remains unavailable because the start label is structural. For custom workflows, editable outline rows also expose **Move up** and **Move down** controls that reorder steps within their current column or template parent; built-in workflows remain read-only and hide those controls. - Simple/mobile authoring exposes dedicated destinations for **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions**. Add includes the node palette plus fragments, built-in step templates, and plugin step templates; Actions includes save, AI edit, auto-layout, export, and delete for custom workflows, plus export and duplicate for built-ins. Settings keeps the Definitions/Values tab split. - The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens @@ -223,16 +238,21 @@ Chat view provides project-scoped conversations with agents. - Entering `/new` or `/clear` (exact match after trimming) in the composer starts a fresh thread for the current chat target instead of sending the literal command to the model - On mobile, the New Chat and Delete Conversation dialogs use a compact inset treatment (centered, viewport-bounded, internally scrollable) instead of the app's default full-height mobile modal chrome. - Full Chat and Quick Chat both consume the same streamed `/api/chat/sessions/:id/messages` response contract, and both now prefer the authoritative assistant `message` snapshot on `done` while still accumulating `text` chunks when present (so providers without incremental text streaming still render output immediately) -- In-progress assistant responses now survive refresh/navigation while generation is still active: Chat restores the last durable in-flight text/thinking/tool state immediately, then resumes streaming from the stored replay point instead of starting from an empty "Connecting…" placeholder. +- In-progress assistant responses now survive refresh/navigation while generation is still active: Chat restores the last durable in-flight text/thinking/tool state immediately, keeps the prior persisted conversation visible, then resumes streaming from the stored replay point; any new text, thinking, or tool-call updates append to that restored bubble instead of replacing it or starting from an empty "Working…" placeholder. - If a regular Chat stream drops with a hidden-tab/browser-suspension error (for example `Load failed`) while the server is still generating, Chat suppresses the false error banner, re-attaches to the in-progress stream using the durable replay state, and reconciles the final assistant reply when generation completes. - If you queue a follow-up user message while the assistant is still streaming, Chat now persists that queued text per session so leaving and returning to the view still restores and sends it once the active response finishes. - Chat message lists now track near-bottom scroll state: while you are reading older messages, live streaming/new replies do not force-scroll; a **Latest** jump control appears until you return to the tail. - On mobile direct-chat threads, entering a thread and restoring Chat after tab/page visibility returns re-anchors to the newest message (`scrollTop = scrollHeight`) so the view always opens at the live tail. - On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows. +- Direct chat sessions can be renamed from the desktop conversation context menu and from the mobile session switcher; blank rename submissions clear the custom title so the default session label is shown again. - On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters. - Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged. + +- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card recognizes provider-native question tools and Fusion's `fn_ask_question`, supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only. - The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately. - Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools. +- Chat attachments are included in agent-visible prompts for both direct sessions and rooms: supported text attachments are appended under an `Attachments` prompt section, and supported images (`png`, `jpeg`, `gif`, `webp`) are passed as image inputs to the model. +- Chat attachments can be sent without accompanying text in both Quick Chat and Main Chat; fully empty sends with no text and no attachments are still blocked. ![Chat view](./screenshots/chat-view.png) @@ -252,11 +272,13 @@ Chat Rooms are project-scoped group conversations for multiple agents. They are - Submitting the room composer calls `rooms.sendRoomMessage(...)`, which immediately inserts a temporary local user message and then posts to `POST /api/chat/rooms/:id/messages`. - The room composer clears immediately when send is dispatched so the user gets instant feedback; on success the optimistic message is reconciled with persisted server data and the transcript is refreshed to authoritative history. - On mobile, room threads use the same keyboard-aware thread anchoring as direct chat, keeping the composer pinned above the soft keyboard while typing. +- On mobile, the room and direct composer send buttons use a two-latch touch/pointer dedupe: pointer/touch events claim only the current gesture, while a separate click latch consumes any trailing synthetic click. One tap dispatches exactly one send, a second iOS tap within the suppressed-click window still sends, and a send-to-stop button swap does not accidentally press stop. - The dashboard backend now orchestrates room responders on that POST: mentioned members are routed as direct responders, additional ambient members may reply (up to the room ambient responder cap), and each assistant reply is persisted with `senderAgentId` via `chatStore.addRoomMessage(...)`. - Room responders can intentionally stay silent by returning the `__SKIP__` sentinel; that sentinel is treated as a no-op and is never persisted, emitted over SSE, or rendered in room transcripts. - If room replies cannot be generated (for example no resolvable responders or all responders fail), the POST fails with an API error (HTTP 502) instead of silently returning only the user message. - If room responders cannot be resolved or all room-reply generations fail, the POST now returns an error instead of silently succeeding with only the user message, so failures are surfaced deterministically. - Room responder prompt construction now keeps the most recent room messages verbatim and, when the room runs long, prepends a compacted summary of older history (span, participants, and key highlights) plus an explicit latest-user-message marker so replies stay thread-aware without unbounded prompt growth. +- Room responder prompts include the latest room message attachments using the same direct-chat behavior: text is inlined into the prompt and supported images are forwarded as model image inputs. - On send failure, `useChatRooms` rolls back/reconciles optimistic state and rethrows; `ChatView` catches once, restores the exact pre-send composer text for retry/edit, and surfaces a single error toast (no duplicate hook+view notifications). - After each send attempt, the room transcript still re-fetches authoritative messages so persisted user/assistant replies remain visible even when SSE delivery is delayed, and `chat:room:message:*` SSE updates continue live fan-out. - Relationship summary: direct Chat runs one target (agent or model) per session; rooms are shared threads with multiple agent members and now use the same message contract as direct Chat; Quick Chat is still a floating panel, but when a room is selected it now reads/writes that room thread directly. @@ -268,9 +290,11 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv - Controlled by the project setting `showQuickChatFAB` - Supports agent mentions (`@agent`) and shared `#` task/file mentions +- Supports `/skill:{name}` in model-loop chat to request a specific enabled skill for that session; the slash token is removed from the model prompt while the original user message remains in chat history - Uses the same model/provider infrastructure as full Chat view - On small screens, compact tool-call summaries in the floating panel intentionally stay single-line (count + tool names + status) to preserve message density - The panel header uses a session-first flow: the main dropdown lists persisted sessions (preferring `session.title`, then falling back to deterministic `Session N` labels) +- Quick Chat sessions can be renamed from the session dropdown, and the active title is shown in the header so custom names remain visible after the dropdown closes. - Selecting a session from that dropdown resumes the persisted conversation; this keeps `switchSession()` resume-oriented rather than forcing a new thread - Entering `/new` or `/clear` (exact match after trimming) in the Quick Chat composer clears the active thread target: direct/model targets use `startFreshSession(...)`, while room targets call `rooms.clearRoom(activeRoom.id)`. - The `+` action opens an inline new-session chooser (inside the panel, not a modal) with `Model` selected by default and optional switch to `Agent` @@ -280,11 +304,15 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv - Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued text and flushes it once the active response completes. - Resume lookups still use targeted session queries instead of loading the full active-session list first - Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping -- On mobile viewports, opening Quick Chat auto-focuses the composer as soon as it is ready so the keyboard opens immediately +- Question tool calls use the same shared response card as full Chat, with compact spacing in the floating panel and read-only answered history so Quick Chat can continue agent clarification loops without exposing raw tool JSON. +- Opening Quick Chat auto-focuses the composer as soon as it is ready on desktop and mobile viewports; mobile additionally uses the stealth-input handoff so the soft keyboard opens immediately - FAB dragging uses pointer events with document-level move/up tracking and a 5px drag threshold so Android touch drags reposition reliably while short taps still open Quick Chat - Quick Chat now mirrors full Chat tail behavior: if you scroll up, live updates stop auto-following and a **Latest** jump control appears until you jump back down. - On mobile, Quick Chat re-anchors to the newest message whenever the panel is opened/reopened and when page visibility is restored, while still preserving the near-bottom gate so intentional scroll-away keeps **Latest** jump behavior. - On mobile, Quick Chat bubbles are slightly wider while keeping compact tool-call summary layout and full-screen/safe-area behavior intact. +- On mobile, Quick Chat send reliability includes a delivery watchdog: if a queued message would otherwise stay stranded in the composer after a dropped or suspended stream, it is re-confirmed and delivered once no generation is in flight and no live stream is connected, so sends are not silently dropped. +- On mobile, Quick Chat sends exactly once per tap even when the browser emits paired pointer and touch events; a stop tap immediately after send is still honored. +- While a response is streaming, the Quick Chat stop control matches the send button's square dimensions (including on mobile) instead of collapsing toward its icon, so it stays an easy touch target. ## Mailbox View @@ -314,6 +342,10 @@ Features: - PTY-backed shell sessions - Ctrl/Cmd+C copies the current terminal selection, while plain Ctrl+C with no selection still sends SIGINT - Ctrl/Cmd+V pastes clipboard text into the active terminal session +- The Shortcuts panel includes Ctrl/Alt helpers, ESC/Tab, common shell shortcuts, and Up/Down/Left/Right arrow buttons that send standard ANSI cursor sequences for keyboard-less shell history and line editing +- The Preferences panel customizes font family, font size, cursor style, cursor blink, and renderer; changes persist in browser `localStorage` under `kb-terminal-preferences`, with the legacy `kb-terminal-font-size` value migrated automatically +- Font and cursor preferences apply live to the active xterm instance; renderer changes apply the next time the terminal opens, and mobile devices keep the WebGL renderer disabled to avoid glyph artifacts +- Embedded CLI session terminals honor the same saved preferences for live, idle, ended, read-only, and interactive session views. Cursor blink still stays disabled for read-only/replay sessions, renderer changes apply on the next session mount, and WebGL never loads on mobile viewports. - Mobile-aware virtual keyboard handling and auto-refit behavior - Reopen/reconnect/session-recovery flows preserve single-keystroke input forwarding (no duplicate characters, no page refresh required) @@ -371,7 +403,11 @@ Branch names are dynamic from merge/audit payloads; the banner is not hardcoded ## OAuth Re-login Banner -The global OAuth re-login banner now clears a provider row immediately after that provider successfully re-authenticates (from Settings → Authentication or Model Onboarding), instead of waiting for the next `GET /auth/status` poll interval. +The global OAuth re-login banner clears a provider row immediately after that provider successfully re-authenticates (from Settings → Authentication or Model Onboarding), instead of waiting for the next `GET /auth/status` poll interval. + +For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. When that refresh succeeds, the banner clears for Claude without manual re-login and without waiting for a separate model request. + +If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. ## Smart Pull @@ -416,6 +452,7 @@ Features: - Open project markdown files with inline preview - Jump directly from a document group to the owning task detail modal - Toggle between raw text and rendered markdown using the **Markdown/Plain** button +- Highlight text in raw or rendered project-file previews, choose **Add comment**, and send the file path, selected snippet, and your comment to the **New Task** dialog ![Documents view](./screenshots/documents-view.png) @@ -446,6 +483,8 @@ Documents view supports toggling between raw text and formatted markdown when vi The toggle button is accessible with `aria-pressed` for screen readers. Toggle state is scoped per-document, so switching between documents resets the view to raw mode. +Project-file previews also support selection comments in both raw and rendered markdown modes. Select text, click **Add comment**, enter a short note, and Fusion opens **New Task** with a seeded description containing the file path, snippet, and comment. + ## Todo View Todo View is an experimental dashboard surface for managing per-project todo lists and turning items into planning or task workflows. @@ -489,10 +528,11 @@ The Files modal provides a workspace-aware file browser and editor. - Use **New File** or **New Folder** in the browser header to create entries in the current folder; new files open in the editor after creation - Source/text editing supports a **Line #** header toggle to show or hide line numbers in the editor gutter - The line-number preference is saved per project and restored automatically when you switch projects +- In editable files and markdown preview mode, highlighted text exposes **Add comment** so you can send the file path, selected snippet, best-effort line range, and your note to the **New Task** dialog without copy/paste ## Memory View -Memory view provides a multi-file editor for project and daily memory files. +Memory view provides a multi-file editor for project and daily memory files. Its file editors share the same highlighted-text **Add comment** affordance as the Files modal, so memory snippets can seed a New Task with file path, snippet, and comment context. > Available when the `experimentalFeatures.memoryView` toggle is enabled. @@ -556,7 +596,8 @@ Goals view is a strategic-goals surface backed by the Goals REST API. What it shows: - Header with active-goal count (`N active goals`) and an **Add Goal** action -- Goal cards with title, optional description, and `Status: active|archived` +- Goal cards with title, optional description, `Status: active|archived`, and a **Linked Missions** section +- Linked-mission chips navigate to Mission Manager, each chip has an unlink control, and the card picker hides missions already linked to that goal - Empty state when no goals exist: `No goals yet. Add one to begin tracking strategic outcomes.` Data behavior: @@ -565,6 +606,7 @@ Data behavior: - Add-form drafting: **Draft with AI** sends the typed goal title to `POST /api/ai/draft-goal-description` and drops the returned `{ description }` into the description textarea for review/editing before save - Edit: per-card inline form patches title/description via `PATCH /api/goals/:id` - Archive/unarchive: `POST /api/goals/:id/archive` and `POST /api/goals/:id/unarchive` +- Linked missions: `GET /api/goals/:id/missions` for the reverse lookup, then `POST`/`DELETE /api/missions/:missionId/goals/:goalId` for link/unlink mutations AI drafting behavior: - The add-goal form enables **Draft with AI** once the title is non-empty @@ -610,13 +652,51 @@ Features: - Dismiss/archive/unarchive insight records as they age - Create triage tasks from selected insights directly from the view +## Command Center + +Command Center is the combined analytics and live-operations surface for a project: it pairs historical usage, cost, throughput analytics, live system telemetry, and a live Mission Control panel. + +Navigation: +- Desktop: **Header → More views → Command Center** +- Mobile: **More** sheet → **Command Center** +- Deep link: `?view=command-center` + +Features: +- Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical. +- **Overview** summarizes token usage/cost, autonomy, active nodes, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, real recharts token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. +- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, and a token-share pie backed by the same grouped token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users. +- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint. +- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users. +- **Productivity** separates outcome counters (commits and pull requests) from volume proxies such as modified files, lines changed, and files by language. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called. +- **Team** shows a per-agent analytics table plus tokens-by-agent and tasks-done-by-agent charts, and adds a real token-share pie from the same per-agent token totals. Metrics come only from the project-scoped `tasks` and `agents` tables: token totals and estimated cost are summed from the `tokenUsage*` columns by `assignedAgentId`, files changed counts parsed `tasks.modifiedFiles` paths, tasks done counts `column = 'done'` moves in the selected range, and in-progress / in-review values reflect current task columns. Agent name, role, and live state come from the `agents` table; deleted-agent task history falls back to the raw agent id instead of crashing. The tab uses `/api/command-center/team`, adds no schema, never calls GitHub, and intentionally leaves per-agent issues filed/fixed to FN-6653. Team has no per-day analytics series today, so it intentionally does not render a line chart or fabricate a trend. Decorative chart reveal motion uses duration tokens and is disabled for reduced-motion users. +- **Ecosystem** shows active model breadth and per-model task activity; unavailable plugin-activation metrics render as unavailable rather than zero. It reuses the tokens analytics endpoint grouped by model, adds a task-share-by-model pie from `TokenAnalytics.groups`, and renders a tokens/tasks trend line when `TokenAnalytics.series` buckets are present; if series buckets are absent, no synthetic trend is shown. +- **GitHub** shows local GitHub issue flow for the selected range: **Filed by Fusion** counts tasks with a persisted `githubTracking.issue`, **Fixed by Fusion** counts tasks imported from GitHub source issues (`sourceIssueProvider = "github"`) that are currently in `done`, using the persisted `sourceIssueClosedAt` / `TaskSourceIssue.closedAt` close time when the reconciler has observed it. Rows that predate the field or have not been observed closed fall back to task `updatedAt` as the documented completion-time approximation; Fusion never fabricates a close timestamp and this analytics path never calls GitHub, the `gh` CLI, or any external network source. To make historical fixed dates exact, use **Backfill exact close times** in the Fixed by Fusion card; the dashboard calls the project-scoped manual `POST /api/git/github/backfill-source-issue-closed-at` endpoint in `{ offset, limit }` batches until `hasMore` is false, then surfaces the accumulated `scanned`, `filled`, `skipped`, and `errors` counts. The endpoint fetches real GitHub `closed_at` values once, fills only missing `sourceIssueClosedAt` values, and never runs automatically or from analytics-time rendering. The area shows filed/fixed/net stat cards, a filed-vs-fixed pie, a filed/fixed recharts trend line, existing daily sparklines, and a by-repository bar breakdown. +- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. It shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns; an empty incidents table renders honest zero counts with MTTR unavailable rather than fabricated signal volume. It adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. External connectors that ingest third-party signals into incidents are tracked separately in FN-6706. +- **System** is the canonical system-telemetry destination. It reuses `GET /api/system-stats` with no new endpoint, renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed. +- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences. +- CSV exports are available from the analytics endpoints with `?format=csv`. The Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`. + +Rendering invariants: +- On mobile (`max-width: 768px`), `.cc-tabpanel` remains the sole vertical scroll owner for every chart-bearing tab. Shared chart primitives (`Bar`, `StackedBar`, `Sparkline`, `LineChart`, `RadialGauge`, `Funnel`, `TokenSeriesChart`, and the Command Center recharts wrappers) must shrink within the tabpanel, keep non-zero usable height, avoid stretch/clipping artifacts, and never introduce a competing vertical overflow container. +- Mobile chart text must not rely on min-content luck: bar labels, values, token-series axis labels, funnel headers, radial labels, legends, and chart tracks need explicit `min-inline-size: 0`, wrapping, or ellipsis rules so long model/agent/repo labels cannot crush the track or create hidden horizontal overflow in a real browser. +- On tablet (`min-width: 769px` and `max-width: 1024px`), `.project-content`, `.command-center`, and `.cc-tabpanel` keep the same definite flex/min-height scroll-owner chain, while the live strip and chart grids collapse before they can create document-level horizontal overflow. +- Command Center stat cards, overview chart cards, live strips, table wrappers, Team chart panels, token-series plots, system control cards, and gauge/chart cards share the same tokenized rhythm: `--space-md` gaps/padding for card-like surfaces, `1px solid var(--border-subtle)` borders, `--radius-md` radii, and `--surface-1` backgrounds. Area-specific accents may use `color-mix(...)`, but layout, border, radius, text color, and motion must stay on design tokens, with the named 4px spacing scale (`--space-xs`/`sm`/`md`/`lg`/`xl`/`2xl`) as the canonical vocabulary. +- The dashboard browser-layout smoke includes a `[data-smoke="command-center-charts"]` fixture that loads emitted lazy Command Center CSS and verifies representative recharts pie, line, and empty states at mobile (390×844) and desktop breakpoints. The fixture asserts non-zero chart and SVG heights, visible empty-state text, no internal/page horizontal overflow, and no chart-level vertical scroll owner before chart layout changes are considered verified. + +Data states: +- Overview shows a loading state while core analytics settle, then shows `No usage data yet. Run some agents to populate the Command Center.` only after the selected range has settled with no core usage data. Overview, Tokens, Tools, Activity, Productivity, Team, Ecosystem, GitHub, Signals, System, and Reliability omit their additive recharts cards in loading/error/empty states, so non-populated data never leaves an empty chart shell. +- GitHub issue analytics is local and additive: empty filed/fixed totals keep the stat cards and historical backfill button available while omitting empty chart shells; malformed historical `githubTracking` JSON is skipped instead of breaking the Command Center. +- Team analytics renders its shared loading/error/empty states for null or zero-agent responses, omits empty chart shells for zero-value datasets, and keeps the Command Center tab panel as the mobile scroll owner. +- System telemetry keeps the previous snapshot visible during refresh failures, renders a first-sample CPU `Sampling…` state without NaN values, shows zero-value task/agent bars for empty collections while omitting the zero-value task-distribution pie, and keeps the Command Center tab panel as the mobile scroll owner. +- Signals is best-effort over local incidents data: if the project has no incidents, the Signals area shows its empty state, omits its status pie, and other Command Center metrics remain valid; endpoint errors surface as the shared analytics error state instead of silently swallowing a missing route. + ## Reliability View Reliability view summarizes in-review pipeline health so operators can spot bounce/merge instability trends without leaving the dashboard. Navigation: -- Desktop: **Header → More views → Reliability** -- Mobile: **More** sheet → **Reliability** +- Desktop and mobile: **Command Center → Reliability** tab +- Legacy persisted `reliability` view state redirects to Command Center so existing browser sessions land on the new tab container instead of an invalid top-level view. Features: - Headline 7-day in-review success rate (derived as `1 - inReviewFailureRate7d`) with color thresholds: success for `≥95%`, warning for `≥90%`, error below `90%`; shows **Insufficient data** when the metric is null @@ -694,7 +774,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou - The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode. - Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form. - These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group. -- Task metadata also shows compact `Created` / `Updated` timestamps: recent values render as relative time (`just now`, `Xm`, `Xh`, `Xd`) and older values switch to short month/day dates; these stay grouped on one row across desktop and mobile widths for a compact metadata layout. +- Task metadata keeps priority, execution mode, provenance, optional PR context, and compact `Created` / `Updated` timestamps in one wrapping row across desktop and mobile widths; recent timestamps render as relative time (`just now`, `Xm`, `Xh`, `Xd`) and older values switch to short month/day dates. - Eligible existing tasks (triage, todo, in-progress, in-review) expose a **GitHub tracking** section directly in Task Detail, even when tracking is currently disabled. - The GitHub tracking section now defaults to a compact summary row; use the disclosure arrow to expand linked-issue details plus tracking edit controls. - Backstop reconciliation runs every 15 minutes to close tracked GitHub issues for soft-deleted and archived tasks even after restart; the sweep is paginated so large archive backlogs are eventually drained. @@ -702,7 +782,7 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou - From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults. - In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab. - Task Detail and list split-pane PR affordances follow the live project auto-merge setting: when auto-merge is off, manual **Create PR** / merge actions are shown; when it is on, the tab shows the automatic auto-merge hint unless a per-task override changes the effective behavior. -- The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit the result, push the branch, and refresh preflight so normal PR creation can continue once all checks pass. +- The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit and push real merge changes, or report success without an empty commit when the selected base is already merged; preflight then refreshes so normal PR creation can continue once all checks pass. - The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring. - AI title/body generation is bounded to 60 seconds and is canceled if the dialog request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever. - The **Review** tab is separate from **Comments**: Review shows actionable PR/reviewer feedback and same-task revision controls, while Comments remains the general collaboration thread. @@ -735,7 +815,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig ### Logs → Agent Log view -The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the composer sends guidance through the same steering path used by comments, including active assigned `in-progress`/`in-review` sessions and messages queued when no session is currently live. On a `done` task, sending a Chat message starts a refinement task using the typed text as feedback and shows a success toast with the new task ID; the current task detail modal remains on the completed task. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. +The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the composer sends guidance through the same steering path used by comments, including active assigned `in-progress`/`in-review` sessions and messages queued when no session is currently live. On a `done` task, sending a Chat message starts a refinement task using the typed text as feedback and shows a success toast with the new task ID; the current task detail modal remains on the completed task. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut. The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions: @@ -1174,7 +1254,11 @@ The `index.html` shell is templated server-side: the server injects a per-user ` `styles.css` is the source of truth for tokens (`--space-*`, `--radius-*`, `--shadow-*`, `--duration-*`, `--transition-*`, `--font-*`, `--header-height`, `--mobile-nav-height`, `--standalone-bottom-gap`, `--overlay-padding-top`) and color variables (`--bg`, `--surface`, `--card`, `--text`, `--text-muted`, status colors `--triage`/`--todo`/`--in-progress`/`--in-review`/`--done`, semantic `--color-success`/`--color-error`/`--color-warning`/`--color-info`, status backgrounds `--status-*-bg`). -**Always reference tokens. Never hardcode pixels, hex, or `rgba()` in component CSS** — the only exception is inside `:root`/theme blocks where tokens are *defined*. For translucent backgrounds use `color-mix(in srgb, var(--color) X%, transparent)`, not `rgba()`. +**Always reference tokens. Never hardcode pixels, hex, or `rgba()` in component CSS** — global/theme token CSS is also covered by `global-theme-css-no-raw-rgba.test.ts`, so raw `rgba()` belongs only in explicit `var(--token, rgba(...))` fallbacks. For translucent backgrounds use `color-mix(in srgb, var(--color) X%, transparent)`, not `rgba()`. + +Command Center chart surfaces are a stricter token-only zone: `CommandCenter.css`, `areas/areas.css`, and `charts/charts.css` should avoid raw color fallbacks and hardcoded dimensions in component rules, keep secondary copy on `--text-muted`, use canonical `--accent` / `--text` for generic accent and primary text styling, use `--duration-*` for animation durations, and encode mobile chart invariants with shared classes rather than one-off area styles. The undefined `--color-accent` / `--text-primary` aliases are forbidden under `components/command-center/**` and guarded by `command-center-css-token-canonicalization.test.ts`. + +Non-Command-Center dashboard CSS uses `--text` as the canonical primary text token. The undefined `--text-primary` alias is forbidden outside `components/command-center/**` and guarded by `packages/dashboard/app/__tests__/text-token-canonicalization.test.ts`. ### Theme system @@ -1217,7 +1301,7 @@ Manage project and global secrets directly inside **Settings → Project → Sec ### Lazy-Loaded Heavy Views -These 19 views are lazy-loaded via `React.lazy()` with ``. `prefetchLazyViews()` warms chunks once on mount via `requestIdleCallback`. **Do not make these eager.** +These 22 views are lazy-loaded via `React.lazy()` with ``. `prefetchLazyViews()` warms App-level chunks once on mount via `requestIdleCallback`; AppModals lazy modal imports (`SettingsModal`, `WorkflowNodeEditor`, `SetupWizardModal`) are part of the same inventory. **Do not make these eager.** - `AgentsView` - `NodesView` @@ -1229,12 +1313,15 @@ These 19 views are lazy-loaded via `React.lazy()` with ` + +## Purpose + +The mobile xterm wide-glyph defect recurred across FN-6390 → FN-6424 → FN-6603 → FN-6638 → FN-6659. Each fix shipped without a real-iOS reproduction because the execution environment had no BrowserStack, Sauce Labs, LambdaTest, or physical-device surface. FN-6641 and FN-6662 therefore had to treat the real-device gate as unavailable instead of verified. + +`scripts/ios-acceptance.mjs` is the reachable-surface plumbing for future terminal acceptance gates: + +- `--check` reports whether real-iOS cloud credentials are present and exits `0` only when a provider is usable. +- `--dry-run` prints the redacted provider/capability plan without opening a network session. +- Session mode opens a real iOS Safari W3C WebDriver session, navigates to a served Fusion dashboard URL, captures a PNG screenshot, and always deletes the cloud session in `finally`. + +The harness is intentionally dependency-light: it uses built-in `fetch` and does **not** install Selenium, WebdriverIO, Appium, or provider CLIs. + +## Real-device options + +### Option A — physical iPhone or iPad + +Use a current iPhone or iPad running Safari with macOS Safari remote Web Inspector: + +1. Serve the built dashboard on a reachable free port. Use `--port 0` or another free port; **never use port 4040**, which is reserved for the production dashboard. +2. Open the URL on the physical device. +3. In macOS Safari, enable Develop menu and choose Develop → device → page. +4. Capture screenshots and measure terminal cell widths through Web Inspector. + +This path does not use `scripts/ios-acceptance.mjs` session mode, but the `--check` probe should still return non-zero unless cloud credentials are also present. A human verifier records the physical-device evidence in the task document. + +### Option B — real-iOS cloud WebDriver + +Supply exactly one complete credential pair for a supported provider. If multiple pairs are present, the harness chooses BrowserStack → Sauce Labs → LambdaTest. + +| Provider | Credential keys | Default hub URL | +|---|---|---| +| BrowserStack | `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY` | `https://hub-cloud.browserstack.com/wd/hub` (`upstream-pending-verification`) | +| Sauce Labs | `SAUCE_USERNAME`, `SAUCE_ACCESS_KEY` | `https://ondemand.us-west-1.saucelabs.com/wd/hub` (`upstream-pending-verification`) | +| LambdaTest | `LT_USERNAME`, `LT_ACCESS_KEY` | `https://mobile-hub.lambdatest.com/wd/hub` (`upstream-pending-verification`) | + +Hub base URLs are region-configurable: + +- `BROWSERSTACK_HUB_URL` +- `SAUCE_HUB_URL` +- `LT_HUB_URL` + +Device defaults are intentionally conservative and may be overridden without code changes: + +- BrowserStack: `BROWSERSTACK_IOS_DEVICE`, `BROWSERSTACK_IOS_VERSION` +- Sauce Labs: `SAUCE_IOS_DEVICE`, `SAUCE_IOS_VERSION` +- LambdaTest: `LT_IOS_DEVICE`, `LT_IOS_VERSION` + +The default capability target is real iOS Safari on `iPhone 15` / iOS `17`; provider-specific options set real-device flags (`realMobile`, `realDevice`, or `isRealMobile`). Do not replace this with Playwright, desktop WebKit, jsdom, or an iOS simulator for terminal acceptance. + +## Storing credentials safely + +Secret values must never be committed, logged, attached, or written into task documents. + +Recommended Fusion setup: + +1. Store each provider credential as a project secret with access policy appropriate for the operator (`auto` for unattended gates, `prompt` for manual approval, `deny` when not exportable). +2. Mark gate credentials `env_exportable=true` and set `env_export_key` to the exact env var name, for example `BROWSERSTACK_USERNAME`. +3. Enable project `secretsEnv.enabled=true` so task worktrees receive a gitignored `.env` file with the materialized keys. +4. Keep `secretsEnv.requireGitignored=true` so plaintext is never written to a tracked path. + +If environment materialization is unavailable, an operator or agent can fall back to `fn_secret_get` for these exact keys (project scope first, then global) and export them only for the acceptance command. The harness prints key names and missing-key lists, never plaintext values. + +## Harness usage + +Probe availability for FN-6662-style gates: + +```bash +pnpm ios:acceptance -- --check +# or +node scripts/ios-acceptance.mjs --check +``` + +- Exit `0`: at least one complete cloud credential pair is present; run the real-iOS gate. +- Non-zero: no cloud provider is complete. Record the missing keys and close the observational gate with: + +```text +NO-OP: real-iOS surface unavailable — credentials missing, cannot run acceptance gate +``` + +Inspect a redacted plan without network access: + +```bash +BROWSERSTACK_USERNAME=... BROWSERSTACK_ACCESS_KEY=... \ + pnpm ios:acceptance -- --dry-run --provider browserstack +``` + +Run a real session and capture evidence: + +```bash +# Serve the dashboard on a free, reachable, non-4040 port first. +DASHBOARD_URL="https://reachable.example.test" \ + pnpm ios:acceptance -- --url "$DASHBOARD_URL" --out screenshots/ios-acceptance.png +``` + +The JSON result includes `provider`, `device`, `platformVersion`, `sessionId`, and `screenshotPath`. The screenshot is a PNG decoded from the WebDriver `/screenshot` response. Authenticated hub URLs and `Authorization` headers are never printed. + +## Serving the dashboard for cloud access + +Build and serve the dashboard from the verification worktree, then make it reachable to the selected real-iOS surface: + +```bash +pnpm build +# Use the project serve/dev command appropriate for the gate and choose --port 0 or a known free non-4040 port. +``` + +For cloud devices, use the provider's documented tunnel, a public preview URL, or another approved remote-access path. The harness does not start tunnels or download provider binaries; it only talks to the hosted WebDriver hub over HTTPS. + +## External Integration Evidence + +This harness integrates hosted SaaS WebDriver hubs over W3C WebDriver using built-in `fetch`; no provider binary is downloaded or executed locally, so checksums are not applicable. + +- **BrowserStack Automate / Live** + - Canonical upstream repo URL: https://github.com/browserstack/browserstack-local-nodejs + - Docs / homepage URL: https://www.browserstack.com/docs/automate (Live: https://www.browserstack.com/live) + - Release / download URL: https://github.com/browserstack/browserstack-local-nodejs/releases/latest — `upstream-pending-verification` + - WebDriver hub (default, env-overridable via `BROWSERSTACK_HUB_URL`): `https://hub-cloud.browserstack.com/wd/hub` — `upstream-pending-verification` + - Binary / CLI name: N/A for this harness (`fetch`-based W3C hub over HTTPS); reference client binary `browserstack-local` + - Credential keys: `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY` + - Checksum: N/A (hosted service, no downloadable artifact bundled) +- **Sauce Labs Real Device Cloud** + - Canonical upstream repo URL: https://github.com/saucelabs/saucectl + - Docs / homepage URL: https://docs.saucelabs.com (Real Device Cloud: https://saucelabs.com/platform/real-device-cloud) + - Release / download URL: https://github.com/saucelabs/saucectl/releases/latest — `upstream-pending-verification` + - WebDriver hub (default, env-overridable via `SAUCE_HUB_URL`): `https://ondemand.us-west-1.saucelabs.com/wd/hub` — `upstream-pending-verification` + - Binary / CLI name: N/A for this harness (`fetch`-based W3C hub over HTTPS); reference CLI `saucectl` + - Credential keys: `SAUCE_USERNAME`, `SAUCE_ACCESS_KEY` + - Checksum: N/A (hosted service, no downloadable artifact bundled) +- **LambdaTest Real Time / Real Device** + - Canonical upstream repo URL: https://github.com/LambdaTest/LT + - Docs / homepage URL: https://www.lambdatest.com/support/docs/ (Real Time: https://www.lambdatest.com/real-time-browser-testing) + - Release / download URL: https://github.com/LambdaTest/LT/releases/latest — `upstream-pending-verification` + - WebDriver hub (default, env-overridable via `LT_HUB_URL`): `https://mobile-hub.lambdatest.com/wd/hub` — `upstream-pending-verification` + - Binary / CLI name: N/A for this harness (`fetch`-based W3C hub over HTTPS); reference tunnel binary `LT` + - Credential keys: `LT_USERNAME`, `LT_ACCESS_KEY` + - Checksum: N/A (hosted service, no downloadable artifact bundled) diff --git a/docs/missions.md b/docs/missions.md index c02b7740fc..bcedf7a5b0 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -46,11 +46,11 @@ Existing missions are intentionally **not** auto-linked to any goals. Fusion doe ### Manual linkage workflow -Mission ↔ goal links are created and removed deliberately as part of normal planning and operations work. Read surfaces can show current associations, and operator-facing write surfaces can add or remove links when a mission should explicitly support a goal. The workflow is intentionally manual so teams can choose the correct strategic relationship per mission instead of inheriting guessed links from older data. +Mission ↔ goal links are created and removed deliberately as part of normal planning and operations work. The dashboard exposes the relationship from both directions: Mission detail has an active-goal picker plus linked-goal chips with unlink controls, and each Goals view card has a mission picker plus linked-mission chips with unlink controls. Archived goals are never offered for new links, duplicate link attempts are no-ops at the store/API layer, and removing the last link restores the empty-state copy rather than leaving an empty control shell. The workflow is intentionally manual so teams can choose the correct strategic relationship per mission instead of inheriting guessed links from older data. ### Unlinked mission indicator -Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. This is a read-only attention badge so operators can quickly find active missions that still need an explicit goal association. +Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. Linking or unlinking from either dashboard surface refreshes this count so operators can quickly find active missions that still need an explicit goal association. The engine also emits a workflow insight with advisory key `unlinked_missions_advisory` when it first observes one or more active missions with zero goal links. The insight is advisory only, includes only the affected mission ids plus a count, and is deduped to one stable row so it does not spam on every scheduler heartbeat. @@ -142,6 +142,7 @@ Fusion surfaces the persisted mission↔goal linkage through REST, CLI, and pi-e | `PATCH /api/missions/:missionId` | Update mission fields. Optional `goalIds: string[]` replaces the full linked-goal set; `[]` clears links and `undefined` leaves links unchanged. | | `GET /api/missions/:missionId` | Return `MissionWithHierarchy`, including `linkedGoals` as an always-present array of `Goal` objects for the selected mission and optional `eventCount` as the authoritative unfiltered mission activity total. | | `GET /api/missions/:missionId/goals` | List linked goals for a mission. Returns `{ goals }`. | +| `GET /api/goals/:goalId/missions` | List linked missions for a goal. Returns `{ missions: [{ id, title, status }] }` and skips stale links whose mission row no longer resolves. | | `PUT /api/missions/:missionId/goals` | Replace the full linked-goal set with body `{ goalIds: string[] }`. Duplicate ids are deduplicated before reconciliation. | | `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. | | `DELETE /api/missions/:missionId/goals/:goalId` | Idempotently unlink one goal from a mission. | @@ -154,7 +155,8 @@ The mission detail payload keeps `linkedGoals` separate from the milestone tree - `fn mission goals ` — list linked goals for a mission. - `fn mission link-goal ` — idempotently link a goal; archived goals reject with `GOAL_ARCHIVED`. - `fn mission unlink-goal ` — idempotently unlink a goal, including archived goals. -- Mission detail screens in the dashboard render linked-goal chips in the mission header; selecting a chip opens the Goals view and scrolls/highlights the anchored goal card. +- Dashboard Mission detail lets operators link active goals, unlink existing goal chips, and select a chip to open the Goals view at the anchored goal card. +- Dashboard Goals cards show linked missions, let operators link/unlink missions for that goal, and select a mission chip to open Mission Manager at that mission. ## Mission Planning Tools (pi extension) diff --git a/docs/plans/2026-06-13-001-fix-test-timeout-failures-plan.md b/docs/plans/2026-06-13-001-fix-test-timeout-failures-plan.md new file mode 100644 index 0000000000..6322d9fd0b --- /dev/null +++ b/docs/plans/2026-06-13-001-fix-test-timeout-failures-plan.md @@ -0,0 +1,380 @@ +--- +title: "fix: Eliminate test timeout failures across CI shards, full suite, and changed-file runs" +type: fix +status: active +created: 2026-06-13 +depth: deep +origin: docs/plans/2026-06-03-001-perf-test-suite-speedup-plan.md (sibling speedup plan; this plan is the reliability-focused successor) +references: + - docs/test-speed-baseline-2026-06-03.md + - docs/test-speed-audit-FN-5048.md + - docs/testing.md + - AGENTS.md +--- + +# fix: Eliminate test timeout failures across CI shards, full suite, and changed-file runs + +## Summary + +Tests in this monorepo fail not because the suite is uniformly slow, but because a small number of **hang / kill / timeout failure modes** are unbounded and undiagnosable. The two structural causes: + +1. **Hangs run to the platform ceiling, silently.** Neither `scripts/ci-test-shard.mjs` nor `scripts/test-changed.mjs` imposes a per-invocation wall-clock limit, and `.github/workflows/full-suite.yml` sets **no `timeout-minutes`** on the `test-shards`, `test-slow`, or `test-inventory-guard` jobs. A single wedged vitest invocation blocks until GitHub's 6h default — so a hang looks like a stuck CI job, not a failing test. Only the dashboard lane runner (`packages/dashboard/scripts/run-vitest-with-heap.mjs`) has a wall-clock killer today. +2. **Concurrency-sensitive tests genuinely hang or error under load — across packages.** The quarantine ledger (`scripts/lib/test-quarantine.json`) holds 12 entries spanning **engine** (`merger-ai*`, `reliability-interactions/*`), **core** (`soft-delete-tasks`, `store-get-task-columns`, `task-dependency-mutation`, `task-node-override`, `db`, …, dated 2026-06-12 — newer than the engine entries), and **dashboard** (`QuickEntryBox`). The dominant signature is identical and cross-package: a `fusion-test-workers` temp-root that disappears under concurrent load (`mkdtemp … ENOENT`, leaked redirect dir, `cwd` gone, missing event). That signature traces to the **shared `WORKER_ROOT` redirect in `packages/core/src/__test-utils__/vitest-setup.ts`** (the same module U4 touches), so the root cause is a shared mechanism, not an engine-local quirk. These are real (test-fixture or product) races, not noise. + +This plan makes hangs **fail fast and diagnosably** (bounded watchdog + open-handle forensics at every layer), then **root-causes the actual hang cluster** (the shared `WORKER_ROOT` temp-isolation mechanism + the engine/core tests that lean on it, the subprocess-guard mis-fire, serialized-project wedge containment), and adds a **shard-balance guardrail** so tail-shard skew can't silently re-create timeouts. Wall-clock speedup is pursued only where it also reduces timeout risk; pure throughput work (e.g. the deferred Vitest-4 `fsModuleCache`) stays deferred. + +**Governing constraint (non-negotiable):** per `AGENTS.md` and `docs/testing.md`, widening timeouts, adding retries, or loosening assertions to make a flake pass is **appeasement and is banned**. Every fix here is either a root-cause fix or an on-sight quarantine via the deletion-ratchet ledger. The bounded-watchdog work in U1 is *not* a timeout widening — it makes an already-unbounded hang terminate sooner with diagnostics. + +--- + +## Problem Frame + +**Who hurts and where:** + +- **CI (post-merge `full-suite.yml`):** a hung shard is a 6h stuck job with no actionable output; tail-shard duration skew pushes the slowest shard toward timeout. +- **Local full suite (`pnpm test:full` / `test:serial`):** running engine + dashboard heavy packages concurrently is the historical OOM/hang path; a wedged invocation hangs the whole run. +- **Local changed-file runs (`pnpm test` → `test-changed.mjs`):** a hang in an affected package stalls the inner dev loop with no timeout and no forensics. + +**The failure modes, concretely:** + +| Failure mode | Where it bites | Current behavior | +|---|---|---| +| Wedged vitest invocation (deadlock, leaked handle, real-timer stall) | all three contexts | Runs to GitHub 6h ceiling (CI) or hangs indefinitely (local). No per-invocation watchdog outside dashboard. | +| Concurrency-sensitive test — shared `WORKER_ROOT` temp-root disappears (`cwd` gone, `mkdtemp … ENOENT`, missing event) | engine **and** core suites under full concurrency (also a dashboard entry) | Intermittent failure; quarantined on sight, root cause unfixed. 11 of 12 ledger entries are this class. | +| Subprocess-guard 30s `SIGKILL` mis-fire under load | engine + any spawned-child test | Premature kill attributed to the wrong test (engine already bumped to 120s as a band-aid). | +| Single-worker serialized project wedge | `engine-reliability`, `engine-slow` | One wedged file stalls the entire `fileParallelism:false`, `maxWorkers:1` project; past `SIGTERM 143` kills (FN-5537). | +| Tail-shard duration skew | CI shards | Duration-weighted sharding landed, but timings (`scripts/test-timings.json`, captured 2026-06-03) can drift with no guardrail. | + +**Success criteria:** + +- No CI test job can run longer than an explicit, committed budget — a hang fails the job in minutes with a diagnostic dump, never at the 6h ceiling. +- A hung local invocation (CI shard or changed-file) terminates within a bounded window and prints what was still pending. +- The cross-package concurrency-sensitivity cluster is root-caused at the shared `WORKER_ROOT` mechanism: the currently-quarantined engine **and** core entries sharing the temp-root-disappeared signature are either rescued with a real fix or deleted per the ratchet — not re-stabilized. +- The subprocess-guard no longer mis-fires under normal concurrent load, or when it does fire it names the offending test/child and reason. +- Shard duration skew is observable and guarded, so tail-shard timeouts can't silently regress. + +--- + +## Scope Boundaries + +### In scope +- Per-job `timeout-minutes` on all `full-suite.yml` test jobs and a per-invocation wall-clock watchdog generalized from the dashboard heap runner into `ci-test-shard.mjs` and `test-changed.mjs`. +- Hang forensics: open-handle / pending-operation diagnostics emitted on any timeout or watchdog kill. +- Root-cause fixes for the cross-package concurrency cluster at the shared `WORKER_ROOT` temp-isolation mechanism (engine + core tests; fixture/temp-dir/cwd isolation, deterministic awaits) and rescue-or-delete of the related quarantine entries. +- Hardening the `vitest-setup.ts` subprocess timeout/attribution logic so it stops mis-firing and logs actionable context. +- Containing wedges in serialized single-worker projects (`engine-reliability`, `engine-slow`). +- A shard-balance guardrail (timings freshness + per-shard budget assertion). + +### Out of scope (true non-goals) +- Migrating off Vitest or swapping the test runner. +- Re-trialing `isolate: false` or `happy-dom` — both were canaried and rejected (`docs/test-speed-baseline-2026-06-03.md`); the `vitest-setup.ts` module-level `fs`/`child_process`/cwd/HOME mutation makes isolation load-bearing. +- Bulk-deleting or `.skip`-ing tests purely to reduce counts (the quarantine ratchet is the only sanctioned removal path). +- Rewriting application code beyond what a confirmed product-race fix requires. + +### Deferred to Follow-Up Work +- Enabling Vitest-4 `experimental.fsModuleCache` with its kill-switch + stale-transform invalidation test (the prior plan's U8, still deferred). Pursue only if a dedicated cold-start/transform-cost measurement (not U2's hang forensics, which surface pending handles rather than transform timing) shows cold-transform cost is a timeout contributor. +- Automating `scripts/test-timings.json` refresh on a schedule (U6 adds the guardrail and the manual refresh path; full automation is a later step). +- Reducing isolation-guard (`check-test-isolation.mjs`) wall-clock overhead — it adds latency, not hangs, so it's outside this reliability scope. + +--- + +## High-Level Technical Design + +This plan adds **defense-in-depth timeout layers** so a hang is caught at the tightest applicable boundary and always produces forensics. The layers, innermost to outermost: + +| Layer | Boundary | Owner | On expiry | +|---|---|---|---| +| L0 assertion timeout | single `it()` | vitest `testTimeout`/`hookTimeout` (per-package config) | fail that test | +| L1 spawned-child timeout | a child process a test spawns | `vitest-setup.ts` subprocess guard (U4) | `SIGKILL` child, attribute to owning test + log reason | +| L2 invocation watchdog | one `vitest run` invocation | `ci-test-shard.mjs` / `test-changed.mjs` (U1) | `SIGTERM`→`SIGKILL` process group, emit open-handle dump (U2), exit non-zero | +| L3 CI job budget | a GitHub job | `full-suite.yml` `timeout-minutes` (U1) | fail the job (backstop if L2 itself wedges) | + +The intent is that **L3 is never the thing that fires** — L2 should always catch a hang first and explain it. L3 exists only as the backstop for a watchdog that itself deadlocks. + +When an invocation hangs, the flow is: + +```mermaid +flowchart TD + A[vitest run invocation] -->|exceeds watchdog budget| B[L2 watchdog fires] + B --> C[Snapshot diagnostics: open handles,
pending timers, live child PIDs, last heartbeat] + C --> D[SIGTERM process group] + D -->|grace window elapses| E[SIGKILL process group] + E --> F[Exit 124 with diagnostic summary] + A -->|completes normally| G[Exit with vitest code] + B -.watchdog itself wedges.-> H[L3 job timeout-minutes
backstop fails job] +``` + +*Directional guidance for reviewers — not an implementation spec. The per-invocation watchdog generalizes the existing, proven pattern in `packages/dashboard/scripts/run-vitest-with-heap.mjs` (detached process group, `SIGTERM`→`SIGKILL` after a grace window, exit 124); the new work is hoisting it into the shared runners and adding the diagnostic snapshot.* + +--- + +## Key Technical Decisions + +**KTD-1 — Generalize the dashboard watchdog, but treat the runner integration as a sync→async rewrite, not a wrap.** `run-vitest-with-heap.mjs` already spawns vitest detached in its own process group with a `FUSION_RUN_VITEST_TIMEOUT_MS` (default 15min) `SIGTERM`→`SIGKILL` killer, driven by an event-loop `setTimeout`. The reuse target is real, but **both `ci-test-shard.mjs` and `test-changed.mjs` invoke vitest via blocking `spawnSync`** — and a `setTimeout`-based watchdog cannot fire while the calling thread is frozen inside `spawnSync`. So the actual work in those two runners is **converting their invocation path from `spawnSync` to async `spawn` (detached, process-group)** and threading the now-Promise-returning call through their control flow (the sequential shard-command loop, exit-status propagation, `ensureTestArtifacts`/skill-sync ordering, and `test-changed.mjs`'s `runMaybeIsolated` + isolated-HOME teardown). This refactor — not the watchdog itself — is the bulk of U1. The dashboard runner is already async, so its delegation is a true extract-and-reuse with behavior preserved. Rationale: one shared killer, but the plan must size the runner conversions honestly. + +**KTD-2 — Per-invocation budgets default to a generous per-class flat ceiling, refined by timings when fresh.** A single global flat timeout would be too tight for `engine-slow` real-git suites or too loose to catch a fast-package hang quickly — but deriving budgets purely from `scripts/test-timings.json` is fragile: the snapshot is 100ms-bucketed and was captured 2026-06-03, and U6 (the freshness guardrail) lands *after* U1, so U1 would derive kill budgets from a possibly-stale file with no guard. Decision: budget = `max(perClassFloor, min(perClassCeiling, expectedDurationMs × multiplier))`, where the **per-class floor/ceiling (one each for shard / changed-file / dashboard-lane) are the load-bearing safety net** and the timings-derived term only *tightens* within that band when the snapshot is fresh. Because a CI shard's `plain` command fans out across multiple packages in one invocation (`pnpm --filter A --filter B … test`), `expectedDurationMs` is the **sum across the packages/lanes packed into that command**, not a per-package lookup — aggregate over the planner's command composition. Refresh `test-timings.json` (`scripts/ci-test-shard.mjs --write-timings`) before U1 derives budgets. Rationale: catches a hang at a multiple of expected duration without making a stale snapshot a false-kill source. This is **not** an assertion-timeout widening; it bounds a currently-unbounded outer wait. + +**KTD-3 — Diagnostics use Vitest/Node's own hang reporting, not a bespoke prober, and live inline in the watchdog.** On watchdog fire, request the hanging-process / open-handle information Vitest and Node already expose (e.g. Vitest's hanging-process reporter, `process._getActiveHandles`-class diagnostics, live child PIDs tracked by the subprocess guard). Implement the snapshot as a **local function inside `run-vitest-watchdog.mjs`**, not a separate `scripts/lib/` module — it has exactly one caller (the watchdog's pre-`SIGTERM` hook) at this point, so a standalone library file and its own test boundary would be premature abstraction. Extract later only if a second caller (e.g. the U4 guard-fire path) actually materializes. Rationale: avoids a fragile custom inspector and an unearned file boundary; surfaces the leaked handle/timer that caused the hang. + +**KTD-4 — The cross-package cluster gets one root-cause fix at the shared `WORKER_ROOT` mechanism, then rescue-or-delete per test.** The cluster's signature (`fusion-test-workers` temp-root disappears → `mkdtemp … ENOENT` / `cwd` gone / missing event under load) is shared across engine and core entries and traces to the `WORKER_ROOT` redirect in `vitest-setup.ts` plus `vi.waitFor` real-timer polling racing microtask chains under CPU contention (the documented U7 recipe in `docs/test-speed-baseline-2026-06-03.md`). Fix the **shared mechanism first** (per-worker/per-test temp-root lifetime so one file's teardown can't delete another's redirect dir), then per affected test give it an isolated temp root and assert via call-signaled deferreds rather than timer polls. Then, per the ratchet, either rescue each quarantined entry (evidence it catches real regressions + the root-cause fix) or let it be deleted. Rationale: a single shared fix likely clears most of the 11 same-signature entries at once; the anti-appeasement rule forbids re-stabilizing, and a recurring cross-package signature is a mechanism bug, not per-test noise. + +**KTD-5 — The subprocess guard ships attribution-first; budget scaling is a data-gated follow-up, not part of this plan.** Engine already overrides the 30s child-`SIGKILL` to 120s because "even 60s can fire prematurely." The in-scope change is **structured logging on fire only** — name the owning test, the child's argv, and elapsed time — which is unambiguously in service of the goal and carries no anti-appeasement risk. The tempting second move, scaling the budget by active worker count / configured concurrency, is **explicitly deferred**: no quarantine entry attributes a failure to the subprocess guard firing, so the "mis-fires under contention" premise is unproven, and silently widening an existing L1 child timeout to make contended runs pass is exactly the shape `AGENTS.md` bans. Only after U3 reduces concurrency pressure and the attribution logging produces evidence that the guard fires on *legitimate* children (not real hangs) should scaling be revisited, with that data in hand. Rationale: a mis-fire that names its victim is debuggable; widening the trigger without evidence is appeasement and could mask a real runaway child. + +**KTD-6 — CI job budgets are explicit and committed, sized from observed durations + headroom.** Add `timeout-minutes` to `test-shards`, `test-slow`, and `test-inventory-guard` in `full-suite.yml`, each sized from current observed wall-clock plus headroom (and strictly above the L2 watchdog ceiling so L2 fires first). Rationale: the gate job already does this (`timeout-minutes: 15` in `pr-checks.yml`); the non-blocking tier should not be exempt. + +--- + +## Output Structure + +New/changed shared infrastructure (illustrative — per-unit `Files` lists are authoritative): + +```text +scripts/ + lib/ + run-vitest-watchdog.mjs # NEW (U1) — shared bounded-invocation runner + process-group killer; + # inline hang-diagnostics snapshot lives here (U2), not a separate module + test-timings.json # EXISTING — refreshed before U1; tightens watchdog budgets within per-class bands (U1) + feeds shard guardrail (U6) + ci-test-shard.mjs # MODIFIED (U1, U6) — spawnSync→async spawn through watchdog; extend existing balance/staleness checks + test-changed.mjs # MODIFIED (U1) — spawnSync→async spawn through watchdog; reconcile with isolated-HOME teardown + __tests__/ + run-vitest-watchdog.test.mjs # NEW (U1) — watchdog contract + inline diagnostics snapshot + ci-shard-budget.test.mjs # NEW (U6) — balance + freshness assertions +.github/workflows/ + full-suite.yml # MODIFIED (U1) — timeout-minutes on all test jobs +packages/ + dashboard/scripts/run-vitest-with-heap.mjs # MODIFIED (U1) — delegate to shared watchdog (already async; behavior preserved) + core/src/__test-utils__/vitest-setup.ts # MODIFIED (U3 WORKER_ROOT temp-root lifetime; U4 guard attribution logging; U2 hanging-process reporting) + core/src/__tests__/... # MODIFIED (U3) — core temp-redirect quarantine cluster: per-test temp isolation + engine/src/__tests__/... # MODIFIED (U3) — engine cluster: fixture isolation, deterministic awaits + engine/vitest.config.ts # MODIFIED (U3, U5) — quarantine exclude edits; serialized-project tuning +scripts/lib/test-quarantine.json # MODIFIED (U3) — rescue/delete cluster entries (engine + core) +``` + +--- + +## Implementation Units + +### U1. Bound every test invocation and CI job with a fail-fast watchdog + +**Goal:** No vitest invocation or CI job can hang past an explicit budget; a hang terminates the process group and exits non-zero in minutes, not hours. + +**Requirements:** Success criteria 1 & 2 (no 6h black holes; local hangs bounded). Addresses failure modes "wedged invocation" and "tail-shard skew" (backstop). KTD-1, KTD-2, KTD-6. + +**Dependencies:** none (foundational). + +**Files:** +- `scripts/lib/run-vitest-watchdog.mjs` (new) — shared **async** detached-spawn + `SIGTERM`→`SIGKILL`-after-grace runner, per-class budget bands tightened by `scripts/test-timings.json`, exit-124 contract, inline hang-diagnostics snapshot (U2). +- `scripts/ci-test-shard.mjs` (modify) — **convert the `spawnSync` invocation path to async `spawn` through the watchdog** and thread the Promise through the sequential shard-command loop, exit-status propagation, and `ensureTestArtifacts`/skill-sync ordering. +- `scripts/test-changed.mjs` (modify) — same `spawnSync`→async conversion; reconcile the watchdog's process-group kill + signal forwarding with the **existing `SIGINT`/`SIGTERM`/`exit` isolated-HOME cleanup handlers** and `runMaybeIsolated`'s before/after passes so a watchdog kill does not leak HOME dirs (the exact thing the isolation guard then flags). +- `packages/dashboard/scripts/run-vitest-with-heap.mjs` (modify) — delegate to the shared helper (already async; true extract-and-reuse); preserve the 6144MiB heap flag, 15min default, 5s grace, heartbeat, signal forwarding, and its signal-re-raise-on-signalled-exit behavior. +- `.github/workflows/full-suite.yml` (modify) — add `timeout-minutes` to `test-shards`, `test-slow`, `test-inventory-guard`. +- `scripts/__tests__/run-vitest-watchdog.test.mjs` (new). + +**Approach:** Extract the dashboard killer's process-group lifecycle into the shared async helper, parameterized by command, env, heap flag, and budget. Budget = `max(perClassFloor, min(perClassCeiling, expectedDurationMs × multiplier))` per KTD-2 — the per-class floor/ceiling (shard / changed-file / dashboard-lane) are the safety net; the timings term (aggregated across all packages in a multi-package `plain` command, multiplier 3-4×) only tightens within the band, and only when the snapshot is fresh; when timings are absent or stale, `deriveBudgetMs` falls back to the per-class **ceiling** (never a median). **Refresh `test-timings.json` before deriving budgets.** CI `timeout-minutes` must exceed the worst-case L2 ceiling so L2 always fires first; document the ordering in a comment. Forwards external signals; cleans up on exit/SIGINT/SIGTERM like the existing runners. Note the two runners import each other and are imported by tests — verify the async conversion doesn't break any synchronous-import caller. + +**Execution note:** Start with a failing test for the watchdog contract (spawns a deliberately-hanging child, asserts `SIGTERM`-then-`SIGKILL` and exit 124 within budget) before extracting the helper. + +**Patterns to follow:** `packages/dashboard/scripts/run-vitest-with-heap.mjs` (process-group kill, detached spawn, grace window, heartbeat); existing `scripts/__tests__/*.test.mjs` style (`node --test`). Respect the port-4040 kill guards — the watchdog kills its own process group only, never by port (`scripts/check-no-kill-4040.mjs`, `AGENTS.md`). + +**Test scenarios:** +- Happy path: a child that exits 0 within budget → watchdog returns the child's exit code, no kill signal sent. +- Happy path: a child that exits non-zero → exit code propagated unchanged. +- Timeout: a child that never exits → `SIGTERM` at budget, `SIGKILL` after the grace window, exit 124, within `budget + grace + epsilon`. +- Edge: budget derivation when the package is absent from `test-timings.json` → per-class floor used; when present → `expected × multiplier`, clamped to the per-class floor/ceiling band. +- Edge: multi-package `plain` command (e.g. `--filter A --filter B test`) → budget aggregates the expected durations of all packed packages, not a single-package lookup. +- Edge: external `SIGTERM`/`SIGINT` to the wrapper → forwarded to the child group, HOME/temp cleanup still runs. +- Integration: a watchdog `SIGKILL` of a hung `test-changed.mjs` invocation → isolated-HOME teardown still runs (no leaked `fusion-test-homes`), and the existing isolation guard passes on the next run. +- Integration: `ci-test-shard.mjs` run with a stubbed hanging command → shard exits non-zero with the watchdog's diagnostic, does not block. +- Regression: dashboard lane via `run-vitest-with-heap.mjs` still applies the 6144MiB heap flag and 15min default after delegation (assert the spawned argv/env). +- Config assertion: parse `.github/workflows/full-suite.yml` and assert every test job declares `timeout-minutes` strictly greater than the configured L2 ceiling. + +**Verification:** A deliberately-wedged test invocation fails locally and in a CI dry-run within minutes with exit 124; the dashboard suite behaves identically to before; `full-suite.yml` jobs all carry a budget. + +--- + +### U2. Emit hang forensics on every timeout + +**Goal:** When the watchdog (U1) or a vitest test times out, the run prints what was still pending — open handles, pending timers, live child PIDs, last heartbeat — so the next hang is diagnosable instead of silent. + +**Requirements:** Success criterion 2 (bounded *and* diagnosable). Enables root-causing U3/U4/U5. KTD-3. + +**Dependencies:** U1 (the watchdog is the trigger point and the home for the inline snapshot). + +**Files:** +- `scripts/lib/run-vitest-watchdog.mjs` (modify, from U1) — add an **inline** snapshot function (active handles/requests, live tracked child PIDs + argv, elapsed-since-heartbeat) producing a compact, log-safe summary, called immediately before `SIGTERM`. Not a separate module (KTD-3) until a second caller exists. +- `packages/core/src/__test-utils__/vitest-setup.ts` (modify) — ensure Vitest's hanging-process reporting is enabled/surfaced so an in-test hang (L0/L1) also produces handle info. + +**Approach:** Prefer Node/Vitest built-ins (hanging-process reporter, active-handle enumeration) over a custom inspector (KTD-3). Redact paths/secrets per existing logging conventions. Keep output bounded (cap the number of handles listed) so a hang dump can't itself flood/wedge CI logs. The snapshot's unit tests live in `scripts/__tests__/run-vitest-watchdog.test.mjs` alongside the watchdog that calls it. + +**Patterns to follow:** the subprocess guard's existing child-PID tracking in `vitest-setup.ts` (reuse its registry for "live children" rather than re-enumerating); existing log redaction helpers. + +**Test scenarios:** +- Happy path: snapshot with a known leaked timer present → summary names the timer/handle type. +- Happy path: snapshot with a tracked live child → summary lists its PID and argv. +- Edge: no open handles → summary states "no pending handles" rather than empty/garbage. +- Edge: output exceeds the cap → list is truncated with a "+N more" marker, not unbounded. +- Integration: watchdog fire path (with U1) prints the snapshot before `SIGTERM` (assert ordering in the wrapper's output). +- `Covers` the diagnosability success criterion: a wedged invocation's output contains an actionable handle/child reference. + +**Verification:** Trigger a known hang (a test that leaves a timer/socket open); confirm the failure output names it. + +--- + +### U3. Root-cause the cross-package `WORKER_ROOT` concurrency cluster + +**Goal:** The shared `WORKER_ROOT` temp-isolation mechanism stops letting one file's teardown disturb another's redirect dir under concurrent load, and the engine **and** core tests quarantined with that signature are fixed at root cause (or deleted per the ratchet) — no re-stabilization. + +**Requirements:** Success criterion 3. Addresses the cross-package "shared `WORKER_ROOT` temp-root disappears" failure mode. KTD-4. Honors the `AGENTS.md` anti-appeasement standing rule and the quarantine deletion ratchet. + +**Dependencies:** U2 (forensics make the leaked state visible); benefits from U1 (bounded reproduction). Not hard-blocked by U1/U2 — the shared-mechanism analysis can begin immediately (this is the highest-pain work; see Sequencing). + +**Files:** +- `packages/core/src/__test-utils__/vitest-setup.ts` (modify) — **the shared fix:** make the `WORKER_ROOT` redirect's temp-root lifetime per-worker/per-test so one file's cleanup can't `rm` another's active dir; this is the common cause behind the 11 same-signature entries. +- `packages/core/src/__tests__/` cluster (modify) — `soft-delete-tasks.test.ts`, `store-get-task-columns.test.ts`, `task-dependency-mutation.test.ts`, `task-node-override.test.ts`, `db.test.ts`, `store-create-summarize-deferred-hook.test.ts`: per-test temp-root isolation; deterministic call-signaled awaits where a timer poll races. +- `packages/engine/src/__tests__/merger-ai.test.ts`, `merger-ai-cleanup.test.ts`, `merger-ai-cleanup-active-session.test.ts`, `bubblewrap-backend.test.ts` (modify) — same treatment; verify `activeSessionRegistry` / `realpathSync` / cwd assumptions don't leak across files. +- `packages/engine/src/__tests__/reliability-interactions/soft-delete-blocker-residue.test.ts` (modify) — missed-event assertion via deterministic await. +- `packages/engine/vitest.config.ts` and the core vitest config (modify) — remove the `exclude` lines for any test rescued. +- `scripts/lib/test-quarantine.json` (modify) — remove rescued entries (with PR evidence) or delete expired ones per the ratchet. + +**Approach:** First confirm the shared mechanism via U2 forensics — reproduce a core entry and an engine entry under concurrent load (`pnpm --filter @fusion/core test`, `pnpm --filter @fusion/engine test`, not standalone) and verify both fail on the same `fusion-test-workers` temp-root disappearance. Fix `vitest-setup.ts`'s `WORKER_ROOT` lifetime once, then re-run the whole quarantined set to see how many clear from the single fix. For residual per-test races, give each test an isolated temp root and await a deferred resolved by the spied function (`signalOnCall`-style) instead of polling a timer. If a flake reveals a **real product race** (KTD-4: a second quarantine in a subsystem is a smell), fix the product code and document via `/ce-compound`. Do not widen timeouts or add retries. + +**Execution note:** Characterization-first — reproduce the flake reliably under concurrency before changing anything, so the fix is provably the cause. + +**Patterns to follow:** the U7 deterministic-await recipe in `docs/test-speed-baseline-2026-06-03.md`; the product-race escalation example in `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`; existing engine test temp-dir/`mkdtemp` helpers. + +**Test scenarios:** +- Shared-mechanism fix: a stress harness that runs two files redirecting through `WORKER_ROOT` concurrently, where one finishes and tears down while the other is mid-`mkdtemp` → the second no longer hits `ENOENT` on its redirect dir. +- Each rescued test passes **standalone AND** under full concurrent suite load (`pnpm --filter @fusion/core test`, `pnpm --filter @fusion/engine test`), repeated (e.g. 10×) without flake. +- Mutate-to-prove: break the product branch the rescued test covers → the test fails (assertion still bites; not a vacuous pass). +- `merger-ai` temp-checkout-disappeared path: under concurrency, the test no longer hits git `ENOENT` / "unable to read cwd". +- `soft-delete-blocker-residue`: the missed `task:deleted` event log entry is asserted via a deterministic await, not a timer poll. +- Ratchet integrity: every entry removed from `test-quarantine.json` has a matching `exclude` removal in the same commit (assert via the existing inventory/quarantine conventions); expired entries deleted are recorded in the commit. + +**Verification:** 10× concurrent core- and engine-suite runs with zero flake in the targeted files; quarantine ledger no longer lists the rescued entries; mutation testing confirms assertions bite. + +--- + +### U4. Make the subprocess guard self-documenting on fire (attribution logging) + +**Goal:** When the `vitest-setup.ts` child-process `SIGKILL` guard fires, it names the owning test, the child argv, and elapsed time — converting a silent kill into a debuggable event and producing the evidence needed to decide *later* whether the guard mis-fires under contention. + +**Requirements:** Success criterion 4 (the "or when it does fire it names the offending test/child and reason" clause). Addresses the "subprocess-guard mis-fire" failure mode's diagnosability half. KTD-5. + +**Dependencies:** U2 (reuse forensics/child-PID registry). + +**Files:** +- `packages/core/src/__test-utils__/vitest-setup.ts` (modify) — `registerTrackedSubprocess` / `withDefaultTimeout` / `afterEach` attribution: emit a one-line structured record (`test id`, `argv`, `elapsedMs`, `reason`) whenever the guard kills a child. **No change to the trigger budget in this unit.** +- the corresponding `vitest-setup` test (locate under `packages/core/.../__tests__/`) (modify/add). + +**Approach:** Keep the guard's behavior and safety purpose (kill genuinely-runaway children, block real-AI-CLI/port-4040) exactly intact; add only the structured logging on fire. **Deliberately out of scope (deferred to a data-gated follow-up, per KTD-5):** scaling the budget by active worker count / concurrency. No quarantine entry attributes a failure to this guard, so the "mis-fires under contention" premise is unproven, and widening an L1 child timeout without evidence is the appeasement shape `AGENTS.md` bans and could delay detection of a real runaway child. The attribution logging this unit ships is what produces that evidence; revisit scaling only if it shows the guard firing on legitimate children after U3 reduces concurrency pressure. + +**Patterns to follow:** the guard's existing kill/attribution code paths; existing structured-log/redaction helpers in `vitest-setup.ts`. + +**Test scenarios:** +- Happy path: a child completing within budget → not killed, no log emitted. +- Timeout: a genuinely-runaway child exceeding the budget → killed (same as today), structured record emitted with owning test id, argv, elapsed, reason. +- Edge: child completes during the grace window → no false kill, no spurious log. +- Regression: real-AI-CLI launch block and port-4040 kill block still fire (guard safety preserved); the kill budget is unchanged from current behavior. +- Mutate-to-prove: disable the attribution → test detects the missing owner reference. + +**Verification:** Run the suite under high concurrency; confirm guard behavior is unchanged and any kill carries a named owner + argv in the output, giving a clear signal for the deferred scaling decision. + +--- + +### U5. Contain wedges in serialized single-worker projects + +**Goal:** A single wedged file in `engine-reliability` or `engine-slow` (both `fileParallelism:false`, `maxWorkers:1`) fails fast rather than stalling the whole project to a `SIGTERM 143`. + +**Requirements:** Success criterion 1 (within-project containment). Addresses the "single-worker serialized project wedge" failure mode (FN-5537 history). + +**Dependencies:** U1 (invocation watchdog is the outer net); U2 (forensics). + +**Files:** +- `packages/engine/vitest.config.ts` (modify) — evaluate a per-file/per-test `testTimeout` appropriate to the serialized projects (root-cause-bounded, not appeasement), and assess whether `engine-reliability` can be split so a wedge doesn't block unrelated files. Document the rationale inline (these projects already carry detailed justification comments). + +**Approach:** The serialized projects exist for real reasons (real worktrees, event-ordering, rowid interleaving — see existing comments). Do **not** parallelize them blind. Instead bound them: ensure a single file's hang is caught by the U1 watchdog with U2 forensics, and consider whether the project can be partitioned into independent serial groups so an unrelated wedge doesn't take the whole project down. If splitting risks the documented ordering guarantees, keep serial and rely on the watchdog + diagnostics as the containment. + +**Execution note:** Decision-bearing, three possible outcomes — (a) partition the serialized project into independent serial groups; (b) if splitting endangers the FN-5521/FN-5537 ordering guarantees, keep serial and record that the U1 watchdog is the chosen containment; (c) **if the U1 watchdog fully contains the wedge risk AND current `testTimeout` values are already appropriate, this unit produces zero code changes** — capture the rationale in a config comment and close. Do not let the `testTimeout` evaluation manufacture a change that serves no confirmed gap. Capture whichever outcome holds in the config comment. + +**Patterns to follow:** the existing per-project comments in `packages/engine/vitest.config.ts` (`engine-reliability`, `engine-slow`); the worker-cap audit test referenced in the prior plan (any pool/worker change must pass it — `docs/plans/2026-06-03-001-perf-test-suite-speedup-plan.md` U5). + +**Test scenarios:** +- A deliberately-wedged file in the serialized project → caught by the U1 watchdog with forensics, project exits non-zero promptly (not at job ceiling). +- Regression: the documented ordering-sensitive suites (`shared-branch-group-lifecycle`, `branch-group-automerge-precedence`) still pass after any partition. +- Worker-cap audit: if pool/worker settings change, the FN-5048 cap-audit test still passes (effective concurrency not raised). +- `Test expectation` note: if the analysis concludes "no split, watchdog is containment," this unit's only code change is documented config + the wedge-containment test above. + +**Verification:** Wedged-file injection in the serialized project fails fast; ordering-sensitive suites unaffected; cap-audit green. + +--- + +### U6. Guard against tail-shard duration skew + +**Goal:** Shard duration imbalance is observable and asserted, so a stale `test-timings.json` can't silently recreate a slow tail shard that drifts toward timeout. + +**Requirements:** Success criterion 5. Addresses the "tail-shard duration skew" failure mode. + +**Dependencies:** U1 (CI budgets define the ceiling the guardrail measures against). + +**Files:** +- `scripts/ci-test-shard.mjs` (modify) — **extend the existing balance/staleness logic, do not re-derive it.** The planner already enforces a `DEFAULT_BALANCE_TOLERANCE = 0.05` variance loop and emits a staleness warning via `TIMINGS_STALENESS_DAYS = 30`, with a `--check-timings-staleness` CLI mode. The new work is a **post-plan assertion that the worst-shard projected duration stays below the U1 L2 ceiling** (a failure mode the existing variance loop doesn't catch — balanced-but-all-slow shards), reusing the existing tolerance/staleness constants rather than introducing divergent ones. +- `scripts/__tests__/ci-shard-budget.test.mjs` (new) — unit-test the new vs-ceiling assertion (and that it reuses the existing constants). +- `docs/testing.md` (modify) — document the manual timings-refresh path (`scripts/ci-test-shard.mjs --write-timings`) and the freshness expectation. + +**Approach:** Build on the existing duration-weighted best-fit-decreasing planner and its `DEFAULT_BALANCE_TOLERANCE` / `TIMINGS_STALENESS_DAYS` / `--check-timings-staleness` machinery. Add one new post-plan check the existing logic lacks: max-shard projected duration ≤ the U1 L2 ceiling (catches the case where shards are well-balanced but all too slow). This is observability + a guardrail extension, not a re-architecture of sharding, and must not introduce a second tolerance constant. + +**Patterns to follow:** the existing weighting/slicing logic in `scripts/ci-test-shard.mjs`; the per-shard timings upload already in `full-suite.yml`. + +**Test scenarios:** +- Balanced timings → guardrail passes, no warning. +- Skewed timings (one package dominating) → guardrail flags the over-budget shard with the offending package named. +- Stale snapshot (age > threshold) → freshness warning emitted with the snapshot date. +- Edge: missing timings entirely → median-fallback path still plans and the guardrail degrades gracefully (warns, doesn't crash). +- Integration: a synthetic timings fixture that would push a shard past the U1 ceiling → guardrail fails the dedicated check. + +**Verification:** Inject a skewed timings fixture; confirm the guardrail names the over-budget shard; confirm a fresh snapshot passes clean. + +--- + +## Risks & Mitigations + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| `spawnSync`→async conversion of `ci-test-shard.mjs` / `test-changed.mjs` (U1) is underestimated and slips, cascading to dependent units | Medium | High | Treat it as a rewrite in scoping (KTD-1), not a wrap; land the watchdog helper + dashboard delegation first (already async), then convert each runner behind its own test; verify no synchronous-import caller breaks. | +| Watchdog budget (U1) too tight (esp. from stale `test-timings.json`) → kills legitimately-slow real-git suites | Medium | High (false failures) | Per-class floor/ceiling bands are the safety net; timings only *tighten* within the band and only when fresh; refresh the snapshot before deriving budgets; validate against `engine-slow` observed durations before merge (KTD-2). | +| The `WORKER_ROOT` shared-mechanism fix (U3) is larger than the cluster suggests, or some entries have distinct causes | Medium | Medium | Confirm the shared signature on one core + one engine entry before the fix; after the single fix, re-run the full quarantined set and treat residuals as separate per-test work rather than assuming one fix clears all. | +| "Fixing" the cluster (U3) drifts into appeasement | Medium | High (banned by policy) | Characterization-first repro + mutate-to-prove; rescue requires documented real-regression evidence; default to ratchet deletion over re-stabilization. | +| Splitting serialized projects (U5) breaks ordering guarantees | Medium | High | Decision-gated (KTD/execution note); default to "no split, watchdog is containment" if ordering is at risk; cap-audit test must stay green. | +| CI `timeout-minutes` set below true worst case → flaky job failures | Low | Medium | Size from observed wall-clock + headroom, strictly above L2 ceiling; start generous and tighten with data. | +| Hang-diagnostics output (U2) floods CI logs | Low | Low | Cap handle list with "+N more"; redact paths. | + +## Dependencies / Sequencing + +- **U1 → U2** (watchdog is the diagnostics trigger; U2's snapshot lives inside the U1 helper). +- **U2 aids U3, U5** (forensics make the leaked state visible) but is **not a hard blocker** — U3's shared-mechanism analysis can begin in parallel. +- **U1 → U6** (the L2 ceiling defines what U6 asserts against). + +**Two tracks, run in parallel:** +- **Reliability track — start here, highest pain.** U3 fixes the actual red tests: 11 of 12 quarantine entries share the `WORKER_ROOT` signature, and the watchdog (U1) fixes none of them — it only makes their eventual failure faster and louder. The single shared-mechanism fix is the highest-leverage change in the plan. +- **Guardrail track.** U1 + U2 (fail-fast watchdog + forensics) close the real CI black-hole gap (no `timeout-minutes` on `full-suite.yml`) and produce the diagnostics U3 leans on. U4 (attribution logging) and U6 (shard-vs-ceiling guardrail) follow U1. + +Honest framing: the guardrail track does not turn the suite green on its own — it bounds and explains hangs and prevents regressions. U3 is what removes the standing red. Sequence so U3 is not starved behind the guardrail work. U5 last (containment, depends on U1). + +## Sources & Research + +- `docs/plans/2026-06-03-001-perf-test-suite-speedup-plan.md` — sibling speedup plan; duration-based sharding (U6), worker-cap policy (U5), deferred `fsModuleCache` (U8). +- `docs/test-speed-baseline-2026-06-03.md` — `isolate:false` and happy-dom canaries rejected; U7 deterministic-await flake recipe. +- `docs/test-speed-audit-FN-5048.md` — FN-6308 dashboard heap-wrapper + bounded-concurrency pattern. +- `docs/testing.md` — quarantine ledger / deletion ratchet; anti-appeasement rule; engine-slow tier. +- `AGENTS.md` — standing rules: do-not-add-slow-tests (FN-5048), flaky-tests-quarantined-on-sight, never widen timeouts/retries to pass flakes, port-4040 protection. +- `scripts/lib/test-quarantine.json` — current cross-package concurrency cluster: 12 entries across engine (`merger-ai*`, `reliability-interactions/*`, `bubblewrap-backend`), core (`soft-delete-tasks`, `store-get-task-columns`, `task-dependency-mutation`, `task-node-override`, `db`, `store-create-summarize-deferred-hook`, dated 2026-06-12), and dashboard (`QuickEntryBox`); 11 share the `fusion-test-workers` temp-root-disappeared signature. +- Repo research: harness map across `scripts/test-changed.mjs`, `scripts/ci-test-shard.mjs`, `packages/dashboard/scripts/run-vitest-with-heap.mjs`, `packages/core/src/__test-utils__/vitest-setup.ts`, `.github/workflows/full-suite.yml`. +- **Note on the "vitest auto-kill incident":** confirmed **fixed** (CLI freemem-metric SIGKILL bug, `packages/core/src/vitest-processes.ts` filtering + `process.availableMemory()` guard). Any remaining exit-137/SIGKILL is real memory pressure or a different killer — do not attribute it to that incident. + +## Deferred Implementation Notes + +- Exact per-class watchdog floor/ceiling bands and the timings multiplier — tune against a freshly-refreshed `test-timings.json` during U1. +- Whether the single `WORKER_ROOT` lifetime fix clears all 11 same-signature entries or leaves per-test residuals — determined empirically after the shared fix in U3. +- Whether `engine-reliability` can be partitioned without breaking ordering — resolved during U5 analysis. +- Whether any U3 flake is a test-fixture race vs. a real product race — determined per-test during characterization; product fixes documented via `/ce-compound`. +- Exact `timeout-minutes` values per CI job — sized from current run durations during U1. diff --git a/docs/plans/2026-06-13-002-feat-compound-engineering-workflow-integration-plan.md b/docs/plans/2026-06-13-002-feat-compound-engineering-workflow-integration-plan.md new file mode 100644 index 0000000000..d35bf5baa4 --- /dev/null +++ b/docs/plans/2026-06-13-002-feat-compound-engineering-workflow-integration-plan.md @@ -0,0 +1,327 @@ +--- +title: "feat: Make the compound-engineering built-in workflow actually run the CE way" +type: feat +status: active +date: 2026-06-13 +plan_depth: deep +branch: feature/ce-workflow-integration +--- + +# feat: Make the compound-engineering built-in workflow actually run the CE way + +## Summary + +The built-in `compound-engineering` workflow (`packages/core/src/builtin-workflows.ts:153-193`) *looks* like compound engineering — Plan → Execute → Review → Code-review → Merge → Document — but on autonomous board runs it doesn't deliver the CE experience: + +1. **Planning questions never reach a human.** The Plan step invokes `ce-plan`, which asks clarifying questions through a blocking tool (`AskUserQuestion`). Workflow steps run as ephemeral, headless sessions with no `actionGateContext` (`packages/engine/src/executor.ts` `executeWorkflowStep` ~11698-11915; ephemeral gate skip at `packages/engine/src/pi.ts:1768-1772`), so the call has no listener — questions are silently lost. +2. **Implementation isn't done the CE way.** The Execute node uses the generic `builtinPromptConfig("execute")` instead of the `compound-engineering:ce-work` skill. +3. **Merge isn't done the CE way.** The Merge node is a generic `builtinPromptConfig("merge")` boundary; it does not use CE's commit / push-PR / resolve-PR-feedback flows. +4. **Subagents don't work inside workflow steps.** The CE skills fan out to subagents (`ce-repo-research-analyst`, the `ce-*-reviewer` personas, parallel `ce-work` executors). Readonly workflow steps strip `fn_spawn_agent` entirely (`packages/engine/src/workflow-step-tool-policy.ts`), and even in `coding` mode the `ce-*` subagent **types are not installed anywhere Fusion can resolve** — the plugin bundles skills but **no agent definitions**. +5. **Three CE skills are missing.** `ce-commit`, `ce-commit-push-pr`, and `ce-resolve-pr-feedback` are referenced by the bundled skills but are **not bundled** in `.fusion-ce-skills/`. + +This plan fixes all five so the workflow genuinely leverages compound engineering end-to-end, with a human-in-the-loop affordance for planning questions surfaced as a **button on the task card** that launches an interactive Q&A session. + +**Target repo:** this repo (kb / Fusion). All paths repo-relative. + +--- + +## Problem Frame + +`ce-plan`, `ce-work`, and `ce-code-review` were authored for an *interactive* Claude Code session where (a) a human is present to answer blocking questions and (b) the `Agent`/`Task` subagent primitive resolves a rich registry of `ce-*` agent types. Fusion runs them in the opposite environment: an **autonomous, ephemeral, readonly-by-default** workflow-step session with **no human attached** and **no `ce-*` agent registry**. The result is a workflow that name-drops compound engineering at each stage but executes a degraded version of it. + +The fix has four threads: +- **Signal** the autonomous/headless context to the skills so they adapt instead of calling dead tools. +- **Enable subagents** inside CE steps (spawn tool + resolvable `ce-*` agent types). +- **Rewire the workflow nodes** to invoke the right CE skills at execute and merge, and to pause for planning questions. +- **Surface planning questions to a human** via a task-card button and interactive answering, then resume. + +--- + +## Requirements + +- **R1** — The Execute node runs `compound-engineering:ce-work` in coding mode, not the generic execute prompt. +- **R2** — The Merge stage runs CE's commit / push-PR flow, and PR-feedback resolution is available as a CE-driven step. +- **R3** — On an autonomous board run, when `ce-plan` has clarifying questions, the task **pauses** with the questions surfaced; a **button on the task card** lets a user open an interactive session to answer them; answers feed back and planning resumes. +- **R4** — When genuinely headless (no human, e.g. LFG/pipeline), `ce-plan` degrades honestly: it records assumptions and proceeds rather than blocking or losing questions. +- **R5** — Subagents spawned by CE skills (research, reviewer personas, parallel executors) **resolve and run** inside CE workflow steps, or degrade to a documented single-agent fallback when they cannot. +- **R6** — `ce-commit`, `ce-commit-push-pr`, and `ce-resolve-pr-feedback` are bundled and installed by the plugin. +- **R7** — All existing `builtin-workflows` tests pass; new behavior is covered by tests. + +--- + +## Key Technical Decisions + +- **KTD-1 — Headless signal via env var on the workflow-step session.** No context flag reaches the skill text today; the only env injected is `FUSION_NODE_PROMPT` (`executor.ts` ~5942/5979). Add a `FUSION_WORKFLOW_STEP=1` (and, when no interactive surface exists, `FUSION_HEADLESS=1`) env var to the workflow-step session's `taskEnv` (built ~`executor.ts:6808-6812`, threaded into `createResolvedAgentSession` ~11857). The CE skills read this to choose the interactive-vs-headless branch they already describe ("LFG or any `disable-model-invocation` context"). *Rationale:* smallest reliable contract; env is already plumbed; skills already have headless branches that just lack a trigger. + +- **KTD-2 — Planning questions use the existing await-input machinery, not a new tool.** Fusion already pauses tasks for input: `runAwaitInputNode` sets `status: "awaiting-user-input"` + `pausedReason: "workflow-input:{nodeId}@{ts}: {question}"`, and resume consumes the newest steering comment as the answer (`executor.ts:5442-5496`; submit route `register-workflow-routes.ts:580-599`; UI `WorkflowResultsTab.tsx:65-944`). `ce-plan` in workflow context emits its questions into this channel; the workflow parks the task; the card button + interactive session capture answers as steering comments; the executor resumes `ce-plan` with the answers available. *Rationale:* reuse the proven pause/resume path instead of inventing a parallel one. + +- **KTD-3 — The task-card button launches the existing chat-steering surface, scoped to the questions.** Live steering already exists (FN-6338): `addSteeringComment` → `POST /tasks/:id/steer`, `TaskChatTab` with `sessionLive`, `isActiveAgentSession()`. The new button (on `TaskCard.tsx` `card-header-actions` ~2025-2102, shown when `status === "awaiting-user-input"` with a planning marker) deep-links into the task's chat/Q&A surface. *Rationale:* the user asked specifically for a card button + interactive answer session; the steering plumbing already carries answers back. + +- **KTD-4 — Add an optional persona-prompt override to `fn_spawn_agent`; install `ce-*` persona defs plugin-locally; the CE skill reads the def and passes it inline.** **VERIFIED (two spikes):** (a) Fusion's spawn primitive is `fn_spawn_agent({ name, role, task })`, `role` an `AgentCapability` — `executor.ts:945-956`; no persona param; children inherit generic `resolveInstructionsForRole(role)` and each gets its own git worktree (`executor.ts:14356-14460`). (b) There is **no plugin agent-contribution channel** in the SDK — `FusionPlugin` contributes `skills`/`workflowSteps`/`traits`/etc. but no `agents`; `pluginRunner` has `getPluginSkills()` but no agent equivalent; `createResolvedAgentSession` threads skills via `additionalSkillPaths` + `requestedSkillNames` but has no agent-definition path. The 43 `ce-*` persona defs ship in the CE plugin cache as plain markdown (frontmatter + system-prompt body). **Chosen approach (lightest that gives real fan-out):** (1) add an optional `systemPromptOverride` (persona prompt) field to `spawnAgentParams`; when present the child session uses it as its system prompt instead of the generic `childBasePrompt` (`executor.ts:14420-14460`). (2) Install the `ce-*` persona defs plugin-locally via `installBundledCeAgents()` (mirror of `installBundledCeSkills()`), and expose the install dir to step sessions via an env var (e.g. `FUSION_CE_AGENTS_DIR`). (3) The CE skills (which have Read in coding mode) read the persona def for the type they want and pass its body as `systemPromptOverride`. *Rationale:* no new plugin-SDK surface (rejected as overkill for one consumer — agents in Fusion are durable store entities, not static plugin contributions); minimal, generic engine change; personas versioned with the plugin. *Fallback (R5):* `role`-only generic child when no override supplied. + +- **KTD-5 — CE steps run in `coding` toolMode where they must spawn or write.** `toolMode` is read from node config (`executor.ts:5969`, default readonly). Execute (ce-work) and the merge/PR steps get `toolMode: "coding"` so `fn_spawn_agent` and write tools are present. Plan/review stay readonly unless subagent fan-out is required there too (then coding). *Rationale:* readonly strips the spawn + write tools the CE skills need. + +- **KTD-6 — CE commit/PR flow must coexist with Fusion's workflow-owned merge.** Fusion has its own merge machinery (the `workflow-owned-merge-*` line of work). The CE merge step prepares the commit + PR (and resolves feedback) but must **not** double-drive the actual board merge transition. Define the boundary: CE step owns commit/push/PR-creation/feedback-resolution; Fusion owns the board-state merge. *Rationale:* avoid two systems racing the same git/branch state. (See Risk-3.) + +--- + +## High-Level Technical Design + +### Current vs. target workflow shape + +```mermaid +flowchart LR + subgraph Current + P1[Plan: ce-plan
questions lost] --> E1[Execute: generic prompt] + E1 --> R1[Review] --> CR1[Code review: ce-code-review gate] + CR1 --> M1[Merge: generic boundary] --> D1[Document: ce-compound] + end + subgraph Target + P2[Plan: ce-plan
headless-aware] -->|has questions| AQ[await-input pause
status: awaiting-user-input] + AQ -.card button.-> QA[Interactive Q&A
steering answers] + QA --> P2 + P2 -->|no questions| E2[Execute: ce-work
toolMode: coding] + E2 --> R2[Review] --> CR2[Code review: ce-code-review gate] + CR2 --> M2[Merge: ce-commit-push-pr
+ ce-resolve-pr-feedback] --> D2[Document: ce-compound] + end +``` + +### Planning-question pause/resume (reusing await-input) + +```mermaid +sequenceDiagram + participant W as Workflow executor + participant CP as ce-plan (step session) + participant T as Task store + participant U as User (dashboard) + W->>CP: run Plan step (FUSION_WORKFLOW_STEP=1) + CP->>CP: detect non-interactive; gather clarifying questions + CP-->>W: emit questions (await-input marker) + W->>T: status=awaiting-user-input, pausedReason=workflow-input:plan@ts: Qs + U->>T: clicks "Answer planning questions" on card + U->>T: interactive answers -> steering comments + U->>W: submit & resume + W->>CP: resume; consume steering answers (watermark) + CP-->>W: write plan with answers; continue to Execute +``` + +### Subagent enablement (the load-bearing gap) + +```mermaid +flowchart TD + S[CE skill in coding step] -->|Task ce-correctness-reviewer| SP{fn_spawn_agent present?} + SP -->|readonly: NO| F1[stripped -> spawn fails] + SP -->|coding: YES| RT{ce-* agent type resolvable?} + RT -->|not installed today: NO| F2[spawn errors / no persona] + RT -->|after install: YES| OK[subagent runs] +``` + +--- + +## Output Structure (new/changed surfaces) + +```text +packages/ + core/src/builtin-workflows.ts # rewire execute/merge/plan nodes + core/src/__tests__/builtin-workflows.test.ts # updated assertions + engine/src/executor.ts # headless env signal; plan await-input wiring + engine/src/workflow-step-tool-policy.ts # confirm coding-mode spawn allowance +dashboard/ + app/components/TaskCard.tsx # "Answer planning questions" button + app/components/WorkflowResultsTab.tsx (or TaskChatTab) # planning Q&A render/capture +plugins/fusion-plugin-compound-engineering/ + .fusion-ce-skills/ce-commit/SKILL.md # NEW (bundled) + .fusion-ce-skills/ce-commit-push-pr/SKILL.md # NEW (bundled) + .fusion-ce-skills/ce-resolve-pr-feedback/SKILL.md # NEW (bundled) + .fusion-ce-agents/ce-*.md # NEW (bundled agent defs) + src/agent-installation.ts # NEW installBundledCeAgents() + src/index.ts # install agents on load + .fusion-ce-skills/ce-plan/SKILL.md # headless/await-input branch +``` + +--- + +## Implementation Units + +### U1. Inject a headless/workflow-step signal into step sessions +- **Goal:** Give skills a reliable way to detect they're running in a Fusion autonomous workflow step with no interactive user. +- **Requirements:** R3, R4 +- **Dependencies:** none +- **Files:** `packages/engine/src/executor.ts` (taskEnv build ~6808-6812 and `executeWorkflowStep` ~11833-11861); test in `packages/engine/src/__tests__/` (mirror existing executor tests). +- **Approach:** Set `FUSION_WORKFLOW_STEP=1` on every workflow-step session env. Additionally set `FUSION_HEADLESS=1` when the run has no interactive/steering surface (i.e. autonomous board execution, LFG/pipeline). Keep the variable names stable — they become the contract the skills read. +- **Patterns to follow:** existing `FUSION_NODE_PROMPT` injection (~5942/5979) and `taskEnv` assembly. +- **Test scenarios:** + - Happy path: a workflow-step session is created with `FUSION_WORKFLOW_STEP=1` in its env. + - Headless: autonomous run sets `FUSION_HEADLESS=1`; an interactive/steered run does not. + - Edge: env var does not leak into the user's interactive chat sessions (non-workflow paths). +- **Verification:** new env keys present on step sessions; absent on interactive sessions. + +### U2. Add an optional `systemPromptOverride` to `fn_spawn_agent` +- **Goal:** Let a spawned child run with a supplied persona system prompt, since today it only spawns generic-role children. (Both spikes done — see KTD-4/Risk-1; this is the build.) +- **Requirements:** R5 +- **Dependencies:** none +- **Files:** `packages/engine/src/executor.ts` (`spawnAgentParams` 945-956; child-session `systemPrompt` build 14420-14460). +- **Approach:** Add an optional `systemPromptOverride` string to `spawnAgentParams`. When present, use it as the child session's `systemPrompt` (still composed with executor instructions via `buildSystemPromptWithInstructions`) instead of the generic `childBasePrompt`; keep `role` for capability/model routing. Absent → unchanged behavior. Keep the param generic (not CE-specific) so it's a clean primitive extension. +- **Patterns to follow:** `buildSystemPromptWithInstructions`; the existing child-session creation block. +- **Test scenarios:** + - Spawn with `systemPromptOverride` uses it as the child system prompt. + - Spawn without it is byte-for-byte the old generic-child behavior. + - Empty/whitespace override falls back to the generic prompt. + - Edge: readonly step has no `fn_spawn_agent` at all (asserts the negative). +- **Verification:** a child spawned with an override runs under that persona's instructions. + +### U3. Install `ce-*` persona defs plugin-locally + expose dir; skills read & inline them +- **Goal:** Ship the 43 `ce-*` persona defs with the plugin and make them reachable so the CE skills can pass them as `systemPromptOverride`. +- **Requirements:** R5 +- **Dependencies:** U2 +- **Files:** `plugins/fusion-plugin-compound-engineering/src/agents/ce-*.md` (NEW, vendored from cache), `plugins/fusion-plugin-compound-engineering/src/agent-installation.ts` (NEW, mirror `skill-installation.ts`), `src/index.ts` (onLoad — call `installBundledCeAgents()`), engine: set `FUSION_CE_AGENTS_DIR` (or generic contributed-agents dir) on step sessions so skills can locate the defs. +- **Approach:** Mirror skill installation: bundled source dir → plugin-local `.fusion-ce-agents/` install → idempotent. Expose the install dir to step sessions via env. CE skills (Read in coding mode) read `/.md`, strip frontmatter, and pass the body as `systemPromptOverride` to `fn_spawn_agent`. (Adapting each CE skill's dispatch sections to this Fusion path is part of U5-scope skill edits.) +- **Patterns to follow:** `skill-installation.ts`, `installBundledCeSkills()`, onLoad block `src/index.ts:111-131`. +- **Test scenarios:** + - onLoad installs persona defs; install result reports counts; idempotent re-install. + - Missing/corrupt def is skipped with a warning, not a throw. + - Step session env exposes the agents dir. +- **Verification:** after load, defs exist at the dir and a skill can read one and spawn with it. + + +### U4. Swap the Execute node to `ce-work` (coding mode) +- **Goal:** Implementation runs the CE way. +- **Requirements:** R1 +- **Dependencies:** U1 (headless signal so ce-work adapts); U2/U3 if ce-work's parallel executors must spawn. +- **Files:** `packages/core/src/builtin-workflows.ts:168`. +- **Approach:** Replace `{ id: "execute", kind: "prompt", config: builtinPromptConfig("execute", "Execute") }` with a skill-executor node mirroring the Plan node: `executor: "skill"`, `skillName: "compound-engineering:ce-work"`, `toolMode: "coding"`, and a short prompt ("Execute the plan, following existing patterns and maintaining quality"). Confirm `ce-work` is bundled (it is: `.fusion-ce-skills/ce-work/SKILL.md`). +- **Patterns to follow:** the Plan and Code-review skill node shapes (`builtin-workflows.ts:158-179`). +- **Test scenarios:** + - `compileWorkflowToSteps` yields an Execute step whose compiled `toolMode === "coding"`. + - The Execute step's prompt is wrapped with the `Invoke the "compound-engineering:ce-work" skill ...` preamble (executor.ts:5903-5904 path). + - Step count/names for the workflow remain valid. +- **Verification:** updated `builtin-workflows.test.ts` asserts the ce-work execute step + coding mode. + +### U5. Make `ce-plan` headless-aware and emit pending questions +- **Goal:** In a Fusion workflow step, `ce-plan` stops calling a dead blocking tool; it either records assumptions (fully headless) or emits clarifying questions into the await-input channel (human reachable via card button). +- **Requirements:** R3, R4 +- **Dependencies:** U1 +- **Files:** `plugins/fusion-plugin-compound-engineering/.fusion-ce-skills/ce-plan/SKILL.md` (Interaction Method + headless-mode branches it already documents), possibly a small reference file. +- **Approach:** Teach the skill's interaction section to read `FUSION_WORKFLOW_STEP` / `FUSION_HEADLESS`: when `FUSION_HEADLESS=1`, take the existing assumptions-writing path (no questions); when in a workflow step that *can* reach a human, emit the clarifying questions in the await-input format the executor consumes (KTD-2) rather than `AskUserQuestion`. Keep interactive Claude Code behavior unchanged. +- **Patterns to follow:** the skill's existing "Headless mode" routing and `references/synthesis-summary.md` headless sections. +- **Test scenarios:** + - With `FUSION_HEADLESS=1`, the plan output contains an `## Assumptions` section and no blocking-tool call. + - With `FUSION_WORKFLOW_STEP=1` (human reachable), unresolved blockers are emitted as await-input questions. + - Interactive (neither var) path still uses `AskUserQuestion`. + - `Test expectation:` skill-doc behavior is asserted via the executor integration test in U6, not a unit test of the markdown. +- **Verification:** running the Plan step headless produces assumptions; running it with a reachable human parks the task with questions. + +### U6. Wire the Plan step's await-input pause/resume into the workflow +- **Goal:** When `ce-plan` emits questions, the workflow parks the task `awaiting-user-input` and resumes with the answers. +- **Requirements:** R3 +- **Dependencies:** U5 +- **Files:** `packages/core/src/builtin-workflows.ts` (Plan node / add an await-input gate keyed off the plan step), `packages/engine/src/executor.ts` (`runAwaitInputNode` reuse ~5442-5496; Plan-step output → await-input marker). +- **Approach:** After the Plan skill step, route emitted questions through the existing await-input marker (`workflow-input:plan@ts: ...`) so the proven pause/resume + steering-answer consumption applies. Prefer reusing `runAwaitInputNode` semantics over a bespoke pause. If `ce-plan` reports no questions, fall straight through to Execute. +- **Patterns to follow:** `runAwaitInputNode`, the submit/resume route `register-workflow-routes.ts:580-599`, watermark answer consumption. +- **Test scenarios:** + - Plan emits questions → task status becomes `awaiting-user-input` with a parseable `pausedReason`. + - A submitted answer (steering comment) resumes the plan step and is available to it. + - No questions → no pause; Execute runs next. + - Edge: resume with no new steering comment re-parks (matches `runAwaitInputNode`). +- **Verification:** executor integration test drives pause→answer→resume→continue. + +### U7. Task-card "Answer planning questions" button + interactive Q&A +- **Goal:** A button on the task card lets the user open an interactive session to answer the pending planning questions. +- **Requirements:** R3 +- **Dependencies:** U6 +- **Files:** `packages/dashboard/app/components/TaskCard.tsx` (`card-header-actions` ~2025-2102), `packages/dashboard/app/components/WorkflowResultsTab.tsx` (question render/submit ~65-944) and/or `TaskChatTab.tsx` (steering surface), API client `packages/dashboard/app/api/legacy.ts` (reuse `addSteeringComment`/`submitTaskWorkflowInput`). +- **Approach:** Show the button when the task is `awaiting-user-input` with a planning marker. Clicking opens the task's Q&A surface (WorkflowResultsTab input banner, or the chat-steering tab) focused on the question; submitting posts a steering comment / workflow input and unpauses (existing routes). Keep it to the existing pause/answer plumbing — no new persistence model. +- **Patterns to follow:** Send-back menu button pattern (`TaskCard.tsx:2066-2096`), `parseWorkflowInputQuestion` + submit banner (`WorkflowResultsTab.tsx:914-944`), live-session detection (`isCliSessionLive`). +- **Test scenarios:** + - Button renders only when status is `awaiting-user-input` with a planning marker; hidden otherwise. + - Clicking surfaces the pending question(s). + - Submitting an answer posts the steering/input request and unpauses the task. + - Edge: multiple queued questions are answered in sequence (one-at-a-time, matching ce-plan's "ask one question at a time"). +- **Verification:** component tests (mirror `TaskChatTab.test.tsx`) for render-gating and submit; manual board run shows the loop end-to-end. + +### U8. Bundle `ce-commit`, `ce-commit-push-pr`, `ce-resolve-pr-feedback` +- **Goal:** Ship the three missing CE shipping skills with the plugin. +- **Requirements:** R6 +- **Dependencies:** none (parallel to U1-U7) +- **Files:** `plugins/fusion-plugin-compound-engineering/.fusion-ce-skills/ce-commit/SKILL.md`, `.../ce-commit-push-pr/SKILL.md`, `.../ce-resolve-pr-feedback/SKILL.md` (NEW; source from the canonical CE skill set), `src/skills.ts` (register them in the bundled list), installer picks them up automatically. +- **Approach:** Add the three skill dirs to the bundled set and the `COMPOUND_ENGINEERING_SKILLS` manifest array (`src/index.ts:48-58`). Vendor the skill content from the upstream CE skill definitions, adapting the Interaction Method sections for the headless signal (KTD-1) like U5. +- **Patterns to follow:** existing bundled skill dirs and `src/skills.ts:16-79`, manifest registration `src/index.ts:48-58`. +- **Test scenarios:** + - Plugin manifest lists the three new skills. + - onLoad installs them; install count increases by 3. + - `Test expectation: none` for the skill markdown content itself; covered by manifest/install assertions. +- **Verification:** the three skills are installed and discoverable after plugin load. + +### U9. Rewire the Merge node to CE commit/push-PR + resolve-feedback +- **Goal:** The merge stage uses CE's commit/PR flow and offers PR-feedback resolution, without fighting Fusion's workflow-owned merge. +- **Requirements:** R2 +- **Dependencies:** U8 (skills must exist), U2/U3 (resolve-feedback spawns `ce-pr-comment-resolver`), KTD-6 boundary. +- **Files:** `packages/core/src/builtin-workflows.ts:181` (merge node), possibly an added post-review step; check engine merge handling (`executor.ts` pre-merge/merge path) for the ownership boundary. +- **Approach:** Replace the generic merge boundary with a `compound-engineering:ce-commit-push-pr` skill step (coding mode) that commits, pushes, and opens the PR; add a `compound-engineering:ce-resolve-pr-feedback` step/gate for addressing review threads. Honor KTD-6: the CE step prepares git/PR state; Fusion's machinery still owns the board-state merge transition. Document the division explicitly in the node config/comments. +- **Patterns to follow:** Code-review gate node shape (`builtin-workflows.ts:170-179`); Fusion merge handling in `executor.ts`. +- **Test scenarios:** + - `compileWorkflowToSteps` includes a `ce-commit-push-pr` merge step (coding mode) and a `ce-resolve-pr-feedback` step. + - The CE merge step does not duplicate Fusion's board merge transition (boundary asserted). + - Step ordering: review → code-review gate → commit/push-PR → resolve-feedback → document. +- **Verification:** updated workflow test asserts the new merge-stage steps and ordering. + +### U10. Update built-in workflow tests +- **Goal:** Lock in the new node wiring and gating. +- **Requirements:** R7 +- **Dependencies:** U4, U6, U9 +- **Files:** `packages/core/src/__tests__/builtin-workflows.test.ts`. +- **Approach:** Extend the existing compound-engineering tests (compile-to-steps ~268-274; plugin gating ~314-333) to assert: ce-work execute step + `toolMode: "coding"`; the plan await-input pause path compiles; the merge stage contains the ce-commit-push-pr + ce-resolve-pr-feedback steps; plugin gating still holds. +- **Patterns to follow:** existing assertions in the same file. +- **Test scenarios:** + - Execute step name/skill/toolMode. + - Merge-stage steps present and ordered. + - Workflow still hidden without plugin, shown with plugin. + - Plan step compiles with the await-input branch. +- **Verification:** `pnpm --filter @fusion/core test builtin-workflows` green. + +--- + +## Scope Boundaries + +**In scope:** the five fixes above for the `builtin:compound-engineering` workflow; the headless signal; subagent enablement; bundling the three shipping skills; the task-card planning-question button and its interactive answer loop. + +### Deferred to Follow-Up Work +- Applying the headless signal / subagent enablement to the *other* built-in workflows (`builtin:coding`, `builtin:stepwise-coding`). +- A general plugin-provided **agent-definition registry** API (this plan installs CE agents via a focused installer; a generic plugin-agents contribution surface is larger). +- A **lightweight read-only spawn path** that avoids a full git worktree per child for read-only reviewer personas (the `ce-code-review` panel can fan out wide); today every `fn_spawn_agent` child gets its own worktree. +- Evidence-capture / demo-reel integration in the PR flow (`ce-demo-reel`). +- HTML/Proof handoff for plans generated inside Fusion. + +### Out of scope +- Redesigning Fusion's workflow engine, the await-input mechanism, or the workflow-owned-merge system itself. +- Changing interactive Claude Code behavior of the CE skills. + +--- + +## Risks & Dependencies + +- **Risk-1 (high, NOW CHARACTERIZED) — Fusion's spawn primitive has no persona/type parameter.** **Confirmed by spike:** `fn_spawn_agent` is `{ name, role: AgentCapability, task }` (`executor.ts:945-956`); no `subagent_type`. The CE skills' named-persona dispatch can't work unmodified. **Mitigation:** KTD-4 — extend `spawnAgentParams` with an optional persona/`agentType` + install `ce-*` definitions; single-agent inline fallback (R5) if persona is absent. **Secondary cost:** every spawned child gets its own git worktree (`createWorktree`), so wide reviewer fan-out (the `ce-code-review` persona panel) is heavier in Fusion than in Claude Code — consider a lighter ephemeral-session spawn path for read-only reviewer personas (see Deferred). *This is the largest design decision in the plan and changes engine scope.* +- **Risk-2 (med) — Readonly default silently degrades CE steps.** Any CE step needing spawn/write must set `toolMode: "coding"` or it loses tools with no error. **Mitigation:** KTD-5; tests assert compiled `toolMode`. +- **Risk-3 (med) — CE PR flow vs. Fusion workflow-owned merge collision.** Two systems touching branch/PR/merge state can race. **Mitigation:** KTD-6 boundary; verify against the engine merge path before U9; keep CE to commit/push/PR-creation/feedback and leave the board merge transition to Fusion. +- **Risk-4 (low) — Question loop UX.** One-at-a-time questions over the steering channel could feel clunky for multi-question plans. **Mitigation:** sequence questions; reuse the existing input banner; keep ce-plan's "ask one question at a time" discipline. +- **Dependency:** the three new shipping skills (U8) must be vendored from the canonical CE skill set with headless adaptation. + +--- + +## Verification Strategy + +- Unit/integration tests per unit (above), centered on `builtin-workflows.test.ts` and executor pause/resume tests. +- A manual autonomous board run of the compound-engineering workflow on a real task: confirm (1) ce-work executes, (2) planning questions appear on the card and the button opens the Q&A, answers resume planning, (3) merge produces a commit + PR and surfaces feedback resolution, (4) subagents either run or fall back as documented. +- `pnpm` typecheck + the affected package test suites green before PR. + +--- + +## Sources & Research + +- Workflow definition + skill node shapes: `packages/core/src/builtin-workflows.ts:153-193`; `builtinPromptConfig` at `packages/core/src/builtin-workflow-prompts.ts:23-25`. +- Skill-executor wrapping + WorkflowStep build: `packages/engine/src/executor.ts:5903-5904`, `:5959-5975`, `executeWorkflowStep` ~11698-11915. +- Readonly tool policy: `packages/engine/src/workflow-step-tool-policy.ts`; mutation tools `packages/engine/src/gating-classifications.ts:48-52`; readonly extension exclusion `packages/engine/src/pi.ts:1930-2042`, ephemeral gate skip `:1768-1772`. +- Await-input pause/resume: `packages/engine/src/executor.ts:5442-5496`; submit route `packages/dashboard/src/routes/register-workflow-routes.ts:580-599`; UI `packages/dashboard/app/components/WorkflowResultsTab.tsx:65-944`. +- Steering / live sessions (FN-6338): `packages/dashboard/app/components/TaskChatTab.tsx`, `TaskDetailModal.tsx`, `app/api/legacy.ts` (`addSteeringComment` ~1509), `register-task-workflow-routes.ts:2720-2752`. +- Plugin install: `plugins/fusion-plugin-compound-engineering/src/index.ts:48-143`, `src/skill-installation.ts`, `src/skills.ts:16-79`; bundled skills under `.fusion-ce-skills/` (7 today; ce-commit/-push-pr/-resolve-pr-feedback absent). +- Tests: `packages/core/src/__tests__/builtin-workflows.test.ts:239-333`. diff --git a/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md b/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md new file mode 100644 index 0000000000..72b96314ea --- /dev/null +++ b/docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md @@ -0,0 +1,499 @@ +--- +title: "feat: Route Claude ask-paths through ACP runtime + claude-code-cli-acp bridge (replace claude -p)" +type: feat +status: active +date: 2026-06-14 +depth: deep +--- + +# feat: Route Claude through the ACP runtime + `claude-code-cli-acp` bridge (replace `claude -p`) + +## Summary + +Fusion invokes Claude through `claude -p` on **two independent routes**, and both must move off `-p` onto the **already-shipped** `fusion-plugin-acp-runtime` (runtimeId `acp`) pointed at the external **`claude-code-cli-acp`** bridge — a Rust ACP server that drives the real interactive `claude` through a PTY and reads the transcript JSONL, exposing it over JSON-RPC/stdio: + +- **Route A — the `pi-claude-cli` provider (PRIMARY, highest traffic).** A vendored pi provider (`@fusion/pi-claude-cli`) registered whenever `useClaudeCli` is on. Selecting it as the model/provider makes *every* AI lane — chat, executor, validator, reviewer, **workflow `model` nodes**, title summarization, reflection, merger — spawn `claude -p --input-format stream-json --output-format stream-json --mcp-config …` (`packages/pi-claude-cli/src/process-manager.ts:37-101`). This is the bulk of real `-p` traffic and is **MCP-tool-bearing** (Fusion injects its tools). +- **Route B — the one-shot seams (planning, validator).** `runOneShotSession` launches `claude -p` and scrapes a `--output-format json` frame for `PlanningResponse` / `ValidatorVerdict`. These are dependency-injected seams with **no production caller today**. + +The bridge is pinned as a dependency of the ACP runtime plugin so it ships with Fusion. For Route B, a thin engine-side "ask once" runner drives a single ACP turn and returns the existing `{ ok, text, parsed }` shape so the rewires stay small. For Route A, the provider's `streamSimple` is re-pointed from `spawnClaude` to an ACP-bridge client. + +**Success criterion: `-p` removal is mandatory, not best-effort.** Removing `claude -p` is the whole point — including for Route A, which is the *bulk* of `-p` traffic. So "leave the provider on `-p`" is **not** an acceptable outcome. If the Route A feasibility gates (U9 external MCP passthrough, U14 internal blockers) return no-go, the response is to **block the feature and sponsor the missing capability upstream** (bridge MCP passthrough and/or the ACP `mcpServers` forwarding), not to ship with Claude still on `-p`. Route B may still ship first as independent progress, but the feature is not "done" until Route A is off `-p` too. + +**Scope is Claude only.** codex/droid/pi keep their existing `exec`/`--print` non-interactive forms (no ACP bridge exists for them); converting them is explicitly deferred. + +--- + +## Problem Frame + +**Why `-p` is being removed.** Per the request, Claude must be driven through an interactive PTY session, not `claude -p`. Investigation showed the cleanest way to get this without re-implementing PTY-spawn + transcript-tailing ourselves is to reuse the existing ACP runtime and an external bridge that already does exactly that PTY+transcript work and speaks ACP. + +**Two independent `-p` routes — do not conflate them.** Investigation found Claude is spawned with `-p` from two unrelated code paths: + +- **Route A — `pi-claude-cli` provider.** Provider id `"pi-claude-cli"` (`packages/pi-claude-cli/index.ts:27,217`), registered into the pi `ModelRegistry` by `registerExtensionProviders` (`packages/engine/src/pi.ts:1366-1422`) inside the shared `createFnAgent` session factory used by **all** lanes. When selected, `streamSimple` → `streamViaCli` → `spawnClaude` → `spawn("claude", ["-p", "--input-format","stream-json","--output-format","stream-json", …, "--mcp-config", …])` (`packages/pi-claude-cli/src/process-manager.ts:37-101`). Gated by `GlobalSettings.useClaudeCli` (`packages/core/src/types.ts:2993`) and surfaced/hidden in model pickers accordingly (`packages/dashboard/src/routes/register-model-routes.ts:140-174`). **This is the high-traffic route and the one the user means by "the claude cli model type used for workflow execution and anywhere else models are used."** +- **Route B — one-shot seams.** `runOneShotSession`/`runCliAgentValidation`/`runCliAgentPlanning` have **no production call site** — they are dependency-injection seams exercised only by tests; the CE orchestrator's `cli-agent` branch is explicitly "not yet wired." Replacing `-p` here = change each seam's injected runner + delete the Claude one-shot branches; there is no live `-p` traffic to cut over, and this plan does **not** make these lanes actually run in production (pre-existing TODO). + +**The MCP-tool dependency (Route A's hard problem).** The `pi-claude-cli` provider injects Fusion's tools into Claude via `--mcp-config` and maps Claude↔pi tool names (`packages/pi-claude-cli/src/{mcp-config.ts,tool-mapping.ts}`). The ACP runtime opens sessions with **empty `mcpServers`** (MCP custom-tool forwarding was explicitly deferred in the ACP plugin — see `plugins/fusion-plugin-acp-runtime` scope and the ACP learning doc). Until ACP forwards MCP servers *and* the bridge passes them through to the underlying `claude`, routing Route A to the bridge would strip Fusion's tool-calling — almost certainly unacceptable for executor/workflow lanes. **This makes ACP MCP forwarding a prerequisite of Route A, not an optional extra (OQ1).** + +**The core technical tension — prose vs structured JSON.** `claude -p --output-format json` returns a structured envelope (`{ type: "result", result, is_error }`); the validator parses it (`OneShotResult.parsed`). ACP delivers the assistant message as **streamed prose** via the `onText` callback; `promptWithFallback` resolves `void` and even the terminal `stopReason` is currently discarded by the adapter (`plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts:143`). So structured parsing must move caller-side: the "ask once" runner accumulates the prose, and the validator coaxes a trailing JSON object out of the model and recovers it. + +**The bridge is young.** `claude-code-cli-acp` is v0.1.1 (Apache-2.0, 11 stars, 2 releases). It is pinned at an exact version and isolated behind the existing ACP security floor (per-category permission gating, env allow-list, realpath path-jail). It still requires `claude` to be installed and authenticated separately. + +--- + +## Requirements + +**Shared foundation** +- **R2** — `claude-code-cli-acp` is pinned as a dependency of `fusion-plugin-acp-runtime`, resolved to an absolute path inside the plugin's own `node_modules` (never a PATH-resolved substitute), with integrity recorded against a source-reviewed pinned commit. +- **R3a** — A read-only ACP ask posture (fs OFF) is available for Route B turns. +- **R3b** — A tool-bearing ACP posture pinned to the bridge (the `acp-claude` runtime, KTD9) is available for the Route A provider, without altering the generic `acp` runtime's "any ACP agent" contract. +- **R8** — The bridge's absence/auth failure surfaces as a typed, actionable error (probe taxonomy), not a hang or opaque crash. +- **R16** — Every bridge subprocess env is built from an explicit allow-list (never inherited `process.env`); the Claude profile's allow-list is enumerated with per-entry justification (`HOME`, `PATH` in; `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` deliberately out — `claude` uses its `~/.claude` session token). + +**Route A — `pi-claude-cli` provider (primary)** +- **R9** — When `useClaudeCli` is on and Claude CLI is the selected provider, AI lanes (chat, executor, validator, reviewer, workflow `model` nodes, summarization) invoke Claude via the ACP bridge, not `claude -p`. Existing persisted `defaultProvider="pi-claude-cli"` selections continue to work (re-routed under the hood; no forced re-selection). +- **R10** — Fusion's MCP tools remain available to Claude over the ACP path (or the route is explicitly gated off until MCP forwarding lands — see OQ1). Tool-name mapping (Claude↔pi) is preserved. +- **R11** — Streaming fidelity is preserved: token/thinking/tool-call deltas reach the lane callbacks with tool-call argument integrity and start/end correlation intact (OQ3). +- **R12** — The picker/auth/status surface (`/auth/claude-cli`, `/providers/claude-cli/status`, `claude-cli-probe`, picker filtering) reflects the ACP-backed reality, with probe `detail` sanitized (no internal paths/OS error strings) before HTTP exposure. +- **R13** — Multi-turn lanes (chat, executor, workflow) preserve conversation context over ACP — via session resume or full-history prompts. No path sends the latest turn only without resume (OQ2). +- **R14** — Route A retains a config-only rollback to `claude -p`: `spawnClaude`/`buildClaudeSpawnArgs` stay behind a runtime kill-switch (not deleted) until the ACP provider path has soaked in production. +- **R15** — The validator never infers `pass` from prose on the ACP path, and an abnormal/truncated stop (`max_tokens`, `cancelled`) maps to `error`. The prose backstop may only ever yield `fail`/`blocked`/`error`. + +**Route B — one-shot seams** +- **R1** — Claude planning/validator ask-paths no longer use `claude -p`; they route through the `acp` runtime driving `claude-code-cli-acp`. +- **R4** — A reusable engine-side "ask once" runner drives a single ACP turn and returns `{ ok, text, parsed }` (plus a typed failure on connection/turn error) so seam consumers change minimally and the validator's "never a silent pass" rule is preserved. +- **R5** — The planning seam (`runCliAgentPlanning`) produces a `PlanningResponse` from ACP prose with no contract change. +- **R6** — The validator seam (`runCliAgentValidation`) produces a `ValidatorVerdict` from ACP prose, keeping the `ValidatorVerdict` contract and never degrading an undecidable result to a silent pass. +- **R7** — The Claude-specific `-p` branches in the one-shot machinery and their tests are deleted; codex/droid/pi one-shot paths remain intact. + +--- + +## High-Level Technical Design + +The ask path crosses three processes. The engine resolves the `acp` runtime, which spawns the bridge subprocess, which in turn drives the real `claude` over a PTY. + +```mermaid +flowchart LR + subgraph Engine["@fusion/engine"] + SEAM["planning / validator seam"] + ASK["askAcpOnce runner (U4)"] + RES["runtime-resolution\ngetRuntimeById('acp')"] + SEAM --> ASK --> RES + end + subgraph Plugin["fusion-plugin-acp-runtime"] + ADP["AcpRuntimeAdapter\ncreateSession / promptWithFallback"] + SPAWN["process-manager spawn\n(env allow-list, path-jail)"] + ADP --> SPAWN + end + subgraph Bridge["claude-code-cli-acp (pinned dep, U1)"] + ACPSRV["ACP server (JSON-RPC/stdio)"] + PTY["claude via PTY + transcript JSONL"] + ACPSRV --> PTY + end + RES -->|runtimeHint acp| ADP + SPAWN -->|stdio| ACPSRV +``` + +One ask turn (the runner's control flow — directional, not implementation spec): + +```mermaid +sequenceDiagram + participant Seam + participant Ask as askAcpOnce + participant ACP as AcpRuntimeAdapter + participant Bridge as claude-code-cli-acp + Seam->>Ask: ask(prompt, {model, cwd, readonly}) + Ask->>ACP: createSession({tools:"readonly", onText: d=>text+=d}) + ACP->>Bridge: spawn + initialize + session/new + Ask->>ACP: promptWithFallback(session, prompt) + ACP->>Bridge: session/prompt + Bridge-->>ACP: session/update (text deltas) + ACP-->>Ask: onText(delta) ... (accumulate) + Bridge-->>ACP: stopReason (turn end) + ACP-->>Ask: promptWithFallback resolves (void) + Ask->>Ask: parsed = recoverJson(text) %% validator only + Ask->>ACP: dispose(session) %% finally + Ask-->>Seam: { ok, text, parsed } +``` + +--- + +## Key Technical Decisions + +- **KTD1 — Reuse the ACP runtime, do not bolt ACP onto the PTY `claude-code` adapter.** The cli-agent `CliAgentAdapter` contract is a PTY byte-stream (readiness detector, injection). ACP is JSON-RPC. The `acp` runtime (`AgentRuntime`) already models ACP correctly. "Have the Claude cli adapter use this" is satisfied by routing Claude through the ACP runtime, not by changing `claude-code.ts`. +- **KTD2 — Pin the bridge as a plugin dependency** (`claude-code-cli-acp@0.1.1` in `plugins/fusion-plugin-acp-runtime/package.json`), resolved to an absolute path from the plugin's `node_modules/.bin` so spawn never depends on global PATH. Chosen over user-installed-probe per the dependency decision; the probe/setup is still added (U3) for the `claude`-binary + auth preconditions the bridge itself needs. +- **KTD3 — Caller-side structured recovery.** The runner accumulates `onText` (the only channel for assistant text — established idiom: `packages/engine/src/evaluator.ts:151-165`). For the validator, the system prompt instructs Claude to end its turn with a single JSON object; the runner recovers it via the existing `extractJsonObjects` (`packages/engine/src/cli-agent/one-shot-session.ts:189`) into `parsed`, so `mapParsedToVerdict` works unchanged off `verdict`/`passed`/`blocked`. The claude-`-p`-specific `is_error` tier becomes dead and is removed. +- **KTD4 — Read-only ask posture.** Ask turns set `tools: "readonly"` and leave fs capabilities OFF (the ACP defaults), so the bridge never trips a gated permission category and no `actionGateContext` is required. This matches the existing read-only posture of validator/planning one-shots. +- **KTD5 — Keep the `OneShotResult` machinery for codex/droid/pi.** Only the `claude-code` branches are deleted (`buildOneShotSettings` lines 67-69, `parseOneShotOutput` lines 139-148). The generic runner and other adapters' non-interactive forms survive. +- **KTD6 — Surface `stopReason` from the adapter (required for the validator path).** `promptWithFallback` returns `void` and discards the SDK `stopReason` (`runtime-adapter.ts:143`; `promptAcpSession` does return it at `provider.ts:374`). Surfacing it is an `AgentRuntime` interface change (a new optional return/callback on `promptWithFallback`, consumed engine-side) — a real cost, but **justified and required for U6**: without it a `max_tokens` truncation that leaves a parseable trailing `{...}` passes silently, violating the validator's cardinal rule (R15). It stays *optional* for U5 (planning tolerates prose). The "JSON-presence-only" fallback is explicitly **rejected for the validator** — it's the exact gap that breaks no-silent-pass. +- **KTD7 — Route A re-points the provider internally; keep the `pi-claude-cli` provider key.** The smallest-blast option is to leave the provider id `"pi-claude-cli"` and `useClaudeCli` semantics intact and replace `spawnClaude`'s NDJSON subprocess inside `@fusion/pi-claude-cli` with an ACP-bridge client — so persisted selections and pickers need no migration (R9). Rejected alternative: register a new ACP-backed provider key and migrate all saved `defaultProvider`/`executionProvider` values (larger blast radius, user-visible churn). +- **KTD8 — Route A is gated on ACP MCP forwarding (prerequisite, not optional).** Fusion's tools reach Claude today via the provider's `--mcp-config`. The ACP runtime opens `session/new` with empty `mcpServers` (hardcoded `mcpServers: []` at `plugins/fusion-plugin-acp-runtime/src/provider.ts:356`, KTD5-deferred). Route A therefore requires: (1) the ACP runtime to forward Fusion's MCP server(s) on `session/new` (U10), **and** (2) the `claude-code-cli-acp` bridge to pass those through to the underlying interactive `claude` **with tool calls still traversing the ACP permission gate** (verified by U9). Because `-p` removal is mandatory (see Summary), a no-go on either does **not** license staying on `-p`: it blocks the feature and triggers upstream work to add the missing capability. This is the plan's central open question (OQ1). +- **KTD9 — Per-route ACP posture needs a real mechanism (the runtime is a single global instance).** `acpRuntimeFactory` builds one `AcpRuntimeAdapter` from a frozen settings blob (`plugins/fusion-plugin-acp-runtime/src/index.ts:22-23`); `binaryPath`/`args`/fs-toggles/`model` are fixed at construction, and per-call `AgentRuntimeOptions` carries only cwd/tools/callbacks/gate. Route A (tool-bearing, Claude bridge) and Route B (read-only ask) cannot both draw distinct postures from one shared constructor. **Decision:** register a second runtime id `acp-claude` pinned to the bridge with tool-bearing defaults, leaving the generic `acp` runtime's "any ACP agent" contract intact — rather than hard-binding the global `acp` default to the bridge. (Resolved in U14; supersedes the earlier U2 framing of "default the `acp` runtime to the bridge.") +- **KTD10 — The pi extension reaches ACP via an injected client, never by importing engine internals.** `@fusion/pi-claude-cli` declares no dependency on `@fusion/engine`/the ACP plugin and cannot resolve `getRuntimeById('acp')` itself. **Decision:** the engine constructs an ACP-bridge client/driver at provider-registration time (`packages/engine/src/pi.ts:1366-1422`) and threads it into the provider's `streamSimple` options — mirroring how `mcpConfigPath` is already passed via `StreamViaCliOptions` — so the vendored fork stays dependency-clean. (Designed in U14, consumed in U11.) +- **KTD11 — `AgentRuntimeOptions` gains an `mcpServers` field (engine + plugin-local copy).** Forwarding MCP is a multi-layer contract change, not a local edit: a new optional field on the engine `AgentRuntimeOptions` (`packages/engine/src/agent-runtime.ts`) and its structural copy (`plugins/fusion-plugin-acp-runtime/src/types.ts`), a new `newAcpSession` signature, the `createSession` call-site, with back-compat default `[]` for Route B. The stdio MCP server shape `mcp-config.ts` already builds (`{ command, args }`) maps directly onto ACP's `mcpServers` entry. + +--- + +## Open Questions + +- **OQ1 (blocking for Route A; resolved by U9) — Can Fusion's MCP tools traverse the ACP bridge to Claude, *through the permission gate*?** Two parts: (a) does `claude-code-cli-acp` plumb `session/new` `mcpServers` to the underlying `claude` (its README does not mention MCP); and (b) **do the resulting tool calls surface as ACP `session/request_permission` (gated), or does `claude` invoke them autonomously inside the bridge, bypassing the gate?** U9 must test **(b) with the real Fusion MCP config** that `mcp-config.ts` builds — not a trivial stub — and record both answers. If tool calls bypass the gate, a separate control (MCP-layer hooks, or excluding sensitive-category tools from forwarding) is required before U10. Mandatory-`-p` means a no-go escalates to upstream work, not a `-p` fallback. + - **FN-6465 recovery outcome (2026-06-14): UNRESOLVED / BLOCKED; combined Route A verdict: NOT GO.** Recovery status: **NOT-RECOVERED** — `fn_task_show FN-6459` retained only archived task metadata plus an archive log entry, this worktree has no `.fusion/tasks/FN-6459/`, and `fn_task_document_read(key="research")` returned not found in FN-6465's context. No authoritative U9 verdict survived to transcribe. The U9 spike was **not re-run to a verdict** in this recovery task: local binaries are present (`claude` 2.1.177 and pinned `claude-code-cli-acp` 0.1.1), but no authenticated, instrumented run against the real Fusion MCP config and ACP `session/request_permission` telemetry was completed. Therefore both security-critical U9 answers remain unknown: (1) forwarded real Fusion MCP tool invocation through the bridge is **unproven**; (2) permission-gate traversal versus bridge-local autonomous invocation is **unproven**. FN-6460 must not start U10-U13 until a follow-up spike records both answers here and in `docs/acp-contract.md`; the no-go path is upstream bridge/ACP MCP passthrough or permission-hook work, not a `claude -p` fallback. + - **FN-6466 authenticated-bridge spike outcome (2026-06-14): still UNRESOLVED / BLOCKED; combined Route A verdict remains NOT GO.** The spike bypassed the ACP runtime's current `mcpServers: []` helper by opening `session/new` directly against pinned `claude-code-cli-acp` **0.1.1** with a **non-empty** ACP payload built from the real Fusion Route-A config shape: one stdio MCP server named `custom-tools`, `command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`, and a schema file containing **62** captured Fusion custom tools from `packages/cli/src/extension.ts`. The bridge accepted `initialize` and `session/new` with that payload, but the first prompt turn ended before any MCP tool invocation with assistant text **`Not logged in · Please run /login`**. Therefore answer **(1)** remains **unproven** — no forwarded Fusion tool was actually invoked through the bridge — and answer **(2)** remains **unproven** because no `session/request_permission` call or tool-call update occurred. The required escalation is unchanged: rerun U9 in an environment where the underlying `claude` is authenticated for the bridge, and if a later authenticated run still ignores `mcpServers` or bypasses the permission gate, sponsor the missing bridge/ACP capability upstream instead of falling back to `claude -p`. + - **FN-6467 rerun outcome (2026-06-14): UNRESOLVED / BLOCKED; combined Route A verdict remains NOT GO.** This rerun verified the same local bridge prerequisites (`claude` **2.1.177** on PATH, pinned `claude-code-cli-acp` **0.1.1** binary present under `plugins/fusion-plugin-acp-runtime/node_modules/.bin`, lockfile integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`) and opened the pinned bridge directly with one non-empty ACP stdio MCP server named `custom-tools`, `command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`, containing **62** Fusion custom-tool names confirmed from `packages/cli/src/extension.ts` and matching the FN-6466 payload shape. `initialize` returned `agentInfo.name="claude-code-cli-acp"`, `version="0.1.1"`, and `authMethods=["claude-code-login"]`; `session/new` accepted the non-empty `mcpServers` entry and returned a session. The prompt explicitly asked Claude to call `fn_task_list`, but the only assistant content was **`Not logged in · Please run /login`** with stopReason `end_turn`, **zero** tool-call updates, and **zero** ACP `session/request_permission` callbacks. Therefore answer **(1)** remains **UNPROVEN / BLOCKED** (no forwarded Fusion tool was invoked) and answer **(2)** remains **UNPROVEN / BLOCKED** (gate traversal cannot be classified as GATED or BYPASSED). The escalation path remains an authenticated rerun or upstream bridge/ACP MCP permission work; a `claude -p` fallback is explicitly not acceptable. + - **FN-6473 escalation outcome (2026-06-15): UNRESOLVED / BLOCKED; combined Route A verdict remains NOT GO.** This explicit escalation again verified real bridge prerequisites (`claude` **2.1.177** at `/Users/eclipxe/.local/bin/claude`, plugin-local pinned `claude-code-cli-acp` **0.1.1**, unchanged lockfile integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`) and drove the pinned bridge with explicit `session/request_permission` instrumentation. The non-empty ACP payload was the Route-A `custom-tools` stdio server (`command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`) carrying **62** Fusion custom-tool names confirmed from `packages/cli/src/extension.ts` and matching `mcp-config.ts`'s `writeMcpConfig` shape. `initialize` returned `agentInfo.name="claude-code-cli-acp"`, `version="0.1.1"`, and `authMethods=["claude-code-login"]`; `session/new` accepted the non-empty `mcpServers` entry. The prompt instructed Claude to call `fn_task_list`, but the turn ended with **`Not logged in · Please run /login`**, stopReason `end_turn`, **zero** tool-call updates, and **zero** ACP `session/request_permission` callbacks. Therefore answer **(1)** remains **UNPROVEN / BLOCKED** and answer **(2)** remains **UNPROVEN / BLOCKED** (neither GATED nor BYPASSED observed). Sponsor bridge/ACP MCP permission-forwarding and rerun in a genuinely authenticated bridge environment; never resolve this by falling back to `claude -p`. + - **FN-6475 upstream sponsorship (2026-06-15): sponsorship authored and filed; combined Route A verdict remains NOT GO.** The ready-to-file package is committed at [`docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md`](../upstream/claude-code-cli-acp-mcp-permission-forwarding.md) and filed upstream as https://github.com/moabualruz/claude-code-cli-acp/issues/2. It requests both required upstream capabilities: forwarding ACP `session/new.mcpServers` to authenticated `claude`, and routing forwarded MCP tool calls through ACP `session/request_permission` or an equivalent MCP-layer permission hook. This is an escalation/tracking action only; OQ1 stays **UNRESOLVED / BLOCKED**, U9 stays **NOT GO**, and no `claude -p` fallback is acceptable. + - **FN-6476 genuinely-authenticated rerun attempt (2026-06-15): still UNRESOLVED / BLOCKED; combined Route A verdict remains NOT GO.** This run re-confirmed `claude` **2.1.177** at `/Users/eclipxe/.local/bin/claude`, pinned `claude-code-cli-acp` **0.1.1** under `plugins/fusion-plugin-acp-runtime/node_modules/.bin`, and unchanged lockfile integrity `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==`. The FN-6473 payload was supplied by the committed OQ1 record and rebuilt from the real Route-A shape: one `custom-tools` stdio server (`command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`) carrying **62** Fusion custom-tool names from `packages/cli/src/extension.ts`. The authenticated-readiness proof opened ACP directly and drove a no-MCP prompt turn before any tool verdict; the bridge returned **`Not logged in · Please run /login`** with stopReason `end_turn`, zero tool-like updates, and zero `session/request_permission` callbacks. Therefore answer **(1)** remains **UNPROVEN / BLOCKED** (no forwarded Fusion tool invoked) and answer **(2)** remains **UNPROVEN / BLOCKED** (neither GATED nor BYPASSED observed). FN-6475 remains the sponsorship path; no `claude -p` fallback is acceptable. + - **✅ INTERACTIVE-SESSION SPIKE (2026-06-15): U9 MECHANICS = GO — overturns the NOT-GO chain above, with one operational precondition.** Run in an **interactive TTY with `claude` logged in** (`loggedIn:true`, claude.ai / eclipxe@gmail.com) — the exact condition every headless task (FN-6466/6467/6473/6476) lacked. Pinned bridge `claude-code-cli-acp` **0.1.1** driven directly over ACP (SDK **0.24.0**) with a **non-empty** `session/new.mcpServers`: one stdio server `custom-tools` (`command:"node"`, `args:[]`, `env:[]`). Observed: **(auth)** no `/login` wall — the bridged `claude` authenticated via the interactive login/keychain session; **(1) forwarded-tool invocation = PROVEN** — Claude invoked `mcp__custom-tools__fn_task_list`, the MCP server's `tools/call` executed (ground-truth marker file written), and the result flowed back as a `tool_call` `session/update`; **(2) gate traversal = GATED** — a `session/request_permission` (options `allow_once`/`allow_always`/`reject`) fired **before** execution. So the bridge forwards MCP **and** the ACP permission floor holds (NOT bypassed) — both security-critical answers resolved positively. **THE RESIDUAL IS OPERATIONAL, NOT MECHANICAL:** auth succeeds only where the bridged `claude` can reach the login/keychain session. FN-6476 "authenticated" but ran in the **Fusion daemon/worker context** detached from that session → `Not logged in`. **Conclusion: U9 mechanics GO; U10–U13 are unblocked for implementation. New Route-A acceptance gate (R17): the runtime that hosts the `pi-claude-cli` provider must have an authenticated `claude` (keychain/login access, or file-based creds the daemon can read).** The FN-6475 upstream issue is no longer the mechanics blocker; the daemon-auth precondition is the remaining ship gate. Harness: `/tmp/acp-u9-*/{spike.mjs,mcp-server.cjs}`. +- **OQ2 (blocking sub-gate of U11) — Resume loss is amnesia, not a slowdown.** On resume the provider sends **only the latest user turn** (`buildResumePrompt`, `packages/pi-claude-cli/src/provider.ts:114-125`) and relies on `--resume` to load prior conversation from disk. The ACP path opens a **fresh session per turn** with no `sessionId` passthrough (`loadAcpSession` deferred). Dropping resume **without** switching to full-history prompts makes Claude answer multi-turn chat/executor conversations with zero prior context — silently. **Decision required in U11:** either thread `sessionId` → `loadAcpSession`, or send full flattened history (`buildPrompt`) every turn. No path may send latest-turn-only without resume. +- **OQ3 (blocking sub-gate of U11) — Tool-call & partial-message fidelity through the round-trip.** The provider consumes native `stream-json` with `--include-partial-messages` (exact tool-call argument boundaries); the ACP path re-derives chunks from transcript-JSONL → ACP `session/update` → the event bridge, which sanitizes/space-repairs/bounds the stream. Confirm tool-call arguments survive with intact start/end correlation and no space-repair corruption of JSON args, and that executor/reviewer lanes tolerate the transformed deltas. Capture exact tool-call argument bytes in U11's characterization tests, not just token ordering. + +--- + +## Output Structure + +New files (everything else is edits to existing files): + +``` +packages/engine/src/ + cli-agent-ask.ts # U4: askAcpOnce runner + typed result + __tests__/cli-agent-ask.test.ts # U4 tests (fake AgentRuntime) +plugins/fusion-plugin-acp-runtime/src/ + setup.ts # U3: PluginSetupManifest + checkSetup (bridge + claude/auth probe) + __tests__/setup.test.ts # U3 tests +``` + +--- + +## Implementation Units + +### U1. Pin and resolve the `claude-code-cli-acp` bridge + +**Goal:** Ship the bridge with the ACP plugin and resolve its binary to an absolute path for spawn. +**Requirements:** R2. +**Dependencies:** none. +**Files:** +- `plugins/fusion-plugin-acp-runtime/package.json` (add `claude-code-cli-acp@0.1.1` to `dependencies`) +- `plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts` (binary resolution) +- `pnpm-lock.yaml`, `pnpm-workspace.yaml` (as needed for the new dep) +- `plugins/fusion-plugin-acp-runtime/README.md` + `AGENTS.md` external-integration evidence (pin version, integrity, repo URL, license) + +**Approach:** Add the pinned npm dependency. In `resolveCliSettings`, when `acpBinaryPath` is unset (or set to the sentinel `claude-code-cli-acp`), resolve the binary's absolute path from the plugin's own `node_modules/.bin` (via `require.resolve` of the package's bin, or the cli-printing-press `executorRuntimeEnv` PATH-prepend pattern at `plugins/fusion-plugin-cli-printing-press/src/runtime/executor-runtime-env.ts:15-75`). Record the bridge version + sha integrity in the external-integration evidence block per `AGENTS.md`. +**Patterns to follow:** existing dep pinning of `@agentclientprotocol/sdk@0.24.0`; bundled-binary PATH exposure in cli-printing-press. +**Test scenarios:** +- Resolves to an absolute, existing path when the dep is installed (happy path). +- Falls back / errors clearly when the binary is absent from `node_modules/.bin` (deferred to U3's probe for the user-facing message — here assert the resolver returns a deterministic path or a typed "not resolved" signal, not a throw mid-spawn). +- An explicit user-supplied `acpBinaryPath` still overrides the bundled default (keeps the "any ACP agent" capability — Covers R2). + +### U2. Read-only Claude ask profile + bridge env allow-list + +**Goal:** Provide a read-only ACP ask posture pinned to the bridge (for Route B) with a justified env allow-list. (The tool-bearing Route A posture is a *separate* registered runtime — see U14/KTD9 — not a mutation of the global `acp` default.) +**Requirements:** R3a, R16. +**Dependencies:** U1. +**Files:** +- `plugins/fusion-plugin-acp-runtime/src/cli-spawn.ts` (`resolveCliSettings`: bridge binary resolution for the ask profile, `acpModel` forwarding, env allow-list default) +- `plugins/fusion-plugin-acp-runtime/src/process-manager.ts` (`buildSpawnEnv` — confirm allow-list discipline) +- `plugins/fusion-plugin-acp-runtime/src/index.ts` (`onLoad` logging) +- `plugins/fusion-plugin-acp-runtime/src/__tests__/` (extend `cli-spawn`/process-manager tests) + +**Approach:** Resolve the bridge binary (U1) for the ask profile, `acpArgs` `[]`, fs toggles OFF (read-only), forward `acpModel` to the adapter's `defaultModelId`/`settings.model` seam (`runtime-adapter.ts:39`). **Enumerate the Claude env allow-list:** `HOME` (required — bridge reads `~/.claude` auth/session), `PATH` (sub-executable resolution); **exclude** `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` (documented: `claude` uses its stored `~/.claude` token; adding them is an extra leakage surface). Do **not** weaken the security floor (`acpAllowUnrestricted` stays default-false). Note: the existing default `acpBinaryPath` is `"acp-agent"` (`cli-spawn.ts:53`), not the bridge — the ask profile overrides it; the generic default stays for the "any ACP agent" contract. +**Patterns to follow:** existing `resolveCliSettings` defaults; `buildSpawnEnv` allow-list (`process-manager.ts:79-86`). +**Test scenarios:** +- Ask profile resolves the bridge binary + empty args + fs OFF. +- `acpModel` is forwarded so the adapter resolves that model (Covers R3a). +- The bridge subprocess env contains exactly the allow-list keys (`HOME`/`PATH`) and **never** `ANTHROPIC_API_KEY`/inherited `process.env` (Covers R16). +- A bridge spawned without `HOME` fails with a typed, actionable error (ties to U3 probe), not a hang. +- `acpAllowUnrestricted` remains false by default; setting it still logs the warning. + +### U3. Bridge readiness probe + setup manifest + +**Goal:** Detect the bridge binary and the `claude`/auth preconditions it depends on, and surface a typed, actionable status. +**Requirements:** R8. +**Dependencies:** U1. +**Files:** +- `plugins/fusion-plugin-acp-runtime/src/probe.ts` (extend `probeAcpReadiness` to target the bridge) +- `plugins/fusion-plugin-acp-runtime/src/setup.ts` (new — `PluginSetupManifest` + `checkSetup`) +- `plugins/fusion-plugin-acp-runtime/src/index.ts` (export setup hooks) +- `plugins/fusion-plugin-acp-runtime/src/__tests__/setup.test.ts` (new), extend `probe.test.ts` + +**Approach:** Reuse the existing probe taxonomy (`probe.ts:16-22`) against the bridge binary. **Note the latent gap:** today `probeAcpReadiness` returns `ok: true` with `authRequired: true` when auth methods are present (`probe.ts:54`) — it never emits `reason: "unauthenticated"`. U3 must either (a) emit `reason: "unauthenticated"` when the bridge reports it can't reach an authenticated `claude`, or (b) map `authRequired: true` (on an `ok` status) to the setup hint. Pick one and make the test assert the actual shape. Add a `PluginSetupManifest` + `checkSetup` following `plugins/fusion-plugin-agent-browser/src/setup.ts:5-31`, mapping `missing_binary` → "install `claude-code-cli-acp`" and the auth signal → "run `claude` to authenticate." Add a **binary-identity check**: the resolved bridge path must be inside the plugin's own `node_modules` (reject a PATH-resolved substitute). Before merging U1, **spot-review the bridge source at the pinned commit** and record that commit hash in the AGENTS.md evidence block. +**Patterns to follow:** `agent-browser/src/setup.ts`; existing `probeAcpReadiness`. +**Test scenarios:** +- `ok` when the bridge handshakes (use the existing echo-agent fixture style). +- `missing_binary` (ENOENT) → setup reports not-installed with the install hint. +- `handshake_timeout` and `incompatible_protocol` map to distinct, non-`ok` statuses. +- The auth-needed signal surfaces the claude-auth hint **in the shape the probe actually returns** (Covers R8); the test fails if it asserts a `reason` the probe never emits. +- A bridge resolved from outside `node_modules` is rejected by the identity check. Use a fake/fixture agent; do **not** spawn the real bridge in CI. + +### U4. `askAcpOnce` reusable runner + +**Goal:** Drive a single ACP ask turn and return `{ ok, text, parsed }` with typed failures. +**Requirements:** R4, R6 (no silent pass). +**Dependencies:** U2 (so a resolved `acp` runtime drives the bridge); does not require U5/U6. +**Execution note:** Implement test-first against a fake `AgentRuntime` — the prose-accumulation + JSON-recovery + dispose-on-failure contract is the crux and is fully unit-testable without a real bridge. +**Files:** +- `packages/engine/src/cli-agent-ask.ts` (new) +- `packages/engine/src/__tests__/cli-agent-ask.test.ts` (new) +- optionally `plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts` (KTD6: surface `stopReason`) +- optionally `plugins/fusion-plugin-acp-runtime/src/__tests__/runtime-adapter.test.ts` + +**Approach:** A function taking a resolved `AgentRuntime` (dependency-injected, mirroring the existing seam style) plus `{ prompt, cwd, model, systemPrompt, timeoutMs, recoverJson? }`. Flow: `createSession({ tools: "readonly", defaultModelId: model, systemPrompt, onText: d => text += d })` → `promptWithFallback(session, prompt)` → on resolve, optionally `parsed = recoverJson(text)` via `extractJsonObjects` → `dispose` in a `finally`. Map a spawn/handshake/turn error or abnormal `stopReason` to a typed failure (`ok: false, reason, message`) so the validator's error path keeps working. Enforce `timeoutMs` by racing the prompt and disposing on timeout. Result shape mirrors `OneShotResult` enough that seams change minimally. +**Patterns to follow:** `packages/engine/src/evaluator.ts:146-173` (accumulate-onText + dispose-in-finally idiom); `OneShotResult`/`OneShotFailure` typing in `one-shot-session.ts`. +**Test scenarios:** +- Happy path: fake runtime streams `"hello"` deltas → `{ ok: true, text: "hello" }`. +- Multi-delta accumulation concatenates in order. +- `recoverJson` extracts a trailing `{ ... }` object embedded in prose → populates `parsed`; absent JSON → `parsed` undefined, `ok` still true. +- Error path: `createSession` throws → typed `ok: false` failure; session never leaks (dispose still attempted / not created). +- Turn error: `promptWithFallback` rejects → typed failure, `dispose` called in `finally`. +- Timeout: prompt that never resolves is killed at `timeoutMs` → typed failure (Covers R4). +- KTD6 (if implemented): abnormal `stopReason` (`max_tokens`) is reflected so the caller can refuse to treat a truncated answer as complete. + +### U5. Rewire the planning seam onto ACP + +**Goal:** `runCliAgentPlanning` produces a `PlanningResponse` from ACP prose. +**Requirements:** R1, R5. +**Dependencies:** U4. +**Files:** +- `packages/engine/src/interactive-ai-session.ts` +- `packages/engine/src/__tests__/interactive-ai-session.test.ts` + +**Approach:** Lowest churn — `parseAgentResponse` (lines 153-188) already extracts JSON from prose. Swap the injected `run` for `askAcpOnce`, feed `result.text` (with `rawOutput` as the same accumulated text) into the existing parser. Translate the prior `opts.settings.model` into the ACP model seam. Keep the existing throw-on-failure behavior. +**Patterns to follow:** existing `runCliAgentPlanning` signature + `parseAgentResponse`. +**Test scenarios:** +- ACP prose containing a `{type:"question",data:{...}}` block parses to a `question` response. +- ACP prose containing `{type:"complete",data:{...}}` parses to `complete`. +- Runner failure → throws the existing planning-failure error. +- Prose with no decodable `{type,data}` → throws the parse error (Covers R5). Update `fakeRun` to return the ACP-shaped `{ ok, text }`. + +### U6. Rewire the validator seam onto ACP + +**Goal:** `runCliAgentValidation` produces a `ValidatorVerdict` from ACP prose without ever silently passing. +**Requirements:** R1, R6. +**Dependencies:** U4. +**Execution note:** This is the contract-sensitive unit — preserve the "never a silent pass" invariant; an undecidable result must map to `error`, never `pass`. +**Files:** +- `packages/engine/src/cli-agent-validator.ts` +- `packages/engine/src/__tests__/cli-agent-validator.test.ts` + +**Approach:** Add a validator system prompt instructing Claude to end its turn with a single JSON object (`{ "verdict": "pass|fail|blocked|error", "summary": "...", "assertions": [...] }`). Drive via `askAcpOnce` with `recoverJson` so `result.parsed` is populated; `mapParsedToVerdict` (lines 65-119) then works off `verdict`/`passed`/`blocked`. Remove the claude-`-p`-specific `is_error` tier (line 78). **Close the silent-pass hole (R15):** (1) **`stopReason` surfacing (KTD6) is REQUIRED for this path** (not optional) — an abnormal/truncated stop (`max_tokens`, `cancelled`) forces `error` regardless of recovered prose, because a truncated answer can leave a syntactically-complete trailing `{...}` that would otherwise parse as authoritative. (2) **`inferVerdictFromProse` may only return `fail`/`blocked`/`error` on the ACP path — never `pass`.** A `pass` requires a recovered structured `verdict:"pass"`/`passed:true` from a clean `end_turn`; absent that, the result is `error`. Map runner failure → `status:"error"` (preserve `oneShotResultToVerdict` 157-166). +**Patterns to follow:** `mapParsedToVerdict`, `parseAssertions`; `inferVerdictFromProse` constrained to non-pass outcomes. +**Test scenarios:** +- Parsed `{verdict:"pass", assertions:[...]}` from a clean `end_turn` → `pass` with assertions. +- Parsed `{verdict:"fail"}` / `{passed:false}` → `fail`; `{blocked:true, reason}` → `blocked`. +- **Truncated stop** (`max_tokens`) with a parseable trailing `{verdict:"pass"}` → `error`, NOT `pass` (Covers R15). +- Prose "all assertions pass" with no recovered JSON → `error`, never `pass` (Covers R15). +- Empty/undecidable ACP prose → `error` (cardinal rule — Covers R6). +- Prose "this fails / blocked" with no JSON → `fail`/`blocked` via the constrained backstop. +- Runner failure → `error` with bounded message in summary. + +### U7. Delete the Claude `-p` branches + +**Goal:** Remove Claude's non-interactive print path and its now-dead parsing/tests. +**Requirements:** R7. +**Dependencies:** U5, U6 (delete only after the replacements are green). +**Files:** +- `packages/engine/src/cli-agent/one-shot-session.ts` (`buildOneShotSettings` claude-code branch lines 67-69; `parseOneShotOutput` claude-code case lines 139-148) +- `packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts` (drop claude `{type:"result"}` shape tests) + +**Approach:** Remove the `case "claude-code"` arms in both helpers, leaving codex/droid/pi/generic intact. Keep `runOneShotSession`, `OneShotResult`, and `extractJsonObjects` (the latter is reused by U4/U6). Verify no remaining reference assumes a claude one-shot branch. +**Patterns to follow:** the surrounding switch arms that remain. +**Test scenarios:** +- `Test expectation: none for new behavior` — this is deletion. Verification is that the codex/droid/pi one-shot tests still pass and no test references the removed claude branch. +- Add a guard test asserting `buildOneShotSettings("claude-code", ...)` is no longer a supported path (throws or routes to generic) so a future caller can't silently re-introduce `-p`. + +### U9. Spike: external MCP-over-ACP feasibility through the bridge (Route A gate 1 of 2) + +**Goal:** Resolve OQ1 — prove (or disprove) that Fusion's MCP tools reach Claude through `claude-code-cli-acp` **and** that tool calls remain gated. +**Requirements:** R10 (feasibility). +**Dependencies:** U1. +**Files:** investigation only; the deliverable is a recorded go/no-go in this plan's **Open Questions (OQ1)** + `docs/acp-contract.md`, committed before U10 starts. +**Approach:** Drive the bridge over ACP with a non-empty `session/new` `mcpServers` carrying **the real Fusion MCP config that `mcp-config.ts` builds today** (not a trivial stub — size, server count, and stdio transport assumptions must be exercised). Verify two things and record both: (1) Claude can invoke a real forwarded Fusion tool; (2) **whether that invocation surfaces as an ACP `session/request_permission` (gated) or is invoked autonomously inside the bridge (gate bypassed)** — this is the security-critical answer (OQ1/security F3). If `mcpServers` is ignored, OR tool calls bypass the gate with no mitigation, Route A is blocked → escalate to upstream bridge/ACP work (mandatory-`-p`: no `-p` fallback). This is a hard go/no-go gate; it is **necessary but not sufficient** — see U14 for the internal blockers. +**Test scenarios:** `Test expectation: none -- spike; the deliverable is a recorded go/no-go decision (with the gate-traversal answer), not shipped code.` + +**FN-6465 status (2026-06-14):** **UNRESOLVED / BLOCKED**. The original FN-6459 decision was not recovered, and this task did not complete an authenticated, instrumented bridge spike with the real `mcp-config.ts` output. U9 remains a hard NOT-GO gate: do not implement U10-U13 until a follow-up proves both forwarded-tool invocation and ACP permission-gate traversal (or records a definitive no-go/escalation). + +**FN-6466 status (2026-06-14):** **UNRESOLVED / BLOCKED after a real bridge attempt.** The spike used a direct ACP `session/new` call (not the runtime helper that still hardcodes `mcpServers: []`) to send a real non-empty Route-A payload derived from `mcp-config.ts`: one stdio server named `custom-tools` with `command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`, and a schema file containing **62** Fusion custom tools. `claude-code-cli-acp` **0.1.1** accepted `initialize` and `session/new`, proving the transport accepted the forwarded server declaration, but the first prompt turn stopped at **`Not logged in · Please run /login`** before any MCP tool call or ACP `session/request_permission` event. U9 therefore remains **NOT GO**: the authenticated-tool path is still unexercised, FN-6460 must not start U10-U13, and the rerun target is an environment where the bridge can reach an authenticated `claude` session. If that authenticated rerun still fails, the escalation is upstream bridge/ACP work — never a `claude -p` fallback. + +**FN-6467 status (2026-06-14):** **UNRESOLVED / BLOCKED after a second direct bridge attempt.** This rerun again bypassed the runtime helper and sent `session/new` with the non-empty `custom-tools` stdio MCP server (`command: "node"`, `args: [packages/pi-claude-cli/src/mcp-schema-server.cjs, ]`, `env: []`) carrying **62** Fusion custom-tool names. The pinned bridge **0.1.1** accepted `initialize` and `session/new`; however, `initialize` advertised `authMethods=["claude-code-login"]`, and the first prompt to invoke `fn_task_list` ended with **`Not logged in · Please run /login`**. No forwarded tool invocation and no `session/request_permission` callback were observed, so U9 remains **NOT GO** and FN-6460 must still not start U10-U13 until an authenticated run proves both forwarded-tool invocation and gate traversal (or records a definitive no-go/escalation). + +**FN-6473 status (2026-06-15):** **UNRESOLVED / BLOCKED after the explicit escalation rerun.** The harness again used the plugin-local pinned bridge **0.1.1** and the real non-empty Route-A `custom-tools` stdio MCP payload carrying **62** Fusion custom-tool names, with explicit client-side `session/request_permission` instrumentation. Local prerequisites were present (`claude` **2.1.177**, bridge binary resolved, lockfile integrity unchanged), and `session/new` accepted the non-empty `mcpServers` payload. The first prompt to invoke `fn_task_list` still returned **`Not logged in · Please run /login`** with stopReason `end_turn`, zero tool-call updates, and zero permission callbacks. U9 therefore remains **NOT GO**: forwarded-tool invocation is still unproven, gate traversal is neither GATED nor BYPASSED, and the next step is sponsored bridge/ACP MCP permission-forwarding plus an authenticated-environment rerun — not a `claude -p` fallback. + +**FN-6475 status (2026-06-15):** **Upstream sponsorship filed; U9 remains NOT GO.** The sponsorship artifact is committed at [`docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md`](../upstream/claude-code-cli-acp-mcp-permission-forwarding.md) and filed upstream as https://github.com/moabualruz/claude-code-cli-acp/issues/2. It requests that `claude-code-cli-acp`/ACP forwarding pass `session/new.mcpServers` through to authenticated Claude and gate forwarded MCP tool calls via ACP `session/request_permission` or an MCP-layer hook. This does not resolve OQ1; it preserves the blocked state until the upstream capability lands or Fusion chooses an explicit local-patch/fork path. + +**FN-6476 status (2026-06-15):** **UNRESOLVED / BLOCKED after the genuinely-authenticated rerun attempt; U9 remains NOT GO.** The run re-confirmed the same pinned bridge prerequisites and the real Route-A `custom-tools` payload with **62** Fusion custom-tool names, but the mandatory authenticated-readiness proof still returned **`Not logged in · Please run /login`** with stopReason `end_turn`, zero tool-like updates, and zero `session/request_permission` callbacks. Because the auth gate failed, the harness did not attempt to classify MCP forwarding or gate traversal; forwarded-tool invocation remains unproven and the security-critical GATED/BYPASSED answer remains unobserved. FN-6475 remains the upstream sponsorship path, and `claude -p` remains an unacceptable Route-A fallback. + +### U14. Design-confirmation: resolve Route A's internal blockers (Route A gate 2 of 2) + +**Goal:** Resolve the internal blockers that no spike screens — knowable today — before committing U10–U13. **KTD9, KTD10, KTD11.** +**Requirements:** R3b, R11 (enablement). +**Dependencies:** U4 (engine ACP-driver patterns), U9 (go). +**Files:** design note in this plan + `docs/acp-contract.md`; no shipped code (the mechanisms land in U10/U11). +**Approach:** Produce and record concrete mechanisms for three blockers the feasibility review surfaced: +1. **pi-extension injection seam (KTD10):** name the engine file/seam (`packages/engine/src/pi.ts:1366-1422`, `registerExtensionProviders`) that constructs an ACP-bridge client and threads it into the provider's `streamSimple` options (mirroring `mcpConfigPath` in `StreamViaCliOptions`), so `@fusion/pi-claude-cli` never imports engine/plugin internals. +2. **`AgentRuntimeOptions.mcpServers` contract (KTD11):** specify the new field on the engine type + the plugin-local structural copy, the `newAcpSession` signature change, and the `[]` back-compat default. +3. **Per-route posture (KTD9):** confirm the `acp-claude` second runtime id (bridge-pinned, tool-bearing) vs. a per-call override, and how lanes select it (model-id/`useClaudeCli` → `runtimeHint`). +**Test scenarios:** `Test expectation: none -- design gate; deliverable is the recorded mechanisms that unblock U10/U11.` + +**FN-6465 U14 confirmation (2026-06-14):** **GO for the internal design mechanisms, subject to U9.** Current source still matches the planned seams: + +- **KTD10 / pi-extension injection seam:** `packages/engine/src/pi.ts:1366-1422` is the provider-registration seam (`registerExtensionProviders`) that discovers the vendored `@fusion/pi-claude-cli` and registers pending providers into the pi `ModelRegistry`. The implementation task should construct/inject an ACP bridge client at this engine-owned seam and thread it through the provider options just as `packages/pi-claude-cli/index.ts:222-234` currently threads `mcpConfigPath` into `streamViaCli`; `packages/pi-claude-cli/src/provider.ts:73-77` reads that option shape and `provider.ts:136-156` passes it to the subprocess layer. Add the ACP client/driver as the analogous option field so `@fusion/pi-claude-cli` stays dependency-clean and never imports `@fusion/engine` or plugin internals. +- **KTD11 / `AgentRuntimeOptions.mcpServers` contract:** `packages/engine/src/agent-runtime.ts:35-106` currently has no `mcpServers` option, and the plugin-local structural copy at `plugins/fusion-plugin-acp-runtime/src/types.ts:81-95` mirrors only the fields the runtime reads. `plugins/fusion-plugin-acp-runtime/src/provider.ts:348-356` still hardcodes `mcpServers: []` in `newAcpSession`, and `plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts:85-89` calls `newAcpSession(connection, { cwd })` without MCP data. U10 should add an optional `mcpServers` field to both option types, change `newAcpSession` to accept it, have `runtime-adapter.ts` pass it through, and default to `[]` when absent for Route-B back compatibility. The existing `packages/pi-claude-cli/src/mcp-config.ts` output is one stdio server under `mcpServers.custom-tools` with `{ command: "node", args: [serverPath, schemaFilePath] }`, which maps directly to an ACP `mcpServers` entry. +- **KTD9 / per-route posture:** `plugins/fusion-plugin-acp-runtime/src/index.ts:13-24` still exposes one global runtime id (`acp`) whose `acpRuntimeFactory` constructs one `AcpRuntimeAdapter` from a frozen settings blob; `plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts:29-35` resolves that blob once in the adapter constructor. Route A therefore needs a distinct `acp-claude` runtime id/posture pinned to the bridge and tool-bearing defaults rather than a per-call override of the generic `acp` runtime. Lanes should select it through the existing model/provider selection path (`pi-claude-cli` / `useClaudeCli` resolving to a Route-A `runtimeHint`), leaving the generic `acp` contract available for arbitrary ACP agents and Route-B read-only asks. + +### U10. ACP MCP-server forwarding in the runtime (Route A enabler) + +**Goal:** Forward Fusion's MCP server(s) on `session/new` so the agent can call Fusion tools — implementing the contract change KTD11 specifies. +**Requirements:** R10. +**Dependencies:** U9 (external go), U14 (internal mechanisms). +**Files:** +- `packages/engine/src/agent-runtime.ts` (new optional `mcpServers` on `AgentRuntimeOptions`) +- `plugins/fusion-plugin-acp-runtime/src/types.ts` (matching field on the structural copy) +- `plugins/fusion-plugin-acp-runtime/src/provider.ts` (`newAcpSession` signature + populate `mcpServers`; today hardcoded `[]` at line 356) +- `plugins/fusion-plugin-acp-runtime/src/runtime-adapter.ts` (`createSession` call-site threads the field) +- `plugins/fusion-plugin-acp-runtime/src/__tests__/provider-session.test.ts` + +**Approach:** Implement the multi-layer `session/new mcpServers` forwarding (KTD11) within the existing security floor. Source the config the way `pi-claude-cli` builds `--mcp-config` (`packages/pi-claude-cli/src/mcp-config.ts` — its `{ command, args }` stdio shape maps directly to an ACP `mcpServers` entry). **Permission-gate caveat (from U9):** the per-category gate protects ACP `session/request_permission` calls; it only covers MCP tool calls **if** U9 confirmed they traverse that path. If U9 found tool calls bypass the gate, U10 must additionally restrict which tools are forwarded (exclude sensitive categories) or add an MCP-layer permission hook — do **not** claim "security floor unchanged" until that is settled. +**Patterns to follow:** existing `newAcpSession` + the permission-gate wiring in `createBridgingClientHandler`. +**Test scenarios:** +- `session/new` is opened with the forwarded `mcpServers` when provided; `[]` when not (back-compat for Route B ask turns). +- A gated tool call is classified per-category (Covers R10), per the U9-confirmed path. +- If forwarding restricts sensitive tools, a sensitive-category tool is absent from the forwarded set. +- Malformed/oversized MCP config is rejected without crashing the turn. + +### U11. Re-point the `pi-claude-cli` provider onto the ACP bridge + +**Goal:** Point the provider's `streamSimple` at the ACP bridge while keeping the provider key, preserving context and streaming fidelity. **This is the highest-risk unit in the plan** — the transport translation (not the provider-key stability) is the load-bearing part; do not treat it as "lowest churn." +**Requirements:** R9, R10, R11, R13, R14. **KTD7, KTD10.** +**Dependencies:** U10, U14 (and U9 go). +**Execution note:** Characterize the existing NDJSON stream/tool-mapping behavior — capturing **exact tool-call argument bytes**, not just token ordering — BEFORE swapping the transport. This provider feeds executor/reviewer/workflow lanes. +**Files:** +- `packages/pi-claude-cli/src/provider.ts` (`streamViaCli`/`streamSimple` → ACP client driver injected per KTD10; resume/history branch at lines 114-125) +- `packages/pi-claude-cli/src/process-manager.ts` (`spawnClaude`/`buildClaudeSpawnArgs` **kept behind a runtime kill-switch — NOT deleted** — for config-only rollback per R14) +- `packages/pi-claude-cli/index.ts` (provider registration; `streamSimple` dispatch) +- `packages/pi-claude-cli/src/{tool-mapping.ts,stream-parser.ts,event-bridge.ts,thinking-config.ts}` (adapt to ACP delta/tool shape) +- `packages/pi-claude-cli/src/__tests__/*` + +**Approach:** Drive the bridge (createSession → promptWithFallback with streaming callbacks) from inside `streamSimple` via the injected ACP client (KTD10), translating ACP `session/update` deltas + tool events into the pi stream-chunk shape. Preserve Claude↔pi tool-name mapping. **Context (R13/OQ2):** since the ACP path has no Claude-side resume, send **full flattened history (`buildPrompt`) every turn** — never `buildResumePrompt` (latest-turn-only) without a real resume. **Rollback (R14):** gate the transport behind a kill-switch so `useClaudeCli` can fall back to `spawnClaude`-`-p` without a code revert until soak completes. **Fidelity (R11/OQ3):** verify tool-call argument integrity survives the transcript→ACP→event-bridge round-trip (no space-repair corruption, intact start/end correlation). Forward the selected model id as the ACP `defaultModelId`. +**Patterns to follow:** existing `streamViaCli` stream-chunk emission; the ACP event bridge (`plugins/fusion-plugin-acp-runtime/src/event-bridge.ts`). +**Test scenarios:** +- Token deltas from a fake ACP turn surface as pi text chunks in order (Covers R11). +- Thinking deltas and tool-start/tool-end events map to the pi shapes lanes consume. +- A tool call round-trips through the Claude↔pi name mapping **with byte-exact arguments** (Covers R11). +- **2nd-turn call carries prior-turn context** (full history sent); no path sends latest-turn-only without resume (Covers R13). +- Kill-switch off → `streamSimple` uses the `-p` `spawnClaude` path unchanged (Covers R14). +- Turn/connection failure surfaces as the provider's existing error-chunk shape (no silent truncation). +- Model id selected in settings is forwarded to the ACP session. +- Characterization tests for the prior `-p` behavior are updated, not left asserting the old transport. + +**Implementation notes (design-confirmed 2026-06-15, ready to execute):** +- **Contract to match:** `streamViaCli(model, context, options): AssistantMessageEventStream` (from `@earendil-works/pi-ai`). The new `streamViaAcp` must return the same `AssistantMessageEventStream` and push the same event shapes: streamed `text`/`thinking` deltas, `ToolCall` events, and a terminal `{ type: "done", reason, message }` (an `AssistantMessage` with `content:[]` on error — pi's `extractResult` crashes on `error`-typed events, so end with `done` even on failure, mirroring `endStreamWithError` at `provider.ts:181-198`). +- **Branch point:** in `index.ts` `streamSimple` (lines 222-235), dispatch on the kill-switch: `useAcpBridge() ? streamViaAcp(model, context, {...options, mcpServers, bridgePath}) : streamViaCli(...)`. Kill-switch OFF by default (R14) — e.g. `FUSION_CLAUDE_ACP==="1"` or a `useClaudeCliAcp` global setting — so the live `-p` path is untouched until soak. +- **KTD10 injection seam:** add `@agentclientprotocol/sdk` as a `pi-claude-cli` dependency (vendored package — allowed) so the extension speaks ACP without importing `@fusion/engine`. The **bridge binary path** is injected via `streamSimple` options the same way `mcpConfigPath` is today (engine resolves it from the acp-runtime plugin bundle at `registerExtensionProviders`, `pi.ts:1366-1422`, and threads it in) — the extension never reaches into the plugin's `node_modules` itself. +- **MCP servers:** reuse the tool list `ensureMcpConfig` already assembles (`index.ts:223-230`) to build the `AcpMcpServer[]` (`{name:"custom-tools",command:"node",args:[schemaServer,schemaFile],env:[]}`) — the same shape U9 proved and U10 forwards. +- **Prompt (R13):** always `buildPrompt(context)` (full flattened history) — never the `buildResumePrompt` latest-turn-only branch (`provider.ts:115-124`), since the ACP path has no `--resume`. +- **ACP→pi event translation** parallels `plugins/fusion-plugin-acp-runtime/src/event-bridge.ts` (ACP `session/update` → callbacks) but targets pi's `AssistantMessageEventStream` instead of Fusion callbacks; reuse `tool-mapping.ts` for Claude↔pi tool names. +- **Verification:** drive the live bridge exactly as the U9 harness did (`/tmp/acp-u9-*/spike.mjs`) but asserting pi-stream output, before enabling the kill-switch in any lane. + +### U12. Settings, picker, auth, and status surface + +**Goal:** Make the Claude-CLI toggle/picker/status reflect the ACP-backed reality without forcing user re-selection. +**Requirements:** R9, R12. +**Dependencies:** U11. +**Files:** +- `packages/dashboard/src/routes/register-model-routes.ts` (picker filtering / `configuredProviders` — lines 140-174) +- `packages/dashboard/src/routes/register-auth-routes.ts` (`/auth/claude-cli`, `/providers/claude-cli/status` — lines 336,344,450-502,579-592) +- `packages/dashboard/src/claude-cli-probe.ts` (probe now targets the ACP bridge + `claude` auth) +- `packages/core/src/types.ts` (`useClaudeCli` doc), `packages/core/src/settings-schema.ts` (`claude-code` entry line 580) +- `packages/cli/src/commands/{claude-cli-extension.ts,provider-auth.ts}` as needed +- corresponding dashboard/core tests + +**Approach:** Keep `useClaudeCli` as the enable flag (KTD7) but make its readiness check go through the U3 ACP/bridge probe (and `claude` auth). Picker continues to show Claude CLI models when enabled; status reports bridge+auth health. No migration of persisted provider selections (re-routed under the hood). **Sanitize the status response (R12):** strip internal file paths / OS error strings from the probe `detail`/`reason` and bound its length before returning it from `/providers/claude-cli/status` (match the redaction the existing `ClaudeCliBinaryStatus.reason` applies). +**Patterns to follow:** existing claude-cli probe/status wiring. +**Test scenarios:** +- Picker shows `pi-claude-cli` models iff `useClaudeCli` is on (unchanged behavior). +- `/providers/claude-cli/status` reports healthy when bridge+auth probe is `ok`, and the specific failure when not (Covers R12). +- A `spawn_error` `detail` containing an absolute path is sanitized before it appears in the HTTP body (Covers R12). +- `useClaudeCli` toggle on/off flips `configuredProviders` correctly. +- A persisted `defaultProvider="pi-claude-cli"` resolves and runs via ACP with no re-selection (Covers R9). + +### U13. Workflow `model`-node verification + +**Goal:** Confirm workflow execution `model` nodes using Claude CLI run over ACP end-to-end (the surface the user explicitly named). +**Requirements:** R9. +**Dependencies:** U11. +**Files:** +- engine workflow executor path (`packages/engine/src/executor.ts` prompt-mode lane) — likely no change beyond U11; this unit is verification + regression tests +- workflow executor tests under `packages/engine/src/__tests__/` + +**Approach:** Since workflow `model` nodes go through `createFnAgent` → the pi registry, U11 should cover them automatically. This unit adds a regression test asserting a workflow `model` step with `pi-claude-cli` selected drives the ACP path (via a fake runtime) and does not spawn `claude -p`. **Also assess Route A multi-turn latency:** with per-turn fresh ACP sessions (no resume) every workflow `model` node pays a cold bridge+`claude` spawn; record a rough budget (turns/workflow × spawn cost) and confirm it's tolerable, or flag session-reuse as a Route-A follow-up blocker (ties to OQ2). +**Patterns to follow:** existing workflow-executor model-node tests. +**Test scenarios:** +- A workflow `model` node with `pi-claude-cli` selected produces streamed output via the ACP path. +- No `claude -p` spawn occurs on this path when the kill-switch is on (guard against regression — Covers R9). +- A multi-step workflow's per-node spawn cost is measured/recorded (latency budget note, not a hard assertion). + +### U8. Docs, scope notes, and CONCEPTS + +**Goal:** Record the new Claude→ACP path and the deferred surfaces. +**Requirements:** Documents the outcomes of R1/R9 (discoverability only — U5/U6/U11 own the functional routing, not this unit). +**Dependencies:** U1, U2, U3, U4, U5, U6, U7. +**Files:** +- `docs/acp-contract.md` (note the Claude bridge profile + ask-once contract) +- `plugins/fusion-plugin-acp-runtime/CHANGELOG.md`, root `CHANGELOG.md` +- `.changeset/*.md` (feature changeset per repo convention) +- `CONCEPTS.md` (only if it exists — add "ACP ask path" / "Claude bridge" if the terms are project-canonical) + +**Approach:** Document the runtimeHint `acp` + bridge profile, the prose→JSON recovery contract, and the deferred follow-ups. Add a changeset. +**Test scenarios:** `Test expectation: none -- documentation only.` + +--- + +## Scope Boundaries + +**In scope:** Both `-p` routes moving to the ACP runtime + pinned bridge — **Route A** the `pi-claude-cli` provider (all lanes incl. workflow `model` nodes); **Route B** the planning + validator one-shot seams + reusable ask-once runner. Plus the shared bridge dependency, probe/setup, MCP forwarding, per-route ACP posture, picker/auth/status surface, and deletion of Claude one-shot branches. **`-p` removal is mandatory for both routes** (see Summary) — the feature is not done while any Claude path still uses `-p`. + +### Sequencing +Route B (U1–U7) is independent and ships first as committed progress. Route A is gated by **two** hard go/no-go checks before U10–U13: **U9** (external — bridge MCP passthrough *and* permission-gate traversal, tested with the real config) and **U14** (internal — pi-extension injection seam, `mcpServers` contract, per-route posture). Both must return go. Because `-p` removal is mandatory, a no-go does **not** drop Route A to a `-p` fallback — it blocks the feature and escalates the missing capability (bridge MCP support / ACP forwarding) to upstream work. Record the gate outcomes in OQ1/U14. + +### Deferred to Follow-Up Work +- **CE orchestrator on ACP.** The CE orchestrator never used one-shot (`plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts:118-127`, "not yet wired"). Routing CE onto the ACP *interactive* runtime is a separate unit; the `CeSessionExecutor` type is untouched by `-p` removal. +- **Production wiring of planning/validator.** These seams have no production caller today; making them actually run is pre-existing TODO, unchanged by this plan. +- **codex / droid / pi off non-interactive mode.** No ACP bridge exists for these agents; their `exec`/`--print` forms stay. +- **Claude-side session continuity over ACP (OQ2).** `loadAcpSession` resume is deferred; if lanes regress without `--resume`, that is follow-up work. +- **OS-level sandboxing of the bridge subprocess.** The ACP runtime does not sandbox the agent's own syscalls (documented v1 residual). + +### Non-Goals +- Changing the cli-agent PTY `claude-code` adapter's interactive task-execution path (it stays as-is for `execute`/`chat`). +- Weakening the ACP security floor (per-category gating, env allow-list, path-jail, `acpAllowUnrestricted` default-false). + +--- + +## Alternatives Considered + +The user directed the bridge-via-ACP direction; this records the rejected options and why, for honest grounding (the bridge does *not* avoid PTY+transcript scraping — it relocates it into a young external process). + +- **Build a thin in-tree PTY+JSONL "ask" ourselves (no external dep).** We already own most pieces (`claude-code.ts` Stop→done hooks + `ClaudeTranscriptTailer`; the legacy `pi-claude-cli` PTY/transcript code). Rejected per user direction in favor of reusing the shipped ACP runtime — but it remains the fallback if the bridge proves unmaintained, and it avoids the supply-chain and path-dependency costs. Recorded so the trade is explicit, not hidden behind "without re-implementing it ourselves." +- **Route the provider through the existing cli-agent PTY `claude-code` adapter.** That adapter already drives interactive Claude over a PTY (the literal "interactive, not `-p`" ask) for execute/chat. Rejected because its `CliAgentAdapter` contract is a raw byte-stream (readiness/injection), not the structured streaming + tool-call/permission surface the provider lanes need; bending it into a model-provider transport is a larger, mismatched change than the ACP path. Noted because "we already have a PTY Claude driver" is a fair challenge to adopting a new dependency. +- **Register a brand-new ACP-backed provider key + migrate saved selections.** Rejected (KTD7) in favor of keeping `pi-claude-cli` and re-routing under the hood — smaller blast radius, no user-visible churn (R9). The cost (a behaviorally-different Claude under a stable label) is mitigated by R11/R13 fidelity bars. + +## Risks & Dependencies + +- **MCP tool-forwarding is the make-or-break for Route A (highest risk).** The high-traffic provider depends on Fusion tools via `--mcp-config`; ACP forwards no MCP servers today and the bridge's MCP passthrough is unconfirmed. If tools can't traverse the bridge, executor/workflow lanes would run tool-less Claude — unacceptable. Mitigation: U9 is a hard go/no-go spike before any Route A build; Route B is fully independent of this. +- **Highest-traffic path swap.** Re-pointing `pi-claude-cli` touches the lane behind chat/executor/reviewer/workflow. Mitigation: characterization tests before the transport swap (U11 execution note); keep the provider key + `useClaudeCli` semantics (KTD7) so selections/migrations don't move; ship Route B first to de-risk the ACP plumbing. +- **Young external dependency (v0.1.1, 11 stars), and rollback is NOT config-only for Route A.** Reverting `acp` config restores the "any ACP agent" default for Route B / the bridge dependency — but once U11 swaps the provider transport, falling back to `-p` requires the U11 **kill-switch** (R14), not a config flip. The young-dep mitigations (exact-version pin + lockfile integrity + source-review at the pinned commit + isolation behind the security floor) reduce but don't remove the bet on one maintainer's project for Fusion's primary Claude path. +- **Supply-chain: the bridge reads `~/.claude` directly.** Unlike other ACP agents constrained by the path-jail, the bridge reads Claude transcript JSONL outside any `fs/*` ACP call — a compromised bridge could exfiltrate historical session content. Mitigation: lockfile SHA + source-review the pinned commit (U1/U3) + binary-identity check (resolved path must be inside `node_modules`). +- **Resume loss is a correctness regression, not a slowdown (R13/OQ2).** The provider relies on `--resume` for multi-turn context; the ACP path has none. Mitigation: U11 sends full history every turn; a 2nd-turn-context test guards it. Residual cost: larger prompts + cold spawns (latency below). +- **Prose↔JSON brittleness for the validator.** Mitigation: explicit system prompt + `extractJsonObjects` recovery + **required** stopReason (KTD6, R15) + a prose backstop constrained to never yield `pass`. The "JSON-presence only" fallback is rejected for the validator. +- **Per-call spawn latency — worse for Route A than Route B.** Fresh handshake + cold `claude` spawn per turn. For Route B (low-frequency planning/validation) it's acceptable; for Route A's multi-turn chat/executor/workflow lanes it compounds (no warm resume). Mitigation: U13 records a per-node budget; session-reuse (`loadAcpSession`) is a Route-A follow-up blocker if the budget is exceeded. +- **`claude` auth/install is a precondition** the bridge needs but cannot satisfy. Mitigation: U3 probe maps the auth/missing-binary signals to actionable setup status (in the shape the probe actually returns). + +--- + +## Sources & Research + +- Existing ACP runtime: `plugins/fusion-plugin-acp-runtime/` (`runtime-adapter.ts`, `provider.ts`, `event-bridge.ts`, `cli-spawn.ts`, `process-manager.ts`, `probe.ts`, `index.ts`, `manifest.json`, `package.json`); shipped via PR #1354, plan `docs/plans/2026-06-02-002-feat-acp-client-integration-plan.md`, learning `docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md`. +- Engine runtime seam: `packages/engine/src/{agent-runtime.ts,runtime-resolution.ts,plugin-runner.ts,agent-session-helpers.ts,evaluator.ts}`. +- Route B consumers: `packages/engine/src/interactive-ai-session.ts`, `packages/engine/src/cli-agent-validator.ts`, `plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts`. +- Route A — `pi-claude-cli` provider: `packages/pi-claude-cli/index.ts` (provider id `pi-claude-cli`, registration), `packages/pi-claude-cli/src/{provider.ts,process-manager.ts,mcp-config.ts,tool-mapping.ts,stream-parser.ts,event-bridge.ts,thinking-config.ts}`; engine registration `packages/engine/src/pi.ts:1366-1422` (+ `resolveModelSelection` 1019-1046); `packages/core/src/{types.ts:2993 (useClaudeCli),pi-extensions.ts:319-350,settings-schema.ts:580}`; workflow node kind `packages/core/src/workflow-ir-types.ts:80`; dashboard `packages/dashboard/src/routes/{register-model-routes.ts:140-174,register-auth-routes.ts}`, `packages/dashboard/src/claude-cli-probe.ts`; CLI `packages/cli/src/commands/{claude-cli-extension.ts,provider-auth.ts}`. +- One-shot machinery being trimmed: `packages/engine/src/cli-agent/one-shot-session.ts` and its tests. +- Pattern refs: `plugins/fusion-plugin-agent-browser/src/setup.ts` (setup manifest + probe), `plugins/fusion-plugin-cli-printing-press/src/runtime/executor-runtime-env.ts` (bundled-binary PATH exposure). +- External bridge: `claude-code-cli-acp` — https://github.com/moabualruz/claude-code-cli-acp (v0.1.1, Apache-2.0; npm `claude-code-cli-acp`; "runs `claude` through a PTY, reads transcript JSONL, exposes an ACP server over stdio"; requires `@anthropic-ai/claude-code` installed + authenticated). +- ACP protocol: https://agentclientprotocol.com — SDK `@agentclientprotocol/sdk@0.24.0`. diff --git a/docs/plans/2026-06-15-001-feat-command-center-and-sdlc-gaps-plan.md b/docs/plans/2026-06-15-001-feat-command-center-and-sdlc-gaps-plan.md new file mode 100644 index 0000000000..7b8a3e364a --- /dev/null +++ b/docs/plans/2026-06-15-001-feat-command-center-and-sdlc-gaps-plan.md @@ -0,0 +1,787 @@ +--- +title: "feat: Command Center dashboard + software-delivery-loop gap-fill" +type: feat +status: active +date: 2026-06-15 +depth: deep +origin: none (solo plan; external research from a competitor product, see Sources) +--- + +# feat: Command Center dashboard + software-delivery-loop gap-fill + +## Summary + +Build a **Command Center** for the Fusion dashboard — a combined **historical analytics** +surface (tokens, tools, activity, productivity, ecosystem, per-agent/per-node breakdowns +over selectable date ranges, with CSV + OpenTelemetry export) **and a live Mission-Control +panel** (concurrent sessions, active nodes, what each agent is doing right now, SDLC funnel +throughput). Then close the gaps between Fusion and an end-to-end software-delivery system — +the **Signal → Triage → Plan → Execute → Validate → Ship → Monitor** loop — by adding the +stages Fusion does not yet cover (external signal ingestion, monitoring/incident response, +persistent knowledge layer) and the cross-cutting capabilities the loop implies (a **Fusion Model Router** that auto-selects the +cheapest-capable model per task, auto-triage of inbound issues **and PRs**, auto-resolution of +PR review comments, and surfacing external signals as dashboard metrics). + +This plan is intentionally large because the user asked for full implementation coverage of +all gaps. It is organized into three phases so it can land incrementally: **Phase A** (metrics +foundation) and **Phase B** (the Command Center itself) deliver the branch's headline feature; +**Phase C** (SDLC gap-fill) is sequenced after, and several of its units are large enough that +the plan flags them as candidates to spin into their own brainstorm before execution. + +--- + +## Problem Frame + +Fusion is the model- and surface-agnostic orchestration layer for a developer driving many +agent sessions across nodes and surfaces (see `STRATEGY.md`). Two problems: + +1. **There is no observability surface.** A developer juggling 10+ agents across machines has + no single place to answer "how much am I spending, on which models, across which nodes, + what's running right now, and what did all this work actually ship?" Fusion captures the + raw data (per-task token columns, agent runs, activity log, commit associations, PRs, CLI + sessions) but exposes only fragmentary panels (`ReliabilityView`, `AgentTokenStatsPanel`). + Fusion's own `STRATEGY.md` key metrics (concurrent sessions, active nodes, ecosystem + breadth, task completion rate, LOC shipped) are precisely what such a view should make + observable. + +2. **Fusion covers the middle of the SDLC loop but not the ends.** The end-to-end delivery + loop is self-reinforcing: *Signal → Triage → Plan → Execute → Validate → Ship → Monitor*. + Fusion is strong on Plan/Execute/Validate/Ship and has partial Triage (GitHub issue + ingestion). It has **no Signal ingestion beyond GitHub, no Monitor stage, and no persistent + knowledge layer** — the parts that make the loop close and compound. + +This plan addresses both: the Command Center (Phases A–B) and the missing stages (Phase C). + +--- + +## Requirements + +Traceability is to Fusion's `STRATEGY.md` key metrics (KM) and the external feature set the +user asked us to match (external research, no formal requirements doc). + +- **R1 — Historical analytics.** Surface token consumption (by model, provider, node, agent, + time), tool usage and autonomy ratio, activity (sessions/messages/active-nodes over time), + productivity (files, commits, PRs, LOC), and ecosystem breadth (unique models + plugins). + (KM: all five.) +- **R2 — Date-range filtering.** All analytics support a selectable range (presets + custom), + mirroring `agent-token-usage.ts`'s windowed aggregation extended to arbitrary ranges. +- **R3 — Live Mission Control.** A real-time panel: concurrent agent sessions, active nodes, + per-agent current activity, and an SDLC funnel (triage→todo→in-progress→in-review→done) + with live throughput. (KM: concurrent agent sessions, active nodes, task completion rate.) +- **R4 — Export.** CSV export of any analytics table, and OpenTelemetry (OTLP) export of the + metrics, for shipping to Datadog/Grafana/etc. +- **R5 — Analytics API.** Programmatic endpoints (activity, tokens, tools, productivity) so an + agent can pull metrics. +- **R6 — Cost.** Derive USD cost from token counts × a model pricing map (Fusion stores tokens + but not cost today). +- **R7 — Signal ingestion.** Ingest external signals beyond GitHub (error trackers / alerting: + Sentry, Datadog, PagerDuty, generic webhook) into triageable tasks. +- **R8 — Triage stage.** Auto-classify and decompose incoming signals/issues into board tasks. +- **R9 — Monitor stage.** Track deployments and production incidents, compute MTTR, and feed + Monitor signals back into the funnel (closing the loop). +- **R10 — Knowledge layer.** A persistent, incrementally-refreshed knowledge index downstream + agents can query. +- **R13 — Fusion Model Router.** Automatic per-task / per-request model selection across + providers (route routine steps to fast/cheap models, reserve stronger models for hard + reasoning), with fallback, prompt-cache awareness, and respect for existing model controls — + a direct expression of Fusion's model-agnostic thesis. +- **R14 — Auto-triage of incoming issues *and* pull requests.** Triage applies to inbound PRs + (external contributions, dependabot, etc.), not just issues/signals — classify, label, and + route or open a follow-up task. +- **R15 — Auto-resolution of PR review comments.** Build on Fusion's existing **Review-response + loop** so PR review threads are acted on automatically (fix + push + reply, or disagree with + reasoning) as a first-class, surfaced capability. +- **R16 — External signals in dashboard metrics.** The Command Center surfaces external signals + (errors, alerts, incidents from R7 sources) as a metric area and in Mission Control, not only + as task-creating triggers. + +--- + +## Key Technical Decisions + +### KTD1 — Mirror the built-in view pattern; no router, no plugin +Register `command-center` as a `BuiltInTaskView` (`useViewState.ts`), lazy-load `CommandCenter` +in `App.tsx`, and add the nav entry in `Header.tsx`, exactly mirroring `reliability`. The +dashboard has **no URL router** (view state is `?view=` + `localStorage`); do not introduce +one. Ship as a built-in view (optionally behind an `experimentalFeatures.commandCenter` flag +like `insights`/`memoryView`), **not** a plugin — it is core product surface. + +### KTD2 — Aggregation lives in `packages/core`; the route is a thin adapter +Put all metric math in new `packages/core/src/*-analytics.ts` modules (so engine/CLI can reuse +it), mirroring `agent-token-usage.ts`. The dashboard exposes it via an `ApiRouteRegistrar` +(`register-command-center-routes.ts`) registered in `routes.ts`, mirroring +`register-usage-routes.ts`. Do **not** import the engine into the React frontend; everything +goes over HTTP/SSE. + +### KTD3 — A queryable telemetry/events table is required (the data is not all queryable today) +Token counts live on `tasks` (queryable) but **tool calls live in per-task JSONL agent logs** +and messages/sessions are spread across `chat_room_messages`/`cli_sessions`. The Tools area +and autonomy ratio need a queryable source. Decision: introduce a `usage_events` table in +`packages/core/src/db.ts` (migration in the same file) fed from the **store-level event seams** +(task-execution `appendAgentLog`, heartbeat/run `appendRunLog`, and the CLI/chat +`chat_room_messages` writer), rather than parsing JSONL at query time. This is the single +highest-risk change — see Risks for the SCHEMA_VERSION trap. **Tool calls are NOT all funneled +through one writer:** task execution, heartbeat agents (`appendRunLog`, callback-mode — bypasses +the file store), and CLI/chat (`chat_room_messages`, not tool-granular today) are distinct paths, +so a dual-write at `agent-log-file-store.ts` alone would silently undercount. The design must +instrument all three OR explicitly scope `usage_events` to task-execution and document the +exclusion. *Alternative (open — see Open Questions):* a lazy-materialization / cache table +populated on first query per time-bucket — viable because R1/R2/R5 state no sub-second +requirement, and it avoids coupling the agent hot-path write to a SQLite transaction. + +### KTD4 — Charting: extend the house "hand-rolled CSS bars" style, do not add a chart lib (default) +The codebase has **zero** charting dependencies and a strong convention of hand-built CSS-bar +histograms (`ReliabilityView.tsx:199-207`). Default to extending that style with a small set of +reusable primitives (bar, sparkline, stacked bar, funnel) under +`packages/dashboard/app/components/command-center/charts/`. Adding a dependency (Recharts) is a +notable departure requiring a changeset + maintainer sign-off; surfaced as a call-out, not +assumed. *Rationale:* keeps bundle lean, matches existing code, avoids a lazy-loaded chart +vendor in a view that already lazy-loads. Revisit only if a chart type (e.g. multi-series time +series) proves impractical by hand. + +### KTD5 — Live data uses push + poll convergence, throttled deltas +The Mission-Control panel follows the documented live-data pattern: an SSE event triggers an +immediate refetch while a poll interval (e.g. 5s) runs as a fallback **only while work is +in-flight**; high-frequency updates are throttled server-side. Historical analytics use plain +query + SWR (no streaming machinery). (See `docs/solutions/architecture-patterns/observable-long-running-agent-turns-through-blocking-plugin-route-seam.md`.) + +### KTD6 — Cost via a versioned pricing map, never persisted as truth +Cost is derived at read time from token columns × a `model-pricing.ts` map (input/output/cache +rates per `modelProvider`+`modelId`), not stored. Unknown models surface tokens with cost +marked unavailable rather than guessing. Keeps historical rows correct when prices change and +avoids a migration to backfill cost. The map carries a `pricingAsOf` date and per-entry source +link; the UI shows "prices as of " and marks entries older than a threshold low-confidence, +so stale-but-present rates (which the unknown-model guard does not catch) are visible rather than +silently wrong. + +### KTD7 — SDLC stages map onto existing workflow columns + new trait-tagged columns +Phase C does not invent a parallel pipeline. Signal/Triage/Monitor attach to the existing +workflow-column system (`Column`, `Trait`, `Workflow Extension` in `CONCEPTS.md`): a `signal` +intake column, a `triage` trait that auto-decomposes, and a `monitor` trait that watches +deployments. This reuses the workflow runtime rather than forking lifecycle policy. + +### KTD8 — Signal ingestion reuses the GitHub ingestion seam +Sentry/Datadog/PagerDuty/webhook ingestion mirrors the existing GitHub source path +(`github-source-issue-close.ts`, `github-poll.ts`, `github-webhooks.ts`) behind a common +`SignalSource` adapter interface, so each provider is a small adapter rather than bespoke wiring. + +### KTD9 — Model Router is a selection layer over existing agent/model resolution, not a new executor +The Fusion Model Router slots into the existing **effective-agent / model-pair resolution** path +(`CONCEPTS.md` Effective agent, Workflow Setting model lanes) as a routing policy that *chooses* +the `(provider, model)` before a session starts (session routing) and may re-route per request +for routine sub-steps. It does **not** add an executor kind — it picks which existing +CLI/provider runs. It respects column-agent overrides and model controls (an org/project that +restricts a model restricts the router's ability to pick it), and reuses the U3 pricing map + +U1 telemetry to make cost/latency-aware decisions and to measure its own savings. Routing rules +are declarative (task complexity signal → model tier) with a safe fallback to the configured +default pair when the router is disabled or a pick is unavailable. This is a natural fit for the +`ecosystem breadth` strategy metric and feeds the Command Center directly. + +### KTD10 — PR-comment auto-resolution extends the existing Review-response loop, not a rebuild +Fusion already has a **Review-response loop** (entry point `packages/engine/src/pr-response-run.ts`). R15 makes it a +first-class, default-surfaced capability rather than new machinery: ensure it triggers on +PR-entity review threads, expose its activity in the Command Center / Mission Control, and gate +it consistently with the merge/auto-merge model. Do not re-implement the loop. + +--- + +## High-Level Technical Design + +### The software delivery loop: Fusion today vs. the gaps this plan fills + +```mermaid +flowchart LR + subgraph Loop["Software delivery loop (SDLC)"] + Signal["Signal\n(R7 — GAP*)"] --> Triage["Triage\n(R8 — partial)"] + Triage --> Plan["Plan\n(have: CE, missions)"] + Plan --> Execute["Execute\n(have: CLI sessions, nodes)"] + Execute --> Validate["Validate\n(have: validator, review loop)"] + Validate --> Ship["Ship\n(have: merge, PR entity)"] + Ship --> Monitor["Monitor\n(R9 — GAP)"] + Monitor -.feeds.-> Signal + end + Knowledge["Knowledge layer (R10 — GAP)"] -.enriches every stage.-> Loop + CC["Command Center (R1–R6) — observes the whole loop"] -.reads telemetry from.-> Loop + style Signal fill:#3b82f6,color:#fff + style Monitor fill:#3b82f6,color:#fff + style Knowledge fill:#3b82f6,color:#fff + style CC fill:#1e40af,color:#fff +``` +*GAP\* = GitHub-only today; other sources are the gap. Blue = net-new in this plan.* + +### Command Center data flow (Phase A → B) + +```mermaid +flowchart TD + subgraph Sources["Existing data (packages/core, SQLite + JSONL)"] + T["tasks (token cols, model, files, timing)"] + AR["agentRuns / agentHeartbeats / agentTaskSessions"] + AL["activityLog"] + CC0["task_commit_associations"] + PR["pull_requests"] + CS["cli_sessions / chat_room_messages"] + JL["per-task JSONL agent logs (tool calls)"] + end + JL -->|U1: writer also appends| UE[("usage_events (new table)")] + CS -->|U1| UE + Sources --> AGG["U2: *-analytics.ts aggregators in packages/core\n(date-range windows, group-by model/node/agent)"] + UE --> AGG + AGG --> PRICE["U3: model-pricing.ts → cost (KTD6)"] + PRICE --> API["U9: register-command-center-routes.ts (ApiRouteRegistrar)\n/api/command-center/{tokens,tools,activity,productivity,live}"] + API -->|HTTP + SWR| HV["U5: Command Center historical areas"] + API -->|SSE + poll (KTD5)| LV["U6b: Mission-Control live panel (frontend)"] + API --> CSV["U8: CSV export"] + API --> OTEL["U10: OTLP exporter"] + AGG --> FUNNEL["U7: SDLC funnel (activityLog transitions)"] + HV --> VIEW["U4: Command Center shell (lazy view, nav entry)"] + LV --> VIEW + FUNNEL --> VIEW +``` + +--- + +## Output Structure + +New files this plan introduces (repo-relative; existing files edited are listed per unit): + +``` +packages/core/src/ + usage-events.ts # U1 write/query the new events table + model-pricing.ts # U3 pricing map + cost derivation + token-analytics.ts # U2 extends agent-token-usage windows → ranges + tool-analytics.ts # U2 tool calls by category, autonomy ratio + activity-analytics.ts # U2 sessions/messages/active-nodes/stickiness + productivity-analytics.ts # U2 files/commits/PRs/LOC, language dist + command-center-live.ts # U6a live snapshot: sessions, nodes, funnel + otel-metrics.ts # U10 OTLP metric mapping (pure mapping; wiring is in dashboard) + model-router.ts # U17 routing policy + rule evaluation +packages/dashboard/src/routes/ + register-command-center-routes.ts # U9 analytics + live + export endpoints + register-signal-routes.ts # U11 inbound signal webhooks +packages/dashboard/src/ + command-center-csv.ts # U8 CSV serialization + signal-source.ts # U11 SignalSource adapter interface + registry (mirrors github-* — dashboard) + signal-sources/{sentry,datadog,pagerduty,webhook}.ts # U11 adapters + knowledge-index.ts # U14 knowledge store + refresh (mirrors insights-routes — dashboard) + monitor-routes.ts # U13 deployment/incident tracking +packages/dashboard/app/components/command-center/ + CommandCenter.tsx # U4 shell + sub-view tabs + CommandCenter.css # U4 + charts/{Bar,StackedBar,Sparkline,Funnel}.tsx + .css # U4 chart primitives + areas/{TokensArea,ToolsArea,ActivityArea,ProductivityArea,EcosystemArea,SignalsArea}.tsx # U5 + MissionControlPanel.tsx # U6b live ops + SdlcFunnel.tsx # U7 funnel/throughput + DateRangePicker.tsx # U5/B shared range control +``` + +> **Package note (feasibility).** New modules that *mirror an existing precedent* must live in +> the same package as that precedent. The GitHub ingestion path, `reliability-metrics.ts`, +> `subtask-breakdown.ts`, `runtime-provider-probes.ts`, and `pr-conflict-resolver.ts` all live in +> `packages/dashboard/src`, **not** `packages/core` — so `signal-source.ts` (mirrors `github-*`), +> `knowledge-index.ts` (mirrors `insights-routes.ts`), the OTel wiring, and `monitor-routes.ts` +> belong in dashboard. Pure, reusable aggregation (`*-analytics.ts`, `model-pricing.ts`, +> `command-center-live.ts`, the OTLP *mapping*, `model-router.ts`) stays in `packages/core` per +> KTD2. If a core module needs GitHub-ingestion code, expose it through a core-level seam rather +> than importing dashboard into core. + +--- + +## Implementation Units + +### Phase A — Metrics foundation + +#### U1. Queryable usage-events telemetry table +**Goal:** Create a normalized, queryable source for tool calls, messages, and session +lifecycle so the Tools/Activity areas and OTel export do not have to parse JSONL at query time. +**Requirements:** R1, R3, R5 (substrate). +**Dependencies:** none. +**Files:** +- `packages/core/src/db.ts` — add the `usage_events` table, a new `applyMigration(N, ...)` block, and bump `SCHEMA_VERSION` to N (currently 117). `applyMigration`, `SCHEMA_VERSION`, `MIGRATION_ONLY_TABLE_SCHEMAS`, and `SCHEMA_COMPAT_FINGERPRINT` all live in `db.ts`, **not** `db-migrate.ts` (which is the legacy-data import path) — see Risks. +- `packages/core/src/usage-events.ts` (new: append + range query helpers) +- a dedicated `emitUsageEvent(...)` capture call invoked from the layer where `model`/`provider`/`nodeId`/`category` are already in scope — the **executor / session-run layer** — **not** by overloading `store.appendAgentLog` / `store.appendRunLog`, whose signatures and the `AgentLogEntry` they persist carry none of those fields (widening them is a high-fanout ~20+ call-site change across `engine/src/merger.ts`, `executor.ts`, etc.). `agent-log-file-store.ts` is likewise unusable (pure-FS, no DB handle). The field-carrying mechanism (dedicated call vs signature-widening vs hot-path lookup) is recorded as an Open Question. +- `packages/core/src/__tests__/usage-events.test.ts`, and the `db.ts` migration test (extend) +**Approach:** Columns: `id`, `ts`, `kind` (`tool_call|tool_result|tool_error|user_message|session_start|session_stop`), `taskId`, `agentId`, `nodeId`, `model`, `provider`, `toolName`, `category`, `meta` (JSON). **v1 scope:** task-execution + run-log events (which can carry model/provider/node from the session context). The chat path (`ChatStore`/`chat_room_messages`, which has no model/provider at its write site) contributes **message counts only** — chat-origin rows are model/provider-null by design, documented in U2. **`nodeId`** is sourced from the run/session context (`agentRuns`/`cli_sessions`), not the `tasks` row (which has no `nodeId`); events with no node context record `nodeId` null. **Mapping:** the agent-log `type` value `tool` maps to `kind: tool_call` (there is no `tool_call` in `AgentLogType`, which is `text|tool|thinking|tool_result|tool_error`); `user_message`/`session_start`/`session_stop` originate from `cli_sessions`/`chat_room_messages`. **`meta` safety:** capped at a fixed byte size (~4 KB, rejected at write); carries only non-sensitive descriptors (error code, category, duration) — **never** tool arguments/content or credential-class fields — with a documented retention/age-out policy. Reads come from SQLite. Index `(ts)`, `(taskId)`, `(agentId)`. +**Patterns to follow:** `packages/core/src/agent-token-usage.ts` (range scans), the `applyMigration` shape in `db.ts`, and the schema-version learning doc. +**Test scenarios:** +- Happy: a `tool`-type agent-log entry inserts one `usage_events` row with `kind: tool_call` and correct `category`. +- Completeness: a heartbeat-run (`appendRunLog`) tool call and a chat tool call either appear in `usage_events` or are asserted intentionally absent per the documented scope (guards the multi-path undercount). +- Edge: a chat-session event with no `taskId` records with `taskId` null and `agentId` set. +- Migration: seed a DB **at the previous schema version**, run migrate, assert the table exists and `SCHEMA_VERSION` equals the highest migration target (fresh-DB tests cannot catch the early-return bug). +- Error: malformed event is skipped without throwing and without aborting the underlying write. +- Edge: a `meta` payload exceeding the byte cap is rejected at write; tool-argument content never lands in `meta`. +- Integration: a real task execution that calls 3 tools yields 3 `tool_call` rows queryable by range, with `model`/`provider`/`nodeId` populated from the session context. + +#### U2. Core analytics aggregators (date-range windows) +**Goal:** Pure, reusable aggregation over tasks + `usage_events` producing the six measurement +areas for an arbitrary date range, grouped by model/provider/node/agent. +**Requirements:** R1, R2. +**Dependencies:** U1. +**Files:** +- `packages/core/src/token-analytics.ts`, `tool-analytics.ts`, `activity-analytics.ts`, + `productivity-analytics.ts` (new) +- `packages/core/src/__tests__/{token,tool,activity,productivity}-analytics.test.ts` +**Approach:** Each exports `aggregate({from, to, groupBy})`. Tokens: sum `tasks.tokenUsage*` +columns filtered by `tokenUsageLastUsedAt` in range. Tools: count `usage_events` by +`category`; **autonomy ratio = tool_call count / human-intervention events** — NOT raw user +messages, which trend to zero for autonomous task execution. The denominator's three components +have distinct, named sources (they are not one queryable thing): **approvals** from +`approval_request_audit_events` (filter to `created`/`approved`); **user-authored steers** from +the `SteeringComment[]` JSON on the task row, filtered to `author === "user"` (agent-authored +steers excluded — note this re-introduces a per-task JSON read, so mirror steers into +`usage_events` if range-querying proves costly); **waiting-on-input** is a task *status*, not a +counted event — drop it unless a concrete answer event is defined. A fully-autonomous session +(zero interventions) reports tool-calls-per-session instead of ∞. Activity: distinct +active nodes/agents per day, sessions from `cli_sessions`, messages from `usage_events`, +**stickiness = DAU/MAU**. Productivity: `tasks.modifiedFiles` count + language distribution, +`task_commit_associations` count, `pull_requests` count; LOC from commit diff stats if +available else flagged unavailable. Generalize `agent-token-usage.ts`'s 24h/7d/all-time windows +to `(from,to)`. +**Patterns to follow:** `packages/core/src/agent-token-usage.ts`. +**Test scenarios:** +- Happy: a known fixture of 5 tasks across 2 models returns correct per-model token totals. +- Edge: empty range returns zeroed structures, not nulls; a range boundary task (exactly at `from`) is included per documented inclusivity. +- Edge: a fully-autonomous session (zero human-intervention events) reports tool-calls-per-session, not ∞ or a divide-by-zero; validated against both an autonomous and an interactive fixture. +- Edge: the intervention denominator counts a user-authored steer and an approval but NOT an agent-authored steer. +- Productivity: LOC unavailable when commit diff stats are missing is reported as `null` + `unavailable: true`, not `0`. + +#### U3. Model pricing → cost derivation +**Goal:** Derive USD cost from token counts without persisting cost. +**Requirements:** R6. +**Dependencies:** U2. +**Files:** `packages/core/src/model-pricing.ts` (new), `packages/core/src/__tests__/model-pricing.test.ts`; consumed by `token-analytics.ts`. +**Approach:** A map keyed by `provider:model` → `{inputPer1M, outputPer1M, cacheReadPer1M, cacheWritePer1M, source}` plus a top-level `pricingAsOf` date. `costFor(usage, model)` returns `{usd, unavailable, stale}`. Unknown model → `unavailable: true`, never a guessed price. +**Patterns to follow:** plain data module; colocate with token-analytics. +**Test scenarios:** +- Happy: known model + token counts yields expected USD to cent precision. +- Edge: unknown model returns `unavailable: true` with `usd: null`. +- Edge: cache tokens priced at cache rate, not input rate. +- Edge: the map carries a `pricingAsOf` date and entries older than the threshold return `stale: true`. + +### Phase B — Command Center dashboard + +#### U4. Command Center shell, nav registration, and chart primitives +**Goal:** Register the `command-center` view end-to-end and build the reusable CSS-bar chart +primitives the areas render with. +**Requirements:** R1 (shell), KTD1, KTD4. +**Dependencies:** none (can start parallel to A; renders real data once A lands). +**Files:** +- `packages/dashboard/app/hooks/useViewState.ts` (add `command-center` to union + array) +- `packages/dashboard/app/App.tsx` (lazy import + prefetch + render branch, mirror `reliability` at App.tsx:1818-1826) +- `packages/dashboard/app/components/Header.tsx` (nav button mirroring reliability at :1223-1234; add `command-center` to active-check at :1095; optional `experimentalFeatures.commandCenter` gate) +- `packages/dashboard/app/components/MobileNavBar.tsx` (mobile parity) +- `packages/dashboard/app/components/command-center/CommandCenter.tsx` + `.css`, `charts/{Bar,StackedBar,Sparkline,Funnel}.tsx` + `.css`, `DateRangePicker.tsx` +- i18n strings under the `app` namespace +- `packages/dashboard/app/components/command-center/__tests__/charts.test.tsx`, `CommandCenter.test.tsx` +**Approach:** Shell renders sub-view tabs (Overview / Tokens / Tools / Activity / Productivity / Ecosystem / Mission Control). Chart primitives are hand-rolled CSS-bar components. Use `--duration-*` tokens (never `--transition-*`) for any loader/pulse animation. +**Overview tab content:** one headline stat card per area (total tokens + cost, autonomy ratio, active nodes, tasks done, unique models, open signals) plus a compact live Mission-Control strip; the date-range picker applies to the cards but not the live strip. A single "no usage data yet" empty state when nothing exists. +**Tab a11y:** sub-tabs use the ARIA tabs pattern (`role=tablist/tab/tabpanel`, arrow-key roving tabindex, Enter/Space activates, Tab moves into the active panel); the DateRangePicker returns focus to its trigger on dismiss. +**Patterns to follow:** `ReliabilityView.tsx` (skeleton, loading/error/empty), `AgentsView.tsx` (sub-view toggles), the reliability nav button. +**Execution note:** Build the chart primitives test-first — they are pure and the CSS-token trap is invisible without a real-browser assertion. +**Test scenarios:** +- Happy: selecting the Command Center nav entry renders the shell with the Overview tab active; `?view=command-center` deep-links to it. +- Edge: empty data renders the documented empty state per area, not a crash. +- CSS (real browser): `getComputedStyle(barEl).animationName !== "none"` for any animated loader (guards the IACVT token trap); extend `animation-duration-tokens.css.test.ts` for new CSS. +- Edge: chart bar with a zero value renders a 0-width bar with accessible label, not NaN width. +- A11y: a keyboard user can arrow between tabs, activate with Enter/Space, and Tab into the panel without losing focus; the date-range picker returns focus to its trigger on dismiss. + +#### U5. Historical analytics areas + date-range filtering +**Goal:** Render the measurement areas from the Phase A aggregators with a shared date-range +control, **including an External Signals area** (errors/alerts/incidents from R7 sources). +**Requirements:** R1, R2, R6, R16. +**Dependencies:** U2, U3, U4, U9; the Signals area depends on U11 data (degrades to empty until U11 lands). +**Files:** `packages/dashboard/app/components/command-center/areas/{TokensArea,ToolsArea,ActivityArea,ProductivityArea,EcosystemArea,SignalsArea}.tsx`, `DateRangePicker.tsx`; tests alongside. +**Approach:** Each area fetches its endpoint via the `api()` helper with the selected range, +renders stat cards + tables + CSS-bar charts. **Productivity framing (A5):** present LOC and +tool-count as *volume* proxies alongside outcome counters (tasks reaching done, PRs merged, +incidents resolved); do not frame high LOC/tool counts as inherently positive. +**Ecosystem area:** unique-active-model count + per-model session count as a bar chart, plugin +activation count, and a sparkline of distinct models/day; empty state when no third-party models +or plugins have been used. Reuses the tokens endpoint grouped by model where possible. +**External Signals area (R16):** signal volume by source/severity over the range, open vs +resolved, and MTTR (from U13) — wired so external signals are visible as dashboard metrics, not +only as task triggers. Until U11/U13 land, the area renders its empty state. +**SWR trap:** key any selection/drill-down reset effect on a derived value (e.g. +`rows.map(r => r.id).join(" ")`), never the array identity, or it resets every revalidation. +**Patterns to follow:** `AgentTokenStatsPanel.tsx` (token tables/totals), `ReliabilityView.tsx`. +**Test scenarios:** +- Happy: Tokens area shows per-model totals + cost; changing the range refetches and re-renders. +- Tools: autonomy ratio displayed; tool categories shown as a sorted bar chart. +- Edge (SWR): a revalidation that returns content-identical rows with new identity does **not** reset the user's column sort / selected row (regression: seed cache, defer fetch, interact, resolve with `JSON.parse(JSON.stringify(original))`, assert state survives). +- Edge: custom range with `from > to` is rejected client-side with a message. +- Productivity: unavailable LOC shows "—" with a tooltip, not `0`. +- Signals (R16): with U11 fixture data, the External Signals area shows volume by source/severity and open-vs-resolved; with no signal data it renders the empty state, not an error. + +#### U6. Live Mission-Control panel +**Goal:** Real-time view of concurrent sessions, active nodes, per-agent current activity, and +the live SDLC funnel. +**Requirements:** R3. +**Dependencies:** U4, and U9 for the endpoint. **To break the U6↔U9 cycle, U6 splits in two:** U6a = the core `command-center-live.ts` snapshot composer (no deps); U6b = the `MissionControlPanel` frontend (deps U4, U9). U9's live branch depends on U6a, not the U6 frontend. +**Files:** `packages/dashboard/app/components/command-center/MissionControlPanel.tsx`, `packages/core/src/command-center-live.ts` (live snapshot, U6a), live branch in `register-command-center-routes.ts`; tests alongside. +**Approach:** `command-center-live.ts` composes a snapshot from `agentHeartbeats`/`agentRuns`/`cli_sessions`/`tasks` (current column counts). Frontend follows **push + poll convergence (KTD5)**: subscribe to the existing SSE bus, refetch on event, poll every ~5s **only while any session is in-flight**, stop polling when idle. Server throttles emits (~500ms) and sends deltas, not full snapshots, for high-churn fields. +**Patterns to follow:** `app/sse-bus.ts`, the observable-long-running-agent-turns learning doc, `AgentsOverviewBar.tsx`. +**Test scenarios:** +- Happy: a newly-started session appears in the live panel within one poll/SSE cycle; ending it removes it. +- Edge: with zero active sessions, polling is not running (assert no interval scheduled when idle). +- Integration: SSE event triggers an immediate refetch (push) even between poll ticks. +- Edge: a node going stale (no heartbeat past threshold) is shown as inactive, not dropped silently. + +#### U7. SDLC funnel + throughput visualization +**Goal:** A funnel/Sankey-style visualization of tasks across columns with throughput (e.g. +tasks/day reaching done) and completion rate, both live and over a range. +**Requirements:** R1, R3 (KM: task completion rate). +**Dependencies:** U2, U4. +**Files:** `packages/dashboard/app/components/command-center/SdlcFunnel.tsx` + `.css`; aggregation in `activity-analytics.ts`; tests alongside. +**Approach:** Map the workflow columns (`triage→todo→in-progress→in-review→done`) to funnel +stages using `activityLog` transitions; show counts per stage and conversion between stages. +Reuse the `Funnel` chart primitive from U4. +**Patterns to follow:** the hand-rolled bar style; `activityLog` event types in `types.ts`. +**Test scenarios:** +- Happy: a fixture of tasks distributed across columns renders correct per-stage counts. +- Edge: workflow-defined custom columns (not the default enum) are mapped by trait, not by hardcoded names. +- Edge: completion rate over a range divides done-in-range by entered-in-range, documented and tested for the zero-denominator case. + +#### U8. CSV export +**Goal:** Export any analytics table as CSV. +**Requirements:** R4. +**Dependencies:** U2. +**Files:** `packages/dashboard/src/command-center-csv.ts` (new), export branch in `register-command-center-routes.ts`; export buttons in the area components; tests alongside. +**Approach:** A route variant sets `Content-Type: text/csv` + `Content-Disposition: attachment`. Server-side serialization of the same aggregator output. **Honors `getScopedStore(req)` before aggregation, exactly like U9's JSON endpoints — no cross-project leak via the export path.** No precedent exists — net-new. +**Test scenarios:** +- Happy: token endpoint with `?format=csv` returns well-formed CSV with a header row and the attachment header. +- Edge: values containing commas/quotes/newlines are RFC-4180 quoted. +- Edge: empty result returns header-only CSV, not a 204. +- Security: a project-A request cannot retrieve project-B data via CSV export (mirrors the U9 scoping test). + +#### U9. Analytics API endpoints +**Goal:** Programmatic endpoints backing the view and usable by agents. +**Requirements:** R5. +**Dependencies:** U2, U3, U6a (the `command-center-live.ts` snapshot composer — not the U6 frontend, which breaks the cycle). +**Files:** `packages/dashboard/src/routes/register-command-center-routes.ts` (new), registered in `packages/dashboard/src/routes.ts` near the other registrars (~:1991); tests in `packages/dashboard/src/__tests__/`. +**Approach:** `GET /api/command-center/{tokens,tools,activity,productivity}` (range + group-by params), +`GET /api/command-center/live` (snapshot), all thin adapters over Phase A aggregators. **Verify the +Vite proxy:** confirm `vite.config.ts`'s negative-lookahead `/api` proxy routes these to the +backend while leaving app source modules on Vite — `curl` both a real endpoint and a `?import` +source path. **Auth:** all routes inherit the dashboard's standard session/auth middleware via the +`ApiRouteRegistrar` (same as `register-usage-routes.ts`); machine/agent callers use the existing +credential model — **no analytics endpoint, including `/live`, is unauthenticated**, and every +endpoint (JSON, `/live`, and the CSV variant) applies `getScopedStore(req)` before aggregation. +**Patterns to follow:** `register-usage-routes.ts` (registrar shape), `ApiRoutesContext` in `routes/types.ts`. +**Test scenarios:** +- Happy: each endpoint returns the aggregator output with correct shape for a fixture DB. +- Edge: missing/invalid range params default to a documented window (e.g. last 7d), not a 500. +- Security: an unauthenticated request to each endpoint (including `/live`) returns 401. +- Security: project scoping — `getScopedStore(req)` is honored on the JSON and `/live` endpoints so cross-project data does not leak. +- Integration (proxy): real endpoint proxies to backend; a same-prefix `.ts?import` source path stays on Vite. + +#### U10. OpenTelemetry (OTLP) metrics export +**Goal:** Export the metrics over OTLP so teams can ship to Datadog/Grafana/etc. +**Requirements:** R4. +**Dependencies:** U2, U3. +**Files:** `packages/core/src/otel-metrics.ts` (new), wiring in the dashboard server (opt-in via config/env), changeset; tests alongside. Adds an OTel SDK dependency (changeset + sign-off). +**Approach:** Map aggregator outputs to OTLP metric instruments (counters/gauges) on a periodic +export, endpoint + headers from config. Disabled by default. **The endpoint is validated on write +(https-only in production; warn loudly on http); auth headers (Datadog/Grafana tokens) are stored +via the same secret-storage strategy as other credentials and are never logged or included in +diagnostic output.** +**Test scenarios:** +- Happy: with an OTLP collector stub, token/cost/activity metrics are exported with expected + metric names + attributes (model, node, provider). +- Edge: disabled by default — no exporter starts without explicit config. +- Security: an `http://` endpoint emits a warning; auth header values are redacted from any log output. +- Error: collector unreachable logs and backs off; it never crashes the server or blocks requests. + +### Phase C — Software-delivery-loop gap-fill + +> **Scope note:** Phase C closes the Signal/Triage/Monitor/Knowledge gaps. +> Per the user's request these are specified as buildable units, but **U11 and U14 are +> each large enough to merit their own `ce-brainstorm` before execution** — they are flagged +> inline. Sequence Phase C after Phases A–B ship. + +#### U11. External signal ingestion (Sentry / Datadog / PagerDuty / webhook) +**Goal:** Ingest signals beyond GitHub into triageable tasks via a common adapter seam. +**Requirements:** R7, KTD8. +**Dependencies:** none (independent of the Command Center); benefits from U13. +**Files:** `packages/dashboard/src/signal-source.ts` (adapter interface + registry — mirrors the GitHub path, lives in dashboard), `packages/dashboard/src/signal-sources/{sentry,datadog,pagerduty,webhook}.ts`, `packages/dashboard/src/routes/register-signal-routes.ts` (inbound webhooks), config/settings entries; tests alongside. +**Approach:** A `SignalSource` interface (`verify(req)`, `normalize(payload) → Signal`) mirroring +the GitHub source path. The normalized `Signal` includes a **`groupingKey`** populated from the +provider's native primitive (Sentry `issue.id`, PagerDuty `incident.id`, …) for U13's storm guard; +the generic webhook requires the caller to supply one or falls back to `source + normalized-title`. +Inbound webhooks land normalized `Signal`s that create tasks in a `signal`/`triage` column. Each +provider is a thin adapter. +**Security (mandatory, not deferred to the brainstorm):** every adapter's `verify(req)` performs +HMAC signature verification against a per-provider secret stored in encrypted settings/env (never +source-controlled); a missing or invalid secret rejects with 401 — **the generic webhook is never +an unauthenticated task-creation endpoint.** Add a replay window (reject timestamps outside ±5 min) +plus delivery-id nonce dedup; treat any URLs in payloads as SSRF-untrusted. Enforce a request body +size cap (~1 MB), per-source rate limiting, and field-length caps on normalized `Signal` fields; +`meta` JSON from external sources is stored as data and never rendered as raw HTML in the dashboard. +**Patterns to follow:** `github-source-issue-close.ts`, `github-webhooks.ts`, `github-poll.ts`. +**Execution note:** Characterize the existing GitHub ingestion path first, then factor the +shared seam — do not break GitHub ingestion while generalizing it. +**Flag:** Candidate for its own brainstorm (provider auth models, dedup, rate limits differ per provider). **Defer the `SignalSource` registry/interface extraction until a second provider exists** — for the first delivery, implement one provider (generic webhook) as a standalone module mirroring `github-webhooks.ts`, then extract the shared interface once the brainstorm settles the auth/dedup/rate-limit shape and two providers coexist. +**Test scenarios:** +- Happy: a valid Sentry webhook creates one triage task with normalized title/severity/link. +- Security: an unsigned/invalid-signature webhook (including the generic webhook with no secret) is rejected with 401 and creates no task. +- Security: a replayed valid payload (timestamp outside the window or duplicate delivery-id nonce) is rejected. +- Edge: duplicate delivery (same external id) is deduped, not double-created (mirror `github-tracking-dedup.ts`). +- Edge: an oversized payload (>1 MB) is rejected; per-source rate limit caps a flood. +- Error: a malformed payload returns 4xx and creates no task. + +#### U12. Triage stage — auto-classify + decompose, for issues *and* pull requests +**Goal:** Auto-classify incoming signals/issues **and inbound pull requests** and decompose or +route them into board tasks. +**Requirements:** R8, R14, KTD7. +**Dependencies:** U11; reuses existing breakdown + GitHub PR ingestion. +**Files:** a `triage` trait/handler via the workflow-extension system, `packages/dashboard/src/subtask-breakdown.ts` reuse, PR-source wiring near `github-poll.ts`/`github-webhooks.ts`; tests alongside. +**Approach:** A triage column trait runs a classify+decompose pass (priority/area/labels), using +the existing subtask-breakdown machinery, then routes to `todo`. Express as a `Trait` with an +`onEnter` hook (see `CONCEPTS.md` Trait), not a hardcoded branch. **PRs:** inbound PRs (external +contributors, dependabot) are classified and either labeled/routed for review or used to open a +follow-up task; PR triage reuses the `pull_requests` / PR-entity model rather than minting issues. +**Patterns to follow:** `subtask-breakdown.ts`, `mission-interview.ts`, `github-webhooks.ts`, the Trait/Workflow-Extension model. +**Test scenarios:** +- Happy (issue): a signal-created task entering `triage` is classified and decomposed into N todo tasks linked back to the signal. +- Happy (PR): an inbound PR is classified (e.g. dependency-bump vs feature) and routed to review or a follow-up task, linked to its PR entity. +- Edge: a signal too small to decompose passes through as a single task, not zero. +- Edge: a PR Fusion itself opened is **not** re-triaged as inbound (no self-loop). +- Error: classifier failure parks the item in triage with a diagnostic, does not drop it. + +#### U13. Monitor stage (deployments, incidents, MTTR) — closes the loop +**Goal:** Track deployments and production incidents, compute MTTR, and feed Monitor signals +back to Signal/Triage. +**Requirements:** R9, KTD7. +**Dependencies:** U11 (signals), U2 (so MTTR surfaces in the Command Center). +**Files:** `packages/dashboard/src/monitor-routes.ts`, a `deployments`/`incidents` table (db.ts + migration), a `monitor` column trait, MTTR aggregation in `activity-analytics.ts`, Command Center surfacing; tests alongside. +**Approach:** Record deploys (from CI/Ship events) and incidents (from U11 signals). MTTR = +incident-open → incident-resolved. A `monitor` trait watches post-ship and can auto-open a +fix task on a regression signal, closing the loop back to Triage. **Storm/dedup guard (required — +production signals are bursty):** grouping requires a **`groupingKey`** that each U11 adapter's +`normalize()` populates from its provider's native primitive (Sentry `issue.id`/`event.fingerprint`, +PagerDuty `incident.id`, Datadog monitor/aggregation key) — there is no Fusion error-fingerprint +concept, and the content-hash `computeContentFingerprint` (task title/description) is wrong for +bursty alerts. The **generic webhook has no native key**: require the caller to supply one, else +fall back to `source + normalized-title` with a documented coarser cooldown. With the key: a +threshold/sustained-duration gate precedes task creation; a cooldown attaches re-firing signals to +the existing fix task (reuse `findLatestByDedupeKey`); a circuit-breaker caps auto-created tasks per +window; a Fusion-opened fix task never re-triggers (no self-loop, mirroring U12). **Deploy/incident +ingestion auth:** the CI→`monitor-routes` endpoint requires a shared secret / bearer token (stored +in encrypted settings, never unauthenticated, 401 on missing/invalid), and payload URLs are +SSRF-untrusted — mirroring U11. The MTTR aggregator lives in `activity-analytics.ts` +(`packages/core`); deployment/incident recording and `monitor-routes.ts` live in +`packages/dashboard/src` (the aggregator is the core seam the route consumes). +**Patterns to follow:** `reliability-metrics.ts` (metric aggregation + endpoint), KTD7 traits. +**Test scenarios:** +- Happy: an incident opened then resolved yields a correct MTTR in the Monitor metrics. +- Integration: a post-ship error signal auto-creates a single linked fix task in triage (loop closure). +- Storm: a 100-event burst sharing one `groupingKey` yields exactly one fix task; a flapping alert yields no new task; an already-open fix task absorbs repeat signals. +- Edge: the generic webhook with no supplied grouping key falls back deterministically (source + normalized-title), not per-event. +- Security: an unauthenticated deploy/incident POST to `monitor-routes` returns 401 and records nothing. +- Edge: an unresolved incident contributes to "open incidents," not to MTTR. +- Edge: deploy with no following incident counts toward deploy frequency / change-fail rate denominator. + +#### U14. Persistent knowledge index +**Goal:** A persistent, incrementally-refreshed knowledge layer downstream agents can query. +**Framing:** this is a *delta* over the existing `insights`/`memoryView` surfaces, which already +provide part of this — characterize what they lack before building. If the delta is small, extend +those surfaces rather than introducing a greenfield store; the new-table spec below applies only if +the brainstorm concludes a separate store is warranted. +**Requirements:** R10. +**Dependencies:** none; integrates with existing `insights`/`memoryView`. +**Files:** `packages/dashboard/src/knowledge-index.ts` (mirrors `insights-routes.ts` — lives in dashboard), a knowledge store table (`db.ts` + migration), refresh hook on task completion, a dashboard surface reusing the `memoryView`/`InsightsView` patterns; tests alongside. +**Approach:** Index repo + task/PR history into queryable knowledge pages, refreshed +incrementally on task completion (not full re-index). Expose a query API agents can call — +**under the same session/auth middleware and `getScopedStore(req)` scoping as U9** (the index +holds sensitive repo/commit/PR content, so it is an information-disclosure surface, not an open +endpoint). +**Patterns to follow:** `InsightsView.tsx` + `insights-routes.ts`, the `memoryView` experimental flag. +**Flag:** Candidate for its own brainstorm (indexing strategy, storage/embedding choice, refresh cost). +**Test scenarios:** +- Happy: completing a task adds/updates a knowledge page; a keyword query returns it. +- Edge: incremental refresh updates only affected pages, not the whole index (assert unaffected pages' timestamps unchanged). +- Integration: an agent query endpoint returns relevant pages for a known fixture. +- Security: an unauthenticated query returns 401; a project-A caller cannot retrieve project-B pages (mirrors U9 scoping). + +#### U17. Fusion Model Router +**Goal:** Automatic per-task / per-request model selection across providers, optimizing +cost/latency while preserving frontier quality on hard work. +**Requirements:** R13, KTD9. +**Dependencies:** U1 (telemetry to measure savings), U3 (pricing); independent of the view UI. +**Files:** `packages/core/src/model-router.ts` (routing policy + rule evaluation), wiring into +the effective-agent / model-pair resolution path, a router config/settings surface, a Command +Center readout of router decisions + realized savings; tests alongside. +**Approach:** Per KTD9 — a selection layer, not an executor. **Session-level routing only for this +unit:** pick the `(provider, model)` pair at session start. *Per-request mid-session re-routing is +deferred* (it needs its own design pass on streaming continuity, context-window compatibility, and +prompt-cache invalidation — see Deferred). **Routing signal (load-bearing, must be settled before +build):** no structured `complexity`/`difficulty` field exists on tasks or steps today, and prompt +size alone is a weak proxy (short-but-hard vs long-but-boilerplate). The classifier signal must be +defined and validated against real Fusion task data, and paired with a **quality guardrail** +(escalation/retry to the strong tier on cheap-tier failure) and a **quality-regression metric** — +not only the cost-savings readout — so the router cannot report savings while silently degrading +output. **The gate is exitable two ways:** the unit does not ship until the brainstorm produces a +validated signal, OR it ships a deliberately-conservative v0 that routes only an allowlist of +mechanical traits (dependabot bumps, lint-only fixes) to the cheap tier and everything else to the +default pair. It must NOT be read as "build the full classifier now" with prompt-size as the de +facto signal. **Resolution lanes:** enumerate which lanes the router governs (execution, planning, +validation, …; `model-resolution.ts` exposes a distinct resolver per lane) and test each — it must +neither leak into ungoverned lanes nor return a forbidden pair in any governed lane. Respects +column-agent overrides and org/project/user model controls (cannot pick a restricted model). Safe +fallback to the configured default pair when disabled or a pick is unavailable. Emits its decisions +(including the counterfactual model that *would* have run) to U1 so the Command Center can show +adoption and realized cost delta versus always-premium. +**Patterns to follow:** the Effective-agent / Workflow-Setting model-lane resolution and +`model-resolution.ts` lanes; `runtime-provider-probes.ts` for provider availability. +**Execution note:** Implement the resolution-seam integration test-first — routing must never +hand back a pair the model controls forbid. +**Flag:** Candidate for its own brainstorm — the routing signal and quality guardrail are +load-bearing and unproven, making this at least as design-heavy as U11/U14. It is also the most +strategy-aligned new capability after the Command Center (it directly expresses the model-agnostic +thesis and feeds `ecosystem breadth`), so it should not be deferred or rejected alongside the +competitor-parity units — elevate it on its own merits. +**Test scenarios:** +- Happy: a routine step routes to the cheap tier; a deep-reasoning task routes to the strong tier. +- Edge: a column-agent `override` binding wins over the router (router defers). +- Security/governance: a model restricted by project policy is never selected, even if it scores best. +- Edge: router disabled → resolution is byte-identical to today's default-pair behavior (no regression). +- Integration: router decisions appear in `usage_events` and the Command Center shows realized cost savings vs premium-only. + +#### U18. PR review-comment auto-resolution (surface + harden the Review-response loop) +**Goal:** Make automatic resolution of PR review comments a first-class, surfaced capability +built on the existing Review-response loop. +**Requirements:** R15, KTD10. +**Dependencies:** none (extends existing PR-entity + review-response machinery); benefits from U6b. +**Files:** wiring/config around the existing Review-response loop — the real entry point is +`packages/engine/src/pr-response-run.ts` (plus the `ce-resolve-pr-feedback` skill), **not** a +`pr-comment-resolver` module by that name — and the PR entity (`CONCEPTS.md` PR entity, +Review-response loop); Command Center / Mission-Control surfacing of in-flight resolutions; tests +alongside. Note the `packages/engine` home of the loop. +**Approach:** Per KTD10 — do not rebuild the loop. Ensure it triggers on PR-entity review +threads (human + bot), is gated consistently with the auto-merge model, and exposes its +activity (threads acted on, fixed vs disagreed) to the Command Center. Make default-on behavior +explicit and configurable. +**Patterns to follow:** the existing `ce-resolve-pr-feedback` skill seam, `pr-conflict-resolver.ts`, the Review-response loop description in `CONCEPTS.md`. +**Test scenarios:** +- Happy: a new review thread dispatches a resolver that fixes, pushes to the PR branch, and replies to the thread. +- Edge: the resolver disagrees → posts reasoning and leaves the thread open (no silent push). +- Edge: auto-resolution respects the auto-merge gate (disabled → resolves but does not merge). +- Integration: in-flight resolutions appear in Mission Control and counts roll into Command Center metrics. + +--- + +## Scope Boundaries + +**In scope:** The Command Center (combined historical analytics + live Mission Control, +including an External Signals metric area), its metrics foundation, export (CSV + OTel), the +Analytics API, and buildable units for every SDLC gap (Signal ingestion, Triage of issues +**and PRs**, Monitor, Knowledge, the **Fusion Model Router**, +and **auto-resolution of PR review comments**). + +### Deferred to Follow-Up Work +- **Per-unit brainstorms for U11 and U14** before execution — each has substantial design + surface (provider auth/dedup; indexing/embedding strategy) that this + plan scopes but does not fully resolve. +- **Per-request mid-session model re-routing (U17)** — this unit ships session-level routing only; + per-request re-routing needs its own design pass on streaming continuity, context-window + compatibility, and prompt-cache invalidation. +- **Recharts (or any chart-lib) adoption** — only if KTD4's hand-rolled approach proves + impractical for a needed chart type; would be a separate changeset + sign-off. +- **Human "Users" analytics** — Fusion's notion of a human user is thin (`assigneeUserId`); + the Users/per-person area is modeled here as **per-agent**. A per-human breakdown waits until + multi-user (the `Pluggable multi-user` track in `STRATEGY.md`) lands. + +### Out of scope +- Replacing or forking the workflow runtime — Phase C attaches to it via traits/extensions. +- A URL router for the dashboard — the `?view=` + `localStorage` model is preserved. + +--- + +## Risks & Dependencies + +- **SCHEMA_VERSION migration trap (high).** U1/U13/U14 add tables. `applyMigration`, + `SCHEMA_VERSION` (currently 117), `MIGRATION_ONLY_TABLE_SCHEMAS`, and `SCHEMA_COMPAT_FINGERPRINT` + all live in `packages/core/src/db.ts`, **not** `db-migrate.ts` (the legacy-data path). Every + `applyMigration(N)` **must** bump `SCHEMA_VERSION` to N in the same change, or the migrate loop early-returns and + the migration silently never runs on already-upgraded DBs (fresh DBs mask it). Also update + `MIGRATION_ONLY_TABLE_SCHEMAS`/`SCHEMA_COMPAT_FINGERPRINT`, add a **seed-at-previous-version** + migration test, and run the version-literal sweep across **plugin** workspaces too, not just + `packages/`. (`docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md`.) +- **Vite `/api` proxy regex (medium).** New endpoints must be verified against the + negative-lookahead proxy in `vite.config.ts` so app source modules aren't proxied; `curl` + both a real endpoint and a `?import` source path. (`docs/solutions/integration-issues/vite-api-source-modules-proxied-to-backend.md`.) +- **CSS IACVT token trap (high).** Chart/loader animations must use `--duration-*` tokens, not + `--transition-*` (which are duration+easing pairs); misuse silently drops the whole + declaration. Extend `animation-duration-tokens.css.test.ts`; verify in a real browser. + (`docs/solutions/ui-bugs/css-animation-frozen-by-transition-token-shape-mismatch.md`.) +- **SWR identity-reset trap (medium).** View state keyed on revalidated array identity resets + every poll tick; key on derived semantic values. (`docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md`.) +- **Browser verification hazard (process).** Verify with `fn dashboard --dev` on a **free, + non-4040** port with `FUSION_CLIENT_DIR=$PWD/packages/dashboard/dist/client` after a fresh + build; never `fn daemon`/`fn serve` (engine + shared DB). If a chart renders empty, check the + served bundle hash first. (`docs/solutions/developer-experience/browser-testing-dashboard-from-worktree-safely.md`; aligns with the port-4040 kill-guard.) +- **Phase C breadth.** Three units are brainstorm-candidates; do not let Phase C block the + Command Center shipping from Phases A–B. + +--- + +## Sources & Research + +- **External analytics product** (six measurement areas — tokens, tools, activity, productivity, + users, agent readiness — plus CSV/OTel/API): factory.ai/news/factory-analytics. +- **External model router** (per-task/per-request auto model selection, ~20–25% cost reduction, + respects org/project/user model controls): docs.factory.ai/web/factory-router → grounds R13/U17. +- **External end-to-end delivery loop + mission-control framing**: factory.ai homepage + (Signal→Triage→Plan→Execute→Validate→Ship→Monitor) and that product's release notes + (mission control, sessions, knowledge wiki, computer use, missions, subagents). +- The X thread that prompted this work (x.com/factoryai/status/2066588050617249904) was + paywalled (HTTP 402); its subject was reconstructed from the public pages above. +- **Fusion grounding**: `STRATEGY.md` (key metrics), `CONCEPTS.md` (Column/Trait/Workflow + Extension, Effective agent, Task lifecycle), and repo research into `packages/dashboard` + + `packages/core` (data model, view registration, `ApiRouteRegistrar`, existing + `ReliabilityView`/`AgentTokenStatsPanel`/`agent-token-usage.ts`). +- **Institutional learnings**: the six `docs/solutions/` entries cited in Risks. + +--- + +## Deferred / Open Questions + +### From 2026-06-15 review + +These are genuine forks the review surfaced that depend on your priorities — left open rather than +decided here. (The factual/feasibility/security findings from the same review were applied inline.) + +- **Plan scope — ship A–B alone, or bundle Phase C?** Three reviewers flagged that Phases A–B + (the Command Center) have a clean, strategy-aligned premise, while Phase C's stages were derived + from a competitor's feature set rather than observed Fusion user pain, and bundling them means + approving the dashboard implicitly blesses the broader SDLC-platform direction. Options: (a) ship + A–B as the plan of record and split Phase C into its own strategy-grounded brainstorm; (b) keep + one plan but state explicitly that approving it is not approving Phase C's direction; (c) proceed + as one plan (current state, per your "build all gaps" instruction). *No change made — your call.* +- **Positioning: neutral orchestrator vs opinionated delivery system.** An opinionated + Signal→…→Monitor pipeline (Monitor stage, MTTR, role/lifecycle features) pulls against + `STRATEGY.md`'s "neutral by design, plugin ecosystem" thesis. Should the Monitor/Signal/Knowledge + stages be core product surface or live in the plugin ecosystem the strategy names as its + extension mechanism? +- **`usage_events` table vs lazy-materialization (KTD3 / U1).** The plan now notes both; the + architecture choice (always-on events table + multi-path instrumentation, vs a cache table + materialized on first query) is unresolved. R1/R2/R5 state no sub-second requirement, which keeps + lazy-materialization on the table. +- **`usage_events` field-carrying mechanism (U1).** `appendAgentLog`/`appendRunLog` have DB handles + but their signatures (and `AgentLogEntry`) carry none of `model/provider/nodeId/category`. The + plan proposes a dedicated `emitUsageEvent` call from the session layer; the alternatives are + widening the log signatures (~20+ call sites) or a per-write DB lookup. **Resolved (2026-06-15): + dedicated `emitUsageEvent(...)` call from the executor/session layer — do not widen the log method + signatures.** +- **OTel export (U10) timing.** CSV (U8) already satisfies R4 for the Command Center's developer + audience; OTLP targets an ops team running a collector. Build now, or defer U10 until a concrete + consumer exists (avoids adding the OTel SDK dependency for a default-disabled feature)? +- **Mission Control placement (D2).** Dedicated tab only (polling stops when inactive), a + persistent live strip across all tabs (SSE always subscribed), or embedded in Overview? Changes + the polling architecture U6 implements. +- **Date-range picker affordance (D3).** Preset labels/windows, calendar vs free-text custom range, + explicit Apply vs update-on-select, and the in-flight refetch state per area. +- **Mobile layout of dense charts (D4).** How charts/tabs reflow on narrow/landscape phones + (collapse to sparklines, hide behind a toggle, horizontal-scroll tab strip) — the mobile + breakpoint includes landscape (`max-height: 480px`). +- **AgentTokenStatsPanel consolidation (D5).** Deprecate it once the Tokens tab ships, keep it as a + linked inline summary, or keep it standalone with explicitly different scope (lifetime vs + windowed) — and document which data source each uses so the numbers don't silently diverge. diff --git a/docs/plans/workflow-owned-merge-stack/s05-runtime-work-item-driver.md b/docs/plans/workflow-owned-merge-stack/s05-runtime-work-item-driver.md new file mode 100644 index 0000000000..ac433eba34 --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s05-runtime-work-item-driver.md @@ -0,0 +1,46 @@ +--- +title: "S05: runtime work-item driver" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S05 +milestone: "Runtime" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-s04-builtin-ir-regions +--- + +# S05: runtime work-item driver + +## Stack Role + +This draft PR reserves the S05 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Runtime + +## Depends On + +S1 workflow work items, S3 generic scheduler claim path, and S4 built-in IR regions. + +## Goal + +Let WorkflowTaskRuntime start from a workflow work item and persist node/work-item outcomes. + +## Expected File Scope + +packages/engine/src/workflow-task-runtime.ts; workflow graph executor and node handler files; runtime tests. + +## Expected Tests + +Runnable completion, retrying work creation, manual hold creation, restart resume, and duplicate lease refusal. + +## Exit Gate + +Runtime can progress workflow work without old merge queue callbacks. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/docs/plans/workflow-owned-merge-stack/s06-git-merge-capabilities.md b/docs/plans/workflow-owned-merge-stack/s06-git-merge-capabilities.md new file mode 100644 index 0000000000..b30c591dfb --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s06-git-merge-capabilities.md @@ -0,0 +1,46 @@ +--- +title: "S06: git and merge capability extraction" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S06 +milestone: "Runtime" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-s05-runtime-work-item-driver +--- + +# S06: git and merge capability extraction + +## Stack Role + +This draft PR reserves the S06 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Runtime + +## Depends On + +S4 built-in IR regions and S5 runtime work-item driver. + +## Goal + +Put checkout preparation, branch integration, merge attempt, squash, finalize, and conflict classification behind workflow node capability modules. + +## Expected File Scope + +packages/engine/src/merger*.ts; packages/engine/src/workflow-merge-nodes.ts; merge capability tests. + +## Expected Tests + +Checkout preparation, file-scope failure, already-on-main finalize, transient retry, permanent conflict routing, and guard-service coverage. + +## Exit Gate + +A merge attempt can be driven by a workflow node capability with the same guard behavior as merger.ts. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/docs/plans/workflow-owned-merge-stack/s07-completion-handoff-merge-work.md b/docs/plans/workflow-owned-merge-stack/s07-completion-handoff-merge-work.md new file mode 100644 index 0000000000..7ab5820093 --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s07-completion-handoff-merge-work.md @@ -0,0 +1,46 @@ +--- +title: "S07: completion handoff creates merge work" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S07 +milestone: "Runtime" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-s06-git-merge-capabilities +--- + +# S07: completion handoff creates merge work + +## Stack Role + +This draft PR reserves the S07 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Runtime + +## Depends On + +S2 projection, S5 runtime driver, and S6 merge capabilities. + +## Goal + +Replace task-moved in-review auto-enqueue as policy authority with workflow completion handoff creating merge work. + +## Expected File Scope + +packages/engine/src/project-engine.ts; packages/engine/src/merger.ts; packages/core/src/store.ts; completion and cutover tests. + +## Expected Tests + +Coding completion creates merge work, autoMerge false creates manual hold, duplicate handoff idempotency, soft-delete cancellation, startup projection dedupe. + +## Exit Gate + +New task completions produce workflow merge work before old queue processing runs. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/docs/plans/workflow-owned-merge-stack/s08-workflow-owned-merge-processing.md b/docs/plans/workflow-owned-merge-stack/s08-workflow-owned-merge-processing.md new file mode 100644 index 0000000000..92fd4a4c04 --- /dev/null +++ b/docs/plans/workflow-owned-merge-stack/s08-workflow-owned-merge-processing.md @@ -0,0 +1,46 @@ +--- +title: "S08: workflow-owned merge queue processing" +type: refactor +status: draft-stack-handoff +date: 2026-06-09 +slice: S08 +milestone: "Gate B" +origin: docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md +stack_base: feature/workflow-owned-merge-s07-completion-handoff-merge-work +--- + +# S08: workflow-owned merge queue processing + +## Stack Role + +This draft PR reserves the S08 review slot in the workflow-owned merge, +retry, scheduling, and recovery migration stack. It is intentionally a handoff +artifact, not the completed implementation for this slice. + +## Milestone + +Gate B + +## Depends On + +S3 scheduler claim path, S6 merge capabilities, and S7 completion handoff. + +## Goal + +Process merge work items through workflow runtime instead of ProjectEngine's in-memory merge queue loop. + +## Expected File Scope + +packages/engine/src/project-engine.ts; packages/engine/src/scheduler.ts; packages/engine/src/merger.ts; packages/core/src/store.ts; merge lifecycle tests. + +## Expected Tests + +Serialized merge claim, successful finalize, transient retry, permanent conflict routing, duplicate lease blocking, hard cancel cancellation. + +## Exit Gate + +Production merge processing no longer depends on a hidden mergeQueue dequeue loop. + +## Full Plan + +See `docs/plans/2026-06-09-003-refactor-workflow-owned-merge-full-migration-slices-plan.md`. diff --git a/docs/plugins/compound-engineering.md b/docs/plugins/compound-engineering.md index 4d7194a06b..657ec9a2f3 100644 --- a/docs/plugins/compound-engineering.md +++ b/docs/plugins/compound-engineering.md @@ -25,6 +25,11 @@ needed. If the plugin is uninstalled, the workflow is hidden again. The Compound Engineering view is registered as a primary plugin destination (`viewId: "compound-engineering"`). +It follows dashboard UI conventions: the view's panels, controls, responsive +layout, spacing, and radii use the shared `--space-*` / `--radius-*` design +tokens and shared button/card/input classes so the plugin remains visually +consistent across light, dark, desktop, and mobile surfaces. + It provides: - An **artifact hub** that discovers CE artifacts from conventional locations (`STRATEGY.md`, `docs/ideation/`, `docs/brainstorms/`, plan docs, `docs/work/`, @@ -118,8 +123,8 @@ Settings render under **Settings → Plugins → Compound Engineering**. the host default. Consumed by the orchestrator's factory call. - `defaultModelId` (string) — model within the provider; blank uses the host default. Consumed by the orchestrator's factory call. -- `enabledStages` (string[], default = full registry) — only these stage IDs may - be launched; the orchestrator rejects others. +- `disabledStages` (string[], default `[]`) — explicit opt-out list. Registered + stages launch by default; the orchestrator rejects only IDs listed here. **Sync** - `reconcileOnHooks` (boolean, default `true`) — auto-fire the reconcile sweep diff --git a/docs/plugins/external-authoring.md b/docs/plugins/external-authoring.md index f25dcbb3e4..fe0ccea5ef 100644 --- a/docs/plugins/external-authoring.md +++ b/docs/plugins/external-authoring.md @@ -46,7 +46,7 @@ You can also run the build yourself: pnpm build ``` -Troubleshooting: plugin entrypoints must be compiled JavaScript. `fn plugin install` and `fn plugin dev` reject `.ts` source entrypoints, so run the build before installing if you are not using the dev loop. +Troubleshooting: plugin entrypoints must be compiled JavaScript. `fn plugin install` and `fn plugin dev` reject `.ts` source entrypoints, so run the build before installing if you are not using the dev loop. A raw `*.tgz` is not a valid `fn plugin install` argument; extract it first with `tar -xzf` and install from the unpacked `./package` directory. ## 3. Test diff --git a/docs/plugins/external-proof-point-runbook.md b/docs/plugins/external-proof-point-runbook.md new file mode 100644 index 0000000000..ae8ca5a17e --- /dev/null +++ b/docs/plugins/external-proof-point-runbook.md @@ -0,0 +1,335 @@ + + +# External Plugin Proof-Point Runbook + +This runbook validates the v1 ecosystem signal for goal **G-MPS8FPMK-0001-SAWD**: an externally authored Fusion plugin can be scaffolded, built, tested, loaded, enabled, and listed against a **released** `@runfusion/fusion` build without using the Fusion monorepo. + +Use the step-by-step authoring guide for command details: [External Plugin Authoring](./external-authoring.md). This runbook adds release selection, evidence capture, and pass/fail criteria for proof-point validation. + +## Purpose & when to run + +Run this proof point when Fusion claims support for external plugin authors, especially before or after a release that changes any of these surfaces: + +- `fn plugin new` +- `fn plugin dev` +- `fn plugin install` +- `fn plugin enable` +- `fn plugin list` +- `@runfusion/fusion/plugin-sdk` +- bundled CLI/runtime dependencies that the released package must resolve without monorepo `workspace:*` links + +The proof point must use the public release artifact. Do not validate with a local workspace build unless the task is explicitly about pre-release smoke testing. + +## Prerequisites + +- Node.js 18+ +- `pnpm` and `npm` +- Public registry/network access for `npm view`, `npx`, and package installation +- A clean temporary workspace **outside** the Fusion repo, for example: + + ```bash + export FUSION_PLUGIN_PROOF_DIR="$(mktemp -d)" + cd "$FUSION_PLUGIN_PROOF_DIR" + ``` + +- Do **not** start or kill anything on port 4040. Port 4040 is reserved for the production dashboard. If a command needs a server port, use a random/free port option such as `--port 0`. +- Do **not** run an unbounded recursive `find` rooted at `/tmp`, `$TMPDIR`, or macOS `/var/folders/...`. If you need to inspect the temp workspace, list only the known proof directory. + +## Released version selection + +Capture the released package version and integrity before running the proof point: + +```bash +npm view @runfusion/fusion version +npm view @runfusion/fusion dist.integrity +``` + +For this runbook update, registry provenance was recaptured on 2026-06-14: + +```text +@runfusion/fusion version: 0.43.0 +dist.integrity: sha512-kvxicT+e8ulc7FDhBVP9NsgaioZv6NDW81N8cXNS/X8M32Eo3Y33xT6JFW2DrSiFXsJmAaib/GnpQE0nYQYApQ== +``` + +The proof point should target a release that includes the external-author fixes tracked by FN-6409, FN-6410, and FN-6435. Before running, confirm the release notes or consumed changeset state include `.changeset/fn-5844-external-plugin-authoring.md`; if that changeset has not been consumed into the published package, record a release-gate failure rather than patching locally. + +Use the concrete release tarball URL for the version under test: + +```text +https://registry.npmjs.org/@runfusion/fusion/-/fusion-.tgz +``` + +Replace `` only with the value returned by `npm view @runfusion/fusion version` for the run being reported. + +## Plugin source selection + +Prefer the released scaffold path because it validates the public author experience end to end: + +```bash +npx @runfusion/fusion@latest plugin new proof-point-plugin +cd proof-point-plugin +``` + +The scaffolded package should be standalone: + +- package name like `fusion-plugin-proof-point-plugin` +- imports SDK helpers from `@runfusion/fusion/plugin-sdk` +- no private `@fusion/*` imports +- no `workspace:*` dependencies +- no references to the Fusion monorepo checkout + +If the task requires testing an already-authored external plugin instead of the scaffold, record its canonical repository, docs/homepage, release/download artifact, binary/CLI if any, and checksum or `upstream-pending-verification` marker before running it. + +## Execution commands + +Follow [External Plugin Authoring](./external-authoring.md) for detailed command behavior. The validated loop is: + +```bash +fn plugin new proof-point-plugin +cd proof-point-plugin +pnpm install +pnpm build +pnpm test +fn plugin dev . --once +fn plugin list +``` + +If the proof point uses the packaged-install path instead of `plugin dev`, run the equivalent install/enable/list loop: + + + +```bash +pnpm build +pnpm test +pnpm pack +tar -xzf fusion-plugin-proof-point-plugin-0.1.0.tgz +fn plugin install ./package +fn plugin enable fusion-plugin-proof-point-plugin +fn plugin list +``` + +Troubleshooting: FN-6471 found that `npx @runfusion/fusion@0.43.1 plugin install ./fusion-plugin-proof-point-plugin-0.1.0.tgz` fails with `Plugin entry file must end with .js, .mjs, or .cjs: /fusion-plugin-proof-point-plugin-0.1.0.tgz`. The failure is expected for a raw tarball because `fn plugin install` accepts a built plugin directory (or installed package name), not a packed `.tgz`; extract first and install from `./package`. This corrects the packaged-install snippet originally added by FN-6438. + +Record the exact commands actually run. Do not summarize a command as successful unless its transcript shows exit code 0 or equivalent success output. + +## Evidence to capture + +Store evidence in a task document named `proof-point-report`. Evidence must **not** live only in task-local scratch files. + +The report should start with a top-level verdict line: + +```text +VERDICT: MET +``` + +or: + +```text +VERDICT: NOT MET — +``` + +Capture at least: + +1. Released `@runfusion/fusion` version. +2. `dist.integrity` from `npm view @runfusion/fusion dist.integrity`. +3. The concrete release/download URL for the tested version. +4. Evidence that `.changeset/fn-5844-external-plugin-authoring.md` has been consumed into the release, or a release-gate failure if it has not. +5. Full command transcript for scaffold, install, build, test, load/install, enable, and list. +6. `fn plugin list` output proving the plugin is present and enabled. +7. Any failure signature and the follow-up task IDs filed for it. + +A minimal report shape: + +````markdown +VERDICT: MET + +## Released package +- Package: @runfusion/fusion +- Version: +- dist.integrity: +- Release URL: https://registry.npmjs.org/@runfusion/fusion/-/fusion-.tgz + +## Commands +```bash + +``` + +## Evidence +```text + +``` + +## Follow-ups +- None, or task IDs for gaps found +```` + +## Expected pass/fail signals + +### MET + +A proof point is **MET** when a standalone external plugin: + +- is created or selected without monorepo-only dependencies, +- installs dependencies from the public registry, +- builds and tests successfully, +- loads/enables through the released `fn` CLI path, and +- appears in `fn plugin list` as enabled. + +### NOT MET + +A proof point is **NOT MET** when any required public-author step fails against the released build. File focused follow-up tasks for release-gate gaps instead of patching product code inside the validation run. + +Known failure signatures to watch: + +- `TS2307: Cannot find module '@fusion/core'` — private SDK typing leakage; tracked by FN-6409. +- `ERR_MODULE_NOT_FOUND` for `@earendil-works/pi-*` — released CLI dependency packaging/resolution gap; tracked by FN-6410. +- `TS2345` with `Property 'state' is missing` — scaffold or SDK type mismatch; tracked by FN-6435. + +If a known signature reappears in a release that should contain its fix, file a new regression task that links the original task and includes the transcript. + +## External integration evidence + +This runbook installs and runs the released third-party-distributed Fusion CLI (`@runfusion/fusion`) from the public npm registry. Provenance recaptured via `npm view @runfusion/fusion version dist.integrity --json` on 2026-06-14: + +- Canonical upstream repo URL: https://github.com/Runfusion/Fusion +- Docs / homepage URL: https://www.npmjs.com/package/@runfusion/fusion; in-repo author guide `docs/plugins/external-authoring.md`; in-repo SDK guide `docs/PLUGIN_AUTHORING.md` +- Release / download URL: https://registry.npmjs.org/@runfusion/fusion/-/fusion-0.43.0.tgz +- Binary / CLI name: `fn` (provided by the published `@runfusion/fusion` package; also invokable via `npx @runfusion/fusion@latest`) +- Checksum (`dist.integrity` for 0.43.0): `sha512-kvxicT+e8ulc7FDhBVP9NsgaioZv6NDW81N8cXNS/X8M32Eo3Y33xT6JFW2DrSiFXsJmAaib/GnpQE0nYQYApQ==` + +For future proof-point runs, replace the release URL and checksum only with values returned by `npm view` for the tested version. If the checksum cannot be verified, write `upstream-pending-verification` and do not fabricate a hash. + +## Reference: concrete validated path (FN-6437) + + + +**VERDICT: NOT MET — blocked-on-release because the released `@runfusion/fusion@0.43.0` package still scaffolded a plugin missing the required `state` field, matching the FN-6435 release-gate signature.** + +Provenance: FN-6449 restored this reference from FN-6437's surviving `task_document_revisions` (`notes`, revisions 1–3; latest revision 3) plus the archived FN-6437 task row. FN-6449's `proof-point-report` / `docs` task document is the canonical restored report; this section transcribes its supported values only. + +### Released package tested + +- Package: `@runfusion/fusion@0.43.0` +- Release URL: `https://registry.npmjs.org/@runfusion/fusion/-/fusion-0.43.0.tgz` +- `dist.integrity`: `sha512-kvxicT+e8ulc7FDhBVP9NsgaioZv6NDW81N8cXNS/X8M32Eo3Y33xT6JFW2DrSiFXsJmAaib/GnpQE0nYQYApQ==` +- Changeset consumption check: `.changeset/fn-5844-external-plugin-authoring.md` present in repo: `no` + +### Environment + +- node: `v26.3.0` +- pnpm: `10.33.0` +- npm: `11.16.0` +- os: `Darwin fusionstudio-8339.local 25.1.0 Darwin Kernel Version 25.1.0: Mon Oct 20 19:30:01 PDT 2025; root:xnu-12377.41.6~2/RELEASE_ARM64_T6031 arm64` +- scratch workspace: `/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/tmp.zLiu2nRpx8` +- scratch workspace under repo tree: `no` + +### Commands and outcomes + +```bash +npm view @runfusion/fusion version +npm view @runfusion/fusion dist.integrity +npx @runfusion/fusion@latest --help +npx @runfusion/fusion@latest plugin --help +npx @runfusion/fusion@latest plugin new proof-point-plugin +cd proof-point-plugin +pnpm install +pnpm build +# pnpm test was not attempted after the blocking compile failure. +``` + +- `npm view @runfusion/fusion version` returned `0.43.0`. +- `npm view @runfusion/fusion dist.integrity` returned `sha512-kvxicT+e8ulc7FDhBVP9NsgaioZv6NDW81N8cXNS/X8M32Eo3Y33xT6JFW2DrSiFXsJmAaib/GnpQE0nYQYApQ==`. +- `npx @runfusion/fusion@latest --help` and `npx @runfusion/fusion@latest plugin --help` passed and showed the expected plugin subcommands, including `list`, `install`, `enable`, `new`, and `dev`. +- `npx @runfusion/fusion@latest plugin new proof-point-plugin` generated `fusion-plugin-proof-point-plugin@0.1.0`. +- `pnpm install` passed. +- `pnpm build` failed with the FN-6435 release-gate signature below. +- `pnpm test` was not attempted after the released scaffold failed to compile. +- Install/enable/load-run and `fn plugin list` were not attempted because the plugin never built. + +### Scaffold evidence + +The generated `package.json` used the published package and did not show monorepo-only dependency leakage: + +```json +{ + "name": "fusion-plugin-proof-point-plugin", + "version": "0.1.0", + "type": "module", + "description": "A standalone Fusion plugin", + "keywords": [ + "fusion-plugin" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "manifest.json" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + }, + "devDependencies": { + "@runfusion/fusion": "^0.43.0", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "vitest": "^4.1.0" + } +} +``` + +The generated `src/index.ts` imported the public SDK path but omitted the required `state` field: + +```ts +import { definePlugin } from "@runfusion/fusion/plugin-sdk"; + +export default definePlugin({ + manifest: { + id: "proof-point-plugin", + name: "Proof Point Plugin", + version: "0.1.0", + description: "A standalone Fusion plugin", + }, + hooks: { + onLoad: async (ctx) => { + ctx.logger.info("Proof Point Plugin plugin loaded"); + }, + }, +}); +``` + +Dependency checks recorded by FN-6437: + +- `@fusion/*` imports in scaffolded source: `none observed` +- `workspace:*` dependency ranges in scaffolded `package.json`: `none observed` +- SDK import surface: `@runfusion/fusion/plugin-sdk` as expected + +### Blocking failure transcript + +```text +> fusion-plugin-proof-point-plugin@0.1.0 build /private/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/tmp.zLiu2nRpx8/proof-point-plugin +> tsc + +src/index.ts(3,29): error TS2345: Argument of type '{ manifest: { id: string; name: string; version: string; description: string; }; hooks: { onLoad: (ctx: PluginContext) => Promise; }; }' is not assignable to parameter of type 'FusionPlugin'. + Property 'state' is missing in type '{ manifest: { id: string; name: string; version: string; description: string; }; hooks: { onLoad: (ctx: PluginContext) => Promise; }; }' but required in type 'FusionPlugin'. + ELIFECYCLE  Command failed with exit code 2. +``` + +### Gaps and follow-up + +- Release-gate blocker: FN-6435 (the scaffold `state` fix had not reached released `@runfusion/fusion@0.43.0`). +- `fn plugin list` enabled-state proof: **not produced — VERDICT NOT MET (blocked at `pnpm build` by the unreleased FN-6435 scaffold-`state` fix)**. +- FN-6409 and FN-6410 remained known checks for released SDK typing and CLI dependency resolution, but FN-6437 did not reach those later surfaces after the FN-6435 compile failure. diff --git a/docs/screenshots/agents-view.png b/docs/screenshots/agents-view.png new file mode 100644 index 0000000000..9595b20952 Binary files /dev/null and b/docs/screenshots/agents-view.png differ diff --git a/docs/screenshots/chat-view.png b/docs/screenshots/chat-view.png new file mode 100644 index 0000000000..bed0177d1f Binary files /dev/null and b/docs/screenshots/chat-view.png differ diff --git a/docs/screenshots/dashboard-overview.png b/docs/screenshots/dashboard-overview.png new file mode 100644 index 0000000000..6dc6c65af8 Binary files /dev/null and b/docs/screenshots/dashboard-overview.png differ diff --git a/docs/screenshots/documents-view.png b/docs/screenshots/documents-view.png new file mode 100644 index 0000000000..eb80c36517 Binary files /dev/null and b/docs/screenshots/documents-view.png differ diff --git a/docs/screenshots/git-manager.png b/docs/screenshots/git-manager.png new file mode 100644 index 0000000000..4747ced14c Binary files /dev/null and b/docs/screenshots/git-manager.png differ diff --git a/docs/screenshots/list-view.png b/docs/screenshots/list-view.png new file mode 100644 index 0000000000..dd86a398d3 Binary files /dev/null and b/docs/screenshots/list-view.png differ diff --git a/docs/screenshots/mailbox-view.png b/docs/screenshots/mailbox-view.png new file mode 100644 index 0000000000..431ab34124 Binary files /dev/null and b/docs/screenshots/mailbox-view.png differ diff --git a/docs/screenshots/memory-view.png b/docs/screenshots/memory-view.png new file mode 100644 index 0000000000..055208e1b0 Binary files /dev/null and b/docs/screenshots/memory-view.png differ diff --git a/docs/screenshots/mission-manager.png b/docs/screenshots/mission-manager.png new file mode 100644 index 0000000000..a636e6db50 Binary files /dev/null and b/docs/screenshots/mission-manager.png differ diff --git a/docs/screenshots/nodes-view.png b/docs/screenshots/nodes-view.png new file mode 100644 index 0000000000..b694816faa Binary files /dev/null and b/docs/screenshots/nodes-view.png differ diff --git a/docs/screenshots/roadmaps-view.png b/docs/screenshots/roadmaps-view.png new file mode 100644 index 0000000000..bee73bc20a Binary files /dev/null and b/docs/screenshots/roadmaps-view.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png new file mode 100644 index 0000000000..138f5cdf3a Binary files /dev/null and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/skills-view.png b/docs/screenshots/skills-view.png new file mode 100644 index 0000000000..9a0f3a4c38 Binary files /dev/null and b/docs/screenshots/skills-view.png differ diff --git a/docs/screenshots/task-detail.png b/docs/screenshots/task-detail.png new file mode 100644 index 0000000000..c487d2bec4 Binary files /dev/null and b/docs/screenshots/task-detail.png differ diff --git a/docs/screenshots/terminal.png b/docs/screenshots/terminal.png new file mode 100644 index 0000000000..3eaf5c6cbb Binary files /dev/null and b/docs/screenshots/terminal.png differ diff --git a/docs/screenshots/workflow-steps.png b/docs/screenshots/workflow-steps.png new file mode 100644 index 0000000000..2b5f95b056 Binary files /dev/null and b/docs/screenshots/workflow-steps.png differ diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 1bf5bc522c..13727f31bc 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -46,7 +46,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `ntfyTopic` | `string` | `undefined` | ntfy topic name. | | `ntfyBaseUrl` | `string` | `undefined` | Optional custom ntfy server base URL (must use `http://` or `https://`). If blank/unset, Fusion uses `https://ntfy.sh` for both runtime and test notifications. | | `ntfyAccessToken` | `string` | `undefined` | Optional ntfy access token. When set, Fusion sends `Authorization: Bearer ` with ntfy publish requests, including Settings → Notifications test sends. Leave blank/unset to publish without authentication. | -| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget" \| "workflow-notify")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and needs re-authentication; Fusion also throttles that notification and the matching startup expiry warning to at most once per provider every 12 hours, and the throttle persists across server restarts. `token-budget` fires when a task crosses token soft/hard caps. `workflow-notify` is emitted by workflow `notify` nodes and is opt-in/off by default; add it to `ntfyEvents` or a provider `events` list to deliver workflow-authored notifications. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. | +| `ntfyEvents` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "token-budget" \| "workflow-notify")[]` | `["in-review","merged","failed","awaiting-approval","awaiting-user-review","planning-awaiting-input","gridlock","board-stall-unrecovered","fallback-used","memory-dreams-processed","message:agent-to-user","message:agent-to-agent","message:room","oauth-token-expired","token-budget"]` | Event types that trigger ntfy notifications. `planning-awaiting-input` fires when planning mode is waiting on user input. `gridlock` fires when all schedulable todo tasks are blocked; delivery is cooldown-throttled (first alert immediately, then suppressed for 15 minutes until gridlock resolves). `board-stall-unrecovered` fires only after a board-stall auto-recovery sweep runs and a follow-up verification tick still sees zero progress. `fallback-used` fires when Fusion recovers from a retryable model failure by switching to a configured fallback model. `task-created` fires when an agent creates a new task (requires `sourceAgentId`) and is opt-in/off by default. `memory-dreams-processed` fires when manual dream processing writes a new `DREAMS.md` entry (project and/or agent); disable it via ntfy/webhook event filters if you want to opt out. `message:agent-to-user` fires when an agent sends a direct message to the user. `message:agent-to-agent` fires when an agent sends a message to another agent (including replies). `message:room` fires when an agent posts an assistant reply in a chat room. `oauth-token-expired` fires when a provider OAuth credential reaches its expiry and still needs re-authentication after any automatic refresh path has been tried; Fusion also throttles that notification and the matching startup expiry warning to at most once per provider every 12 hours, and the throttle persists across server restarts. `token-budget` fires when a task crosses token soft/hard caps. `workflow-notify` is emitted by workflow `notify` nodes and is opt-in/off by default; add it to `ntfyEvents` or a provider `events` list to deliver workflow-authored notifications. If you use a custom `ntfyEvents` list, these message events must be present (or `ntfyEvents` must be unset so defaults apply) for the corresponding notifications to send. | | `ntfyDashboardHost` | `string` | `undefined` | Dashboard host used to build deep links in notifications. | | `taskTokenBudget` | `{ soft?: number; hard?: number; perSize?: { S?: { soft?: number; hard?: number }; M?: { soft?: number; hard?: number }; L?: { soft?: number; hard?: number } } }` | `undefined` | Global fallback per-task token budget policy. Project `taskTokenBudget` overrides this. | | `webhookEnabled` | `boolean` | `false` | Enable webhook notifications for task lifecycle events. Part of the legacy flat settings; prefer `notificationProviders` for new setups. | @@ -162,7 +162,7 @@ When `id` is `"ntfy"` in `notificationProviders`, the provider `config` supports | `topic` | `string` | _required_ | ntfy topic name (1–64 chars, alphanumeric + `-_`). | | `ntfyBaseUrl` | `string` | `"https://ntfy.sh"` | Optional custom ntfy server URL. | | `ntfyAccessToken` | `string` | `undefined` | Optional access token. When set, provider sends `Authorization: Bearer ` on ntfy publishes. | -| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "workflow-notify")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired; Fusion suppresses repeat delivery for the same provider for 12 hours even across server restarts, and applies the same persisted window to the startup expiry warning log. `workflow-notify` is emitted by workflow `notify` nodes and remains opt-in/off by default because it is not included in `DEFAULT_NTFY_EVENTS`. | +| `events` | `("in-review" \| "merged" \| "failed" \| "awaiting-approval" \| "awaiting-user-review" \| "planning-awaiting-input" \| "gridlock" \| "board-stall-unrecovered" \| "fallback-used" \| "task-created" \| "memory-dreams-processed" \| "message:agent-to-user" \| "message:agent-to-agent" \| "message:room" \| "oauth-token-expired" \| "workflow-notify")[]` | `DEFAULT_NTFY_EVENTS` | Event filter list used by the provider. For `gridlock`, enabled events are still cooldown-throttled at runtime (15-minute suppression window, reset on full resolution). `board-stall-unrecovered` is emitted when board-stall verification fails after an attempted auto-recovery sweep. `task-created` is available as an opt-in event and only fires for agent-created tasks (`sourceAgentId` required). `memory-dreams-processed` is emitted when manual dream processing appends a new project/agent `DREAMS.md` entry. `message:agent-to-user`/`message:agent-to-agent` are emitted for mailbox messages and deep-link to the specific message when `dashboardHost` is configured. `message:room` is emitted for assistant replies in chat rooms and deep-links to the room when `dashboardHost` is configured. `oauth-token-expired` is emitted when a provider OAuth credential has expired and cannot be automatically refreshed; Fusion suppresses repeat delivery for the same provider for 12 hours even across server restarts, and applies the same persisted window to the startup expiry warning log. `workflow-notify` is emitted by workflow `notify` nodes and remains opt-in/off by default because it is not included in `DEFAULT_NTFY_EVENTS`. | | `dashboardHost` | `string` | `undefined` | Dashboard host for deep links in notifications. | Disable daily update checks globally: @@ -171,6 +171,8 @@ Disable daily update checks globally: fn settings set updateCheckEnabled false ``` +When the dashboard footer reports that a newer `@runfusion/fusion` version is available, **Update now** runs the same global npm install as `fn update` (`npm install -g @runfusion/fusion@latest`) and retries once with `--force` for the legacy `fn`/`fusion` binary-collision case. A successful install updates the global package on disk, but the currently running Fusion server is not hot-swapped; restart Fusion to run the newly installed version. + --- ## Workflow Settings @@ -183,14 +185,24 @@ govern that execution belong to the workflow. **Where to set them.** The common model lanes for a project's default workflow are available directly in **Settings → Project Models → Default workflow model lanes**: -Plan/Triage, Executor, and Reviewer. Those dropdown controls use the shared model -picker and are persisted by the Settings modal's primary **Save** action, which -writes workflow setting values for the active project's default workflow; they do -not restore the old project settings keys. +Plan/Triage, Executor, Reviewer, and the Planning/Reviewer fallback lanes declared +by the default workflow. Those dropdown controls use the shared model picker and +are persisted by the Settings modal's primary **Save** action, which writes +workflow setting values for the active project's default workflow; they do not +restore the old project settings keys. The global **Fallback Model** remains in +Settings → General Models, and workflow-specific fallbacks are also editable from +the workflow editor Values tab. Title summarization is separate: set it in +**Settings → Project Models → Title and Git Commit Message Summarization Model**, +with its global baseline in Settings → General/Global Models. -For step execution, review/approval policy, fallbacks, title summarization, and -custom workflow settings, open the **workflow editor** (the workflow node editor in -the dashboard) and select the **Settings** panel. On mobile, Settings is a + + +For step execution, review/approval policy, and custom workflow settings, open the +[**workflow editor**](./workflow-editor.md) (the workflow node editor in the +dashboard) and select the **Settings** panel. On mobile, Settings is a dedicated workflow editor destination beside Graph, Add, Fields, Columns, and Actions. It has two tabs: @@ -257,13 +269,13 @@ The built-in workflows also declare triage/spec policy settings that were **not* | `leanPlanning` | `false` | Workflow-native fast-mode policy: select the lean `planning-fast` prompt variant instead of the full triage spec prompt. | | `autoApproveSpec` | `false` | Workflow-native fast-mode policy: auto-approve generated specs and skip the independent spec reviewer. | -In the dashboard Settings modal, Project Models now exposes Plan/Triage, Executor, -and Reviewer dropdown controls for the default workflow. The modal's primary -**Save** action persists pending default-workflow model lane overrides; there is no -separate workflow-model save button. The workflow editor's Settings → Values tab -uses the same dropdown picker for declared provider/model pairs, including -fallbacks. Former locations for advanced workflow policy still show a short -redirect stub linking to the workflow editor (for one release). +In the dashboard Settings modal, Project Models exposes Plan/Triage, Executor, +Reviewer, and declared fallback dropdown controls for the default workflow. The +modal's primary **Save** action persists pending default-workflow model lane +overrides; there is no separate workflow-model save button. The workflow editor's +Settings → Values tab uses the same dropdown picker for declared provider/model +pairs, including fallbacks. Former locations for advanced workflow policy still +show a short redirect stub linking to the workflow editor (for one release). > Note: the global baseline model lanes (`executionGlobalProvider` etc.) and > integrity guarantees stay where they are — only the per-workflow process policy @@ -277,9 +289,10 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` > 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 **Settings → Project Models** for default-workflow Plan/Triage, Executor, and -> Reviewer lanes, or in **workflow editor → Settings → Values** for advanced -> workflow policy. They are not writable through `PUT /api/settings`. +> in **Settings → Project Models** for default-workflow Plan/Triage, Executor, +> Reviewer, and declared fallback lanes, or in **workflow editor → Settings → +> Values** for advanced workflow policy. They are not writable through +> `PUT /api/settings`. | Setting | Type | Default | Description | |---|---|---:|---| @@ -295,7 +308,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` | `heartbeatScopeDiscipline` | `"strict" \| "lite" \| "off"` | `"strict"` | Heartbeat prompt procedure mode. `strict` keeps coordination-heavy scope discipline, `lite` restores pre-2026-05-11 wording, and `off` uses a minimal procedure. Per-agent `runtimeConfig.heartbeatScopeDiscipline` can override this default. | | `heartbeatPromptTemplate` | `"default" \| "compact"` | `"default"` | Heartbeat execution-prompt trim template default. Per-agent `runtimeConfig.heartbeatPromptTemplate` overrides this value. Role fallback when unset everywhere is `executor`→`default`, non-executor coordination roles→`compact`. | | `autoClaimCandidatesInPrompt` | `number` | `5` | Default no-task heartbeat candidate list length. Integer range `0-10`; `0` suppresses candidate prompt injection. | -| `engineerBacklogAutoClaim` | `boolean` | `false` | Opt engineer-role agents into no-task backlog auto-claim for implementation tasks. The default remains executor-only; per-agent `runtimeConfig.engineerBacklogAutoClaim` overrides this project default, and explicit routing/delegation is unchanged. Configure the project default in **Settings → Scheduling & Capacity → Let engineer agents auto-claim backlog tasks**; configure the per-agent override in **Agents → Agent Detail → Settings → Heartbeat Settings → Engineer Backlog Auto-Claim**. | +| `engineerBacklogAutoClaim` | `boolean` | `false` | Opt engineer-role agents into no-task backlog auto-claim for implementation tasks. The default remains executor-only; per-agent `runtimeConfig.engineerBacklogAutoClaim` overrides this project default, and explicit routing/delegation is unchanged. Configure the project default in **Settings → Scheduling & Capacity → "Let engineer agents auto-claim backlog tasks"**; configure the per-agent override in **Agents → Agent Detail → Settings → Heartbeat Settings → "Engineer Backlog Auto-Claim"**. | | `defaultNodeId` | `string` | `undefined` | Optional project default execution node for task dispatch. When set, tasks without a per-task `nodeId` override resolve to this node (`routing source: project-default`). See [Task Management → Node Routing](./task-management.md#node-routing). | | `unavailableNodePolicy` | `"block" \| "fallback-local"` | `"block"` | Project routing policy used during scheduler dispatch when a task resolves to a remote node and node health is known. `"block"` keeps the task in `todo` if the node is unhealthy; `"fallback-local"` reroutes dispatch to local execution. See [Architecture → Task Routing Architecture](./architecture.md#task-routing-architecture). | | `secretsAccessPolicy` | `"auto" \| "prompt" \| "deny"` | `undefined` | Project-level default secret access policy (overrides global default when present). | @@ -306,6 +319,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` | `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. | | `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. | | `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | +| `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. | | `mergeRequestContractShadowEnabled` | `boolean` | `false` | Phase-1 FN-5741 write-only shadow flag (project/global setting). When enabled, executor/self-healing/merger persist merge-request records and `completion_handoff_accepted` markers for observation only; legacy mergeQueue + lifecycle remains authoritative. | | `mergeStrategy` | `"direct" \| "pull-request"` | `"direct"` | Completion mode (local direct merge vs PR-first). | | `directMergeCommitStrategy` | `"auto" \| "always-squash" \| "always-rebase"` | `"always-squash"` | Direct-merge commit routing mode. `always-squash` (default) forces the legacy squash path. `auto` keeps the legacy squash path for branches with zero or one substantive commit, but switches multi-substantive direct merges to a history-preserving rebase-and-merge/cherry-pick path so commit boundaries, subjects, and `Fusion-Task-Id` trailers survive on `main`. `always-rebase` always preserves per-commit history. Only applies when `mergeStrategy="direct"`. | @@ -371,7 +385,7 @@ Sandbox backend precedence is: | `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. | | `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. | -| `worktreeInitCommand` | `string` | `undefined` | Shell command run after worktree creation and again to bootstrap the merge worktree before AI merge verification. Useful for project-specific setup beyond package install (for example `pnpm install --frozen-lockfile`, `cp .env.local .env`, or codegen/bootstrap scripts). | +| `worktreeInitCommand` | `string` | `undefined` | Shell command run after task worktree creation and in temporary merge worktrees before merge/review verification. In standalone AI merge, this runs inside each fresh `fusion-ai-merge-*` clean-room worktree after `git worktree add`; when unset, Fusion infers a package-manager install from the lockfile and may skip only when the install marker matches. Useful for project-specific setup beyond package install (for example `pnpm install --frozen-lockfile`, `cp .env.local .env`, or codegen/bootstrap scripts). | | `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | | `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. | @@ -421,6 +435,7 @@ Default notes: | `buildRetryCount` | `number` | `0` | Build retry attempts during merge. | | `verificationFixRetries` | `number` | `3` | In-merge auto-fix retry attempts after deterministic test/build verification failures (0-3). | | `buildTimeoutMs` | `number` | `300000` | Build timeout in milliseconds (5 minutes). | +| `verificationCommandTimeoutMs` | `number` | `undefined` | Optional project-scoped default timeout in milliseconds for executor `fn_run_verification` and configured deterministic test/build verification commands. When unset, `fn_run_verification` keeps its scope defaults (300s package, 900s workspace); when set to a positive value, it overrides both scope defaults while all verification still respects the 1800s hard cap. Set `0` or leave unset to use the legacy scope defaults. Marathon command shapes (`pnpm test`, `pnpm test:full`, `pnpm verify:workspace`, whole-package tests without file filters, and repeat loops) are soft-capped unless the agent explicitly passes `allowFullSuite: true`; opt-in full-suite runs still emit progress heartbeats and obey the hard cap. Project settings override global/default settings via the normal project settings precedence. | | `requirePlanApproval` | `boolean` | `false` | Require manual approval before planning → todo. | | `ephemeralAgentsEnabled` | `boolean` | `true` | Defaults to `true` for both new projects (seeded into `.fusion/fusion.db` on init) and upgrades from pre-FN-4153 projects (falls back to `true` whenever the persisted `config.settings` row omits the key). Users who explicitly set `false` keep that choice. When enabled, Fusion spawns short-lived `executor-FN-XXXX` workers for task execution. When disabled, only permanent executor agents run tasks; the scheduler auto-assigns dispatchable tasks using reporting-chain-aware load balancing, and tasks stay queued until an eligible permanent executor is available. | | `agentProvisioning` | `{ approvalMode?: "always" \| "trusted-only" \| "never"; trustedRoles?: string[]; trustedAgentIds?: string[]; alwaysApproveDelete?: boolean }` | `{}` | Approval policy for `fn_agent_create`/`fn_agent_delete` (`approvalMode` default `trusted-only`, delete approvals default on via `alwaysApproveDelete: true`). | @@ -617,6 +632,12 @@ Recovery entrypoints in the dashboard: - **Settings → Research (project)**: re-enable project research or source toggles when runs are blocked by project settings. - **Settings → Experimental Features**: enable `researchView` when Research surfaces or `fn_research_*` tools report feature-disabled. +### OAuth credential refresh + +Fusion automatically refreshes Claude/Anthropic OAuth credentials before reporting auth status when the stored OAuth credential includes a refresh token and the access token is expired or within the refresh buffer. A successful refresh updates auth storage and prevents `oauth-token-expired` notifications or startup warnings for that provider, so users usually do not need manual re-login after the initial Claude OAuth login. + +Manual re-login is still required when no refresh token is stored, the refresh request fails, or the expired OAuth credential belongs to a non-Anthropic provider. In those cases the credential remains expired, `oauth-token-expired` notifications/startup warnings may fire subject to their 12-hour provider throttle, and users should re-authenticate from **Settings → Authentication** or Model Onboarding. + ### Authentication troubleshooting (mobile OAuth fallback) #### `/api/auth/login` response shape for device-code providers @@ -797,7 +818,9 @@ Short-lived token bounds are enforced server-side: ## Model Selection Hierarchy -Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. +Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models. + +Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` environment variable and includes `zai/glm-5.2` as a selectable model in the same dropdowns and workflow lane controls as the other built-in GLM models. If a pi extension also registers the `zai` provider, Fusion preserves the extension's models and re-adds any missing built-in Z.ai models so built-in GLM choices remain available. ### Planning model diff --git a/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md b/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md index 4a12845a97..55d167b2b1 100644 --- a/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md +++ b/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md @@ -42,7 +42,7 @@ related_components: - **Per-category permission gating, never per-preset.** The shipped default policy preset is `unrestricted` (every category → allow). A preset-level shortcut auto-approves everything the moment the runtime is selected. Classify each call's kind into a category and read `permissionPolicy.rules[category]`; add an explicit acknowledgement setting before honoring blanket allows on sensitive categories. - Select `allow_once` only — never `allow_always`/`reject_always` (a persisted grant inside untrusted code loses per-call interception). Unmappable/missing kinds, missing gate/policy, and HITL-without-a-readable-decision all default-deny. Require **both** `pauseForApproval` AND `findApprovalByDedupeKey` before creating an approval request — otherwise a human approval is silently discarded and a pending record is orphaned. - **Filesystem jail = realpath, not string checks.** `project-root-guard.ts` is a suffix check, not a jail. Use realpath-within-realpath(cwd), `lstat` the final component for new files, `O_NOFOLLOW` open, and **truncate only after post-open re-validation** (passing `O_TRUNC` into open() truncates an escaped target before validation — write-path TOCTOU). Deny-list secrets and `.git/**` by basename regardless of cwd membership. Stat-gate reads (a full `readFile` before a byte ceiling is an OOM vector). -- **Bound everything the agent emits**, including the channels that don't look like output: per-turn + per-chunk caps on text/thinking, ANSI/control stripping, bounded identifier lengths and correlation maps, and **plan/structured events** (entry size was bounded but entry *count* wasn't — 1,000 × 64KB entries bypassed the per-turn budget). Redact stderr across chunk boundaries, not per-chunk (secrets split across `data` events evade per-chunk regexes). Build the subprocess env from an allow-list, never inherited `process.env`. +- **Bound everything the agent emits**, including the channels that don't look like output: per-turn + per-chunk caps on text/thinking, ANSI/control stripping, bounded identifier lengths and correlation maps, and **plan/structured events** (entry size was bounded but entry *count* wasn't — 1,000 × 64KB entries bypassed the per-turn budget). Redact stderr across chunk boundaries, not per-chunk (secrets split across `data` events evade per-chunk regexes). Build the subprocess env from an allow-list, never inherited `process.env` — but make the list **complete**: a thin `{HOME,PATH}` starves agent CLIs of the vars they use to find auth (`XDG_CONFIG_HOME`/`XDG_CACHE_HOME`/`USER`/`SHELL`/`LANG`), and even a correct env can't beat macOS login-Keychain session isolation for detached daemons. See `integration-issues/acp-bridge-not-logged-in-thin-env-keychain-isolation.md`. **4. Per-turn bridge state must actually reset per turn.** Anything accumulated per "turn" (output budgets, cap-flag latches, tool-call correlation maps) needs an explicit `reset()` invoked at the top of each prompt — a latch that never resets silently suppresses all output for the rest of the session after one flood. Write a two-turns-through-the-same-handler test; single-turn tests cannot catch it. diff --git a/docs/solutions/architecture-patterns/release-triage-requires-user-authorization.md b/docs/solutions/architecture-patterns/release-triage-requires-user-authorization.md new file mode 100644 index 0000000000..3d506b7eb3 --- /dev/null +++ b/docs/solutions/architecture-patterns/release-triage-requires-user-authorization.md @@ -0,0 +1,33 @@ +--- +category: architecture +module: engine +tags: + - triage + - release-safety + - authorization +problem_type: security +applies_when: + - triage finalizes tasks that mention package release or publish commands + - agents or automation can create follow-up tasks +--- + +# Release-class triage requires explicit user authorization + +## Problem + +Autonomous agents can draft tasks that mention release mechanics such as `pnpm release --yes`, `scripts/release.mjs`, changeset publish, npm publish, semver tags, or release-version commits. Without a triage boundary, an agent-authored release task can be dispatched to execution and reach publish-class commands without a user intentionally authorizing the release. + +## Solution + +Release authorization is enforced as a pure triage gate before finalize dispatch moves work to `todo`: + +1. Classify release-class tasks from the combined title, description, and prompt text. +2. For release-class tasks, require a user-authored source (`dashboard_ui`, `quick_chat`, `chat_session`, or `cli`). +3. Require the prompt marker `**Release Authorized By User:** yes` for those user-authored sources. +4. Fail closed for unknown, internal, API, imported, duplicated, refined, workflow, recovery, research, cron, and agent-authored sources. + +The marker alone is intentionally insufficient. A non-user source that embeds the marker remains blocked because agents and integrations can write prompt text. + +## Verification + +Use the pure classifier tests in `packages/engine/src/__tests__/triage-release-authorization.test.ts` to cover the invariant without store, network, or timer dependencies. The test matrix should include the FN-6469 incident shape, all documented release signal patterns, all user-authored sources, representative non-user sources, marker parsing, and non-release pass-through behavior. diff --git a/docs/solutions/integration-issues/acp-bridge-not-logged-in-thin-env-keychain-isolation.md b/docs/solutions/integration-issues/acp-bridge-not-logged-in-thin-env-keychain-isolation.md new file mode 100644 index 0000000000..0f83b602fc --- /dev/null +++ b/docs/solutions/integration-issues/acp-bridge-not-logged-in-thin-env-keychain-isolation.md @@ -0,0 +1,90 @@ +--- +title: "ACP bridge returns 'Not logged in' despite a working claude -p: thin spawn env + Keychain session isolation" +date: 2026-06-15 +category: integration-issues +module: pi-claude-cli +problem_type: integration_issue +component: tooling +symptoms: + - "ACP-bridged turns return the literal assistant text 'Not logged in · Please run /login' instead of real answers" + - "claude -p \"say hi\" works in the same shell while the bridge fails" + - "A verification harness forwarding only HOME and PATH fails even inside an authenticated terminal" + - "Reproducible under detached/headless runners (launchd daemon, autonomous task runner) but not interactively" +root_cause: incomplete_setup +resolution_type: code_fix +severity: high +tags: [acp, claude-code, keychain, spawn-env, authentication, macos, pi-claude-cli] +related_components: [authentication, tooling] +--- + +# ACP bridge returns 'Not logged in' despite a working claude -p: thin spawn env + Keychain session isolation + +## Problem + +The `claude-code-cli-acp` ACP bridge — driven by Fusion's `pi-claude-cli` provider to replace `claude -p` — returned the assistant text **"Not logged in · Please run /login"** instead of real answers, even though `claude -p "say hi"` succeeded in the same shell. The cause was environmental, not an upstream bridge limitation: a thin spawn env starved `claude` of the variables it needs to locate its auth, and macOS Keychain session isolation blocked headless processes from reading the login Keychain at all. + +## Symptoms + +- ACP-bridged turns return the literal text `Not logged in · Please run /login` (no tool calls, no real content), while `claude -p "say hi"` works in the same interactive shell. +- A verification harness that forwarded only `{HOME, PATH}` to the bridge failed **even inside an authenticated terminal**, falsely implying the auth itself was broken. +- The failure is reproducible in detached/headless contexts (launchd daemon, autonomous task-runner subprocess) but not in interactive ones — "works when I run it, fails when the daemon runs it." +- `~/.claude/.credentials.json` exists but is an empty **directory**, making file-based credential debugging a dead end. + +## What Didn't Work + +- **Six autonomous headless task attempts** (FN-6466/6467/6473/6476) re-ran the bridge spike, each hit "Not logged in," concluded **NOT-GO**, and even filed upstream issue `moabualruz/claude-code-cli-acp#2` — misattributing an environmental problem to an upstream bridge gap. +- **A `{HOME, PATH}`-only verification harness** kept failing in an authenticated terminal. Because it failed where auth was known-good, it masked that the *env*, not the *auth state*, was wrong — and reinforced the wrong conclusion across every retry. +- **Re-running `claude` / `claude --print` to "re-auth"** — print mode is non-interactive and cannot perform interactive OAuth login, so this could never repair the session. + +## Solution + +Two changes, one per root cause. + +**1. Forward the full env allow-list when spawning the bridge.** Build the bridge subprocess env from an explicit allow-list (never inherited `process.env`, never API keys), and make that list *complete* — not just `{HOME, PATH}`. + +`packages/pi-claude-cli/src/acp-driver.ts`: + +```ts +const BRIDGE_ENV_ALLOWLIST = [ + "HOME", "PATH", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", + "TERM", "TERMINFO", "TMPDIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "COLORTERM", +]; + +function buildBridgeEnv(supplied?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const source = supplied ?? process.env; + const env: NodeJS.ProcessEnv = {}; + for (const key of BRIDGE_ENV_ALLOWLIST) { + const v = source[key]; + if (typeof v === "string") env[key] = v; + } + return env; +} +// spawn(options.bridgePath, [], { ..., env: buildBridgeEnv(options.bridgeEnv) }) +``` + +The critical additions over a naive `{HOME, PATH}` env are **`XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `USER`, `SHELL`, `LANG`**. With the full list, auth succeeds immediately. + +> The allow-list itself never carries API keys. The one exception is an **explicit operator opt-in**, `FUSION_CLAUDE_ACP_FORWARD_AUTH=1`, which forwards a single Claude auth token (`CLAUDE_CODE_OAUTH_TOKEN` > `ANTHROPIC_AUTH_TOKEN` > `ANTHROPIC_API_KEY`) for headless daemons that can't reach the login Keychain (gate R17). It is **OFF by default**, so the no-secrets posture above is the standing default — the opt-in only widens exposure when the operator deliberately enables it. + +**2. The Keychain finding (gate R17).** Claude Code stores its OAuth credentials in the macOS **login Keychain** as a generic-password item (service `"Claude Code-credentials"`), *not* a file (`~/.claude/.credentials.json` is an empty directory). A detached/headless process runs in a **different security session** and cannot read the login Keychain, so it fails regardless of env; a login-session process (interactive terminal, or an `fn` daemon launched from a login shell) can. This is codified as gate **R17**: the provider's runtime must have login-Keychain access. The driver also detects a not-logged-in turn and writes a best-effort cross-process signal (`fusion-acp-bridge-auth.json`) that `GET /providers/claude-cli/status` reads, so the dashboard can raise an auth-failure banner with a "Use `claude -p`" fallback. + +## Why This Works + +Two independent environmental causes were compounding, which is why the failure looked like a flaky upstream bug: + +1. **Thin spawn env (the silent one).** `claude` resolves config/auth through more than `{HOME, PATH}` — it reads `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` for config locations and relies on `USER`/`SHELL`/`LANG` for session and locale context. Spawned with only `{HOME, PATH}` it can't locate its auth context and reports "Not logged in." The `{HOME,PATH}`-only harness reproduced this *even in an authenticated terminal*, which is exactly why it misdirected six investigations: it "proved" the bridge couldn't auth using a starved env. + +2. **macOS Keychain session isolation.** Even with a perfect env, the login Keychain is bound to the login security session. Interactive terminals (and daemons started from a login shell) share that session and can read the `"Claude Code-credentials"` item; detached launchd daemons and autonomous subprocesses run in a separate session and cannot. Same machine, same credentials, different security session — the precise reason `claude -p` worked interactively while the headless tasks failed. + +## Prevention + +- **When spawning an agent CLI as a subprocess, forward the full env allow-list, not a thin `{HOME, PATH}`.** Agent CLIs resolve auth/config through `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `USER`, `SHELL`, and locale vars. Keep the allow-list explicit (no inherited `process.env`, no API keys) but make it *complete*. +- **Never trust a verification harness that uses a thinner env than the real spawn path.** A harness that forwards fewer vars than production manufactures failures and masks the real cause. Match the production allow-list exactly, or the harness lies. +- **Treat "works interactively but fails headless" as a session/Keychain problem first.** On macOS, OAuth/login credentials live in the session-bound login Keychain. A detached daemon or autonomous task-runner is in a different security session and cannot read them — no amount of env or file fiddling fixes that. Ask "is this process in the login session?" before assuming the tool is broken. +- **Headless daemons need an explicit credential-delivery story.** Don't assume a daemon inherits interactive credentials. Either launch it from a login shell/session or provide credentials through a session-independent channel, and encode it as a runtime gate (here, R17) so it's checked rather than rediscovered. +- **Don't let autonomous/headless task-runners conclude "impossible" or file upstream issues from a single un-isolated failure.** Six runs reached NOT-GO and an upstream issue from one un-diagnosed environmental cause. Require an environmental-isolation step (interactive vs. headless, full vs. thin env) before declaring an integration unworkable. + +## Related Issues + +- `docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md` — the ACP runtime integration pattern. Its §3 rule "build the subprocess env from an allow-list, never inherited `process.env`" is the principle this doc operationalizes; this doc is its concrete failure mode (allow-list too thin → "Not logged in") plus the Keychain-isolation dimension that pattern doc does not cover. +- Upstream `moabualruz/claude-code-cli-acp#2` — filed during the failed investigation; the issue is environmental (this doc), not an upstream bridge gap. diff --git a/docs/solutions/ui-bugs/mobile-workflow-board-fill-chain.md b/docs/solutions/ui-bugs/mobile-workflow-board-fill-chain.md new file mode 100644 index 0000000000..179fa82cba --- /dev/null +++ b/docs/solutions/ui-bugs/mobile-workflow-board-fill-chain.md @@ -0,0 +1,61 @@ +--- +title: "Mobile workflow board fill chain" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/styles.css +problem_type: ui_bug +component: frontend_css +symptoms: + - "On mobile viewports, workflow-mode kanban renders as a small content-sized box in the upper-left corner" + - "The mobile footer/nav still spans the viewport while the workflow toolbar and columns do not" +root_cause: mobile_css_fill_chain_gap +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/components/Board.tsx + - packages/dashboard/app/components/Lane.css + - packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx + - packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts +tags: + - mobile-board + - workflow-mode + - css-fill-chain + - scroll-containment + - css-regression-test +applies_when: + - "A board variant is wrapped by `.project-content` and must fill the mobile viewport" + - "Later mobile `.board` rules can override base/tablet workflow fill rules" +--- + +# Mobile workflow board fill chain + +## Problem + +Workflow-mode board rendering uses `.board-workflow-view` around `main.board.board-workflow-columns`. On phones (`max-width: 768px`), the generic mobile board sizing rules can win after the workflow fill rules and leave the workflow board content-sized. The visible symptom is a small toolbar/column cluster in the upper-left while the rest of the dashboard chrome still fills the viewport. + +## Root cause + +The desktop/tablet workflow rules established a fill chain, but the mobile tier did not restate it after the generic `.board` and `.board > .column` overrides. That made the mobile path depend on inherited/earlier flex sizing through: + +```text +.project-content → .board-workflow-view → .board.board-workflow-columns → .column +``` + +When the later mobile rules changed board/column sizing without reasserting definite `flex`, `width`, `height`, `min-height: 0`, and stretch behavior for the workflow path, the workflow board could collapse to its intrinsic content size. + +## Solution + +In the mobile media query, explicitly restate the full workflow fill contract after the generic board rules: + +- `.project-content` remains a stretching flex container with `min-width: 0`, `min-height: 0`, and hidden outer overflow. +- `.board-workflow-view` fills its parent as a column flex container. +- `.board.board-workflow-columns` fills available width/height, remains the horizontal scroller, and keeps `overscroll-behavior-x: contain`, `touch-action: pan-x pan-y`, and `scroll-snap-type: x proximity`. +- Workflow columns keep a fixed mobile column basis/min-width while stretching vertically. + +Do not solve this by relaxing page-level mobile pan locks, changing board snap to `x mandatory`, or clipping the workflow board's horizontal overflow; those changes regress established mobile board navigation and overscroll behavior. + +## Regression coverage + +`packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx` should assert the mobile CSS fill chain for `.project-content`, `.board-workflow-view`, `.board.board-workflow-columns`, and workflow columns, including toolbar-present/toolbar-absent and empty/populated workflow states. + +Keep `packages/dashboard/app/__tests__/board-mobile-overscroll-containment.test.ts` green alongside it so future fill fixes cannot weaken horizontal overscroll containment or change snap strictness. diff --git a/docs/solutions/ui-bugs/quick-chat-last-opened-session-restore.md b/docs/solutions/ui-bugs/quick-chat-last-opened-session-restore.md new file mode 100644 index 0000000000..d595a2f3c6 --- /dev/null +++ b/docs/solutions/ui-bugs/quick-chat-last-opened-session-restore.md @@ -0,0 +1,60 @@ +--- +title: "Quick Chat last-opened session restore" +date: 2026-06-17 +category: ui-bugs +module: packages/dashboard/app/components/QuickChatFAB +problem_type: ui_bug +component: frontend_quick_chat +applies_when: "Quick Chat restores direct chat sessions after reloads, project switches, or a cold FAB open while session fetching is still in flight." +symptoms: + - "Opening Quick Chat restores an older or seemingly random direct thread" + - "The wrong thread often shares the same agent or model target as the intended last-opened session" + - "The persisted last-session localStorage key is overwritten before the real session list restore can run" +root_cause: automatic_same_target_resolution_raced_persisted_id_restore +resolution_type: code_fix +severity: medium +related_components: + - packages/dashboard/app/components/QuickChatFAB.tsx + - packages/dashboard/app/hooks/useQuickChat.ts + - packages/dashboard/app/hooks/quickChatLastSessionStorage.ts + - FN-3972 + - FN-4235 + - FN-4430 + - FN-6510 +tags: + - quick-chat + - session-restore + - localstorage + - same-target-collision + - regression-test +--- + +# Quick Chat last-opened session restore + +## Problem + +Quick Chat stores the last opened direct session in `fusion:quick-chat-last-session:`. A cold open can request sessions and models at the same time. If automatic target initialization (`switchSession` / `startModelChat`) runs before the session list returns, it can resolve a same-target session from the server, set it active, and trigger the hook's active-session persistence effect. That overwrites the persisted id before the restore effect can find the user's exact last-opened session. + +This failure is easy to miss when tests only use different targets. The important repro has two active sessions sharing the same agent or model target, with the persisted session not being the newest/touched one for that target. + +## Solution + +Treat the persisted id as the source of truth until the initial direct-session restore has either used it or proven it stale. + +- While a persisted last-session id exists and the initial session fetch is still loading, do not run automatic target initialization. +- When a session is restored from the list, skip the first automatic same-target switch. Restore is id-specific; same target is not equivalent. +- For stale or missing persisted ids, rank fallback sessions by `lastMessageAt` before `updatedAt` so metadata-only updates do not displace the latest real conversation. +- Keep chat rooms separate from direct-session restore; room active state should not feed the last direct-session key. + +## Regression coverage + +Use DOM tests around `QuickChatFAB` for the real symptom because the race spans component restore effects, model/agent target selection, and the `useQuickChat` persistence effect. + +Cover: + +- Agent-backed and model-backed same-target collisions. +- Delayed session fetches where auto-init would previously clobber `localStorage`. +- Valid, stale/missing, and archived persisted ids. +- Empty/single/multiple session lists. +- Fresh render, warm close/reopen, project switch, desktop FAB, and mobile FAB paths. +- Hook-level same-target replay (`selectSession` followed by `switchSession` for the same target) so the active id and persisted id remain the selected session. diff --git a/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md b/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md index ed4531f5f0..eee5d5e462 100644 --- a/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md +++ b/docs/solutions/ui-bugs/quick-chat-mobile-keyboard-board-shift.md @@ -45,6 +45,10 @@ Model fullscreen mobile overlays as explicit board-layout suppressors in `comput This keeps the board's footer/mobile-nav padding classes present for the entire time Quick Chat is open. The board therefore never shifts in response to the Quick Chat keyboard, leaving nothing to snap back after the overlay closes. +## Related viewport-smoothing pitfall + +FN-6498 found a separate Quick Chat viewport-tracking jank source inside `QuickChatFAB.tsx`: mobile `visualViewport` `resize` and `scroll` events can report the same `{ height, offsetTop }` sample during one keyboard animation tick, especially on Android Chrome with `interactive-widget=resizes-content`. The sheet should still own `--vv-height` / `--vv-offset-top`, but same-sample writes are deduped so the overlay does not add redundant style/layout invalidation while the board-shift suppression described above keeps the board underneath stable. + ## Regression coverage Cover the invariant at the pure helper seam: diff --git a/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md b/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md index e1b80a123d..a4f679863e 100644 --- a/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md +++ b/docs/solutions/ui-bugs/tablet-keyboard-viewport-mode-flip.md @@ -44,6 +44,8 @@ This preserves landscape-phone behavior while preventing keyboard-driven height ChatView also keeps a defense-in-depth CSS guard from FN-6210: `.chat-sidebar` has a non-mobile `max-width` matching `CHAT_SIDEBAR_MAX_WIDTH`, with the mobile media rule overriding it back to `100%`. That guard bounds the sidebar even if viewport-mode state is temporarily wrong and the inline sidebar width is removed. +FN-6516 refined the FN-6494 keyboard-open behavior: tablet chat sidebars remain visible at the user's current/persisted width while the software keyboard is open, rather than narrowing to the minimum width. Resize controls still stay disabled while typing, collapsed sidebars remain collapsed, and the FN-6210 `max-width` CSS guard remains the upper bound. + ## Regression coverage Cover the invariant rather than the single repro: diff --git a/docs/solutions/ui-bugs/visually-hidden-unoffset-inflates-mobile-scrollwidth.md b/docs/solutions/ui-bugs/visually-hidden-unoffset-inflates-mobile-scrollwidth.md new file mode 100644 index 0000000000..78534ba512 --- /dev/null +++ b/docs/solutions/ui-bugs/visually-hidden-unoffset-inflates-mobile-scrollwidth.md @@ -0,0 +1,108 @@ +--- +title: "Unoffset .visually-hidden inflates mobile scrollWidth and breaks kanban scroll" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/styles.css +problem_type: ui_bug +component: frontend_css +symptoms: + - "Mobile kanban board would not scroll left/right cleanly on iOS Safari" + - "Dragging near the bottom slid whole columns off-screen" + - "Table/list view rendered cut off and zoomed out, even though its own layout was clean" + - "documentElement.scrollWidth measured ~1388px on a 390px viewport" +root_cause: mobile_viewport_containment +resolution_type: css_fix +severity: high +related_components: + - packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx + - packages/dashboard/app/__tests__/mobile-horizontal-pan-containment.test.ts +tags: + - mobile + - viewport + - overflow + - visually-hidden + - sr-only + - containing-block + - ios-safari + - kanban-board +--- + +# Unoffset .visually-hidden inflates mobile scrollWidth and breaks kanban scroll + +## Problem +A `.visually-hidden` (screen-reader-only) utility positioned `absolute` with no offsets sat at its static-flow position — off-screen-right inside the horizontally-scrolled kanban columns — and, because no ancestor was its containing block, escaped the board's overflow clipping and ballooned `documentElement.scrollWidth` to ~1388px on a 390px viewport. On iOS Safari this triggered a persistent shrink-to-fit zoom-out and let the whole page pan columns off-screen. Desktop was unaffected. + +## Symptoms +- On a mobile (390px) viewport, `document.documentElement.scrollWidth` measured ~1388px while `clientWidth` stayed 390px. +- iOS Safari zoomed the page out (shrink-to-fit) despite `maximum-scale=1, user-scalable=no`. +- The zoom-out persisted after navigating to List view, making List look zoomed even though its own layout was clean (`scrollWidth` 390). +- The over-wide document allowed the entire page to pan horizontally, dragging kanban columns out of the viewport. + +## What Didn't Work +The bug initially looked like a problem with the visible kanban columns — the natural assumption being that the `.board` flex scroller or the `.column` widths were leaking past the viewport. Setting `.column { position: relative }` *did* fix the measurement (`scrollWidth` → 390), proving the columns' containing block was implicated — but it was rejected as the fix: it only patches the board, would need repeating for lane mode and any future horizontal scroller, and treats the symptom rather than the cause. + +The List view was a second red herring. It never overflowed (`scrollWidth` 390); it only *appeared* zoomed because iOS retains the board's shrink-to-fit zoom state across in-app navigation. Don't chase the List-view layout or the column widths — neither is the cause. + +## Solution +The culprit was the shared `.visually-hidden` utility in `packages/dashboard/app/styles.css` (~line 49). Pin it to the origin with `top: 0; left: 0`. + +**Before:** +```css +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +``` + +**After:** +```css +.visually-hidden { + position: absolute; + /* Pin to the containing block's origin. Without offsets an absolute box + renders at its static-flow position; inside a horizontal scroller (e.g. + the kanban board) that position is off-screen-right, and because the + scroll container isn't a positioned containing block its overflow can't + clip the span — so it balloons documentElement.scrollWidth and triggers + iOS shrink-to-fit zoom-out + whole-page panning on mobile. */ + top: 0; + left: 0; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +``` + +With the pin, the span's box stays 1px×1px (computed `top`/`left` = 0, still clipped and invisible) and `documentElement.scrollWidth` returns to 390. One global edit fixes the utility everywhere — board, lane mode, and any future horizontal scroller — with zero a11y or visual change. Shipped in commit `3cc82bdc4`. + +## Why This Works +1. **No offsets → static-flow position.** A `position: absolute` element with no `top/left/right/bottom` is painted at its *static-flow* position — where it would have sat in normal flow. So each hidden span inherited the x-position of its parent column. +2. **The columns' static positions are off-screen-right.** `.board` is a horizontal flex scroller (`overflow-x: auto`); its 6 `.column` children lay out from x≈12 to x≈1872 at a 390px viewport. Columns past the fold sit at x≈1300+, and so do the hidden spans inside them. +3. **Overflow only clips descendants in *its own* containing block.** An ancestor's `overflow` clips an absolutely-positioned descendant *only if that ancestor is the descendant's containing block* — the nearest ancestor with `position != static` (or one otherwise establishing a containing block). Both `.column` and `.board` were `position: static`, so the spans' containing block was the **initial containing block** (``), not the board. The board's `overflow-x: auto` therefore could not clip them. +4. **The document grew, not the board.** Unclipped, the spans extended `documentElement.scrollWidth` to ~1388px while ``/viewport stayed 390px. +5. **iOS shrink-to-fit.** On iOS Safari, `maximum-scale=1, user-scalable=no` is ignored, and an over-wide document triggers an automatic shrink-to-fit zoom-out. That zoom state persists across in-app navigation (hence List view looking zoomed), and the over-wide document also makes the whole page pannable. + +Pinning `top: 0; left: 0` overrides the static-flow position and parks the span at its containing block's origin, so it can no longer push the document width — while the existing `width:1px / clip / overflow:hidden` keep it visually hidden and accessible exactly as before. + +## Prevention +- **Treat offset-less `position: absolute` sr-only utilities as unsafe inside scroll containers.** Any `visually-hidden` / `sr-only` pattern that is `position: absolute` with no `top/left` floats to its static-flow position; inside a horizontal scroller that position can be off-screen and will widen the document. Pin such utilities to the origin (`top: 0; left: 0`), or guarantee every scroll container establishes a containing block. Pinning the utility is preferred — one edit, can't regress per-container. +- **Add a CSS-fixture regression assertion.** This repo guards layout invariants with static CSS-text tests (`packages/dashboard/app/test/cssFixture.ts` → `loadAllAppCss()`), not layout measurement — see `mobile-horizontal-pan-containment.test.ts` and `dashboard-overflow-containment.test.tsx`. The matching guard here is an assertion that the `.visually-hidden` rule block contains `top: 0;` and `left: 0;` (or otherwise pins its position). That style of test would have caught this regression. +- **jsdom cannot catch it by measurement.** jsdom has no layout engine, so `scrollWidth` is always 0 — a runtime-measurement test passes while the bug ships. Real-layout verification needs a browser/Playwright assertion: at a mobile viewport (e.g. 390×844), `document.documentElement.scrollWidth <= document.documentElement.clientWidth`. +- **Manual check after any horizontal-scroller change:** load at 390px and compare `documentElement.scrollWidth` vs `clientWidth`; a gap means something escaped the scroll container's clip. + +## Related Issues +- [Mobile document horizontal pan containment](./mobile-horizontal-pan-document-viewport-containment.md) — sibling fix to the same "document must stay at horizontal offset zero" invariant, via root-chrome `touch-action`/`overflow-x` (FN-6365). Its containment contract did not catch a stray absolutely-positioned descendant escaping a non-positioned scroll container — which this doc explains. +- [Mobile board iOS horizontal overscroll containment](./mobile-board-ios-horizontal-overscroll-containment.md) — same component + iOS Safari, different mechanism (`overscroll-behavior-x: contain` rubber-band, FN-6378). +- [Mobile auto-merge toggle document scroll blank](./mobile-auto-merge-toggle-document-scroll-blank.md) — same failure class (unintended mobile document horizontal scroll), different trigger (FN-6243). +- Commit `3cc82bdc4` — `fix: lock mobile board to viewport by pinning .visually-hidden` (changeset `.changeset/mobile-board-sr-only-overflow.md`). diff --git a/docs/solutions/ui-bugs/xterm-async-font-remeasure-paste-dedupe.md b/docs/solutions/ui-bugs/xterm-async-font-remeasure-paste-dedupe.md new file mode 100644 index 0000000000..eee69957c3 --- /dev/null +++ b/docs/solutions/ui-bugs/xterm-async-font-remeasure-paste-dedupe.md @@ -0,0 +1,71 @@ +--- +title: "xterm async font remeasure and native paste" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/components/TerminalModal +problem_type: ui_bug +component: frontend_terminal +applies_when: "An xterm.js terminal opens before its web font finishes loading, or a custom paste shortcut competes with xterm's helper textarea paste path." +symptoms: + - "Terminal glyphs render with oversized inter-character spacing after a font-display: swap web font loads" + - "Cmd/Ctrl+V paste sends the same payload to the PTY twice" +root_cause: xterm_opened_with_fallback_font_metrics_and_duplicate_clipboard_delivery +resolution_type: code_fix +severity: high +related_components: + - packages/dashboard/app/components/TerminalModal.tsx + - packages/dashboard/app/components/TerminalModal.css + - packages/dashboard/app/components/SessionTerminal.tsx + - packages/dashboard/app/components/SessionTerminal.css + - packages/dashboard/app/utils/terminalPreferences.ts + - packages/dashboard/app/components/__tests__/TerminalModal.test.tsx + - packages/dashboard/app/components/__tests__/SessionTerminal.test.tsx + - packages/dashboard/app/__tests__/terminal-input.test.ts + - FN-6390 + - FN-6638 +tags: + - xterm + - font-loading + - font-display-swap + - clipboard + - paste + - mobile-safari +--- + +# xterm async font remeasure and native paste + +## Problem + +xterm.js measures character-cell geometry when `terminal.open()` runs. If a custom web font is declared with `font-display: swap`, a cold load can let xterm cache fallback-font metrics and then swap to the real font later. The renderer may keep the stale cell width, producing widely spaced glyphs on mobile/DOM-renderer surfaces. + +FN-6638 was the fourth recurrence of the mobile wide-cell defect (FN-6390 → FN-6424 → FN-6603 → FN-6638). The FN-6603 font-stack ordering hypothesis was ruled out: the supplied diagnostic measured `66.76px for AGENTS.md` identically for symbols-first, symbols-last, and system-mono stacks, and desktop/mobile-emulated WebKit rendered ASCII tightly while a real iOS Safari screenshot still showed `A G E N T S . m d`. Treat Playwright/desktop WebKit emulation as a blind spot for this class; it can prove CSS contracts and fallback paths but cannot be the acceptance surface. + +The recurrence path was stricter real-iOS font/text measurement behavior. A long `document.fonts.load(`${fontSize}px ${resolvedFontFamily}`)` shorthand can reject on iOS WebKit; returning from that catch prevented xterm from reapplying `fontFamily`/`fontSize`, running `fitAddon.fit()`, publishing resize, and refreshing rows. Separately, the xterm measurement subtree lacked `-webkit-text-size-adjust: 100%`, allowing iOS Safari text inflation to perturb cell metrics. + +A second pitfall is custom paste handling. If an `attachCustomKeyEventHandler` Cmd/Ctrl+V branch reads `navigator.clipboard.readText()` and forwards that text to the PTY while the browser also performs the native paste into xterm's helper textarea, the same payload reaches `terminal.onData` and is sent twice. + +## Solution + +Keep one canonical paste path and remeasure after font resolution. + +- Prefer xterm's native helper-textarea paste for Cmd/Ctrl+V; return `true` from the custom key handler so the browser/xterm path runs, and do not read/send clipboard text manually. +- Preserve custom copy behavior only for selected text, where suppressing terminal input is intentional. +- After `terminal.open()`, treat FontFaceSet loading as best-effort: try the full stack, fall back to concrete individual families only if the full shorthand rejects, await `document.fonts.ready`, and never let an iOS shorthand rejection skip the later remeasure. +- Guard async remeasure work with the expected session id and current terminal/addon refs so stale font-load promises cannot mutate a disposed or switched terminal. +- Reapply font options, run `fitAddon.fit()`, publish the resized cols/rows, and refresh visible rows once the FontFaceSet has settled. +- Pin `-webkit-text-size-adjust: 100%` / `text-size-adjust: 100%` on the xterm host subtree (`.terminal-xterm` and `.cli-session-terminal__viewport`) so iOS Safari cannot inflate DOM/canvas measurement nodes. + +`SessionTerminal` is unaffected by paste duplication because it does not install a custom paste handler; native xterm paste is its only input path. It is affected by the font/cell-measurement invariant because it constructs xterm with the same user-selectable font presets and mobile DOM/canvas renderer path, so it must share both the best-effort font-load remeasure and the text-size-adjust pin. + +## Regression coverage + +Cover the invariant across terminal surfaces and input paths: + +- Keyboard paste on macOS (`metaKey`) and non-mac (`ctrlKey`) returns `true`, does not call `clipboard.readText()`, and sends exactly one PTY input frame via xterm `onData`. +- Native helper-textarea paste without the shortcut handler sends exactly once, covering mobile/iOS context-menu paste. +- A controlled `document.fonts.load()` promise resolving after `terminal.open()` triggers a post-font-load fit, resize, and refresh. +- A controlled `document.fonts.load()` rejection (the real-iOS shorthand failure mode) still triggers font option reapply, fit/resize, and refresh for both `TerminalModal` and `SessionTerminal`. +- CSS contract tests assert both xterm host subtrees pin `text-size-adjust` to 100%. +- `SessionTerminal` asserts it uses the shared terminal font presets, does not attach a custom key handler, and sends one native xterm paste input frame. + +This avoids downstream byte de-duplication and fixes the two root causes at their renderer/input seams. diff --git a/docs/solutions/ui-bugs/xterm-symbols-nerd-font-unicode-range.md b/docs/solutions/ui-bugs/xterm-symbols-nerd-font-unicode-range.md new file mode 100644 index 0000000000..4d8a31ed21 --- /dev/null +++ b/docs/solutions/ui-bugs/xterm-symbols-nerd-font-unicode-range.md @@ -0,0 +1,76 @@ +--- +title: "xterm symbols Nerd Font unicode-range scoping" +date: 2026-06-13 +category: ui-bugs +module: packages/dashboard/app/components/TerminalModal +problem_type: ui_bug +component: frontend_terminal +applies_when: "A symbols-only Nerd Font is listed in an xterm.js fontFamily stack with font-display: swap." +symptoms: + - "Terminal glyphs render with oversized inter-character spacing after the symbols font loads" + - "Mobile DOM/canvas xterm output wraps after very few columns even for ASCII commands" + - "Powerline prompt glyphs are needed, but ASCII must measure against a real monospace text font" +root_cause: symbols_only_font_face_participated_in_ios_xterm_ascii_cell_measurement_even_when_unicode_range_scoped +resolution_type: code_fix +severity: high +related_components: + - packages/dashboard/app/components/TerminalModal.css + - packages/dashboard/app/components/TerminalModal.tsx + - packages/dashboard/app/components/SessionTerminal.tsx + - packages/dashboard/app/__tests__/terminal-input.test.ts + - FN-6390 + - FN-6424 + - FN-6603 + - FN-6638 + - FN-6659 +tags: + - xterm + - font-loading + - font-display-swap + - unicode-range + - nerd-font + - mobile-safari +--- + +# xterm symbols Nerd Font unicode-range scoping + +## Problem + +A symbols-only Nerd Font can corrupt xterm.js cell measurement when it participates in the terminal `fontFamily` stack. FN-6390 correctly added an async post-font-load remeasure, but FN-6424 found the recurrence: the browser could still measure ASCII cells against `SymbolsNerdFontMono` after `font-display: swap`, producing huge gaps such as `p n p m b u i l d` on mobile. + +FN-6603 found the third recurrence: the FN-6390 remeasure and FN-6424 `unicode-range` were both present, but the shared terminal preference stack still listed the symbols face first. Mobile WebKit/xterm canvas measurement could still use that first face for cell metrics while actual ASCII glyph rendering fell through to a later monospace font. The visible symptom was the same wide-cell layout (`A G E N T S . m d`) with intact powerline glyphs. + +FN-6638 then added a `text-size-adjust: 100%` pin plus best-effort `document.fonts` settlement and unconditional xterm option reapply/fit/refresh. That recurrence's diagnostic measured `66.76px for AGENTS.md` across symbols-first, symbols-last, and system-mono stacks and was initially read as "font-stack ordering is inert." FN-6659 corrected that reading: all three diagnostic stacks were still symbols-inclusive because every preset appended `"Fusion Terminal Nerd Font Symbols"`, and that symbols face was the only bundled/loaded terminal `@font-face`. Playwright/desktop WebKit emulation and the unfinished real-iOS acceptance gate let four blind fixes ship despite the real iOS Safari symptom remaining. + +## Solution + +Keep the symbols font available for powerline/Nerd-Font codepoints, but do not let it participate in xterm's measured `fontFamily` option: + +1. Scope its `@font-face` with `unicode-range` so printable ASCII is never resolved through that family during normal glyph fallback. +2. Keep `XTERM_FONT_FAMILY` and every terminal preset symbols-free. `TerminalModal` and `SessionTerminal` must pass only real text monospace stacks to `new Terminal(...)`, remeasure, and live-preference updates. +3. If a DOM-renderer symbols fallback is needed, attach it through a separate scoped CSS variable/rule for `.xterm-rows span` (for example `--terminal-glyph-font-family`) rather than the xterm option that drives ASCII cell measurement. Do not re-tune ordering: FN-6659 showed symbols-last was still unsafe on real iOS because the symbols face's mere presence polluted the measured shorthand. + +Use the standard Symbols Nerd Font ranges, including powerline and private-use blocks, for example: + +```css +@font-face { + font-family: "Fusion Terminal Nerd Font Symbols"; + src: url("/fonts/SymbolsNerdFontMono-Regular.ttf") format("truetype"); + font-display: swap; + unicode-range: U+23FB-23FE, U+2665, U+26A1, U+2B58, U+E000-E00A, U+E0A0-E0D7, U+E200-E2A9, U+E300-E3E3, U+E5FA-E6B7, U+E700-E8EF, U+EA60-EC1E, U+ED00-F2FF, U+F300-F533, U+F0001-F1AF0; +} +``` + +Do not replace this with fixed `letterSpacing`, hardcoded column counts, or by removing the async remeasure. xterm should still refit after web fonts load; the measured xterm font stack must stay symbols-free so symbols-only metrics cannot apply to ASCII on real iOS Safari. + +## Regression coverage + +Automated jsdom tests cannot validate font advance widths, so cover the enforceable CSS contract and then run a real-browser check. + +- Parse emitted/app CSS and assert the terminal symbols `@font-face` has a `unicode-range`. +- Assert the range contains required Nerd-Font/powerline blocks such as `U+E0A0-E0D7`, `U+E700-E8EF`, and `U+F0001-F1AF0`. +- Assert no range overlaps printable ASCII (`U+0020-007E`). +- Assert the shared default stack and every terminal font preset do **not** include `"Fusion Terminal Nerd Font Symbols"` in the xterm-measured family. +- Assert the retained symbols-rendering mechanism is separate from xterm measurement (for example CSS rules using `--terminal-glyph-font-family` on DOM row spans). +- Check every xterm consumer: `TerminalModal` and `SessionTerminal` both use `resolveTerminalFontFamily()`, so both need component-level coverage that the stack passed to `new Terminal(...)`, remeasure, and live preference updates is symbols-free. +- Verify on a real iOS Safari device/cloud path (not Playwright/desktop WebKit emulation) that ASCII output renders tightly while the powerline glyph still renders for the default `nerd-font` and `system-mono` presets on both `TerminalModal` and `SessionTerminal`. diff --git a/docs/storage.md b/docs/storage.md index 6b19d533e1..db538f09e3 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -387,9 +387,16 @@ Backups in `.fusion/backups/` now capture the project DB and (when present) the FN-5240/FN-5241/FN-5242 establish the handoff invariant: the only legal executor/self-healing path into `in-review` after execution finishes is `TaskStore.handoffToReview(...)`. That helper runs the column move, `mergeQueue` insert, and handoff audit fan-out inside one `BEGIN IMMEDIATE` transaction so observers never see `column = "in-review"` without the matching queue row. Direct `moveTask(taskId, "in-review")` writes remain allowed for explicit non-handoff/test paths but emit `task:handoff-invariant-violation` run-audit events unless the caller opts into the narrow allowlist flag. The `tasks.githubTracking` JSON column stores per-task GitHub tracking state (`enabled`, optional `repoOverride`, linked issue metadata, and `unlinkedAt`). It is additive and default-off; imported-source issue metadata remains in `issueInfo` / `sourceIssue`. Behavior wiring (issue creation/lifecycle sync and UI surfacing) lands in FN-3870/FN-3873/FN-3874. + +The `tasks.sourceIssueClosedAt` column (migration 122) backs `TaskSourceIssue.closedAt`, a nullable ISO-8601 timestamp for the originating external issue's real close time. Going forward, the GitHub source-issue reconciler fills it when it closes the linked issue itself or observes GitHub's `closed_at`/`closedAt` value. Historical GitHub-imported `done`/`archived` rows that still have `NULL` can be filled retroactively by the optional manual `POST /api/git/github/backfill-source-issue-closed-at` sweep, now exposed as **Backfill exact close times** in the Command Center GitHub area's Fixed by Fusion card. The sweep is idempotent, paginated, writes only real GitHub `closed_at` values, reports `scanned`/`filled`/`skipped`/`errors`, and never overwrites an existing timestamp or runs automatically. Command Center "Fixed by Fusion" analytics read this exact timestamp when available and fall back to `updatedAt` only when it has not been observed. + +The `tasks.tokenUsage*` columns store cumulative per-task token usage for analytics. `tokenUsageModelProvider` and `tokenUsageModelId` are analytics-only snapshots of the actually-used runtime model recorded when usage is accumulated; they let Command Center group and price resolved-via-settings usage by provider/model without writing the task-level `modelProvider` / `modelId` own-model override fields that control future model resolution. Cost attribution reads the snapshot first and falls back to the legacy own-model columns for pre-snapshot rows. + +The `task_commit_associations.additions` and `task_commit_associations.deletions` columns (migration 123) store nullable merge-time git shortstat counts for the associated commit. Command Center Productivity uses `SUM(additions + deletions)` as the Lines changed source when at least one in-range association has non-null stats. `NULL` means stats were unknown or unavailable for that association, not zero; ranges with no non-null stats keep the unavailable `—` sentinel instead of reporting `0`. | `config` | Single-row project configuration (`nextId`, settings payload, workflow step counters). | | `workflow_steps` | Workflow step definitions (`prompt`/`script`) with phase, template metadata, and model overrides. | | `activityLog` | Per-project activity/event log with timestamp/type/task indexes. | +| `task_commit_associations` | Commit-to-task-lineage associations for canonical and legacy landed-commit attribution. Includes nullable `additions`/`deletions` diff-stat columns captured at merge time for Command Center Productivity LOC; `NULL` means stats unknown, not zero. | | `archivedTasks` | Archived task snapshots (compact JSON payload + archive timestamp). | | `automations` | Scheduled automation definitions, run state, and run history. | | `agents` | Agent registry/state/task assignment metadata. | diff --git a/docs/task-management.md b/docs/task-management.md index 849f2641c4..b2b4c5270d 100644 --- a/docs/task-management.md +++ b/docs/task-management.md @@ -97,7 +97,9 @@ Near-duplicate flagging now keeps the task in its normal flow column (`todo` / a - optional `source.sourceMetadata.nearDuplicateDismissed = true` after user chooses Keep - activity event `task:near-duplicate-flagged` -Dashboard surfaces this as a yellow Duplicate chip plus modal actions: +A near-duplicate flag is only actionable while the canonical task is active. The triage backstop does not persist `nearDuplicateOf` for archived, soft-deleted, done, or missing canonicals; when a canonical later becomes inactive through archive, soft-delete, or move-to-done, the store clears `nearDuplicateOf`, `nearDuplicateScore`, `nearDuplicateSharedTokens`, and `nearDuplicateDismissed` from active referrers and records an informational log entry without pausing or failing those tasks. + +Dashboard surfaces this as a yellow Duplicate chip plus modal actions only while the canonical exists and is active: - **Archive** (user-initiated archive path) - **Keep** (dismisses the warning by setting `nearDuplicateDismissed: true`) @@ -194,6 +196,8 @@ For full Todo View behavior (enablement, list/item actions, API routes, and stor Use the 🌳 button: - Generate 2–5 candidate subtasks +- Shows live thinking/progress immediately while generation runs, before the candidate list is ready +- Send the run to the background or close the dialog without canceling it; use the background-session indicator to resume running, waiting, or completed breakdowns - Drag to reorder - Add dependencies only on earlier items - Set each subtask's **Priority** (`low`, `normal`, `high`, `urgent`) before create diff --git a/docs/test-feedback-loop-baseline.md b/docs/test-feedback-loop-baseline.md new file mode 100644 index 0000000000..ce17e52ed9 --- /dev/null +++ b/docs/test-feedback-loop-baseline.md @@ -0,0 +1,48 @@ +# Test feedback-loop baseline + +> Publish this page's latest-cycle summary in #leads each week. The objective is signal-per-second: keep the merge gate thin, keep `pnpm test` flat or faster, and ratchet flaky/low-signal tests toward rescue or deletion. + +## Latest #leads summary + +- Cycle: **2026-W25** (2026-06-18T02:11:11.998Z) +- Gate suite wall-time: **7.2s** (trend: n/a) +- `pnpm test` wall-time: **36.9s** (trend: n/a) +- Flake/quarantine count: **5** ledger entries across **4** files +- Timing snapshot source: `scripts/test-timings.json` captured at **2026-06-03T23:45:49.672Z** + +## Slowest 20 test files + +| Rank | File | Package | Duration | +|---:|---|---|---:| +| 1 | `packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts` | @fusion/engine | 13.9s | +| 2 | `packages/core/src/__tests__/agent-store.test.ts` | @fusion/core | 11.6s | +| 3 | `packages/dashboard/src/__tests__/routes-agents.test.ts` | @fusion/dashboard | 11.2s | +| 4 | `packages/core/src/__tests__/mission-store.test.ts` | @fusion/core | 10.7s | +| 5 | `packages/core/src/__tests__/db.test.ts` | @fusion/core | 10.1s | +| 6 | `packages/dashboard/src/__tests__/routes-git.test.ts` | @fusion/dashboard | 9.4s | +| 7 | `packages/engine/src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts` | @fusion/engine | 9.0s | +| 8 | `packages/engine/src/__tests__/merger-ai.test.ts` | @fusion/engine | 8.7s | +| 9 | `packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts` | @fusion/engine | 8.4s | +| 10 | `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` | @fusion/engine | 8.4s | +| 11 | `packages/core/src/__tests__/task-documents.test.ts` | @fusion/core | 8.3s | +| 12 | `packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts` | @fusion/engine | 7.8s | +| 13 | `packages/cli/src/__tests__/extension.test.ts` | @runfusion/fusion | 7.0s | +| 14 | `packages/core/src/__tests__/run-audit.test.ts` | @fusion/core | 6.9s | +| 15 | `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts` | @fusion/engine | 6.1s | +| 16 | `packages/dashboard/src/__tests__/routes-planning.test.ts` | @fusion/dashboard | 5.6s | +| 17 | `packages/core/src/__tests__/store-merge-queue.test.ts` | @fusion/core | 5.2s | +| 18 | `packages/dashboard/app/components/__tests__/FileEditor.test.tsx` | @fusion/dashboard | 5.1s | +| 19 | `packages/engine/src/__tests__/reliability-interactions/integration-worktree-state.test.ts` | @fusion/engine | 4.9s | +| 20 | `packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts` | @fusion/engine | 4.9s | + +## Trend + +| Cycle | Captured at | Gate suite | `pnpm test` | Quarantine entries | Quarantined files | +|---|---|---:|---:|---:|---:| +| 2026-W25 | 2026-06-18T02:11:11.998Z | 7.2s | 36.9s | 5 | 4 | + +## Operating rules + +- Record a new row weekly with `node scripts/test-feedback-baseline.mjs --record --gate-ms --test-ms ` after running `pnpm test:gate` and `pnpm test`. +- Use the slowest-file list as the candidate queue for FN-5048 rewrites or deletion-ratchet review; do not add coverage for its own sake. +- Quarantined tests remain on the 14-day rescue-or-delete clock in `scripts/lib/test-quarantine.json`; deleting a low-signal expired test is a valid positive outcome. diff --git a/docs/test-feedback-loop-baselines.json b/docs/test-feedback-loop-baselines.json new file mode 100644 index 0000000000..e71abe868a --- /dev/null +++ b/docs/test-feedback-loop-baselines.json @@ -0,0 +1,122 @@ +{ + "baselines": [ + { + "capturedAt": "2026-06-18T02:11:11.998Z", + "cycle": "2026-W25", + "gateWallTimeMs": 7200, + "pnpmTestWallTimeMs": 36900, + "timingSnapshotCapturedAt": "2026-06-03T23:45:49.672Z", + "slowest20": [ + { + "packageName": "@fusion/engine", + "file": "packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts", + "durationMs": 13900 + }, + { + "packageName": "@fusion/core", + "file": "packages/core/src/__tests__/agent-store.test.ts", + "durationMs": 11600 + }, + { + "packageName": "@fusion/dashboard", + "file": "packages/dashboard/src/__tests__/routes-agents.test.ts", + "durationMs": 11200 + }, + { + "packageName": "@fusion/core", + "file": "packages/core/src/__tests__/mission-store.test.ts", + "durationMs": 10700 + }, + { + "packageName": "@fusion/core", + "file": "packages/core/src/__tests__/db.test.ts", + "durationMs": 10100 + }, + { + "packageName": "@fusion/dashboard", + "file": "packages/dashboard/src/__tests__/routes-git.test.ts", + "durationMs": 9400 + }, + { + "packageName": "@fusion/engine", + "file": "packages/engine/src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts", + "durationMs": 9000 + }, + { + "packageName": "@fusion/engine", + "file": "packages/engine/src/__tests__/merger-ai.test.ts", + "durationMs": 8700 + }, + { + "packageName": "@fusion/engine", + "file": "packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts", + "durationMs": 8400 + }, + { + "packageName": "@fusion/engine", + "file": "packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts", + "durationMs": 8400 + }, + { + "packageName": "@fusion/core", + "file": "packages/core/src/__tests__/task-documents.test.ts", + "durationMs": 8300 + }, + { + "packageName": "@fusion/engine", + "file": "packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts", + "durationMs": 7800 + }, + { + "packageName": "@runfusion/fusion", + "file": "packages/cli/src/__tests__/extension.test.ts", + "durationMs": 7000 + }, + { + "packageName": "@fusion/core", + "file": "packages/core/src/__tests__/run-audit.test.ts", + "durationMs": 6900 + }, + { + "packageName": "@fusion/engine", + "file": "packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts", + "durationMs": 6100 + }, + { + "packageName": "@fusion/dashboard", + "file": "packages/dashboard/src/__tests__/routes-planning.test.ts", + "durationMs": 5600 + }, + { + "packageName": "@fusion/core", + "file": "packages/core/src/__tests__/store-merge-queue.test.ts", + "durationMs": 5200 + }, + { + "packageName": "@fusion/dashboard", + "file": "packages/dashboard/app/components/__tests__/FileEditor.test.tsx", + "durationMs": 5100 + }, + { + "packageName": "@fusion/engine", + "file": "packages/engine/src/__tests__/reliability-interactions/integration-worktree-state.test.ts", + "durationMs": 4900 + }, + { + "packageName": "@fusion/engine", + "file": "packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts", + "durationMs": 4900 + } + ], + "flakeCount": 5, + "uniqueQuarantinedFileCount": 4, + "quarantinedFiles": [ + "packages/core/src/__tests__/task-list-format.test.ts", + "packages/core/src/__tests__/test-project.test.ts", + "plugins/fusion-plugin-compound-engineering/src/__tests__/sync.test.ts", + "plugins/fusion-plugin-compound-engineering/src/__tests__/work-bridge.test.ts" + ], + "notes": "FN-6612 first-cycle publication artifact measured in this worktree: pnpm test:gate 7.2s; pnpm test 36.9s." + } + ] +} diff --git a/docs/test-velocity-baseline.md b/docs/test-velocity-baseline.md new file mode 100644 index 0000000000..7bc1070f1b --- /dev/null +++ b/docs/test-velocity-baseline.md @@ -0,0 +1,92 @@ +# Test velocity baseline + +> Weekly FN-6612 signal-per-second baseline. Measure and report feedback-loop velocity; do **not** add slow tests or wire this report into blocking PR checks. The merge gate remains the existing thin Lint, Typecheck, Build, and Gate path. + +## Latest baseline + +- Cycle: **2026-W25** +- Captured at: **2026-06-18T16:12:01.248Z** +- Timing snapshot: `scripts/test-timings.json` captured at **2026-06-03T23:45:49.672Z** +- Quarantine ledger: `scripts/lib/test-quarantine.json` + +## Metrics + +| Metric | Current | Delta vs previous | +|---|---:|---:| +| Merge gate wall-time (`pnpm test:gate`) | 5.4s | -779ms | +| Boot smoke wall-time (`pnpm smoke:boot`) | 18.1s | -123ms | +| Changed-only test wall-time (`pnpm test`) | 7.2s | -500ms | +| Quarantine / flake count | 0 | -2 | +| Deletion-due quarantines | 0 | n/a | + +## Measurement failures + +- None recorded. + +## Slowest 20 test files + +| Rank | File | Package | Duration | +|---:|---|---|---:| +| 1 | `packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts` | @fusion/engine | 13.9s | +| 2 | `packages/core/src/__tests__/agent-store.test.ts` | @fusion/core | 11.6s | +| 3 | `packages/dashboard/src/__tests__/routes-agents.test.ts` | @fusion/dashboard | 11.2s | +| 4 | `packages/core/src/__tests__/mission-store.test.ts` | @fusion/core | 10.7s | +| 5 | `packages/core/src/__tests__/db.test.ts` | @fusion/core | 10.1s | +| 6 | `packages/dashboard/src/__tests__/routes-git.test.ts` | @fusion/dashboard | 9.4s | +| 7 | `packages/engine/src/__tests__/reliability-interactions/branch-group-automerge-precedence.test.ts` | @fusion/engine | 9.0s | +| 8 | `packages/engine/src/__tests__/merger-ai.test.ts` | @fusion/engine | 8.7s | +| 9 | `packages/engine/src/__tests__/reliability-interactions/branch-group-merge-routing.test.ts` | @fusion/engine | 8.4s | +| 10 | `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion-gate.test.ts` | @fusion/engine | 8.4s | +| 11 | `packages/core/src/__tests__/task-documents.test.ts` | @fusion/core | 8.3s | +| 12 | `packages/engine/src/runtimes/__tests__/in-process-runtime.test.ts` | @fusion/engine | 7.8s | +| 13 | `packages/cli/src/__tests__/extension.test.ts` | @runfusion/fusion | 7.0s | +| 14 | `packages/core/src/__tests__/run-audit.test.ts` | @fusion/core | 6.9s | +| 15 | `packages/engine/src/__tests__/reliability-interactions/branch-group-promotion.test.ts` | @fusion/engine | 6.1s | +| 16 | `packages/dashboard/src/__tests__/routes-planning.test.ts` | @fusion/dashboard | 5.6s | +| 17 | `packages/core/src/__tests__/store-merge-queue.test.ts` | @fusion/core | 5.2s | +| 18 | `packages/dashboard/app/components/__tests__/FileEditor.test.tsx` | @fusion/dashboard | 5.1s | +| 19 | `packages/engine/src/__tests__/reliability-interactions/integration-worktree-state.test.ts` | @fusion/engine | 4.9s | +| 20 | `packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts` | @fusion/engine | 4.9s | + +## Quarantine age buckets + +| Age bucket | Count | +|---|---:| +| 0-6 days | 0 | +| 7-13 days | 0 | +| deletion due (>=14 days) | 0 | +| unknown/future | 0 | + +### Deletion-due entries + +| File | Quarantined at | Age (days) | +|---|---:|---:| +| — | — | — | + +## Before / after trend + +| Row | Captured at | Gate | Boot smoke | `pnpm test` | Quarantine count | +|---|---|---:|---:|---:|---:| +| Previous | 2026-06-18T03:04:28.794Z | 6.2s | 18.2s | 7.7s | 2 | +| Latest | 2026-06-18T16:12:01.248Z | 5.4s | 18.1s | 7.2s | 0 | +| Delta | — | -779ms | -123ms | -500ms | -2 | + +_Future weekly rows append to `scripts/test-velocity-history.json`; compare the latest row against the previous row before posting to #leads._ + +## Post to #leads + +```text +FN-6612 weekly test velocity: gate 5.4s (-779ms), boot smoke 18.1s (-123ms), pnpm test 7.2s (-500ms), quarantine ledger 0 (-2). Slowest file: packages/engine/src/__tests__/reliability-interactions/shared-branch-group-lifecycle.test.ts at 13.9s. Deletion-due quarantines: 0. +``` + +## How to refresh + +```bash +pnpm test:velocity -- --measure --write-report +``` + +Report-only regeneration is cheap and does not run any suite: + +```bash +pnpm test:velocity +``` diff --git a/docs/testing.md b/docs/testing.md index b4edb7b682..3e2718bc94 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,6 +10,18 @@ CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint 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. +## Weekly signal-per-second baseline + +Refresh and publish the test feedback-loop baseline in #leads once per weekly cycle: + +```bash +pnpm test:gate # capture wall-time in ms +pnpm test # capture wall-time in ms +node scripts/test-feedback-baseline.mjs --record --gate-ms --test-ms --print-leads +``` + +The generated `docs/test-feedback-loop-baseline.md` is the publication artifact: it reports gate wall-time, `pnpm test` wall-time, the slowest 20 test files from `scripts/test-timings.json`, and the current quarantine/flake count from `scripts/lib/test-quarantine.json`. Keep the trend flat or net-negative; use the slowest-file list to drive FN-5048 rewrites and deletion-ratchet reviews instead of adding low-signal coverage. + **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 @@ -28,10 +40,20 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N `pnpm test:full` runs each package's default test script with capped worker fanout (`FUSION_TEST_TOTAL_WORKERS=4 FUSION_TEST_CONCURRENCY=2 pnpm -r --workspace-concurrency=2 test`). Do not casually raise worker counts; dashboard/jsdom and integration-heavy packages destabilize when oversubscribed. Use `VITEST_MAX_WORKERS=` only for targeted package-level investigation. + +Custom workflow reliability release signoff has a dedicated on-demand lane: `pnpm test:workflow-release-check` runs the manifest-listed targeted seams from `scripts/lib/workflow-reliability-release-check.json`, while `--dry-run` validates the manifest and prints planned commands and `--json` emits machine-readable item/seam evidence. This lane is **not** part of the merge gate and should not be added to `test:gate` or the `engine-core` allow-list. + + +Terminal acceptance tasks that require real mobile Safari should use [`docs/ios-acceptance.md`](./ios-acceptance.md) for the `--check` run-vs-NO-OP probe, credential wiring, and physical/cloud real-iOS evidence workflow. + +Agents running verification through `fn_run_verification` are bounded by default: project `verificationCommandTimeoutMs` when set, otherwise 300s for package scope and 900s for workspace scope, with an 1800s hard cap. Marathon invocations such as root `pnpm test`, `pnpm test:full`, `pnpm verify:workspace`, whole-package tests without file filters, and shell repeat loops are soft-capped unless the agent explicitly passes `allowFullSuite: true`; the escape hatch still emits progress heartbeats and respects the hard cap. Prefer targeted commands such as `pnpm --filter @fusion/ exec vitest run src/path/to/test.ts --silent=passed-only --reporter=dot` before opting into a full run. + ## Fresh-worktree dist bootstrap `pnpm test` auto-runs `scripts/ensure-test-artifacts.mjs` to rebuild missing/stale dist artifacts. Dashboard and `dependency-graph` package lanes auto-bootstrap too. If you hit opaque `Failed to resolve import "./cli-spawn.js"` (or similar), treat it as bootstrap regression against FN-4605 — don't work around with a manual `pnpm build`. +Public `@fusion/core` exports consumed by runtime tools should include a literal built-dist guard (for example importing `packages/core/dist/index.js`) when package test aliases otherwise resolve `@fusion/core` to source. + ## Dashboard Test Lanes ```bash @@ -45,6 +67,19 @@ pnpm --filter @fusion/dashboard test:build # built client output contra Run `test:deep` when changing broad dashboard architecture, shared modal/view infrastructure, or route registration. Run `test:browser-smoke` for layout/responsive/navigation/modal/CSS changes. Run `test:build` for Vite output, lazy-loading, chunking, or client-dist changes. + +The dashboard CSS contract lane includes `app/__tests__/dashboard-css-token-validity.css.test.ts`, which scans raw component/app CSS and fails any `var(--token)` reference that is not defined by CSS, assigned by React inline style, or explicitly allowlisted as runtime-local. Run it with `component-css-no-raw-rgba`, `dashboard-component-color-tokenization`, and `text-token-canonicalization` when touching design-token usage. + + + +Command Center responsive chart fixes need evidence beyond jsdom. Keep the jsdom scroll-owner tests for rule/structure coverage, but pair them with `packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts`, which reads the co-located Command Center CSS files directly and asserts the mobile shrink/height/border rules that real layout depends on. For visible defects, also capture a real browser/device (or headless Chrome/Blink) reproduction with `scrollWidth > clientWidth`, zero/clipped `clientHeight`, or stretch measurements; do not close a Command Center mobile chart bug on jsdom-green assertions alone. The local `pnpm --filter @fusion/dashboard test:browser-smoke --require-browser` lane now includes `[data-smoke="command-center-charts"]` and gates representative Command Center recharts pie, line, and empty states at 390×844 mobile plus desktop viewports for visible SVG/container height, overflow containment, empty-state text, and chart scroll-owner violations. + +The shared mobile/tablet overflow-containment net lives at `packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx`. It covers board/kanban columns, task-detail modal shell, workflow/simple workflow editors, and Activity Log modal at mobile, tablet, and landscape-phone breakpoints. Run it directly when touching dashboard viewport containment or shared modal/workflow CSS: + +```bash +pnpm --filter @fusion/dashboard exec vitest run --project dashboard-app app/__tests__/dashboard-overflow-containment.test.tsx --silent=passed-only --reporter=dot --exclude '**/build-output.test.ts' +``` + `pnpm --filter @fusion/dashboard test` runs the curated app/API quality gate through `packages/dashboard/scripts/run-quality-tests.mjs` (FN-6308). The orchestrator keeps the historical app/API quality split and the curated/backfill lane boundaries, but @@ -95,6 +130,8 @@ every entry needs a non-empty `reason` (empty reasons are rejected). Skip-list p that is pre-existing-failing orphans (tests that were never executed in CI and fail in isolation) and `build-output.test.ts` (runs standalone via `test:build` after a Vite build). Each carries a one-line reason. +- Every skip-list `reason` for a pre-existing failing/orphaned test must reference a concrete `FN-NNNN` tracking task; if the test is rescued, remove the entry instead of leaving a tracking placeholder. +- The guard rejects any skip-list entry whose file is already executed by a quality project. Remove the entry instead; the skip-list is only for genuinely non-executed files. - To remove a file from the skip-list: fix the test, confirm it passes under its project, delete the skip-list entry. The backfill lane then executes it. - The skip-list is shared verbatim with `vitest.config.ts`, which excludes the same @@ -142,6 +179,24 @@ Flaky tests are quarantined ON SIGHT and deleted on a 2-week clock. This is writ **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). +### Vitest timeout-appeasement guard + +`scripts/check-no-test-timeout-appeasement.mjs` runs in the fast `pretest`, `pretest:full`, and `test:gate` paths. It scans tracked `packages/**/*.test.*` and `plugins/**/*.test.*` files for per-file or suite-level Vitest timeout bumps, including `vi.setConfig({ testTimeout: ... })`, `vi.setConfig({ hookTimeout: ... })`, and bare `testTimeout:` / `hookTimeout:` properties in test files. It deliberately ignores global `vitest.config.*` timeouts. + +Legitimate legacy exceptions must be recorded in `scripts/lib/test-timeout-appeasement-allowlist.json` as `{ "file": "", "reason": "", "allowlistedAt": "YYYY-MM-DD" }`. Allowlisting is temporary: the real fix is to quarantine the flaky test or narrow the slow seam, then remove both the timeout bump and the allowlist entry. + +**CLI shared-fixture rescue pattern (FN-6430):** the 2026-06-14 `@runfusion/fusion` quarantine batch passed direct runs but timed out or bled state only under package/workspace load. The rescue fixed the shared isolation seam, not the timeout: sweep stale top-level `fn-test-home-*` roots with a bounded one-level prefix scan, reject inherited `HOME` values that do not live under the current `fusion-test-workers-*` root, recreate/remark the worker root before each `mkdtemp`, reset module/singleton fixture state in the affected suites, close real stores created by research helpers, and narrow slow real-store seams by moving package imports out of timed test bodies. When rescuing a similar CLI batch, prove it with repeated rescued-file runs plus `pnpm --filter @runfusion/fusion test`, audit rescued files for `vi.setConfig`/`testTimeout`/`hookTimeout` appeasement, and keep ledger/config removals in the same commit. + +**Non-CLI quarantine sweep pattern (FN-6433):** for engine/core/dashboard batches, first remove quarantine excludes only in temporary local configs and run the exact quarantined files together so suite-load coupling is visible before editing the ledger. Rescue is valid when the grouped package lane proves the invariant now holds (for example, FN-6433 fixed engine cross-file interference by replacing broad `activeSessionRegistry.clear()` cleanup with path-scoped unregistering) or when a prior shared-fixture fix is demonstrated under package load. Delete duplicate/low-value files under the ratchet when another deterministic suite owns the same invariant. Finish by making `scripts/lib/test-quarantine.json` and every package Vitest exclude array converge in one commit, then prove the empty/non-empty state with package lanes, `pnpm test:gate`, `pnpm test`, `pnpm build`, and the bounded temp-leak output from `pnpm test`. + +**2026-06-15 rescue batch (FN-6486):** two same-day quarantines were rescued before their 2026-06-29 deletion deadline. `store-concurrent-writes.test.ts` kept its WAL/`transactionImmediate` regression value by making the external lock helper's timed release use synchronous `Atomics.wait` inside the child process, removing event-loop timer scheduling as the load-only flake source without widening retry windows. `extension-task-tools.test.ts` kept its worktree-root task-tool coverage by closing each real `TaskStore` fixture before temp-root removal and using non-hoisted mock cleanup. The reusable pattern is to remove scheduler/resource leaks in the helper or fixture seam, then prove the rescue with repeated exact-file runs plus package lanes, not with timeout bumps, retries, assertion loosening, or worker changes. + +**2026-06-17 core cleanup rescue (FN-6600):** a broad `@fusion/core` timeout cluster was accompanied by `fusion-test-workers-*` `ENOTEMPTY`, while the named files passed in isolation and then under the package lane with the broad-run worker budget. The rescue hardened the shared worker-root teardown's bounded `ENOTEMPTY`/`EBUSY` retry window and added explicit cleanup-invariant coverage, then removed the same-day core quarantine entries in ledger/config lockstep after proving the unexcluded package lane. Reusable pattern: when multiple core files fail with a shared worker-root cleanup signature, fix or prove the shared cleanup seam first; only quarantine residual files after the loaded unexcluded core lane still fails without a seam fix. + +**2026-06-18 engine isolation rescue (FN-6610):** a full `@fusion/engine` lane reported unrelated expectation drift, vanished-cwd/git-config errors, and SQLite `unable to open database file` failures. The reusable isolation fix is to revalidate the shared test cwd/HOME/worker-root seam at the operation boundary: subprocess wrappers recreate the owned worker root, HOME, and cwd immediately before `git`, direct SQLite setup helpers recreate their redirected `.fusion` parent before `DatabaseSync`, and regression coverage removes the redirect sink/HOME/cwd mid-test before proving `mkdtemp`, SQLite open, and git config all still work. Do not mask this class with retries, worker reductions, or timeout bumps; quarantine only residual files after the shared seam and direct-open parents are proven under package load. + +**2026-06-16 rescue (FN-6514):** `packages/dashboard/app/components/__tests__/QuickEntryBox.test.tsx` was rescued before its 2026-06-30 deletion deadline. The file still caught real quick-entry behavior regressions, but it leaked jsdom descriptors for `window.innerWidth`, `window.matchMedia`, `document.visibilityState`, `URL.createObjectURL`, and `URL.revokeObjectURL`; a mobile viewport helper could leave later tests in the same dashboard backfill shard observing `innerWidth=375` and mismatched responsive assertions. The rescue removed the ledger/config quarantine entries in lockstep, captured each original `PropertyDescriptor` at module load, restored those descriptors (or deleted own properties that were originally absent) in `afterEach`, and added a guard test that mutates all rescued globals before asserting they return to their original descriptors. Reusable pattern: any test file that changes jsdom globals with `Object.defineProperty` or spies on replaceable globals must snapshot the original descriptor at the top of the file, restore it in every `afterEach`, and prove the invariant with a guard test; do not use timeout bumps, retries, worker changes, or blanket `vi.restoreAllMocks()` when module mocks depend on stable implementations. + **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. @@ -187,6 +242,24 @@ shard artifacts into `.timings/` first (the default lookup directory), or pass scheduled job can gate on freshness via `node scripts/ci-test-shard.mjs --check-timings-staleness`, which exits non-zero when the snapshot is missing or older than the 30-day budget. +## Weekly test velocity baseline + +FN-6612 tracks feedback-loop velocity as signal-per-second, not as a new blocking gate. Refresh the weekly baseline from a clean worktree with: + +```bash +pnpm test:velocity -- --measure --write-report +``` + +The script runs `pnpm test:gate`, `pnpm smoke:boot`, and `pnpm test` with bounded async process supervision, then appends the measured row to `scripts/test-velocity-history.json` and rewrites the postable artifact at `docs/test-velocity-baseline.md`. It reads the slowest 20 files from the committed `scripts/test-timings.json` snapshot and the flake/quarantine count plus 14-day deletion-clock buckets directly from `scripts/lib/test-quarantine.json`; do not run the full suite just to populate the slowest-file table. + +Use cheap report-only regeneration when measurements already exist: + +```bash +pnpm test:velocity +``` + +Each week, copy the `Post to #leads` block from `docs/test-velocity-baseline.md`. If a measured command fails because the local environment is not ready, keep the failure recorded in the report instead of fabricating a time, then fix or rerun separately as appropriate. Do not wire `pnpm test:velocity`, `test:full`, or any slow-suite expansion into PR checks; the merge gate stays the thin Lint, Typecheck, Build, and Gate path. + ## Targeted commands ```bash @@ -315,6 +388,7 @@ Prefer `it.each` over copy-pasted `it()` blocks. When trimming, keep: first case Copy this checklist into a bug-fix or UI-affordance add/remove task's `## Surface Enumeration` section and make the implementation tests prove the invariant across every checked surface. This checklist applies to bug-fix tasks and UI-affordance add/remove tasks that add, remove, or restructure icons, buttons, chevrons/arrows, toggles, badges, menu entries, or click targets. See `AGENTS.md` → **Standing Rule: Fix the Invariant, Not the Repro (FN-5893)** for the enforced planning/review contract. - [ ] Providers / bridges / execution paths touched by the invariant +- [ ] Long-running subprocess or verification-active surfaces when the invariant involves engine liveness, stuck detection, or command execution (`fn_run_verification`, configured commands, timeout/deadline behavior) - [ ] Desktop + mobile breakpoints / platforms that exercise the behavior - [ ] Empty / undefined / duplicate / populated data states - [ ] Shared hooks / components / modules / helpers reusing the logic diff --git a/docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md b/docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md new file mode 100644 index 0000000000..ac3cc93939 --- /dev/null +++ b/docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md @@ -0,0 +1,121 @@ +# Upstream sponsorship: ACP MCP passthrough and permission forwarding for `claude-code-cli-acp` + +**Submission status:** filed upstream at https://github.com/moabualruz/claude-code-cli-acp/issues/2 + +**Ready-to-file upstream title:** Forward ACP `session/new.mcpServers` to Claude and gate forwarded MCP tool calls + +## Ready-to-file upstream issue / feature request + +### Summary + +Fusion is evaluating `claude-code-cli-acp@0.1.1` as the Route-A replacement for direct `claude -p` usage. Route A is Fusion's highest-traffic Claude path: chat, executor, validator, reviewer, workflow model nodes, title summarization, reflection, and merger all currently rely on the `pi-claude-cli` provider, which injects Fusion tools through Claude's MCP config. + +We need `claude-code-cli-acp` (or the ACP forwarding layer it uses) to support two linked capabilities before Fusion can safely cut Route A over: + +1. **MCP passthrough:** forward the ACP `session/new.mcpServers` declaration to the underlying authenticated `claude` session so Claude can see, list, and invoke those MCP tools. +2. **Permission-gate traversal:** route each forwarded MCP tool invocation back to the ACP client as `session/request_permission`, or expose an equivalent MCP-layer permission hook the ACP client can drive. The bridge must not invoke forwarded MCP tools autonomously without a permission round trip. + +This is a security-critical request: Fusion's existing ACP client-side handler gates tool use by category. Forwarded MCP tools must remain subject to that gate. + +### Why this matters + +Fusion's current Claude provider passes tools to Claude with `--mcp-config`. The ACP route instead has to pass tool servers through ACP `session/new.mcpServers`. Direct fallback to `claude -p` is not acceptable for this migration because the feature's success criterion is to remove `-p` from Claude traffic, including the high-volume Route-A provider path. + +### Reproduction from Fusion spikes + +Environment and package evidence: + +- Upstream repo/homepage: https://github.com/moabualruz/claude-code-cli-acp and https://github.com/moabualruz/claude-code-cli-acp#readme +- npm package: https://www.npmjs.com/package/claude-code-cli-acp +- Bridge binary: `claude-code-cli-acp`, wrapping authenticated `claude` (`@anthropic-ai/claude-code`) +- Tested bridge version: `claude-code-cli-acp@0.1.1` +- Lockfile integrity verified in Fusion's `pnpm-lock.yaml`: `sha512-qpfRGOXkOs9mqI7oumsGistWisyXcCC0r7ng7wdLvGMIORdzHjmUUa+94Jftgr/NYAVnAUe6N7kimD8PaO3D5g==` +- Related ACP protocol surface: https://agentclientprotocol.com, especially `session/new.mcpServers` and `session/request_permission` + +Fusion's Route-A MCP payload is not a stub. It is the real stdio server shape produced by `packages/pi-claude-cli/src/mcp-config.ts`: + +```json +{ + "mcpServers": { + "custom-tools": { + "command": "node", + "args": [ + "packages/pi-claude-cli/src/mcp-schema-server.cjs", + "" + ], + "env": [] + } + } +} +``` + +The `` in the spike contained 62 captured Fusion custom tools. The spike opened ACP directly against pinned `claude-code-cli-acp@0.1.1` with this non-empty `session/new.mcpServers` payload, bypassing Fusion's current helper that still sends `mcpServers: []`. + +Observed result across FN-6466, FN-6467, and FN-6473: + +- `initialize` succeeded. +- `session/new` accepted the non-empty `mcpServers` declaration. +- The prompt turn ended with `Not logged in · Please run /login` and `stopReason: "end_turn"` before any forwarded MCP tool could be invoked. +- The instrumented ACP client observed zero tool-call updates. +- The instrumented ACP client observed zero `session/request_permission` callbacks. + +This means Fusion could not prove whether the bridge forwards `mcpServers` to Claude, and could not classify forwarded tool execution as GATED vs BYPASSED. Route A remains NOT GO until both answers are proven. + +### Expected behavior + +Given an authenticated `claude` session and a non-empty ACP `session/new.mcpServers` declaration: + +1. `claude-code-cli-acp` should launch/connect the underlying Claude CLI session with those MCP servers available to Claude. +2. Claude should be able to list/invoke a tool from the forwarded server (for example a Fusion `custom-tools` tool). +3. Before the bridge executes the forwarded MCP tool, it should issue an ACP `session/request_permission` callback to the client that includes enough tool-call identity and options for the client to allow, deny, or cancel. +4. If ACP cannot represent the forwarded MCP permission decision directly, the bridge should expose an equivalent MCP-layer permission hook that Fusion can drive with the same allow/deny/cancel semantics. +5. If the client denies or cancels the permission request, the forwarded MCP call must not execute. + +### Actual behavior observed + +`claude-code-cli-acp@0.1.1` accepts the non-empty `session/new.mcpServers` field at the ACP boundary, but Fusion has not observed a forwarded MCP tool invocation or any permission callback. The authenticated rerun still reached `Not logged in · Please run /login` from the bridge-managed Claude session before tool use, so the bridge's MCP passthrough and permission behavior remain unproven. + +### Acceptance criteria + +- A client can send `session/new` with a stdio MCP server in `mcpServers` and the bridge makes that server available to the underlying authenticated `claude` session. +- Claude can invoke a tool from that forwarded MCP server through the bridge. +- Each forwarded MCP tool invocation is gated through ACP `session/request_permission`, or through an explicit MCP-layer permission hook that the ACP client controls. +- Denied/cancelled permission decisions prevent MCP tool execution. +- The bridge never autonomously executes forwarded MCP tools without a permission round trip. +- The implementation supports stdio MCP servers with `command`, `args`, and an explicit per-server `env` array/object without inheriting the bridge process environment wholesale. +- Tests or examples cover a non-empty `mcpServers` declaration and the allow/deny permission paths. + +### Security constraints Fusion needs preserved + +- Fusion defaults ACP ask/bridge turns to `tools: "readonly"` unless a task lane explicitly enables broader categories. +- Fusion's unrestricted ACP permission mode (`acpAllowUnrestricted`) remains default-false. +- Fusion bridge subprocess environments are built from an allow-list only. For the Claude bridge posture, that means `HOME` and `PATH` are allowed so Claude can find the user's `~/.claude` auth session and the `claude` binary; `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` are intentionally not forwarded. +- The bridge should keep using the authenticated local Claude CLI session (`~/.claude`), not require API-key forwarding through ACP. +- Any MCP passthrough implementation should preserve ACP's client-controlled permission boundary rather than moving tool authorization fully inside the bridge. + +## Technical proposal + +One possible bridge implementation shape: + +1. Parse and retain `session/new.mcpServers` in the ACP session state. +2. When spawning or controlling the underlying Claude CLI, translate the ACP MCP server declarations into the mechanism Claude CLI expects for MCP registration. For stdio servers, preserve `command`, `args`, and explicit server env; do not merge in `process.env` except for narrowly required bridge/Claude process env that is already configured by the caller. +3. Correlate Claude transcript/tool-use events for forwarded MCP calls with ACP permission requests. +4. Before dispatching the MCP call to the forwarded server, send `session/request_permission` to the ACP client with the tool call metadata. Execute only after an allow outcome; surface deny/cancel back to Claude as a tool error/result without invoking the server. +5. If Claude CLI's MCP stack does not expose a pre-call authorization hook, add a bridge-local MCP proxy layer: Claude connects to bridge-managed proxy servers, the proxy forwards list/call requests to the real configured MCP server, and the proxy performs the ACP permission round trip before forwarding each `tools/call`. +6. Add integration coverage with a small stdio MCP server and an ACP test client that asserts both allow and deny paths. The deny test should prove the real MCP server handler is not called. + +A Fusion-authored PR could focus on the proxy approach if Claude's native CLI integration does not expose sufficient permission hooks. The key contract is not the specific implementation; it is that `session/new.mcpServers` becomes effective for Claude and forwarded tool calls remain externally gateable by the ACP client. + +## Fusion references + +- Fusion OQ1 record: `docs/acp-contract.md` → `### OQ1 — Route A MCP-over-ACP forwarding and permission-gate traversal` +- Fusion route plan: `docs/plans/2026-06-14-001-feat-claude-acp-runtime-plan.md` → Summary (`-p` removal), KTD8, KTD11, U9, U10, and Open Questions OQ1 +- Fusion permission handler: `plugins/fusion-plugin-acp-runtime/src/provider.ts` → `createBridgingClientHandler(...).requestPermission(...)` +- Fusion current ACP session helper: `plugins/fusion-plugin-acp-runtime/src/provider.ts` → `newAcpSession(...)` currently defaults to `mcpServers: []` until FN-6460/U10 +- Fusion Route-A MCP config builder: `packages/pi-claude-cli/src/mcp-config.ts` + +## Internal decision recorded by FN-6475 + +Fusion is sponsoring this upstream capability rather than shipping a Route-A fallback to `claude -p`. OQ1 remains UNRESOLVED / BLOCKED and Route A remains NOT GO until an authenticated rerun proves both forwarded MCP invocation and permission-gate traversal. + +Filed upstream: https://github.com/moabualruz/claude-code-cli-acp/issues/2 diff --git a/docs/workflow-editor.md b/docs/workflow-editor.md new file mode 100644 index 0000000000..efaf14906d --- /dev/null +++ b/docs/workflow-editor.md @@ -0,0 +1,158 @@ +# Workflow Editor + +[← Docs index](./README.md) + + + +The workflow editor is Fusion's visual workflow authoring surface in the dashboard. It uses the `@xyflow/react` canvas to view built-in lifecycle workflows and create or edit custom workflow definitions backed by Fusion's [Workflow IR](./workflow-steps.md#workflow-ir-v1). The graph you see is the same policy model the runtime uses for task lifecycle routing: nodes describe work or control-flow boundaries, edges describe how execution moves between them, and side panels declare workflow-specific columns, task fields, and typed workflow settings. + +Use this guide when you want to inspect the shipped lifecycle, copy a built-in workflow before customizing it, tune workflow setting values, or design a new workflow for a project. For lower-level execution semantics, see [Workflow Steps](./workflow-steps.md). For model lane and settings resolution details, see [Settings Reference](./settings-reference.md#workflow-settings). For dashboard navigation basics, see the [Dashboard Guide](./dashboard-guide.md). + +## Opening the editor + +The shipped dashboard opens the same workflow editor from four places: + +- **Desktop header:** click the **Workflow** button in the top header. +- **Compact/mobile header overflow:** when the header collapses, open the overflow menu and choose **Workflows**. +- **Mobile bottom navigation:** open **More** and choose **Workflows**. +- **Task detail modal:** open a task, select the **Workflow** tab, and use **Edit workflow** to open the editor with that task's workflow context. +- **Settings moved-setting stubs:** settings sections whose policy moved into workflow settings show an **Open workflow settings** redirect. It closes Settings and opens the workflow editor with the **Settings** panel selected for the active project's default workflow. + +These entry points do not create different workflow formats. Desktop and mobile render different layouts for the same workflow definition. + +## Canvas anatomy + +The editor is a modal with a workflow picker, toolbar actions, a React Flow graph, and inspectors: + +- **Workflow list / picker:** choose a built-in or custom workflow. Built-ins are labeled and remain read-only; custom workflows are editable. +- **Graph canvas:** the central React Flow surface where nodes and edges are displayed. Drag nodes to rearrange them, connect handles to create edges, and select a node or edge to inspect it. +- **Minimap and controls:** the canvas includes React Flow's minimap plus controls for zooming and fitting the graph. +- **Swimlane column bands:** workflow-defined columns render as background bands behind nodes. They mirror the Columns panel and help show where lifecycle work occurs. +- **Node palette:** add new nodes from the palette. On mobile, the same palette lives under the **Add** destination. +- **Templates section:** insert reusable graph fragments, built-in workflow-step templates, and plugin-contributed workflow-step templates when available. +- **Inspectors and side panels:** selecting a node opens its configuration inspector; selecting an edge opens the edge inspector. Separate panels manage Columns, Fields, and Settings. +- **Validation and status banners:** save-time validation errors, import warnings, branch/interpreter notices, and read-only built-in hints appear inline instead of relying only on toasts. + +## Node palette + +The palette contains the following shipped node options: + +| Palette label | Purpose | +|---|---| +| **Prompt** | Run a model/agent prompt step in the workflow. | +| **User input** | A prompt node preset to wait for user input before continuing. | +| **Script** | Run a named script or command-like workflow step. | +| **Gate** | Evaluate a pass/fail policy boundary before routing onward. | +| **Merge boundary** | Represent the workflow's merge handoff / merge-policy seam. | +| **Hold** | Park the task until a release condition is satisfied; the palette preset uses manual release. | +| **Split** | Fan out into multiple branches. | +| **Join** | Rejoin branches; the default join waits for all branches and collects branch failures. | +| **For-each step** | Iterate over the task step list (`task-steps`) and run a template per step. | +| **Loop** | Repeat a contained sequence until its exit condition or max-iteration limit is reached. | +| **Step review** | Model per-step review verdict routing such as approve, revise, rethink, or unavailable. | +| **Parse steps** | Parse a declared artifact, such as `PROMPT.md`, into the canonical task step list. | +| **Code** | Run timeout-bounded sandboxed TypeScript for custom workflow logic. | +| **Notify** | Send a workflow-authored notification event and then continue on the normal success path. | + +Some nodes expose specialized inspector fields: for example prompt execution details, hold release condition, split/join behavior, for-each concurrency and max rework cycles, parse-step artifact/parser selection, code source, and notification event/title/message. + +## Edges, conditions, and rework + +Create edges by connecting node handles on the graph. In the mobile and compact simple graph, use a node's **Connect** action and target picker to create the same edge without dragging on the canvas; built-in workflows hide this mutation control because they are read-only. A new connection defaults to a **success** edge. The edge inspector lets you edit routing details when the source node supports it: + +- **Success / failure conditions:** prompt, script, gate, code, and for-each style sources can route on `success` or `failure`. +- **Outcome conditions:** review-style nodes route verdicts as `outcome:` values. The shipped verdict list is `approve`, `revise`, `rethink`, and `unavailable`. +- **Read-only conditions:** for node kinds whose outgoing condition is fixed, the inspector shows the current condition instead of an editable selector. +- **Rework edges:** mark an edge as a bounded rework loop from the edge inspector. Rework edges are the only legal author-time cycles and are intended to loop within a for-each step instance, bounded by the for-each node's max rework cycles. + +The editor prevents ordinary cycles while connecting nodes. If a graph branches in a way that cannot compile to the older linear step engine, the editor shows an informational interpreter banner: the workflow can still run on the graph interpreter. + +## Columns panel + +The **Columns** panel edits workflow-defined swimlanes. A column has an id, name, ordered position, and composable traits. The panel can add, rename, reorder, and remove columns for custom workflows; built-ins show the same data read-only. + +When column-agent support is enabled by the required experimental features, a column can also assign a permanent agent with one of two modes: + +- **defer:** use the column agent only when the work has no more specific agent/model setting. +- **override:** let the column agent supersede task or node agent/model choices. + +Trait composition problems and policy-escalation confirmations surface in the editor before or during save. Column bands on the canvas update from this panel so the graph and lifecycle lanes stay aligned. + +## Fields panel + +The **Fields** panel declares custom task fields for tasks using the workflow. Field definitions include an id, display name, type, required flag, default value, enum options where applicable, and render controls such as placement and widget. Supported field types are `string`, `text`, `number`, `boolean`, `enum`, `multi-enum`, `date`, and `url`. + +Fields placed on cards show a badge preview so authors can see how the value will render on the board. Server-side validation still owns the final save contract: unique ids, legal type/widget combinations, enum options, and render placement are checked when the workflow is saved. + +## Settings panel: Definitions and Values + +Workflow settings are typed settings declared by a workflow in its IR. The editor uses the same terms as [Concepts](../CONCEPTS.md): a **Workflow Setting** has a declaration, and the engine consumes **Effective Settings** after resolving stored values against defaults. + +The **Settings** panel has two tabs: + +- **Definitions:** edit the workflow's setting schema — id, name, type, default, enum options, description, and widget. This tab is read-only for built-in workflows and editable for custom workflows. Declarations save with the workflow IR through the editor's normal **Save** action. +- **Values:** edit per-project values for the currently open workflow. Values are writable even for built-in workflows. Edits batch locally and commit through the tab's dedicated **Save values** action, separate from the workflow IR save. + +Resolution is `stored value ?? declaration default`. Stored values that no longer validate against the current declaration are treated as orphaned and dropped from the effective settings the engine reads. The Values tab exposes provider/model lane pairs with the same model dropdown used elsewhere in Settings, while custom settings use controls based on their declared type. See [Settings Reference → Workflow Settings](./settings-reference.md#workflow-settings) for moved settings, model lane hierarchy, export behavior, and sync posture. + +## Templates and reusable pieces + +The editor has two template concepts: + +1. **New workflow templates:** when creating a workflow, start from **Blank**, from a built-in workflow, or from one of your existing custom workflows. Choosing a source creates a fresh copy with new ids; it is not a live reference to the source. +2. **Palette templates:** inside an editable workflow, the Templates section can insert reusable fragments, built-in workflow-step templates, and plugin-contributed workflow-step templates. Fragment insertion remaps ids and refuses seam conflicts that would duplicate a protected workflow seam. + +Plugin-contributed workflow-step templates appear alongside built-ins when installed plugins provide them. They insert as preconfigured prompt or script nodes using the same metadata that powers the workflow-step chooser described in [Workflow Steps](./workflow-steps.md#plugin-contributed-steps). + +## AI-assisted design + +The editor can call `designWorkflow` from two places: + +- **New workflow dialog:** expand the AI design area, describe the workflow you want, and submit **Design with AI**. On success, Fusion creates and opens the designed workflow. The request can be cancelled while in flight, and failures render inline in the dialog. +- **Toolbar design action:** run AI design against the active workflow. The returned graph is a proposed replacement; the editor asks for confirmation because applying it replaces the current graph and unsaved changes are lost. The replacement remains unsaved until you explicitly click **Save**. + +If you switch workflows while an AI design request is in flight, the stale result is discarded instead of applying to the newly selected workflow. + +## Import, export, auto-layout, save, and delete + +- **Export:** downloads the active persisted workflow as a JSON envelope. Export is available for built-ins too because it reads the server's saved definition. +- **Import:** choose a JSON workflow envelope to create a workflow from it. Invalid JSON and server validation errors render in a persistent inline error region; non-blocking import warnings render beside it. +- **Auto-layout:** applies a left-to-right tidy layout to editable graph nodes. It changes positions only and marks the workflow dirty. +- **Save:** custom workflows serialize the current graph, columns, fields, and setting declarations to Workflow IR and update the active workflow. After saving, Fusion compiles the workflow to report whether it can run on the linear engine or must run on the graph interpreter. +- **Delete:** deletes the active custom workflow after confirmation. Built-in workflows cannot be deleted. +- **Duplicate to customize:** copies the active workflow, including built-ins, into a new editable custom workflow. + +Save is blocked by client-side issues such as unplaced nodes and blocking column-trait violations, then by server-side workflow validation. Built-ins show read-only hints and disable mutation controls instead of allowing edits that cannot be saved. + +## Built-in vs. custom workflows + +Fusion ships built-in workflows as read-only references: + +- `builtin:coding` — the default coding lifecycle and fallback for tasks without a workflow selection. +- `builtin:stepwise-coding` — a graph variant that models per-step parse, execute, review, and rework structure. + +Built-ins can be viewed, exported, and used as templates, but their graph, columns, field declarations, and setting declarations are not editable. Their per-project setting **values** are editable from the Settings panel's Values tab. + +To customize behavior, create a workflow from **Blank** or copy a built-in/custom workflow with **Duplicate to customize**. Tasks select a workflow by workflow id. Agents and automation can discover workflows with `fn_workflow_list`, assign one to an existing task with `fn_workflow_select`, or pass `workflow_id` when creating tasks through `fn_task_create` / delegation tools. + +## Mobile editor + +Mobile uses staged destinations to keep the same editor usable on narrow screens: + +- **Graph:** shows a mobile-friendly graph/list representation and lets you select nodes or edges. +- **Add:** contains the node palette and available templates. +- **Settings:** opens the same Definitions/Values settings panel. +- **Fields:** opens custom task field definitions. +- **Columns:** opens workflow columns and traits. +- **Actions:** groups workflow-level actions such as AI design, import/export, save, duplicate, and delete depending on read-only state. + +The mobile destinations edit the same workflow IR as desktop. There is no separate mobile workflow format. + +## Related docs + +- [Workflow Steps](./workflow-steps.md) — Workflow IR, runtime behavior, built-in workflow ids, and workflow-step templates. +- [Settings Reference](./settings-reference.md#workflow-settings) — workflow setting values, effective settings, model lane hierarchy, and moved settings. +- [Dashboard Guide](./dashboard-guide.md) — general dashboard navigation and UI surfaces. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 11cbabeb58..1ebf7352c6 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -4,9 +4,43 @@ Workflow steps are reusable quality gates that run around task completion. +## Workflow overview + + + +Fusion workflows define the task lifecycle policy that moves work from an idea to delivery. The default coding path is **Plan/Triage → Execute → Workflow steps → Review → Merge**, but that path is now represented as a workflow selection rather than only as fixed engine behavior. A task with no explicit workflow resolves to `builtin:coding`; an explicit missing/corrupt custom workflow fails closed instead of silently falling back. + +### Selecting workflows + +Operators can select workflows in the dashboard wherever the task or board workflow selector is shown. Agents and automation can discover and assign them with the workflow tools: + +- `fn_workflow_list` — list built-in and custom workflow definitions. +- `fn_workflow_select` — assign a workflow to the current or named task. +- `workflow_id` on `fn_task_create` / delegation tools — create a task with a workflow already selected. + +Decision-only or investigation tasks can also declare `noCommitsExpected` / `**No commits expected:** true`; the built-in triage policy prefers the Quick fix workflow for that no-commit lane. + +### Built-in workflow catalog + +| Workflow | ID | Notes | +|---|---|---| +| Coding | `builtin:coding` | Default coding lifecycle and fallback for tasks without an explicit selection. | +| Quick fix | `builtin:quick-fix` | Short path for trivial or no-commit/decision work; omits the standard review stage. | +| Review-heavy | `builtin:review-heavy` | Standard execute/review/merge path with an additional gated security review. | +| Compound engineering | `builtin:compound-engineering` | Plugin-gated workflow that invokes Compound Engineering skills for planning, work, review, PR/feedback, and learnings capture. | +| Stepwise coding | `builtin:stepwise-coding` | Graph-executor workflow that models per-step parse/execute/review/rework explicitly. | +| PR lifecycle | `builtin:pr-workflow` | Reusable PR lifecycle graph fragment (create PR → await review → respond → gate → merge); it is a fragment, not directly selectable as a task workflow. | + +### Custom workflow authoring + +Use the dashboard [Workflow Editor](./workflow-editor.md) to inspect read-only built-ins, duplicate them, or author custom workflows. Custom workflows can declare graph nodes and edges, columns/traits, task fields, typed workflow settings, model lanes, optional workflow-step templates, and author-time validation. Use this page for runtime semantics; use the editor guide for the visual authoring surface. + ## Workflow IR (v1) -Fusion also defines a separate **Workflow Intermediate Representation (IR)** contract in `@fusion/core` for editor↔interpreter graph exchange. This IR is distinct from the post-implementation quality gates documented on this page (`WorkflowStep` templates and execution policies). +Fusion also defines a separate **Workflow Intermediate Representation (IR)** contract in `@fusion/core` for editor↔interpreter graph exchange. This IR is distinct from the post-implementation quality gates documented on this page (`WorkflowStep` templates and execution policies). For the user-facing visual authoring surface, see the [Workflow Editor guide](./workflow-editor.md). Workflow IR v1 is a JSON-safe graph document: @@ -341,7 +375,7 @@ For new workflow step prompts, prefer the structured JSON contract. #### Malformed Output -If output matches neither structured JSON nor known prose fallback patterns, Fusion records the step output as `malformed`. Operationally, this means no workflow verdict could be inferred from that response. +If output matches neither structured JSON nor known prose fallback patterns, Fusion records the step output as `malformed`. Operationally, this means no workflow verdict could be inferred from that response. A malformed `gateMode: "gate"` prompt step is a blocking failure rather than an approval; a malformed `gateMode: "advisory"` step is recorded as `advisory_failure` and does not block completion. ### Behavior @@ -497,7 +531,7 @@ Prompt-mode workflow agents should emit a trailing JSON object: - `verdict` and `notes` are persisted on `WorkflowStepResult` when present. - Script-mode steps do not populate these fields. - Backward compatibility remains for legacy prose-only responses via heuristic fallback (`REQUEST REVISION` and approval keywords). -- If neither structured JSON nor fallback prose can be interpreted, output is recorded as `malformed` (no inferable verdict) instead of hard-failing the task. +- If neither structured JSON nor fallback prose can be interpreted, output is recorded as `malformed` (no inferable verdict). Malformed blocking gates fail closed; advisory gates record `advisory_failure` without blocking. ## Workflow Graph Executor @@ -514,6 +548,7 @@ Traversal semantics: - `outcome:` routes when the node result value matches exactly - unsupported conditions throw `WorkflowIrError` - per-node retries are bounded and deterministic +- terminal success requires every workflow-declared task-document artifact key (`ir.artifacts[].key`) to exist. No-artifact workflows keep the implicit `PROMPT.md` parse-step default and do not require a task document. Coverage includes lifecycle ordering, primitive invocation, merge/file-scope failure routing, and downstream halt behavior for hard-cancel/recovery style failures. diff --git a/package.json b/package.json index 25abf887f7..f6fe96b9b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fusion-workspace", - "version": "0.42.0", + "version": "0.44.0", "private": true, "license": "MIT", "homepage": "https://github.com/Runfusion/Fusion#readme", @@ -14,9 +14,9 @@ "type": "module", "packageManager": "pnpm@10.33.0", "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", + "pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", + "pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs", + "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.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", @@ -32,8 +32,11 @@ "build:exe:all": "pnpm build && pnpm --filter @runfusion/fusion build:exe:all", "test": "node scripts/test-changed.mjs", "test:scripts": "node --test scripts/__tests__/*.test.mjs", + "test:workflow-release-check": "node scripts/workflow-reliability-release-check.mjs", "fn:cache-stats": "node scripts/cache-stats.mjs", "test:full": "node scripts/test-changed.mjs --full --no-cache && pnpm --filter @fusion/engine test:slow", + "test:velocity": "node scripts/test-velocity-baseline.mjs", + "test:feedback-baseline": "node scripts/test-feedback-baseline.mjs", "test:ci:shard": "node scripts/ci-test-shard.mjs", "test:serial": "FUSION_TEST_CONCURRENCY=1 FUSION_TEST_WORKSPACE_CONCURRENCY=1 pnpm test:full", "test:fast": "FUSION_TEST_CONCURRENCY=4 FUSION_TEST_WORKSPACE_CONCURRENCY=4 pnpm test:full", @@ -64,6 +67,8 @@ "mobile:dev:ios": "pnpm --filter @fusion/mobile dev:ios", "mobile:dev:android": "pnpm --filter @fusion/mobile dev:android", "mobile:sync": "pnpm --filter @fusion/mobile cap sync", + "mobile:run:android": "bash scripts/mobile-run-android.sh", + "ios:acceptance": "node scripts/ios-acceptance.mjs", "build:desktop": "pnpm --filter @fusion/desktop build", "dist:desktop:win": "pnpm --filter @fusion/desktop build && pnpm --filter @fusion/desktop dist:win" }, diff --git a/packages/cli-alias/CHANGELOG.md b/packages/cli-alias/CHANGELOG.md index da600b29c1..236d40841d 100644 --- a/packages/cli-alias/CHANGELOG.md +++ b/packages/cli-alias/CHANGELOG.md @@ -1,5 +1,133 @@ # runfusion.ai +## 0.44.0 + +### Patch Changes + +- Updated dependencies [c8788d8] +- Updated dependencies [265d9ec] +- Updated dependencies [6427802] +- Updated dependencies [def4bd9] +- Updated dependencies [c1b581e] +- Updated dependencies [898ac1e] +- Updated dependencies [62335f8] +- Updated dependencies [cd2da10] +- Updated dependencies [fee0178] +- Updated dependencies [863ebfa] +- Updated dependencies [bc6dfd3] +- Updated dependencies [0093678] +- Updated dependencies [3158e9c] +- Updated dependencies [0db8134] +- Updated dependencies [a15b4ca] +- Updated dependencies [21c4d3e] +- Updated dependencies [198fb17] +- Updated dependencies [98cb80d] +- Updated dependencies [a453716] +- Updated dependencies [4a9fe99] +- Updated dependencies [d35f93e] +- Updated dependencies [550715d] +- Updated dependencies [a998f63] +- Updated dependencies [89171e0] +- Updated dependencies [914842f] +- Updated dependencies [6ced5d7] +- Updated dependencies [e2a3a37] +- Updated dependencies [a84a8e1] +- Updated dependencies [593ebac] +- Updated dependencies [05fe6e5] +- Updated dependencies [403bd9d] +- Updated dependencies [01b80db] +- Updated dependencies [5b9ff04] +- Updated dependencies [1bd8f6d] +- Updated dependencies [19aac38] +- Updated dependencies [a013bc0] +- Updated dependencies [b6ac5f2] +- Updated dependencies [0453a65] +- Updated dependencies [4c3186d] +- Updated dependencies [0767d1b] +- Updated dependencies [29b27a7] +- Updated dependencies [cdadac1] +- Updated dependencies [98ccf8a] +- Updated dependencies [4dd5337] +- Updated dependencies [4929198] +- Updated dependencies [673a8a6] +- Updated dependencies [58a34e9] +- Updated dependencies [3d28b3b] +- Updated dependencies [dae0bde] +- Updated dependencies [ab8ecb2] +- Updated dependencies [b1a2aee] +- Updated dependencies [16b6e5d] +- Updated dependencies [0ed46d9] +- Updated dependencies [3b32b53] +- Updated dependencies [b6823af] +- Updated dependencies [2367918] +- Updated dependencies [f41732d] +- Updated dependencies [504305e] +- Updated dependencies [64092ca] +- Updated dependencies [36f1fee] +- Updated dependencies [662a09b] +- Updated dependencies [af31f7d] +- Updated dependencies [11c4120] +- Updated dependencies [21d8076] +- Updated dependencies [ef54459] +- Updated dependencies [94a081f] +- Updated dependencies [317b08b] +- Updated dependencies [9b396b6] +- Updated dependencies [2059790] +- Updated dependencies [fe207ca] +- Updated dependencies [d6e2f92] +- Updated dependencies [99d799c] +- Updated dependencies [5e1a4ff] +- Updated dependencies [0f021ae] +- Updated dependencies [282b069] +- Updated dependencies [9d07e85] +- Updated dependencies [cfddde5] +- Updated dependencies [47e7b4a] +- Updated dependencies [cc02286] +- Updated dependencies [84cf3ff] +- Updated dependencies [c1b581e] +- Updated dependencies [283f689] +- Updated dependencies [168dc2f] +- Updated dependencies [951c6ef] +- Updated dependencies [0a87890] + - @runfusion/fusion@0.44.0 + +## 0.43.1 + +### Patch Changes + +- Updated dependencies [59f2596] +- Updated dependencies [1f540b2] +- Updated dependencies [19eca3d] + - @runfusion/fusion@0.43.1 + +## 0.43.0 + +### Patch Changes + +- Updated dependencies [9149121] +- Updated dependencies [740c712] +- Updated dependencies [b1ba87e] +- Updated dependencies [65a4c51] +- Updated dependencies [64de883] +- Updated dependencies [20aad56] +- Updated dependencies [066c919] +- Updated dependencies [0d75725] +- Updated dependencies [67ae2be] +- Updated dependencies [fd6caaa] +- Updated dependencies [9eeaaa7] +- Updated dependencies [ee6d7ac] +- Updated dependencies [e8c2d51] +- Updated dependencies [14ed177] +- Updated dependencies [df01ab7] +- Updated dependencies [96773dd] +- Updated dependencies [67d4d51] +- Updated dependencies [7b83906] +- Updated dependencies [be2773b] +- Updated dependencies [3cc82bd] +- Updated dependencies [aa71ace] +- Updated dependencies [417183d] + - @runfusion/fusion@0.43.0 + ## 0.42.0 ### Patch Changes diff --git a/packages/cli-alias/package.json b/packages/cli-alias/package.json index 14d4b0536d..dd8269ff97 100644 --- a/packages/cli-alias/package.json +++ b/packages/cli-alias/package.json @@ -1,6 +1,6 @@ { "name": "runfusion.ai", - "version": "0.42.0", + "version": "0.44.0", "license": "MIT", "description": "Launch Fusion with `npx runfusion.ai` — tiny alias for @runfusion/fusion.", "homepage": "https://runfusion.ai", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 11b3627754..291c0c3ba0 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,187 @@ # @runfusion/fusion +## 0.44.0 + +### Minor Changes + +- 6427802: Route Fusion's Claude CLI path through the ACP bridge (`claude-code-cli-acp`) instead of `claude -p` (Route A, dormant behind an OFF-by-default kill-switch). + + - **U10** — forward `mcpServers` on ACP `session/new` through the runtime contract (`AgentRuntimeOptions.mcpServers` + the plugin's `newAcpSession`); defaults to `[]` so existing read-only ACP "ask" turns are unchanged. + - **U11** — `streamViaAcp`: the `pi-claude-cli` provider can drive Claude through the bundled ACP bridge, returning the same `AssistantMessageEventStream` as the `-p` path. Dispatched only when `FUSION_CLAUDE_ACP=1` and a bridge path are present, so the live `-p` path is byte-for-byte untouched by default. Full-history prompting, schema-only MCP forwarding with break-early on pi-known tools, control-char/size sanitization, env allow-list, process-registry registration, and inactivity timeout. + - **KTD10** — the ACP runtime plugin publishes its identity-pinned bundled bridge path on load so the kill-switch needs no manual path; it does not enable the transport. + - **OQ2** — opt-in connection reuse (`FUSION_CLAUDE_ACP_REUSE=1`, default OFF): a warm bridge connection + ACP session is kept across turns of one conversation (keyed by `sessionId`), so multi-turn lanes skip the cold bridge/`claude` spawn and `session/new` round-trip and send only the latest-turn delta (`buildResumePrompt`). A stable `router` indirection serves each turn's handlers; a warm-child death routes failure to the current owner turn (no 30-min inactivity hang), eviction is cache-identity-aware (a concurrent cold turn can't kill a newer entry's child), an empty resume cold-starts instead of issuing an empty prompt, and a per-turn token drops cross-turn stray updates. The idle reaper is `unref`'d. Default OFF → the cold path is functionally unchanged. + + The Claude-via-pi OAuth path is unchanged. Live verification confirmed the bridge gates tool execution behind `session/request_permission` (forwarded MCP tools and native tools do not execute when cancelled). Remaining for a follow-up: picker/auth/status surface (U12), workflow `model`-node verification (U13), and production rollout. + +- c1b581e: Add the **Command Center** dashboard — a combined analytics/observability and live Mission-Control view (`?view=command-center`). + + - **Telemetry** — a queryable `usage_events` SQLite table populated via a dedicated `emitUsageEvent` capture seam (tool calls, messages, session lifecycle), feeding date-range aggregators for tokens, tool usage + autonomy ratio, activity (sessions/messages/active-nodes/stickiness), productivity (files/commits/PRs/LOC), and ecosystem breadth — all in `packages/core` and reusable by CLI/engine. + - **Cost** — derived from token counts via a hand-maintained `model-pricing` map carrying `pricingAsOf` + a staleness flag; unknown models report unavailable rather than guessing. + - **View** — a new lazy-loaded, ARIA-tabbed Command Center with hand-rolled CSS-bar chart primitives, a date-range picker, per-area panels, a live Mission-Control panel (SSE push + idle-aware polling), and an SDLC funnel. + - **API** — `GET /api/command-center/{tokens,tools,activity,productivity,live}` (agent-usable), each under session auth and project scoping, with `?format=csv` export and an opt-in OpenTelemetry (OTLP) metrics exporter. + +- 898ac1e: Add the Command Center signals analytics endpoint backed by local incidents data and document honest empty-state sentinels for signal metrics. +- 863ebfa: Add a CLI session relaunch route and enable the dashboard's resume-exhausted "Relaunch fresh" action to re-enqueue the owning task for a fresh CLI-agent run. +- 21c4d3e: Dashboard agent chat sessions now load the same agent-declared and enabled plugin-contributed skills as task execution sessions, so plugin skills such as `ce-debug` are available in chat. +- a453716: Render assistant question tool calls as shared in-chat response cards in full Chat and Quick Chat. +- a998f63: Enable creating workflow node connections from the mobile workflow editor. +- e2a3a37: Add a project setting for configuring the auto-merge conflict retry cap before Fusion parks or bounces tasks for recovery. +- 05fe6e5: Compound Engineering now treats stage launch settings as an explicit `disabledStages` opt-out list so newly bundled stages, including `ce-debug`, remain launchable on existing installs with stale settings snapshots. +- b6ac5f2: Add bounded-by-default verification guardrails: project `verificationCommandTimeoutMs`, marathon command detection, and an explicit `allowFullSuite` escape hatch for full verification runs. +- 0453a65: Request agent and enabled plugin skills across planning, mission interview, workflow design, memory insight, and scheduled automation agent sessions. +- cdadac1: Load selected Fusion and enabled plugin skills in milestone/slice interview and agent-onboarding dashboard sessions. +- f41732d: Add a live-updating, animated Command Center token-usage-over-time view with hour/day/week granularity and bounded polling for token totals. +- 504305e: Add a Command Center GitHub issue analytics endpoint and dashboard area showing issues filed by Fusion, issues fixed by Fusion, net flow, daily trends, and by-repository breakdowns from the local project task store. +- 64092ca: Add Command Center agent-run sheets that show total, active, completed, and failed heartbeat runs in the Activity area and Overview, plus agent-run daily activity and CSV export rows. +- 36f1fee: Add the Command Center Team tab and `/api/command-center/team` endpoint for project-scoped per-agent token, cost, files-changed, task-completion, and live-status analytics. +- af31f7d: Move System Stats into the Command Center as a redesigned graph-rich System area with gauges, trend sparklines, task/agent bars, and relocated Vitest controls; remove the standalone System Stats modal plus its Header and mobile More affordances. +- 94a081f: Persist GitHub source issue closure timestamps and use them for exact Command Center "Fixed by Fusion" date bucketing, falling back to task `updatedAt` only when the real close time has not been observed. +- 9b396b6: Add an optional project-scoped GitHub source-issue closed-at backfill endpoint that fills historical imported tasks with real GitHub `closed_at` values for more accurate Fixed by Fusion analytics. +- 2059790: Add a Command Center GitHub affordance for operators to run the historical source-issue closed-at backfill and review accumulated scanned, filled, skipped, and error counts. +- d6e2f92: Add `recharts` and shared Command Center PieChart/LineChart wrappers for downstream graphical chart migrations. The wrappers are token-themed, responsive, reduced-motion aware, and safe for empty, zero, negative, NaN, and Infinity inputs; the current production build shows no observable Command Center chunk-size increase yet because no Command Center surface imports the new wrappers until the dependent migration tasks land (CommandCenter chunk remains 74.68 kB / 16.46 kB gzip in this task's build output). +- 99d799c: Add Command Center pie and line chart affordances to the Overview, Tokens, Tools, Activity, and Productivity analytics surfaces using existing analytics data. +- 5e1a4ff: Add Command Center pie and line charts to Team, Ecosystem, GitHub, Signals, and System surfaces using existing analytics data. +- 47e7b4a: Populate Command Center Productivity Lines changed from merge-time commit association diff stats when available. +- c1b581e: Add the **Monitor stage** (U13) — deployment and incident tracking that closes the SDLC loop. + + - **Schema** — new `deployments` and `incidents` SQLite tables (`packages/core/src/db.ts`, `SCHEMA_VERSION` 119 → 120, migration added in the same change; fingerprint auto-covers SCHEMA_SQL tables). + - **Metrics** — real MTTR (incident-open → resolved) plus deploy/incident counts in `activity-analytics`, replacing the prior unavailable seam. + - **Ingestion** — `POST /api/monitor/{deployments,incidents}` self-authenticate via a shared ingest secret (constant-time bearer check, fail-closed) with SSRF-untrusted payload links; `GET /api/monitor/metrics` exposes the aggregates. + - **Loop closure** — a `monitor` workflow trait can auto-open a single fix task on a regression signal, guarded by `groupingKey` grouping, a threshold/sustained gate, cooldown absorption, a per-window circuit breaker, and a self-loop guard. + +- 168dc2f: Export Command Center analytics over OpenTelemetry (OTLP) so teams can ship token / cost / activity metrics to Datadog / Grafana / etc. **Disabled by default** (U10, R4). + + - New pure mapping `mapAnalyticsToOtlp` in `@fusion/core` (`otel-metrics.ts`) turns the token/cost/activity aggregator outputs into the OTLP/HTTP JSON wire shape (`resourceMetrics`) — counters for token/cost, gauges for activity — with `model` / `provider` / `node.id` / `agent.id` attributes per data point. Fully testable without a live collector; no SDK dependency in core. + - Dashboard exporter (`otel-exporter.ts`) periodically maps current analytics and POSTs them to a configured collector, wired into `server.ts` startup/shutdown. + + **SDK choice:** ships a **minimal OTLP/HTTP JSON exporter rather than the official `@opentelemetry/*` SDK** — and therefore adds **no new runtime dependency**. The OTLP/HTTP JSON protocol is a single, stable `POST /v1/metrics` of a well-defined JSON envelope (built in core), so for a default-disabled feature we avoid pulling the multi-package SDK (sdk-metrics + exporter-metrics-otlp-http + resources + api). The wire shape is collector-compatible; swapping in the official SDK later is mechanical. (If maintainers prefer the real SDK, that is a follow-up changeset + dependency add.) + + **Enabled only via env** (none set ⇒ nothing starts): `FUSION_OTEL_METRICS_ENDPOINT` (full `/v1/metrics` URL, required to enable), `FUSION_OTEL_METRICS_HEADERS` (`k=v,k2=v2` auth headers), `FUSION_OTEL_METRICS_INTERVAL_MS`, `FUSION_OTEL_METRICS_TIMEOUT_MS`, `FUSION_OTEL_RESOURCE_ATTRIBUTES`. + + **Security:** endpoint validated on write — `http://` is rejected in production (exporter does not start) and warns loudly otherwise; auth header (Datadog/Grafana token) VALUES are never logged and are masked in diagnostics; a collector-unreachable failure logs (redacted) and backs off exponentially without crashing the server or blocking requests. + +- 951c6ef: Ingest external signals (Sentry / Datadog / PagerDuty / generic webhook) into triage tasks via a common `SignalSource` adapter seam (U11, KTD8). + + - New `POST /api/signals/:provider` endpoints, mirroring the GitHub ingestion path. Verified, normalized signals create a task in the `triage` column via the existing task store. + - Generic webhook is the must-work path; Sentry/Datadog/PagerDuty are thin adapters with provider-specific HMAC verification + payload normalization. Each normalized `Signal` carries a `groupingKey` (Sentry `issue.id`, PagerDuty `incident.id`, Datadog monitor key; the generic webhook requires a caller-supplied key or falls back to `source + normalized-title`) for the downstream storm guard. + - Security (mandatory): per-provider HMAC against an env-sourced secret (never source-controlled) with 401 on missing/invalid secret or signature — the generic webhook is never an unauthenticated task-creation endpoint; ±5 min replay window + delivery-id nonce dedup; persistent external-id dedup; ~1 MB body cap; per-source rate limit; field-length + meta-byte caps; SSRF-untrusted handling of payload URLs; `meta` stored as data, never rendered as raw HTML. + +- 0a87890: Add a persistent, incrementally-refreshed knowledge index (U14) downstream agents can query. + + - **Schema** — new `knowledge_pages` SQLite table (`packages/core/src/db.ts`) with `SCHEMA_VERSION` bumped 118 → 119 (added in the same change as the migration; the fingerprint auto-covers SCHEMA_SQL tables). Keyword search uses a denormalized lowercased `searchText` column with AND-of-terms `LIKE` matching, deliberately avoiding SQLite FTS5 (not available on every build) and any external embedding API. + - **Index module** (`packages/dashboard/src/knowledge-index.ts`) — upsert-by-source-key pages, a model-free keyword query API, and `refreshKnowledgeForTask` that re-indexes a single completed task (one upsert, never a full re-index, so unaffected pages keep their timestamps). This is the delta over the existing `insights`/`memoryView` surfaces, which are LLM-extracted learnings, not a deterministic searchable index of concrete task/PR history. + - **Refresh hook** — `KnowledgeIndexRefreshService` listens for `task:moved → done` (mirroring `GitHubSourceIssueCloseService`) and is wired alongside the other completion listeners; fail-soft so it can never disrupt task completion. + - **Query API** (`register-knowledge-routes.ts`) — `GET /api/knowledge/query` and `POST /api/knowledge/refresh`, registered as an `ApiRouteRegistrar` so they inherit the dashboard's standard session/auth middleware (401 when unauthenticated) and apply `getScopedStore(req)` (no cross-project reads), exactly like U9. + +### Patch Changes + +- c8788d8: Align the workflow editor's client-side column trait validation details with the server validator so conflicting trait compositions identify the same source traits before save. +- 265d9ec: Fix task workflow selection so successful workflow changes and clears notify dashboard clients to refresh board workflow lanes. +- def4bd9: Add dashboard controls for renaming regular Chat and Quick Chat sessions. +- 62335f8: Fix two post-merge Full Suite test failures. Sync the roadmap store's schema-version assertion to core's `SCHEMA_VERSION` (116 → 117). Stop `useCeSessions` background refreshes (poll fallback and push events) from clearing an error a `cancel`/`remove` just surfaced — an in-flight session kept the poll running, which silently erased the action error before the user could see it. +- cd2da10: Wire dashboard CLI session banner actions so needs-attention sessions surface, supported actions call existing routes/settings flows, and unsupported actions render disabled instead of silently doing nothing. +- fee0178: Guard no-commits-expected tasks from being finalized as done by no-op merge/self-healing lanes when skipped or incomplete steps outweigh completed work. +- bc6dfd3: Surface paused workflow graph exits that occur outside `in-progress` as operator-actionable failures instead of leaving tasks stranded. +- 0093678: Block release and publish-class tasks during triage unless they were explicitly authorized by a user-authored source. +- 3158e9c: Fix the dashboard TUI Agents view so pressing `s` starts the selected agent without also switching back to Main. +- 0db8134: Bound `fn_task_list` text output across CLI, dashboard, and engine tool surfaces so oversized board listings remain plain text with an explicit truncation marker instead of overflowing host response budgets. +- a15b4ca: Keep the chat sidebar visible at a compact bounded width when a tablet software keyboard opens, then restore the previous width when the keyboard closes. +- 198fb17: Keep prior chat thread messages visible while reconnecting to an in-flight streamed assistant response. +- 98cb80d: Fix the tablet task detail modal sizing so the action footer remains on-screen and the modal uses more viewport width. +- 4a9fe99: Allow chat attachments to be sent without accompanying text in Quick Chat and Main Chat while still rejecting fully empty sends. +- d35f93e: Refresh dashboard mobile and PWA home-screen icons from the canonical Fusion logo and bump the service-worker cache for installed app updates. +- 550715d: Bringing up Quick Chat now focuses the composer input on desktop (matching existing mobile behavior). +- 89171e0: Polish the bundled Compound Engineering dashboard view so its spacing, radii, and controls align with Fusion dashboard design tokens and shared component classes. +- 914842f: Make Chat the first tab and default active view in the task detail modal while preserving explicit initial tab requests. +- 6ced5d7: Fix workflow graph merge-node failures so merge-seam aborts are not misclassified as pause/resume aborts. Non-paused merge failures now route to the bounded auto-merge retry path instead of being parked failed with no merge retry count. +- a84a8e1: Fix `fn_task_list` crashes when the runtime `@fusion/core` formatter export is unavailable by resolving defensively and returning bounded fallback text. +- 593ebac: Resolve task-list text formatting defensively when an installed core package is missing the `formatTaskListText` runtime export, preserving `fn_task_list` output with a bounded inline fallback. +- 403bd9d: Prevent custom workflows from reaching terminal success when declared task-document artifacts are missing, and keep malformed blocking gate verdicts from being treated as successful workflow-step passes. +- 01b80db: Add a Fusion-native `fn_ask_question` tool for dashboard chat agents so structured questions render in the existing chat response card and answers return through the next chat message. +- 5b9ff04: Keep previously persisted main-chat conversation messages visible while reconnecting to an in-flight assistant response. +- 1bd8f6d: Fix mobile terminal cell measurement by making xterm font stacks use real monospace text faces before the Nerd Font symbols fallback. +- 19aac38: Load dashboard chat skills requested with `/skill:{name}` and strip the command token from model prompts. +- a013bc0: Fix the perpetual step off-by-one: `fn_task_update` and `fn_review_step` now treat `step` as 0-based, matching the `### Step N:` numbering in PROMPT.md (Step 0 = Preflight) and `TaskStore.updateStep`. Previously the tools were 1-indexed while everything agent-facing was 0-based, so agents could not mark Step 0 done and reviews/progress landed one step early. +- 4c3186d: Prefer fresher TypeScript plugin source over stale gitignored dist output in dev/worktree plugin resolution when no `bundled.js` exists. Production bundled installs remain unaffected because `bundled.js` still always wins. +- 0767d1b: Generalize bundled plugin freshness checks across staged CLI plugin artifacts. +- 29b27a7: Improve Command Center tool analytics by categorizing Fusion tool families and re-bucketing historical `other` rows. +- 98ccf8a: Completed no-commit executions that finalize to in-review are no longer re-parked as failed "engine abort during pause/resume" operator-action graph failures; genuine pause and hard-cancel semantics are preserved. +- 4dd5337: Close cached CLI extension TaskStore instances on session shutdown so task-tool runs do not leave SQLite handles behind. +- 4929198: Lower the shared `fn_task_list` plain-text budget and cover filtered column listings with realistic regression cases so large todo, planning, and done outputs stay host-safe. +- 673a8a6: Fix `fn_task_list` column filters so empty target columns return explicit text instead of an empty content block. +- 58a34e9: Clamp Command Center SDLC completion analytics to cohort-based conversion rates and add the radial completion gauge plus animated live activity signals. +- 3d28b3b: Preserve already-streamed chat text, thinking, and tool-call state when the dashboard reattaches to an in-flight assistant response. +- dae0bde: Encourage dashboard chat agents to use structured `fn_ask_question` cards when offering choices or alternatives. +- ab8ecb2: Stop the engine from registering merge-trait hooks that collided with core's in-review field-effects adapter and could crash workflow-column moves. +- b1a2aee: Expose task document read/write tools to dashboard chat agents with explicit `task_id` targeting. +- 16b6e5d: Fix mobile iOS terminal cell measurement by making xterm font remeasure resilient to strict FontFaceSet shorthand rejection and pinning text-size adjustment on terminal viewports. +- 0ed46d9: Expose task document read/write tools to planning agents with explicit task IDs, matching chat session behavior. +- 3b32b53: Completed/no-commit executions that finalize to review no longer get re-parked failed when later teardown overwrites completion-finalize abort provenance with a hard-cancel marker. Genuine user/global pauses, merge-seam retry routing, and active-execution hard-cancel behavior are preserved. +- b6823af: Fix completed tasks being parked failed in in-review with a spurious "engine abort during pause/resume — operator action required" error (FN-6648; recurrence of FN-6478/FN-6568/FN-6625/FN-6644/FN-6647). The paused-after-completion graceful-exit path finalizes a fully completed task to in-review while leaving a non-user `paused` flag set; `handleGraphFailure`'s completion-finalized guards required `paused !== true`, so the trailing graph failure was misclassified as an operator-action pause abort once the volatile completion markers were lost. The classifier now recognizes finalized completions regardless of a lingering non-user pause flag, while genuine user/global pauses and in-progress tasks are unaffected. +- 2367918: Add attractive Command Center Overview charts for tokens by model, tool categories, and daily activity using existing analytics data. +- 662a09b: Add live animated Command Center Activity line charts for messages, active agents, active nodes, and combined throughput, backed by a reusable zero/NaN-safe LineChart primitive. +- 11c4120: Fix mobile terminal font measurement by keeping the symbols-only Nerd Font out of xterm's measured ASCII font stack while retaining a scoped DOM glyph fallback. +- 21d8076: Fix Command Center mobile chart rendering so chart primitives shrink inside the tabpanel without scroll-stealing overflow, zero-height collapse, or stretch artifacts, and normalize chart/card border and spacing rhythm across the combined analytics surfaces. +- ef54459: Fix Command Center token analytics so Tokens by model and the per-model table group tasks by the actually-used runtime model instead of collapsing resolved-via-settings usage into `(unknown)`. +- 317b08b: Command Center token-cost analytics now price resolved-via-settings task usage from the actually-used model snapshot, with legacy own-model fallback, instead of showing those costs as unavailable. +- fe207ca: Fix Command Center mobile chart rendering by bounding chart label/track layouts in real mobile engines and normalizing chart/card/table border spacing across the dashboard bundle. +- 0f021ae: Fix Command Center charts and shell styling to use the canonical `--accent` and `--text` dashboard tokens instead of undefined `--color-accent` and `--text-primary` aliases, so chart accents and primary text render with the intended colors. +- 282b069: Replace non-Command-Center dashboard CSS references to the undefined `--text-primary` alias with the canonical `--text` token so primary text uses the intended theme-aware color. +- 9d07e85: Fix served dashboard lazy-view preloads so persisted Command Center and other lazy views include their extracted CSS chunks. +- cfddde5: Fix Command Center activity chart rendering so plotted extrema stay visible and chart wrappers keep a measurable default height. +- cc02286: Inline direct and room chat attachments into agent prompts so agents can read text files and receive supported image attachments. +- 84cf3ff: Guard task detail activity-log rendering against legacy/operator log entries that use text/detail instead of action/outcome. +- 283f689: Repair mission autopilot reconciliation so stale triaged/in-progress features without live task cards are retriaged, while generated fix-loop debris is blocked instead of recreating duplicate tasks. + +## 0.43.1 + +### Patch Changes + +- 59f2596: Fix the standalone `fn plugin new` scaffold so generated plugins include the required `state: "installed"` field and build unedited with `pnpm build`. This also lets the documented `fn plugin dev . --once` path complete its pre-load build step instead of failing TypeScript validation for a missing `FusionPlugin.state`. + + Manual end-to-end spot-check for release validation: `npx @runfusion/fusion@ plugin new proof-point-plugin && cd proof-point-plugin && pnpm install && pnpm build && npx @runfusion/fusion@ plugin dev . --once`. + + Registry evidence captured for the original failing release: `npm view @runfusion/fusion@0.43.0 dist.integrity` returned `sha512-kvxicT+e8ulc7FDhBVP9NsgaioZv6NDW81N8cXNS/X8M32Eo3Y33xT6JFW2DrSiFXsJmAaib/GnpQE0nYQYApQ==`. + +- 1f540b2: Persist planning-session response history before agent continuation so retry/replay and SQLite session recovery retain answered turns when generation errors or transitions complete. +- 19eca3d: Park incomplete tasks that exhaust stuck-loop recovery instead of making them scheduler-runnable again. + +## 0.43.0 + +### Minor Changes + +- 9149121: Enable Z.ai GLM-5.2 model selection. +- 64de883: Make the built-in compound-engineering workflow run the CE way end-to-end: + + - **Execute** stage invokes the `compound-engineering:ce-work` skill in coding mode instead of the generic executor prompt. + - **Merge** stage adds `ce-commit-push-pr` and `ce-resolve-pr-feedback` skill steps (CE owns commit/push/PR + feedback; Fusion's merge seam still owns the board-state merge). The plugin now bundles `ce-commit`, `ce-commit-push-pr`, and `ce-resolve-pr-feedback`. + - **Planning questions reach a human:** workflow-step sessions carry a `FUSION_WORKFLOW_STEP` signal; in that mode the CE skills emit an await-input sentinel instead of calling a blocking tool with no listener. The executor parks the task `awaiting-user-input` with the question, and a new task-card **"Answer questions"** button opens the workflow tab where the existing input banner captures the answer and resumes the step. + - **Subagents work in workflow steps:** `fn_spawn_agent` gains an optional `systemPromptOverride`; the plugin installs the 43 `ce-*` persona definitions plugin-locally and exposes their directory via `FUSION_CE_AGENTS_DIR`, so the CE skills read a persona def and spawn it as a real subagent (falling back to inline single-agent work when unavailable). + +- e8c2d51: Add a one-click dashboard Update now action for installing available Fusion updates. + +### Patch Changes + +- 740c712: Inline the private `@fusion/core` types into the published `@runfusion/fusion/plugin-sdk` declaration entry so standalone external plugins created with `fn plugin new` can typecheck and `pnpm build` cleanly against released Fusion. Human spot-check: `npx @runfusion/fusion@0.42.0 plugin new proof-point-plugin && cd proof-point-plugin && pnpm install && pnpm build`. +- b1ba87e: Ensure `zai/glm-5.2` reliably appears in the model list after user Z.ai provider extensions load. +- 65a4c51: Recover stale Compound Engineering sessions on plugin load and session reads so persisted active rows without live agent handles no longer leave the dashboard stuck waiting for work that is not running. +- 20aad56: Fix "Couldn't start local Fusion" on the Linux AppImage (and any packaged build launched from a desktop launcher). The embedded local runtime now roots its data at the user's home directory (`~/.fusion`) instead of `process.cwd()`, which was `/` or the read-only AppImage mount point and caused database creation to fail with EACCES/EROFS. Set `FUSION_HOME` to override the location. +- 066c919: Preserve the original corrupt project database at `fusion.db` when startup recovery fails after moving it aside. +- 0d75725: Fixed unreliable horizontal scrolling when swiping across task cards on the mobile board. Native HTML5 drag is now disabled on touch-primary devices (where it never worked anyway), so the browser no longer hijacks swipe-to-scroll gestures that start on a card. +- 67ae2be: Fix two mobile chat send failures. The regular chat send button was dead to touch because the action only ran on `onClick`, which iOS suppresses after `preventDefault()` in the touch sequence — it now fires from pointerdown/touchstart with a dedupe latch. Quick chat messages could strand in the composer (shown locally but never sent to the agent or persisted) when a queued message's delivery trigger bailed — a dropped stream leaving the streaming flag stuck `true`, or a stream that looked healthy when queued but then stalled. A queued send now detects a stale flag at send time via the stream's connection state and the server's generation status, and a delivery watchdog re-confirms any message that stays pending and force-delivers it once no generation is actually in flight. +- fd6caaa: Fix sporadic quick chat send failures on mobile (notably the first message after a response). A real touch tap dispatches both `pointerdown` and `touchstart`, and the quick chat send button ran its action on each — firing `handleSendMessage` twice per tap. Because React had not yet flushed the composer clear between the two events, both reads saw the same text and sent, and the hook's second send closed the first's freshly-opened stream and re-POSTed, which could drop the response. The send and stop buttons now claim a single action per tap so only the first of the paired events fires. +- 9eeaaa7: Fix the quick chat stop button rendering too narrow. It borrowed ChatView's `.chat-input-stop` styling, which sizes itself with `--chat-input-control-size` — a variable scoped to ChatView's composer and undefined in the quick chat DOM — collapsing the button toward its icon width. It is now pinned to the send button's square dimensions. +- ee6d7ac: Workflow step execution now surfaces task attachment locations in the context-recovery prompt path and no longer tells autonomous agents to ask for context. +- 14ed177: Restored horizontal swiping on mobile kanban board columns while preserving page-level horizontal pan containment. +- df01ab7: Fix Create Pull Request conflict preflight to derive `conflictsWithBase` from `git merge-tree --write-tree` exit codes instead of non-empty output, and treat no-op PR conflict resolution merges as successful without attempting an empty commit. +- 96773dd: Fix standalone installs of the published CLI crashing with `ERR_MODULE_NOT_FOUND` for `@earendil-works/pi-coding-agent`. `@earendil-works/pi-coding-agent` and `@earendil-works/pi-ai` are now plain required dependencies instead of also being optional peers, so clean npm and pnpm installs resolve the pi runtime packages. +- 67d4d51: Move task-card timing badges from the top metadata cluster into the bottom-right footer chip cluster so timers align with retry and GitHub footer badges. +- 7b83906: Run the configured or inferred dependency install inside temporary standalone AI-merge clean-room worktrees before merge/review verification. +- be2773b: Fix scheduler concurrency diagnostics and semaphore slot accounting so queued tasks are not held behind contradictory or negative capacity readings. +- 3cc82bd: Fix mobile board horizontal overflow that caused iOS Safari to zoom-out/cut-off the board and let the whole page pan off-screen. Screen-reader-only `.visually-hidden` spans were `position: absolute` with no offsets, so inside the horizontally-scrolled kanban columns they rendered off-screen-right and ballooned the document's scroll width. Pinning the utility to its containing block's origin keeps the document locked to the viewport on mobile. +- aa71ace: Refresh expired Claude OAuth access tokens from Fusion auth storage instead of requiring repeated manual re-login. +- 417183d: Send task-detail Chat composer messages on plain Enter while preserving Shift+Enter newlines and Cmd/Ctrl+Enter sending. + ## 0.42.0 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 560b4de631..a45d2b9f30 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@runfusion/fusion", - "version": "0.42.0", + "version": "0.44.0", "license": "MIT", "description": "Fusion CLI: HTTP API server, daemon, dashboard launcher, and task tooling for the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", @@ -74,17 +74,9 @@ "ws": "^8.18.0" }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*", "typebox": "*" }, "peerDependenciesMeta": { - "@earendil-works/pi-ai": { - "optional": true - }, - "@earendil-works/pi-coding-agent": { - "optional": true - }, "typebox": { "optional": true } diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index 2a76fe16a3..92293fc9ed 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -31,7 +31,7 @@ Mission → Milestone → Slice → Feature → Task - **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues` - **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_list_goals`, `fn_mission_link_goal`, `fn_mission_unlink_goal`, `fn_mission_backfill_assertions`, `fn_mission_delete`, `fn_mission_update`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_feature_delete`, `fn_slice_delete`, `fn_milestone_delete`, `fn_slice_activate`, `fn_feature_link_task`, `fn_feature_update`, `fn_milestone_update` - **Goal tools** — `fn_goal_list`, `fn_goal_create`, `fn_goal_archive`, `fn_goal_show` -- **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart` +- **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_set_instructions`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart` - **Skills tools** — `fn_skills_search`, `fn_skills_install` - **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show` - **Other tools** — `fn_web_fetch`, `fn_secret_get`, `fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`, `fn_experiment_finalize` diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 19c8ce6e51..e91335396b 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -13,8 +13,8 @@ These tools are **not** part of the user-invokable extension surface. They are i |---|---|---|---| | `fn_task_create` | triage, executor, heartbeat | Create a follow-up task from within an agent run | `description` (string), `dependencies?` (string[]), `priority?` (`low` \| `normal` \| `high` \| `urgent`), `workflow_id?` (string) | | `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) | -| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) | -| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) | +| `fn_task_document_write` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string); chat/planning also require `task_id` (string) | +| `fn_task_document_read` | triage, executor, heartbeat; chat/planning (explicit `task_id`) | Read one task document or list all | `key?` (string); chat/planning also require `task_id` (string) | | `fn_goal_list` | triage, executor, heartbeat | List goals with concise citation-ready snippets and active-goal warning details | `status?` (`active` \| `archived` \| `all`) | | `fn_goal_show` | triage, executor, heartbeat | Show one goal's full detail on demand, including the full description body | `id` (string) | | `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none | @@ -30,6 +30,8 @@ These tools are **not** part of the user-invokable extension surface. They are i | `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_ask_question` | chat | Ask the user a structured question that renders as an interactive chat card; after calling it, end the turn and wait for the user's next message | `questions` (array of objects with `question`, optional `header`, optional `description`, optional `type`, optional `options`, optional `multiSelect`) | | `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, 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) | @@ -68,10 +70,10 @@ Note: step-session execution (`step-session-executor.ts`) reuses executor coordi | Tool | Purpose | Parameters | |---|---|---| -| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`), task dependencies, and/or workflow-defined custom field values | `step?` (number, 1-indexed), `status?` (enum), `dependencies?` (string[]), `custom_fields?` (object keyed by field id; validated against the workflow field schema, `null` clears a field) | +| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`), task dependencies, and/or workflow-defined custom field values | `step?` (number, 0-indexed; matches `### Step N:` in PROMPT.md, Step 0 = Preflight), `status?` (enum), `dependencies?` (string[]), `custom_fields?` (object keyed by field id; validated against the workflow field schema, `null` clears a field) | | `fn_task_add_dep` | Add a dependency to current task (confirmation-gated) | `task_id` (string), `confirm?` (boolean) | | `fn_task_done` | Mark task complete and optionally store summary | `summary?` (string) | -| `fn_review_step` | Spawn step plan/code reviewer | `step` (number), `type` (`plan` \| `code`), `step_name` (string), `baseline?` (string) | +| `fn_review_step` | Spawn step plan/code reviewer | `step` (number, 0-indexed; matches `### Step N:` in PROMPT.md), `type` (`plan` \| `code`), `step_name` (string), `baseline?` (string) | | `fn_spawn_agent` | Spawn child agent in separate worktree | `name` (string), `role` (enum), `task` (string) | ## Merger-only runtime tools (`merger.ts`) diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 34845be5d9..1ef669d999 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -418,6 +418,16 @@ Create a new non-ephemeral agent. | `max_concurrent_runs` | number | — | | | `message_response_mode` | union | — | | +### fn_agent_set_instructions + +Set the instructionsText and/or instructionsPath of one of the caller's direct or indirect reports. At least one of instructions_text or instructions_path is required; pass an empty string to clear a field. The change is persisted and recorded as a config revision. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `agent_id` | string | ✓ | Target agent whose instructions to set | +| `instructions_text` | string | — | Inline instructions. Pass an empty string to clear. | +| `instructions_path` | string | — | Path to a markdown instructions file. Pass an empty string to clear. | + ### fn_agent_delete Delete a non-ephemeral agent. diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 7430fc20b6..bfb8800676 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -67,6 +67,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names | `fn_agent_stop` | Stop a running agent — pauses its execution. Transitions the agent from running/active to paused state. | | `fn_agent_start` | Start a stopped agent — resumes its execution. Transitions the agent from paused to active state. | | `fn_agent_create` | Create a new non-ephemeral agent. | +| `fn_agent_set_instructions` | Set the instructionsText and/or instructionsPath of one of the caller's direct or indirect reports. At least one of instructions_text or instructions_path is required; pass an empty string to clear a field. The change is persisted and recorded as a config revision. | | `fn_agent_delete` | Delete a non-ephemeral agent. | | `fn_list_agents` | List all available agents in the system. Shows each agent's name, role, state, personality (soul), and current assignment. Use this to discover which agents exist and what they specialize in before delegating work. | | `fn_delegate_task` | Create a new task and assign it to a specific agent for execution. The task goes to 'todo' and will be picked up by the target agent on their next heartbeat cycle. Use fn_list_agents first to find available agents and their capabilities. Optionally pass workflow_id to select a workflow at creation time; use fn_workflow_list to discover valid IDs. | diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts index 7911b8d387..ddc93fee31 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -69,12 +69,22 @@ describe("dev-with-memory prebuild options", () => { ]); }); - it("defaults dashboard startup to client-only prebuild instead of full workspace build", () => { + it("rebuilds core + engine + dashboard (UI) for dashboard startup, not the full workspace", () => { + // FN-6638/stale-dist: dev dashboard must refresh engine + core dist (not + // just the client bundle) so landed fixes are not silently stale. expect(resolvePrebuildMode("auto", ["dashboard", "--port", "4050"])).toBe("client"); expect(getPrebuildCommand("client")).toEqual({ command: "pnpm", - args: ["--filter", "@fusion/dashboard", "build:client"], - label: "dashboard client build", + args: [ + "--filter", + "@fusion/core", + "--filter", + "@fusion/engine", + "--filter", + "@fusion/dashboard", + "build", + ], + label: "core + engine + dashboard build", }); }); diff --git a/packages/cli/src/__tests__/docs-readme-index.test.ts b/packages/cli/src/__tests__/docs-readme-index.test.ts index e596db7a29..df5291c212 100644 --- a/packages/cli/src/__tests__/docs-readme-index.test.ts +++ b/packages/cli/src/__tests__/docs-readme-index.test.ts @@ -7,10 +7,19 @@ const docsReadmePath = resolve(workspaceRoot, "docs", "README.md"); const requiredDocs = [ "docs/dev-server-modules.md", + "docs/workflow-editor.md", + "docs/plugins/external-proof-point-runbook.md", "docs/research/pi-autoresearch-analysis.md", "docs/research/research-hardening-preflight.md", + "docs/upstream/claude-code-cli-acp-mcp-permission-forwarding.md", ] as const; +/* +FNXC:DocsIndex 2026-06-15-01:35: +FN-6479 keeps CLI Printing Press design and research entries indexed only as Audit Reports, not duplicated in Plugins. +This test guards the documentation-index dedup invariant while requiredDocs guards committed upstream artifacts that must remain discoverable. +*/ + describe("docs README index", () => { it("includes links for required docs and those files exist", () => { expect(existsSync(docsReadmePath)).toBe(true); @@ -22,4 +31,18 @@ describe("docs README index", () => { expect(existsSync(resolve(workspaceRoot, relativePath))).toBe(true); } }); + + it("keeps CLI Printing Press entries in Audit Reports only", () => { + const docsReadme = readFileSync(docsReadmePath, "utf8"); + const pluginsHeadingIndex = docsReadme.indexOf("### Plugins"); + expect(pluginsHeadingIndex).toBeGreaterThanOrEqual(0); + + const nextHeadingIndex = docsReadme.indexOf("\n### ", pluginsHeadingIndex + 1); + expect(nextHeadingIndex).toBeGreaterThan(pluginsHeadingIndex); + + const pluginsSection = docsReadme.slice(pluginsHeadingIndex, nextHeadingIndex); + expect(pluginsSection).not.toContain("cli-printing-press"); + expect(docsReadme).toContain("./design/cli-printing-press-plugin.md"); + expect(docsReadme).toContain("./research/cli-printing-press.md"); + }); }); diff --git a/packages/cli/src/__tests__/docs-screenshot-links.test.ts b/packages/cli/src/__tests__/docs-screenshot-links.test.ts new file mode 100644 index 0000000000..97adc882c4 --- /dev/null +++ b/packages/cli/src/__tests__/docs-screenshot-links.test.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; +import { describe, expect, it } from "vitest"; + +const workspaceRoot = resolve(import.meta.dirname, "../../../.."); +const docsRoot = resolve(workspaceRoot, "docs"); + +/* +FNXC:DocsScreenshots 2026-06-17-00:38: +Published docs render on GitHub and in fresh clones, so screenshot image references must resolve to committed files, not only developer-local files that happen to exist on disk. +Assert both filesystem presence and `git ls-files` tracking so a gitignored-but-present `docs/screenshots/` directory cannot regress silently. +*/ + +function collectMarkdownFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = resolve(directory, entry.name); + if (entry.isDirectory()) { + return collectMarkdownFiles(entryPath); + } + if (entry.isFile() && entry.name.endsWith(".md")) { + return [entryPath]; + } + return []; + }); +} + +function toRepoRelativePath(absolutePath: string): string { + return relative(workspaceRoot, absolutePath).split(sep).join("/"); +} + +function gitTracks(relativePath: string): boolean { + const output = execFileSync("git", ["ls-files", "--", relativePath], { + cwd: workspaceRoot, + encoding: "utf8", + }).trim(); + return output.length > 0; +} + +describe("docs screenshot links", () => { + it("points every screenshot image reference at an existing tracked asset", () => { + const markdownFiles = [...collectMarkdownFiles(docsRoot), resolve(workspaceRoot, "README.md")]; + const screenshotReferences: Array<{ source: string; target: string; resolvedPath: string; repoPath: string }> = []; + + for (const markdownFile of markdownFiles) { + const markdown = readFileSync(markdownFile, "utf8"); + const imagePattern = /!\[[^\]]*\]\(([^)]+)\)/g; + for (const match of markdown.matchAll(imagePattern)) { + const rawTarget = match[1]?.trim().replace(/^<|>$/g, "") ?? ""; + const targetWithoutTitle = rawTarget.split(/\s+/)[0] ?? ""; + const targetWithoutFragment = targetWithoutTitle.replace(/[?#].*$/, ""); + if (!/(?:^|\/)screenshots\/[^/]+\.png$/i.test(targetWithoutFragment)) { + continue; + } + + const resolvedPath = resolve(dirname(markdownFile), targetWithoutFragment); + screenshotReferences.push({ + source: toRepoRelativePath(markdownFile), + target: targetWithoutTitle, + resolvedPath, + repoPath: toRepoRelativePath(resolvedPath), + }); + } + } + + expect(screenshotReferences.map(({ repoPath }) => repoPath).sort()).toEqual([ + "docs/screenshots/agents-view.png", + "docs/screenshots/chat-view.png", + "docs/screenshots/dashboard-overview.png", + "docs/screenshots/dashboard-overview.png", + "docs/screenshots/dashboard-overview.png", + "docs/screenshots/documents-view.png", + "docs/screenshots/git-manager.png", + "docs/screenshots/list-view.png", + "docs/screenshots/mailbox-view.png", + "docs/screenshots/memory-view.png", + "docs/screenshots/mission-manager.png", + "docs/screenshots/nodes-view.png", + "docs/screenshots/skills-view.png", + "docs/screenshots/task-detail.png", + "docs/screenshots/task-detail.png", + "docs/screenshots/terminal.png", + "docs/screenshots/workflow-steps.png", + ]); + + const missingFiles = screenshotReferences + .filter(({ resolvedPath }) => !existsSync(resolvedPath)) + .map(({ source, target, repoPath }) => `${source} -> ${target} (${repoPath})`); + const untrackedFiles = screenshotReferences + .filter(({ repoPath }) => !gitTracks(repoPath)) + .map(({ source, target, repoPath }) => `${source} -> ${target} (${repoPath})`); + + expect(missingFiles).toEqual([]); + expect(untrackedFiles).toEqual([]); + }); +}); diff --git a/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts b/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts new file mode 100644 index 0000000000..4b504c499b --- /dev/null +++ b/packages/cli/src/__tests__/extension-agent-set-instructions.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { AgentStore } from "@fusion/core"; +import kbExtension from "../extension.js"; + +function createMockAPI() { + const tools = new Map(); + return { + registerTool(def: any) { + tools.set(def.name, def); + }, + registerCommand() {}, + registerShortcut() {}, + registerFlag() {}, + on() {}, + tools, + } as any; +} + +async function withOrg( + run: (ctx: { + cwd: string; + tool: any; + agentStore: AgentStore; + ids: { manager: string; middle: string; leaf: string; peer: string }; + }) => Promise, +): Promise { + const cwd = await mkdtemp(join(tmpdir(), "fn-ext-agent-instructions-")); + const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") }); + try { + await agentStore.init(); + const manager = await agentStore.createAgent({ name: "manager", role: "engineer", metadata: {} }); + const middle = await agentStore.createAgent({ + name: "middle-manager", + role: "engineer", + reportsTo: manager.id, + metadata: {}, + }); + const leaf = await agentStore.createAgent({ + name: "leaf-agent", + role: "executor", + reportsTo: middle.id, + metadata: {}, + }); + const peer = await agentStore.createAgent({ name: "peer-agent", role: "executor", metadata: {} }); + + const api = createMockAPI(); + kbExtension(api); + const tool = api.tools.get("fn_agent_set_instructions"); + expect(tool).toBeTruthy(); + + await run({ + cwd, + tool, + agentStore, + ids: { manager: manager.id, middle: middle.id, leaf: leaf.id, peer: peer.id }, + }); + } finally { + agentStore.close(); + await rm(cwd, { recursive: true, force: true }); + } +} + +describe("fn_agent_set_instructions", () => { + it("allows a manager to set inline instructions for a direct report", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-1", + { agent_id: ids.middle, instructions_text: "Direct report instructions" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(result.isError).not.toBe(true); + expect(result.details).toMatchObject({ outcome: "updated", agentId: ids.middle }); + expect(result.details.updatedFields).toEqual(["instructionsText"]); + await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({ + instructionsText: "Direct report instructions", + }); + }); + }); + + it("allows a manager to set instructions for an indirect report", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-2", + { agent_id: ids.leaf, instructions_text: "Grandchild instructions" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(result.isError).not.toBe(true); + expect(result.details).toMatchObject({ outcome: "updated", agentId: ids.leaf }); + await expect(agentStore.getAgent(ids.leaf)).resolves.toMatchObject({ + instructionsText: "Grandchild instructions", + }); + }); + }); + + it("rejects peer or unrelated targets and leaves instructions unchanged", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + await agentStore.updateAgent(ids.peer, { instructionsText: "Original peer instructions" }); + + const result = await tool.execute( + "call-3", + { agent_id: ids.peer, instructions_text: "Unauthorized edit" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(result.isError).toBe(true); + expect(result.details).toMatchObject({ outcome: "denied", rule: "direct-or-indirect-reports-only" }); + await expect(agentStore.getAgent(ids.peer)).resolves.toMatchObject({ + instructionsText: "Original peer instructions", + }); + }); + }); + + it("rejects self-targeting", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-4", + { agent_id: ids.manager, instructions_text: "Self edit" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("direct or indirect reports"); + expect((await agentStore.getAgent(ids.manager))?.instructionsText).toBeUndefined(); + }); + }); + + it("rejects upward edits from a subordinate to its manager", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-5", + { agent_id: ids.manager, instructions_text: "Upward edit" }, + undefined, + undefined, + { cwd, agentId: ids.leaf }, + ); + + expect(result.isError).toBe(true); + expect(result.details).toMatchObject({ outcome: "denied", rule: "direct-or-indirect-reports-only" }); + expect((await agentStore.getAgent(ids.manager))?.instructionsText).toBeUndefined(); + }); + }); + + it("allows privileged user calls without ctx.agentId to update any agent", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const result = await tool.execute( + "call-6", + { agent_id: ids.peer, instructions_text: "Privileged user edit" }, + undefined, + undefined, + { cwd }, + ); + + expect(result.isError).not.toBe(true); + await expect(agentStore.getAgent(ids.peer)).resolves.toMatchObject({ + instructionsText: "Privileged user edit", + }); + }); + }); + + it("sets instructions_path without changing text and clears fields with explicit empty strings", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + await agentStore.updateAgent(ids.middle, { + instructionsText: "Keep this text", + instructionsPath: "old.md", + }); + + const setPathResult = await tool.execute( + "call-7", + { agent_id: ids.middle, instructions_path: "new.md" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(setPathResult.isError).not.toBe(true); + expect(setPathResult.details.updatedFields).toEqual(["instructionsPath"]); + await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({ + instructionsText: "Keep this text", + instructionsPath: "new.md", + }); + + const clearResult = await tool.execute( + "call-8", + { agent_id: ids.middle, instructions_text: "", instructions_path: "" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + + expect(clearResult.isError).not.toBe(true); + await expect(agentStore.getAgent(ids.middle)).resolves.toMatchObject({ + instructionsText: "", + instructionsPath: "", + }); + }); + }); + + it("returns validation errors for missing agents and omitted instruction fields", async () => { + await withOrg(async ({ cwd, tool, agentStore, ids }) => { + const missingTarget = await tool.execute( + "call-9", + { agent_id: "agent-does-not-exist", instructions_text: "No target" }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + expect(missingTarget.isError).toBe(true); + expect(missingTarget.details.outcome).toBe("not_found"); + + const missingFields = await tool.execute( + "call-10", + { agent_id: ids.middle }, + undefined, + undefined, + { cwd, agentId: ids.manager }, + ); + expect(missingFields.isError).toBe(true); + expect(missingFields.details.outcome).toBe("invalid"); + expect((await agentStore.getAgent(ids.middle))?.instructionsText).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli/src/__tests__/extension-integration.test.ts b/packages/cli/src/__tests__/extension-integration.test.ts index 680a466fa2..730d9ff565 100644 --- a/packages/cli/src/__tests__/extension-integration.test.ts +++ b/packages/cli/src/__tests__/extension-integration.test.ts @@ -7,10 +7,15 @@ import { setTimeout as delay } from "node:timers/promises"; import { AgentStore, TaskStore } from "@fusion/core"; import { buildCliWithRealDashboardAssets, - extensionBundlePath, + cliRoot, } from "./bundle-output-helpers"; -vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 }); +/* +FNXC:CliTests 2026-06-14-03:43: +This opt-in built-extension integration suite keeps the one-time 300s beforeAll build override, but every per-test and per-hook path must stay under Vitest's default 5s test and 10s hook caps. +FN-6436 removed the hidden file-wide 30s timeout appeasement after FN-6430 fixed the shared CLI isolation path and FN-6431 established the sibling REMOVE audit pattern. +*/ +const extensionBundlePath = join(cliRoot, "dist", "extension.js"); const SHOULD_RUN_EXTENSION_INTEGRATION = process.env.FUSION_TEST_EXTENSION_INTEGRATION === "1" || @@ -216,7 +221,7 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr const deleteTool = api.tools.get("fn_agent_delete")!; const deleted = await deleteTool.execute( "delete-agent-1", - { id: created.details.agentId }, + { agent_id: created.details.agentId }, undefined, undefined, makeCtx(tmpDir), @@ -260,9 +265,18 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr it("returns explicit error when fn_delegate_task hits task-id collision", async () => { const agent = await seedAgent(tmpDir, { name: "release-agent" }); - const delegateTool = api.tools.get("fn_delegate_task")!; - const createSpy = vi.spyOn(TaskStore.prototype, "createTask").mockRejectedValueOnce(new Error("Task ID already exists: FN-001")); + const store = new TaskStore(tmpDir); + await store.init(); + store.getDatabase().exec(` + CREATE TRIGGER force_delegate_collision + BEFORE INSERT ON tasks + WHEN NEW.description = 'collision task' + BEGIN + SELECT RAISE(ABORT, 'Task ID already exists: FN-001'); + END; + `); + const delegateTool = api.tools.get("fn_delegate_task")!; const result = await delegateTool.execute( "delegate-collision", { agent_id: agent.id, description: "collision task" }, @@ -274,6 +288,5 @@ describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integr expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Task ID already exists: FN-001"); expect(result.details.error).toContain("Task ID already exists: FN-001"); - createSpy.mockRestore(); }); }); diff --git a/packages/cli/src/__tests__/extension-task-tools.test.ts b/packages/cli/src/__tests__/extension-task-tools.test.ts index 7debb9de66..d517332342 100644 --- a/packages/cli/src/__tests__/extension-task-tools.test.ts +++ b/packages/cli/src/__tests__/extension-task-tools.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +/* +FNXC:CliTests 2026-06-14-01:25: +FN-6430 requires rescued CLI suites to run on the default timeout after shared HOME isolation, not via the older file-wide 20s timeout. +Keep this worktree-root regression slice fast by relying on module resets and bounded temp fixtures. -vi.setConfig({ testTimeout: 20000, hookTimeout: 20000 }); +FNXC:CliTests 2026-06-15-07:44: +FN-6486 rescues this load-only timeout by closing each real TaskStore before removing its temp root and by using non-hoisted mock cleanup. The suite keeps the worktree-root regression coverage without widening timeouts, adding retries, or changing package worker settings. + +FNXC:CliTests 2026-06-17-23:58: +FN-6626 requires these canonical-project-root tool tests to close the extension module's cached TaskStore instances after every case, because fixture-store cleanup alone does not close the second store opened by fn_task_show/fn_task_list. +*/ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -11,8 +20,11 @@ function makeCtx(cwd: string) { return { cwd } as any; } +let closeLoadedExtensionStores: (() => void) | undefined; + async function loadExtension() { const mod = await import("../extension.js"); + closeLoadedExtensionStores = mod.closeCachedStores; return mod.default; } @@ -26,8 +38,10 @@ describe("extension task tools resolve repo root from worktrees", () => { }); afterEach(() => { + closeLoadedExtensionStores?.(); + closeLoadedExtensionStores = undefined; vi.restoreAllMocks(); - vi.unmock("@fusion/core"); + vi.doUnmock("@fusion/core"); }); it("exports getProjectRootFromWorktree from @fusion/core", () => { @@ -37,10 +51,11 @@ describe("extension task tools resolve repo root from worktrees", () => { it("uses canonical project root for fn_task_show and fn_task_list from worktree cwd", async () => { const repoRoot = await mkdtemp(join(tmpdir(), "fn-4904-cli-")); const worktreeRoot = join(repoRoot, ".worktrees", "feature"); + let store: TaskStore | undefined; try { await mkdir(join(repoRoot, ".fusion"), { recursive: true }); - const store = new TaskStore(repoRoot); + store = new TaskStore(repoRoot); await store.init(); const created = await store.createTask({ description: "Task from canonical root" }); @@ -71,6 +86,7 @@ describe("extension task tools resolve repo root from worktrees", () => { expect(show.content[0].text).toContain("Task from canonical root"); expect(list.content[0].text).toContain(created.id); } finally { + store?.close(); await rm(repoRoot, { recursive: true, force: true }); } }); @@ -78,6 +94,7 @@ describe("extension task tools resolve repo root from worktrees", () => { it("uses canonical project root for task tools from AI merge temp linked worktrees", async () => { const repoRoot = await mkdtemp(join(tmpdir(), "fn-6079-cli-")); const mergeRoot = await mkdtemp(join(tmpdir(), "fusion-ai-merge-fn-6079-")); + let store: TaskStore | undefined; try { git(repoRoot, "init -q -b main"); git(repoRoot, "config user.email test@example.com"); @@ -86,7 +103,7 @@ describe("extension task tools resolve repo root from worktrees", () => { git(repoRoot, "add -A"); git(repoRoot, "commit -q -m base"); - const store = new TaskStore(repoRoot); + store = new TaskStore(repoRoot); await store.init(); const created = await store.createTask({ description: "Task visible from merge worktree" }); git(repoRoot, `worktree add --detach ${JSON.stringify(mergeRoot)} HEAD`); @@ -113,6 +130,7 @@ describe("extension task tools resolve repo root from worktrees", () => { expect(show.content[0].text).toContain("Task visible from merge worktree"); expect(list.content[0].text).toContain(created.id); } finally { + store?.close(); try { git(repoRoot, `worktree remove --force ${JSON.stringify(mergeRoot)}`); } catch { @@ -126,10 +144,11 @@ describe("extension task tools resolve repo root from worktrees", () => { it("falls back when getProjectRootFromWorktree is unavailable in no-task context", async () => { const repoRoot = await mkdtemp(join(tmpdir(), "fn-4927-cli-")); const worktreeRoot = join(repoRoot, ".worktrees", "ambient"); + let store: TaskStore | undefined; try { await mkdir(join(repoRoot, ".fusion"), { recursive: true }); - const store = new TaskStore(repoRoot); + store = new TaskStore(repoRoot); await store.init(); const created = await store.createTask({ description: "Ambient tool check" }); @@ -166,6 +185,7 @@ describe("extension task tools resolve repo root from worktrees", () => { expect(show.content[0]?.text).toContain(created.id); expect(warnSpy).toHaveBeenCalledTimes(1); } finally { + store?.close(); await rm(repoRoot, { recursive: true, force: true }); } }); diff --git a/packages/cli/src/__tests__/extension.test.ts b/packages/cli/src/__tests__/extension.test.ts index 2815f7ee0b..6979fb7956 100644 --- a/packages/cli/src/__tests__/extension.test.ts +++ b/packages/cli/src/__tests__/extension.test.ts @@ -1,19 +1,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { setTimeout as delay } from "node:timers/promises"; -// Each test spins up a fresh temp workspace, mounts the full extension API, -// registers tools, and exercises them through real TaskStore/MissionStore -// machinery (atomic JSON writes, ID allocator with disk sync, async memory -// flushes). Under heavy parallel FS load on a busy machine, individual -// tests can occasionally cross 5s — and the same load also produces -// ENOTEMPTY teardown races when async work outlives the test body. A -// generous testTimeout absorbs both effects without masking real bugs: -// any test that genuinely hangs will still trip the bump, and the suite -// already runs well under the cap on a quiet machine. -vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 }); +/* +FNXC:CliTests 2026-06-14-01:22: +FN-6430 rescues the extension suite by fixing shared HOME isolation and closing research stores in the active slice, not by preserving the older file-wide timeout bump. +Keep this file on the default 5s Vitest timeout so future slow seams are narrowed or quarantined instead of hidden. +*/ vi.mock("@fusion/core/gh-cli", () => ({ isGhAvailable: vi.fn(() => true), @@ -26,12 +22,15 @@ vi.mock("../commands/task.js", () => ({ runTaskPlan: vi.fn(), })); -import kbExtension from "../extension.js"; -import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES } from "@fusion/core"; +import kbExtension, { resolveTaskListFormatter } from "../extension.js"; +import { TaskStore, AgentStore, MANUAL_RETRY_RESET_COUNTER_KEYS, RESEARCH_RUN_STATUSES, MAX_TASK_LIST_TEXT_CHARS, formatTaskListText, COLUMN_LABELS } from "@fusion/core"; import type { WorkflowIr } from "@fusion/core"; import { isGhAvailable, isGhAuthenticated, runGhJsonAsync } from "@fusion/core/gh-cli"; +import { hasBuiltCoreDistBarrel } from "@fusion/test-utils"; import { runTaskPlan } from "../commands/task.js"; +const __dirname = dirname(fileURLToPath(import.meta.url)); + // ── Mock ExtensionAPI that captures registrations ────────────────── interface RegisteredTool { @@ -2552,6 +2551,379 @@ describe("fn pi extension (runnable structured-output regression slice)", () => expect(result.content[0].text).toContain(result.details.taskId); }); + describe("fn_task_list", () => { + const HOST_SAFE_TASK_LIST_TEXT_CEILING = 3_000; + + function expectSingleBoundedTextBlock(result: any) { + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toBeTruthy(); + expect(result.content[0].text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + expect(result.content[0].text.length).toBeLessThanOrEqual(HOST_SAFE_TASK_LIST_TEXT_CEILING); + } + + function realisticTaskTitle(column: string, index: number) { + return `${column} realistic task ${String(index).padStart(3, "0")} keeps enough descriptive context for text agents without artificial padding`; + } + + it("returns bounded text for omitted and provided column/limit params", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + try { + await store.createTask({ description: "Planning task one" }); + await store.createTask({ description: "Todo task one", column: "todo" }); + } finally { + store.close(); + } + + const listTool = api.tools.get("fn_task_list")!; + for (const [callId, params] of [ + ["list-all-default", {}], + ["list-todo-default", { column: "todo" }], + ["list-todo-large-limit", { column: "todo", limit: 50 }], + ] as const) { + const result = await listTool.execute(callId, params, undefined, undefined, makeCtx(tmpDir)); + expectSingleBoundedTextBlock(result); + expect(result.details.count).toBe(2); + } + }); + + it("returns explicit text for empty active-column filters on a non-empty board", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + try { + await store.createTask({ description: "Finished task keeps the board non-empty", column: "done" }); + } finally { + store.close(); + } + + const listTool = api.tools.get("fn_task_list")!; + for (const column of ["triage", "todo", "in-progress", "in-review"] as const) { + const result = await listTool.execute( + `empty-${column}`, + { column }, + undefined, + undefined, + makeCtx(tmpDir), + ); + const text = result.content[0].text; + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content.some((block: any) => block.type === "image")).toBe(false); + expect(text).toBeTruthy(); + expect(text.trim()).not.toBe(""); + expect(text).toContain(COLUMN_LABELS[column]); + expect(text).toContain(column); + expect(result.details.count).toBe(1); + } + }); + + it("keeps small column-filtered listings complete without the clamp marker", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + try { + const first = await store.createTask({ description: "Small todo task one", column: "todo" }); + await store.createTask({ description: "Small todo task two", column: "todo", dependencies: [first.id] }); + } finally { + store.close(); + } + + const listTool = api.tools.get("fn_task_list")!; + const result = await listTool.execute( + "list-small-todo", + { column: "todo", limit: 50 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + const text = result.content[0].text; + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content.some((block: any) => block.type === "image")).toBe(false); + expect(text).toBeTruthy(); + expect(text.trim()).not.toBe(""); + expect(text).toContain("Todo (2):"); + expect(text).toContain("FN-001"); + expect(text).toContain("FN-002"); + expect(text).toContain("[deps: FN-001]"); + expect(text).not.toContain("No tasks in Todo (todo)."); + expect(text).not.toContain("truncated to fit; narrow with column/limit"); + expect(result.details.count).toBe(2); + }); + + it("bounds realistic column-filtered listings below the host-safe text budget", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + try { + const todoFirst = await store.createTask({ + title: realisticTaskTitle("todo", 1), + description: "Realistic todo task 001", + column: "todo", + }); + for (let i = 2; i <= 60; i += 1) { + await store.createTask({ + title: realisticTaskTitle("todo", i), + description: `Realistic todo task ${String(i).padStart(3, "0")}`, + column: "todo", + dependencies: [todoFirst.id], + }); + } + for (let i = 1; i <= 35; i += 1) { + await store.createTask({ + title: realisticTaskTitle("triage", i), + description: `Realistic triage task ${String(i).padStart(3, "0")}`, + }); + } + for (let i = 1; i <= 30; i += 1) { + await store.createTask({ + title: realisticTaskTitle("done", i), + description: `Realistic done task ${String(i).padStart(3, "0")}`, + column: "done", + }); + } + } finally { + store.close(); + } + + const listTool = api.tools.get("fn_task_list")!; + const broadResult = await listTool.execute( + "list-realistic-broad", + { limit: 20 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + expectSingleBoundedTextBlock(broadResult); + expect(broadResult.content.some((block: any) => block.type === "image")).toBe(false); + expect(broadResult.content[0].text).toContain("Planning (35):"); + expect(broadResult.details.count).toBe(125); + + for (const { callId, params, header, ids } of [ + { + callId: "list-realistic-todo", + params: { column: "todo", limit: 50 }, + header: "Todo (60):", + ids: ["FN-001", "FN-002"], + }, + { + callId: "list-realistic-triage", + params: { column: "triage", limit: 50 }, + header: "Planning (35):", + ids: ["FN-061", "FN-062"], + }, + { + callId: "list-realistic-done", + params: { column: "done", limit: 50 }, + header: "Done (30):", + ids: ["FN-096", "FN-097"], + }, + ] as const) { + const result = await listTool.execute(callId, params, undefined, undefined, makeCtx(tmpDir)); + const text = result.content[0].text; + + expectSingleBoundedTextBlock(result); + expect(result.content.some((block: any) => block.type === "image")).toBe(false); + expect(text).toContain(header); + for (const id of ids) { + expect(text).toContain(id); + } + expect(text).toContain("truncated to fit; narrow with column/limit"); + expect(result.details.count).toBe(125); + } + }); + + it("bounds broad listings as a single plain-text block", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + try { + for (let i = 1; i <= 60; i += 1) { + await store.createTask({ + title: `Planning task ${String(i).padStart(3, "0")} ${"x".repeat(1_600)}`, + description: `Large planning task ${String(i).padStart(3, "0")}`, + }); + } + } finally { + store.close(); + } + + const listTool = api.tools.get("fn_task_list")!; + const result = await listTool.execute( + "list-large-broad", + { limit: 10 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + const text = result.content[0].text; + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content.some((block: any) => block.type === "image")).toBe(false); + expect(text).toBeTruthy(); + expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + expect(text).toContain("Planning (60):"); + expect(text).toContain("FN-001"); + expect(text).toContain("truncated to fit; narrow with column/limit"); + expect(result.details.count).toBe(60); + }); + + it("bounds large column-filtered listings as a single plain-text block", async () => { + const store = new TaskStore(tmpDir); + await store.init(); + try { + const first = await store.createTask({ + title: `Todo task 001 ${"x".repeat(260)}`, + description: "Large todo task 001", + column: "todo", + }); + for (let i = 2; i <= 60; i += 1) { + await store.createTask({ + title: `Todo task ${String(i).padStart(3, "0")} ${"x".repeat(260)}`, + description: `Large todo task ${String(i).padStart(3, "0")}`, + column: "todo", + dependencies: [first.id], + }); + } + } finally { + store.close(); + } + + const listTool = api.tools.get("fn_task_list")!; + const result = await listTool.execute( + "list-large-todo", + { column: "todo", limit: 50 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + const text = result.content[0].text; + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content.some((block: any) => block.type === "image")).toBe(false); + expect(text).toBeTruthy(); + expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + expect(text).toContain("Todo (60):"); + expect(text).toContain("FN-001"); + expect(text).toContain("FN-002"); + expect(text).toContain("[deps: FN-001]"); + expect(text).toContain("truncated to fit; narrow with column/limit"); + expect(result.details.count).toBe(60); + }); + + /** + * FNXC:TaskListOutput 2026-06-17-02:37: + * FN-6535 reproduces the heartbeat failure at the actual CLI tool surface while forcing @fusion/core to resolve through the built dist barrel. The normal CLI suite aliases @fusion/core to source, so this targeted mock is the regression guard for stale exports.import dist artifacts. + * + * FNXC:CoreTests 2026-06-18-01:35: + * FN-6627 aligns the skip gate with every built @fusion/core dist artifact this runtime-dist mock loads, so a partial stale dist skips cleanly while a complete dist still exercises the heartbeat fn_task_list surface. + */ + it.skipIf(!hasBuiltCoreDistBarrel(resolve(__dirname, "../../../core/dist")))( + "executes with @fusion/core resolved through the built dist barrel", + async () => { + const distCoreIndex = resolve(__dirname, "../../../core/dist/index.js"); + + const store = new TaskStore(tmpDir); + await store.init(); + try { + const first = await store.createTask({ + title: `Runtime-dist todo task 001 ${"x".repeat(700)}`, + description: "Runtime-dist todo task 001", + column: "todo", + }); + for (let i = 2; i <= 60; i += 1) { + await store.createTask({ + title: `Runtime-dist todo task ${String(i).padStart(3, "0")} ${"x".repeat(700)}`, + description: `Runtime-dist todo task ${String(i).padStart(3, "0")}`, + column: "todo", + dependencies: [first.id], + }); + } + } finally { + store.close(); + } + + vi.resetModules(); + vi.doMock("@fusion/core", async () => import(pathToFileURL(distCoreIndex).href)); + try { + const { default: runtimeCoreExtension } = await import("../extension.js?fn6535-runtime-core-dist"); + const runtimeApi = createMockAPI(); + runtimeCoreExtension(runtimeApi); + const listTool = runtimeApi.tools.get("fn_task_list")!; + + const broadResult = await listTool.execute( + "list-runtime-dist-broad", + { limit: 20 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + const broadText = broadResult.content[0].text; + expect(broadResult.content).toHaveLength(1); + expect(broadResult.content[0].type).toBe("text"); + expect(broadText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + expect(broadText).toContain("Todo (60):"); + expect(broadText).toContain("truncated to fit; narrow with column/limit"); + + const todoResult = await listTool.execute( + "list-runtime-dist-todo", + { column: "todo", limit: 50 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + const todoText = todoResult.content[0].text; + expect(todoResult.content).toHaveLength(1); + expect(todoResult.content[0].type).toBe("text"); + expect(todoText.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + expect(todoText).toContain("Todo (60):"); + expect(todoText).toContain("FN-001"); + expect(todoText).toContain("[deps: FN-001]"); + expect(todoText).toContain("truncated to fit; narrow with column/limit"); + expect(todoResult.details.count).toBe(60); + } finally { + vi.doUnmock("@fusion/core"); + vi.resetModules(); + } + }, + ); + + it("degrades to bounded text when formatter exports are unavailable", () => { + const boardLinesWithoutParams = [ + "Planning (2):", + ` FN-001 Planning task ${"x".repeat(6_000)}`, + ` FN-002 Planning task ${"x".repeat(6_000)}`, + "", + ]; + const boardLinesWithColumnAndLimit = [ + "Todo (2):", + ` FN-003 Todo task ${"x".repeat(6_000)}`, + " ... and 1 more", + "", + ]; + + /* + FNXC:TaskListOutput 2026-06-17-07:32: + FN-6573 exercises the resolver seam called by the CLI surface because the extension harness imports @fusion/core before per-test mocks can safely replace the large cross-package namespace with a stale dist missing only task-list formatter exports. + These line sets mirror fn_task_list with params omitted and with column/limit provided, reproducing the prior missing `formatTaskListText` crash condition and the worse both-helpers-missing condition as bounded text instead of a throw. + */ + const staleNamespaces = [ + { formatTaskListText: undefined, clampTaskListText: formatTaskListText }, + { formatTaskListText: undefined, clampTaskListText: undefined }, + ]; + for (const coreNamespace of staleNamespaces) { + const formatter = resolveTaskListFormatter(coreNamespace); + for (const lines of [boardLinesWithoutParams, boardLinesWithColumnAndLimit]) { + const text = formatter(lines, { clamp: coreNamespace.clampTaskListText }).trimEnd(); + expect(text).toBeTruthy(); + expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + } + } + }); + }); + it("returns structured details for invalid task assignment", async () => { const createTool = api.tools.get("fn_task_create")!; const result = await createTool.execute( @@ -3412,64 +3784,72 @@ describe("fn pi extension (runnable structured-output regression slice)", () => }); it("fn_research_run preserves fire-and-forget behavior when wait_for_completion is false", async () => { - await enableResearch(tmpDir); - const tool = api.tools.get("fn_research_run")!; + const store = await enableResearch(tmpDir); + try { + const tool = api.tools.get("fn_research_run")!; - const result = await tool.execute( - "research-run-ff", - { query: "test query", wait_for_completion: false }, - undefined, - undefined, - makeCtx(tmpDir), - ); + const result = await tool.execute( + "research-run-ff", + { query: "test query", wait_for_completion: false }, + undefined, + undefined, + makeCtx(tmpDir), + ); - expect(result.content[0].text).toContain("Start the project engine to process pending runs"); - expect(result.details.status).toBe("queued"); + expect(result.content[0].text).toContain("Start the project engine to process pending runs"); + expect(result.details.status).toBe("queued"); + } finally { + store.close(); + } }); it("fn_research_run waits and returns terminal run details when wait_for_completion is true", async () => { const store = await enableResearch(tmpDir); - const tool = api.tools.get("fn_research_run")!; - const researchStore = store.getResearchStore(); + try { + const tool = api.tools.get("fn_research_run")!; + const researchStore = store.getResearchStore(); - const settleRunToCompleted = () => { - const queuedRun = researchStore.listRuns({ limit: 1 })[0]; - if (!queuedRun) { - return false; - } - if (queuedRun.status === "completed") { - return true; - } - if (queuedRun.status === "queued") { - researchStore.updateRun(queuedRun.id, { status: "running" }); - } - researchStore.updateRun(queuedRun.id, { - status: "completed", - results: { summary: "done", findings: [{ heading: "h1", content: "f1", sources: [] }], citations: [] }, - }); - return true; - }; - - if (!settleRunToCompleted()) { - const interval = setInterval(() => { - if (settleRunToCompleted()) { - clearInterval(interval); + const settleRunToCompleted = () => { + const queuedRun = researchStore.listRuns({ limit: 1 })[0]; + if (!queuedRun) { + return false; } - }, 25); - setTimeout(() => clearInterval(interval), 500); + if (queuedRun.status === "completed") { + return true; + } + if (queuedRun.status === "queued") { + researchStore.updateRun(queuedRun.id, { status: "running" }); + } + researchStore.updateRun(queuedRun.id, { + status: "completed", + results: { summary: "done", findings: [{ heading: "h1", content: "f1", sources: [] }], citations: [] }, + }); + return true; + }; + + if (!settleRunToCompleted()) { + const interval = setInterval(() => { + if (settleRunToCompleted()) { + clearInterval(interval); + } + }, 25); + setTimeout(() => clearInterval(interval), 500); + } + + const result = await tool.execute( + "research-run-wait", + { query: "terminal query", wait_for_completion: true, max_wait_ms: 4000 }, + undefined, + undefined, + makeCtx(tmpDir), + ); + + expect(result.details.status).toBe("completed"); + expect(result.details.summary).toBe("done"); + expect(result.content[0].text).toContain("is completed"); + } finally { + store.close(); } - - const result = await tool.execute( - "research-run-wait", - { query: "terminal query", wait_for_completion: true, max_wait_ms: 4000 }, - undefined, - undefined, - makeCtx(tmpDir), - ); - - expect(result.details.status).toBe("completed"); - expect(result.details.summary).toBe("done"); - expect(result.content[0].text).toContain("is completed"); }); }); diff --git a/packages/cli/src/__tests__/external-proof-point-runbook-install.test.ts b/packages/cli/src/__tests__/external-proof-point-runbook-install.test.ts new file mode 100644 index 0000000000..b455e846e5 --- /dev/null +++ b/packages/cli/src/__tests__/external-proof-point-runbook-install.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const workspaceRoot = resolve(import.meta.dirname, "../../../.."); +const runbookPath = resolve(workspaceRoot, "docs", "plugins", "external-proof-point-runbook.md"); +const authoringPath = resolve(workspaceRoot, "docs", "plugins", "external-authoring.md"); + +const rawTarballInstallPattern = /fn plugin install\s+\S*\.tgz\b/; + +/* +FNXC:Plugins 2026-06-15-02:57: +FN-6474 guards the packaged-install proof path after FN-6471 showed raw tarballs are rejected as non-JS file entrypoints. Keep this test static and docs-only so the runbook cannot regress without invoking the real CLI or network. +*/ +describe("external plugin proof-point packaged install docs", () => { + it("does not tell readers to install a raw tarball in the runbook", () => { + const runbook = readFileSync(runbookPath, "utf8"); + + expect(runbook).not.toMatch(rawTarballInstallPattern); + }); + + it("extracts the packed tarball before installing the unpacked package directory", () => { + const runbook = readFileSync(runbookPath, "utf8"); + const packIndex = runbook.indexOf("pnpm pack"); + const extractIndex = runbook.indexOf("tar -xzf fusion-plugin-proof-point-plugin-0.1.0.tgz"); + const installIndex = runbook.indexOf("fn plugin install ./package"); + + expect(packIndex).toBeGreaterThanOrEqual(0); + expect(extractIndex).toBeGreaterThan(packIndex); + expect(installIndex).toBeGreaterThan(extractIndex); + }); + + it("keeps the authoring guide from installing raw tarballs", () => { + const authoringGuide = readFileSync(authoringPath, "utf8"); + + expect(authoringGuide).not.toMatch(rawTarballInstallPattern); + expect(authoringGuide).toContain("tar -xzf fusion-plugin-my-plugin-0.1.0.tgz"); + expect(authoringGuide).toContain("fn plugin install ./package"); + }); +}); diff --git a/packages/cli/src/__tests__/package-config.test.ts b/packages/cli/src/__tests__/package-config.test.ts index 8e318a504b..21ce456497 100644 --- a/packages/cli/src/__tests__/package-config.test.ts +++ b/packages/cli/src/__tests__/package-config.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { builtinModules } from "node:module"; import { parse } from "yaml"; +import { applyPrepackTransform } from "../../scripts/prepare-publish-manifest.mjs"; const workspaceRoot = join(__dirname, "..", "..", "..", ".."); @@ -32,6 +33,43 @@ function hasProjectArg(script: string | undefined, project: string): boolean { return parts.some((part, index) => part === "--project" && parts[index + 1] === project); } +function assertRuntimeDepsAreNotOptionalPeers(pkg: any, label: string): void { + const dependencies = pkg.dependencies ?? {}; + const peerDependencies = pkg.peerDependencies ?? {}; + const peerDependenciesMeta = pkg.peerDependenciesMeta ?? {}; + + for (const dependencyName of Object.keys(dependencies)) { + expect( + peerDependenciesMeta[dependencyName]?.optional, + `${label}: runtime dependency "${dependencyName}" must not also be an optional peer; npm/pnpm may omit it from clean standalone installs.`, + ).not.toBe(true); + } + + for (const dependencyName of ["@earendil-works/pi-coding-agent", "@earendil-works/pi-ai"]) { + expect(dependencies, `${label}: ${dependencyName} must remain a required runtime dependency`).toHaveProperty( + dependencyName, + "^0.79.1", + ); + expect(peerDependencies, `${label}: ${dependencyName} must not be a peer dependency`).not.toHaveProperty( + dependencyName, + ); + expect(peerDependenciesMeta, `${label}: ${dependencyName} must not have peer metadata`).not.toHaveProperty( + dependencyName, + ); + } + + expect(dependencies, `${label}: typebox must not be promoted into runtime dependencies`).not.toHaveProperty( + "typebox", + ); + expect(peerDependencies, `${label}: typebox remains the optional peer control`).toHaveProperty( + "typebox", + "*", + ); + expect(peerDependenciesMeta.typebox, `${label}: typebox remains optional peer metadata`).toEqual({ + optional: true, + }); +} + describe("CLI package.json publishing config", () => { const pkg = loadPackageJson("cli"); const prepackScript = loadCliPrepackScript(); @@ -93,6 +131,15 @@ describe("CLI package.json publishing config", () => { expect(deps).toContain("ioredis"); }); + /** + * FNXC:Packaging 2026-06-13-16:36: + * Standalone npm/pnpm installs may omit a package when the published manifest declares it as both a runtime dependency and an optional peer. Keep the pi runtime packages as plain dependencies so dist/bin.js and dist/extension.js can resolve their static imports outside the monorepo, while leaving typebox as the optional-peer control because Fusion does not import it at runtime. + */ + it("does not declare runtime dependencies as optional peers in source or published manifests", () => { + assertRuntimeDepsAreNotOptionalPeers(pkg, "source manifest"); + assertRuntimeDepsAreNotOptionalPeers(applyPrepackTransform(pkg), "published manifest"); + }); + it("defines test:docs-index as a single-file docs README index lane", () => { const script = pkg.scripts?.["test:docs-index"]; const parts = script?.trim().split(/\s+/) ?? []; diff --git a/packages/cli/src/__tests__/plugin-scaffold.test.ts b/packages/cli/src/__tests__/plugin-scaffold.test.ts index 3e16a3849e..b4fcbceef9 100644 --- a/packages/cli/src/__tests__/plugin-scaffold.test.ts +++ b/packages/cli/src/__tests__/plugin-scaffold.test.ts @@ -5,6 +5,10 @@ import { tmpdir } from "node:os"; import { validatePluginManifest } from "@fusion/plugin-sdk"; import { resolvePluginEntryFile } from "../commands/plugin.js"; import { runPluginCreate, runPluginNew } from "../commands/plugin-scaffold.js"; +import { + standaloneScaffoldPluginFixture, + verifyStandaloneScaffoldPluginFixture, +} from "../type-guards/plugin-scaffold-fusion-plugin.js"; describe("plugin-scaffold", () => { const tmpBase = join(tmpdir(), `fn-scaffold-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -16,6 +20,13 @@ describe("plugin-scaffold", () => { ]; const caretRangePattern = /^\^\d+\.\d+\.\d+$/; + function expectStandaloneIndexInvariants(indexContents: string): void { + expect(indexContents).toContain('import { definePlugin } from "@runfusion/fusion/plugin-sdk";'); + expect(indexContents).toContain('state: "installed"'); + expect(indexContents).not.toContain("@fusion/"); + expect(indexContents).not.toContain("workspace:"); + } + beforeEach(() => { mkdirSync(tmpBase, { recursive: true }); }); @@ -90,8 +101,7 @@ describe("plugin-scaffold", () => { const readmeContents = readFileSync(join(outputDir, "README.md"), "utf-8"); expect(packageContents).not.toContain("@fusion/"); expect(packageContents).not.toContain("workspace:"); - expect(indexContents).not.toContain("@fusion/"); - expect(indexContents).not.toContain("workspace:"); + expectStandaloneIndexInvariants(indexContents); expect(readmeContents).toContain("fn plugin dev ."); expect(readmeContents).toContain("fn plugin dev . --once"); @@ -118,6 +128,14 @@ describe("plugin-scaffold", () => { }; expect(packageJson.name).toBe("@acme/fusion-plugin-scoped-plugin"); expect(Object.keys(packageJson.devDependencies)).toEqual(standaloneDevDependencyKeys); + + const indexContents = readFileSync(join(outputDir, "src/index.ts"), "utf-8"); + expectStandaloneIndexInvariants(indexContents); + }); + + it("keeps the standalone scaffold shape assignable to FusionPlugin", () => { + // The runtime identity assertion is intentionally small; the regression value is the tsc guard in the imported fixture. + expect(verifyStandaloneScaffoldPluginFixture()).toBe(standaloneScaffoldPluginFixture); }); it("rejects invalid plugin names", async () => { diff --git a/packages/cli/src/__tests__/plugin-sdk-export.test.ts b/packages/cli/src/__tests__/plugin-sdk-export.test.ts index a9ee5891a5..8899323235 100644 --- a/packages/cli/src/__tests__/plugin-sdk-export.test.ts +++ b/packages/cli/src/__tests__/plugin-sdk-export.test.ts @@ -51,4 +51,13 @@ describe("plugin-sdk export surface", () => { const built = readFileSync(distPath, "utf-8"); expect(built.includes("@fusion/")).toBe(false); }); + + it("has no @fusion specifiers in built plugin-sdk declaration artifact when present", () => { + const distPath = join(workspaceRoot, "packages", "cli", "dist", "plugin-sdk", "index.d.ts"); + if (!existsSync(distPath)) { + return; + } + const built = readFileSync(distPath, "utf-8"); + expect(built.includes("@fusion/")).toBe(false); + }); }); diff --git a/packages/cli/src/__tests__/skill-sync.test.ts b/packages/cli/src/__tests__/skill-sync.test.ts index 1655025f56..fec82aa2b5 100644 --- a/packages/cli/src/__tests__/skill-sync.test.ts +++ b/packages/cli/src/__tests__/skill-sync.test.ts @@ -410,7 +410,11 @@ describe("Skill-Extension Sync", () => { const engineTools = getEngineSessionToolNames(); const documented = getDocumentedEngineToolNames(); const missing = engineTools.filter((name) => !documented.includes(name)); - expect(missing).toEqual([]); + // FNXC:SkillSync 2026-06-17-23:06: This test enforces the invariant that every engine session-scoped `fn_*` registration across the engine source set must be mirrored in `engine-tools.md`, so failures must print the exact undocumented names instead of hiding drift behind a generic deep-equality diff. + expect( + missing, + `undocumented engine tools in engine-tools.md: ${missing.join(", ") || "none"}`, + ).toEqual([]); }); it("covers the full Fusion skill markdown surface", () => { diff --git a/packages/cli/src/commands/__tests__/daemon.test.ts b/packages/cli/src/commands/__tests__/daemon.test.ts index 21f70d75a1..ea17ee0ab1 100644 --- a/packages/cli/src/commands/__tests__/daemon.test.ts +++ b/packages/cli/src/commands/__tests__/daemon.test.ts @@ -563,8 +563,9 @@ vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/engine", async (importOriginal) => { const { createCliEngineMock } = await import("../../test/mockCoreEngine"); return createCliEngineMock(() => importOriginal(), { - ProjectEngine: mocks.projectEngineCtor, - ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { + createFusionAuthStorage: vi.fn(() => mocks.authStorage), + ProjectEngine: mocks.projectEngineCtor, + ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { const engines = new Map(); return { startAll: vi.fn(async () => { @@ -682,6 +683,17 @@ describe("runDaemon", () => { await runDaemon({}); expect(mockSyncStartupModels).toHaveBeenCalledTimes(1); }); + + it("registers built-in zai GLM-5.2 before refreshing models", async () => { + await runDaemon({}); + + expect(mocks.modelRegistry.registerProvider).toHaveBeenCalledWith("zai", expect.objectContaining({ + models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]), + })); + expect(mocks.modelRegistry.refresh).toHaveBeenCalled(); + + await triggerSignal("SIGINT"); + }); const originalCwd = process.cwd; const originalExit = process.exit; diff --git a/packages/cli/src/commands/__tests__/dashboard.test.ts b/packages/cli/src/commands/__tests__/dashboard.test.ts index 19e9327970..e39b42c005 100644 --- a/packages/cli/src/commands/__tests__/dashboard.test.ts +++ b/packages/cli/src/commands/__tests__/dashboard.test.ts @@ -689,6 +689,7 @@ vi.mock("@fusion/engine", async (importOriginal) => { // Keep real WorktreePool & AgentSemaphore WorktreePool: original.WorktreePool, AgentSemaphore: original.AgentSemaphore, + createFusionAuthStorage: vi.fn(() => mockAuthStorage), // Stub heavy classes/functions ProjectEngine, ProjectEngineManager: makeConstructibleMock((centralCore: any, options: any) => { @@ -832,10 +833,23 @@ async function runDashboard(...args: Parameters): Retur // ── Tests ─────────────────────────────────────────────────────────── describe("runDashboard — startup model sync", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("invokes shared startup model sync", async () => { await runDashboard(0, { open: false }); expect(mockSyncStartupModels).toHaveBeenCalledTimes(1); }); + + it("registers built-in zai GLM-5.2 before refreshing models", async () => { + await runDashboard(0, { open: false }); + + expect(mockModelRegistry.registerProvider).toHaveBeenCalledWith("zai", expect.objectContaining({ + models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]), + })); + expect(mockModelRegistry.refresh).toHaveBeenCalled(); + }); }); function resetGitHubMocks() { @@ -3124,9 +3138,9 @@ describe("runDashboard — merge stream sink routing", () => { resetGitHubMocks(); process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token"; const { TaskStore, AutomationStore, AgentStore, PluginStore, PluginLoader, CentralCore } = await import("@fusion/core"); - const { aiMergeTask } = await import("@fusion/engine"); + const { aiMergeTask, createFusionAuthStorage } = await import("@fusion/engine"); const { createServer } = await import("@fusion/dashboard"); - const { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } = await import("@earendil-works/pi-coding-agent"); + const { DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, createExtensionRuntime } = await import("@earendil-works/pi-coding-agent"); (TaskStore as unknown as ReturnType).mockImplementation(() => makeMockStore()); (AutomationStore as unknown as ReturnType).mockImplementation(() => ({ @@ -3155,7 +3169,7 @@ describe("runDashboard — merge stream sink routing", () => { listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]), })); - (AuthStorage.create as unknown as ReturnType).mockReturnValue({ + (createFusionAuthStorage as unknown as ReturnType).mockReturnValue({ getApiKey: vi.fn().mockResolvedValue(undefined), getAuth: vi.fn(), setAuth: vi.fn(), diff --git a/packages/cli/src/commands/__tests__/mission.test.ts b/packages/cli/src/commands/__tests__/mission.test.ts index 37e88a9058..e976634c60 100644 --- a/packages/cli/src/commands/__tests__/mission.test.ts +++ b/packages/cli/src/commands/__tests__/mission.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock node:readline/promises before importing the module under test @@ -31,6 +34,8 @@ vi.mock("../../project-resolver.js", () => ({ import { createInterface } from "node:readline/promises"; import { getStore } from "../../project-resolver.js"; +const { TaskStore: ActualTaskStore } = await vi.importActual("@fusion/core"); + // Import after mocks const { runMissionCreate, @@ -931,14 +936,13 @@ describe("mission commands", () => { }); it("operates end-to-end against a real temp-project store", async () => { - const { TaskStore } = await vi.importActual("@fusion/core"); - const { mkdtempSync, rmSync } = await import("node:fs"); - const { tmpdir } = await import("node:os"); - const { join } = await import("node:path"); - + /* + * FNXC:CliTests 2026-06-14-01:04: + * The quarantine rescue must narrow genuinely slow CLI seams instead of widening test timeouts. Keep the real in-memory TaskStore coverage, but hoist module and stdlib loading out of the timed test body so this high-value mission/goal regression joins the default lane without per-test package-load overhead. + */ const rootDir = mkdtempSync(join(tmpdir(), "kb-mission-cli-goals-")); const globalDir = join(rootDir, ".fusion-global-settings"); - const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + const store = new ActualTaskStore(rootDir, globalDir, { inMemoryDb: true }); await store.init(); const mission = store.getMissionStore().createMission({ title: "CLI Mission" }); diff --git a/packages/cli/src/commands/__tests__/onboard.test.ts b/packages/cli/src/commands/__tests__/onboard.test.ts index 6c5bdd8464..ade041b23c 100644 --- a/packages/cli/src/commands/__tests__/onboard.test.ts +++ b/packages/cli/src/commands/__tests__/onboard.test.ts @@ -35,19 +35,17 @@ class MockCentralCore { vi.mock("../init.js", () => ({ runInit: mockRunInit })); vi.mock("../project-context.js", () => ({ resolveProject: mockResolveProject })); vi.mock("../provider-auth.js", () => ({ - createReadOnlyAuthFileStorage: vi.fn(() => ({})), - mergeAuthStorageReads: vi.fn((primary) => primary), wrapAuthStorageWithApiKeyProviders: vi.fn(() => mockProviderAuthFactory()), })); vi.mock("../auth-paths.js", () => ({ - getFusionAuthPath: vi.fn(() => "/tmp/auth.json"), - getLegacyAuthPaths: vi.fn(() => []), getModelRegistryModelsPath: vi.fn(() => "/tmp/models.json"), })); vi.mock("@earendil-works/pi-coding-agent", () => ({ - AuthStorage: { create: vi.fn(() => ({})) }, ModelRegistry: { create: vi.fn(() => ({})) }, })); +vi.mock("@fusion/engine", () => ({ + createFusionAuthStorage: vi.fn(() => ({})), +})); vi.mock("@fusion/core", () => ({ CentralCore: MockCentralCore, GlobalSettingsStore: MockGlobalSettingsStore, diff --git a/packages/cli/src/commands/__tests__/plugin.test.ts b/packages/cli/src/commands/__tests__/plugin.test.ts index d70109d9f1..2a5180e8a8 100644 --- a/packages/cli/src/commands/__tests__/plugin.test.ts +++ b/packages/cli/src/commands/__tests__/plugin.test.ts @@ -123,6 +123,11 @@ describe("plugin commands", () => { const tempDirs: string[] = []; beforeEach(() => { + /* + * FNXC:CliTests 2026-06-14-01:28: + * FN-6430's plugin-suite rescue depends on clearing loader path state before every case so a package-load sibling cannot inherit the previous taskStore root. + * Reset the hoisted PluginLoader/PluginStore mocks rather than widening timeouts or serializing the whole CLI lane. + */ mocks.reset(); vi.mocked(resolveProject).mockResolvedValue({ projectPath: "/tmp/fn-project" } as never); vi.spyOn(console, "log").mockImplementation(() => {}); diff --git a/packages/cli/src/commands/__tests__/provider-settings.test.ts b/packages/cli/src/commands/__tests__/provider-settings.test.ts index 1b9c17d109..cd68ac9298 100644 --- a/packages/cli/src/commands/__tests__/provider-settings.test.ts +++ b/packages/cli/src/commands/__tests__/provider-settings.test.ts @@ -4,14 +4,11 @@ import { describe, expect, it, vi } from "vitest"; import { tempWorkspace } from "@fusion/test-utils"; import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "../provider-settings.js"; -// All tests here are pure synchronous FS operations against a temp workspace, -// so they shouldn't take more than a handful of milliseconds. They have -// occasionally tripped vitest's default 5s timeout when the worker pool is -// starved by a parallel FS-heavy suite (one slot stalls long enough that the -// runner gives up before the test body even gets a turn). Bumping the -// per-test cap rules out worker contention as a flake source without -// changing what the tests actually verify. -vi.setConfig({ testTimeout: 30000 }); +/* +FNXC:CliTests 2026-06-14-01:47: +Provider-settings tests are synchronous temp-workspace filesystem checks, so they must stay on Vitest's default 5s timeout. +FN-6431 removed the hidden file-wide 30s timeout appeasement after FN-6430 fixed the shared CLI fixture isolation path that previously caused package-load starvation. +*/ function writeJson(path: string, value: Record): void { writeFileSync(path, JSON.stringify(value, null, 2)); diff --git a/packages/cli/src/commands/__tests__/serve.test.ts b/packages/cli/src/commands/__tests__/serve.test.ts index d1c7a9bf7d..d8c7d8390b 100644 --- a/packages/cli/src/commands/__tests__/serve.test.ts +++ b/packages/cli/src/commands/__tests__/serve.test.ts @@ -625,8 +625,9 @@ vi.mock("@fusion/dashboard", () => ({ vi.mock("@fusion/engine", async (importOriginal) => { const { createCliEngineMock } = await import("../../test/mockCoreEngine"); return createCliEngineMock(() => importOriginal(), { - ProjectEngine: mocks.projectEngineCtor, - ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { + createFusionAuthStorage: vi.fn(() => mocks.authStorage), + ProjectEngine: mocks.projectEngineCtor, + ProjectEngineManager: vi.fn().mockImplementation(function (centralCore: any, options: any) { const engines = new Map(); return { startAll: vi.fn(async () => { @@ -752,6 +753,17 @@ describe("runServe", () => { await runServe(4040, {}); expect(mockSyncStartupModels).toHaveBeenCalledTimes(1); }); + + it("registers built-in zai GLM-5.2 before refreshing models", async () => { + await runServe(0, {}); + + expect(mocks.modelRegistry.registerProvider).toHaveBeenCalledWith("zai", expect.objectContaining({ + models: expect.arrayContaining([expect.objectContaining({ id: "glm-5.2" })]), + })); + expect(mocks.modelRegistry.refresh).toHaveBeenCalled(); + + await triggerSignal("SIGINT"); + }); const originalCwd = process.cwd; const originalOn = process.on; const originalExit = process.exit; diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 4d3ba603ae..c709dfd695 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -20,7 +20,9 @@ import { GlobalSettingsStore, resolveGlobalDir, getEnabledPiExtensionPaths, + mergeBuiltInZaiProviderModels, reconcileClaudeCliPaths, + registerBuiltInZaiProvider, } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard"; @@ -30,9 +32,9 @@ import { HybridExecutor, shouldUseHybridExecutor, setHostExtensionPaths, + createFusionAuthStorage, } from "@fusion/engine"; import { - AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, @@ -69,8 +71,8 @@ import { setCachedLlamaCppResolution, } from "./llama-cpp-extension.js"; import { resolveSelfExtension } from "./self-extension.js"; -import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; -import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; +import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; +import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js"; import { handleOpencodeGoApiKeySaved, syncStartupModels } from "./startup-model-sync.js"; @@ -544,15 +546,10 @@ export async function runDaemon(opts: DaemonOptions = {}) { const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop(); const automationStore = primaryEngine.getAutomationStore(); - const authStorage = AuthStorage.create(getFusionAuthPath()); - const supplementalAuthStorage = createReadOnlyAuthFileStorage([ - ...getLegacyAuthPaths(), - getCodexCliAuthPath(), - ...getClaudeCodeCredentialPaths(), - ]); - const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]); - const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); - const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); + const authStorage = createFusionAuthStorage(); + const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); + registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); + const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails let packageManager: DefaultPackageManager | undefined; @@ -666,6 +663,7 @@ export async function runDaemon(opts: DaemonOptions = {}) { } extensionsResult.runtime.pendingProviderRegistrations = []; + mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); modelRegistry.refresh(); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx index eca1bb773f..dab1a89bc7 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx +++ b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx @@ -45,6 +45,7 @@ function makeInteractiveData(opts: { settings?: SettingsValues; models?: ModelItem[]; taskDetail?: TaskDetailData | null; + updateAgentState?: (id: string, state: string) => Promise; remote?: Partial<{ getSettings: () => Promise<{ activeProvider: "tailscale" | "cloudflare" | null; tailscaleEnabled: boolean; cloudflareEnabled: boolean; shortLivedEnabled: boolean; shortLivedTtlMs: number }>; getStatus: () => Promise<{ provider: "tailscale" | "cloudflare" | null; state: "stopped" | "starting" | "running" | "error"; url: string | null; lastError: string | null }>; @@ -105,7 +106,7 @@ function makeInteractiveData(opts: { }) as TaskItem, listAgents: async () => agents, getAgentDetail: async (_id: string) => detail, - updateAgentState: async (_id: string, _state: string) => {}, + updateAgentState: opts.updateAgentState ?? (async (_id: string, _state: string) => {}), deleteAgent: async (_id: string) => {}, getSettings: async () => settings, updateSettings: async (_partial: Partial) => {}, @@ -408,6 +409,105 @@ describe("Agents view", () => { unmount(); }); + + it("starts the selected agent with s without leaving the Agents view", async () => { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + const updateAgentState = vi.fn(async (_id: string, _state: string) => {}); + const agents: AgentItem[] = [ + { id: "a1", name: "worker-1", state: "idle", role: "executor" }, + ]; + controller.setInteractiveData(makeInteractiveData({ agents, updateAgentState })); + controller.setMode("interactive"); + controller.setInteractiveView("agents"); + + const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); + await waitForFrameContains(lastFrame, "worker-1"); + + stdin.write("s"); + await vi.waitFor(() => expect(updateAgentState).toHaveBeenCalledWith("a1", "active")); + await waitForFrameUpdateAfterInput(); + + const snapshot = controller.getSnapshot(); + expect(snapshot.mode).toBe("interactive"); + expect(snapshot.interactiveView).toBe("agents"); + unmount(); + }); + + it("switches to Main with m from the Agents view", async () => { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + const agents: AgentItem[] = [ + { id: "a1", name: "worker-1", state: "idle", role: "executor" }, + ]; + controller.setInteractiveData(makeInteractiveData({ agents })); + controller.setMode("interactive"); + controller.setInteractiveView("agents"); + + const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); + await waitForFrameContains(lastFrame, "worker-1"); + + stdin.write("m"); + await waitForFrameUpdateAfterInput(); + + expect(controller.getSnapshot().mode).toBe("status"); + unmount(); + }); + + it("keeps the s-to-Main alias in non-Agents interactive views", async () => { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + controller.setInteractiveData(makeInteractiveData({ + projects: [{ id: "p1", name: "alpha", path: "/tmp/alpha" }], + tasks: [{ id: "t1", title: "first", description: "", column: "todo" }], + })); + controller.setMode("interactive"); + controller.setInteractiveView("board"); + + const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); + await waitForFrameContains(lastFrame, "alpha"); + + stdin.write("s"); + await waitForFrameUpdateAfterInput(); + + expect(controller.getSnapshot().mode).toBe("status"); + unmount(); + }); + + it("keeps s as a no-op when already in status mode", async () => { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + controller.setMode("status"); + controller.setInteractiveView("agents"); + + const { stdin, unmount } = render(renderDashboardAppNode(controller)); + stdin.write("s"); + await waitForFrameUpdateAfterInput(); + + expect(controller.getSnapshot().mode).toBe("status"); + unmount(); + }); + + it("treats s as a no-op in an empty Agents view", async () => { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + const updateAgentState = vi.fn(async (_id: string, _state: string) => {}); + controller.setInteractiveData(makeInteractiveData({ agents: [], updateAgentState })); + controller.setMode("interactive"); + controller.setInteractiveView("agents"); + + const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); + await waitForFrameContains(lastFrame, "Agent Detail"); + + stdin.write("s"); + await waitForFrameUpdateAfterInput(); + + const snapshot = controller.getSnapshot(); + expect(snapshot.mode).toBe("interactive"); + expect(snapshot.interactiveView).toBe("agents"); + expect(updateAgentState).not.toHaveBeenCalled(); + unmount(); + }); }); describe("Settings view", () => { diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index a8441ea749..2de93cba81 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -4335,9 +4335,12 @@ export function DashboardApp({ controller }: DashboardAppProps) { return; } - // 'm' / 's' (alias) — switch to Main (status mode). Lowercase only; - // capital S/M are reserved for vim-style "jump to end" semantics. - if (input === "m" || input === "s") { + /* + FNXC:DashboardTui 2026-06-16-17:40: + The global `s` shortcut remains a Main/status alias everywhere except the Agents interactive view, where `s` is reserved for starting the selected agent. Keep `m` as the universal Main switch so Agents users can start an agent without being bounced out of the view. + */ + const agentsStartKeyOwnsInput = state.mode === "interactive" && state.interactiveView === "agents"; + if (input === "m" || (input === "s" && !agentsStartKeyOwnsInput)) { if (state.mode === "interactive") { controller.setMode("status"); return; diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 4ce90ae0ee..00d8dc4e43 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -19,7 +19,9 @@ import { isWorkflowColumnsEnabled, resolveColumnFlags, BUILTIN_CODING_WORKFLOW_IR, + mergeBuiltInZaiProviderModels, parseWorkflowIr, + registerBuiltInZaiProvider, type WorkflowIrColumn, type TraitFlags, } from "@fusion/core"; @@ -28,6 +30,7 @@ import { AttachTicketStore, CliInputAttributionLog, CliConfirmAdvanceRegistry, + CliRelaunchRegistry, GitHubClient, createSkillsAdapter, getCliPackageVersion, @@ -49,8 +52,9 @@ import { HybridExecutor, shouldUseHybridExecutor, setHostExtensionPaths, + createFusionAuthStorage, } from "@fusion/engine"; -import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@earendil-works/pi-coding-agent"; +import { DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@earendil-works/pi-coding-agent"; import { getMergeStrategy, getTaskBranchName, @@ -63,8 +67,8 @@ import { import { promptForPort } from "./port-prompt.js"; import { ensureCwdProjectRegistered } from "./ensure-project-registered.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; -import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; -import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; +import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; +import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; import { ensureClaudeSkillsForAllProjectsOnStartup, @@ -1361,15 +1365,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // ModelRegistry discovers available models from configured providers. // Passing these to createServer enables the dashboard's Authentication // tab (login/logout) and Model selector. - const authStorage = AuthStorage.create(getFusionAuthPath()); - const supplementalAuthStorage = createReadOnlyAuthFileStorage([ - ...getLegacyAuthPaths(), - getCodexCliAuthPath(), - ...getClaudeCodeCredentialPaths(), - ]); - const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]); - const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); - const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); + /* + FNXC:AuthRefresh 2026-06-13-22:46: + Dashboard status polling, model discovery, and execution-facing auth reads must share the engine auth store so expired Claude OAuth credentials refresh once and legacy Claude/Codex credentials keep working. + */ + const authStorage = createFusionAuthStorage(); + const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); + registerBuiltInZaiProvider(modelRegistry, (message) => logSink.log(message, "extensions")); + const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails. // packageManager.resolve() walks installed npm/git/local pi packages and is @@ -1488,6 +1491,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: } extensionsResult.runtime.pendingProviderRegistrations = []; + mergeBuiltInZaiProviderModels(modelRegistry, (message) => logSink.log(message, "extensions")); modelRegistry.refresh(); try { @@ -1767,6 +1771,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: ticketStore: new AttachTicketStore(), attributionLog: new CliInputAttributionLog(), confirmAdvance: new CliConfirmAdvanceRegistry(), + relaunch: new CliRelaunchRegistry(), } : undefined; diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 7c4bdb2a04..ece6370d5a 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,19 +1,12 @@ import { existsSync } from "node:fs"; import { createInterface } from "node:readline"; -import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { ModelRegistry } from "@earendil-works/pi-coding-agent"; import { CentralCore, GlobalSettingsStore, getDefaultCentralDbPath } from "@fusion/core"; +import { createFusionAuthStorage } from "@fusion/engine"; import { resolveProject } from "../project-context.js"; import { runInit } from "./init.js"; -import { - createReadOnlyAuthFileStorage, - mergeAuthStorageReads, - wrapAuthStorageWithApiKeyProviders, -} from "./provider-auth.js"; -import { - getFusionAuthPath, - getLegacyAuthPaths, - getModelRegistryModelsPath, -} from "./auth-paths.js"; +import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; +import { getModelRegistryModelsPath } from "./auth-paths.js"; export interface OnboardOptions { force?: boolean; @@ -186,11 +179,9 @@ export async function runOnboard(options: OnboardOptions = {}): Promise { } } - const authStorage = AuthStorage.create(getFusionAuthPath()); - const supplementalAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths()); - const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]); - const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); - const providerAuth = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); + const authStorage = createFusionAuthStorage(); + const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); + const providerAuth = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); await runSkippableStep(prompts, "AI provider setup", async () => { const apiProviders = providerAuth.getApiKeyProviders(); diff --git a/packages/cli/src/commands/plugin-scaffold.ts b/packages/cli/src/commands/plugin-scaffold.ts index 3d102f9c84..4dc61ae4ce 100644 --- a/packages/cli/src/commands/plugin-scaffold.ts +++ b/packages/cli/src/commands/plugin-scaffold.ts @@ -257,6 +257,10 @@ export default definePlugin({ `; } +/** + * FNXC:PluginScaffold 2026-06-14-01:40: + * The published FusionPlugin type requires state: PluginState, so standalone `fn plugin new` output must emit `state: "installed"` and stay in sync with the workspace scaffold plus SDK type surface to build unedited. + */ function generateStandaloneIndexTs(name: string): string { const titleCase = toTitleCase(name); return `import { definePlugin } from "@runfusion/fusion/plugin-sdk"; @@ -268,6 +272,7 @@ export default definePlugin({ version: "0.1.0", description: "A standalone Fusion plugin", }, + state: "installed", hooks: { onLoad: async (ctx) => { ctx.logger.info("${titleCase} plugin loaded"); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 5945d98672..f53430595b 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -21,6 +21,8 @@ import { GlobalSettingsStore, resolveGlobalDir, getEnabledPiExtensionPaths, + mergeBuiltInZaiProviderModels, + registerBuiltInZaiProvider, } from "@fusion/core"; import type { AutomationRunResult, ScheduledTask } from "@fusion/core"; import { createServer, GitHubClient, createSkillsAdapter, getProjectSettingsPath, loadTlsCredentialsFromEnv, registerGithubTrackingHook } from "@fusion/dashboard"; @@ -30,9 +32,9 @@ import { HybridExecutor, shouldUseHybridExecutor, setHostExtensionPaths, + createFusionAuthStorage, } from "@fusion/engine"; import { - AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, @@ -49,8 +51,8 @@ import { } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; -import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; -import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; +import { wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; +import { getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; import { ensureClaudeSkillsForAllProjectsOnStartup, @@ -594,15 +596,10 @@ export async function runServe( const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop(); const automationStore = primaryEngine.getAutomationStore(); - const authStorage = AuthStorage.create(getFusionAuthPath()); - const supplementalAuthStorage = createReadOnlyAuthFileStorage([ - ...getLegacyAuthPaths(), - getCodexCliAuthPath(), - ...getClaudeCodeCredentialPaths(), - ]); - const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]); - const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); - const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); + const authStorage = createFusionAuthStorage(); + const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath()); + registerBuiltInZaiProvider(modelRegistry, (message) => console.log(`[extensions] ${message}`)); + const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry); // PackageManager may be used for skills adapter even if extension loading fails let packageManager: DefaultPackageManager | undefined; @@ -717,6 +714,7 @@ export async function runServe( } extensionsResult.runtime.pendingProviderRegistrations = []; + mergeBuiltInZaiProviderModels(modelRegistry, (message) => console.log(`[extensions] ${message}`)); modelRegistry.refresh(); try { diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 1dc927202e..c50b590c9d 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -1,6 +1,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type, type TSchema } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; +import * as fusionCore from "@fusion/core"; import { TaskStore, COLUMNS, @@ -25,6 +26,7 @@ import { getTaskDuplicateLineage, resolveAgentProvisioningPolicy, TASK_PRIORITIES, + MAX_TASK_LIST_TEXT_CHARS, resolveSecretAccessPolicy, getProjectRootFromWorktree, resolveTaskGithubTracking, @@ -58,6 +60,39 @@ import { spawn, type ChildProcess } from "node:child_process"; // ── Helpers ──────────────────────────────────────────────────────── +type TaskListClamp = (lines: string[], opts?: { maxChars?: number }) => string; +type TaskListFormatter = ( + lines: string[], + opts?: { maxChars?: number; clamp?: TaskListClamp }, +) => string; + +export function inlineTaskListFallback( + lines: string[], + opts: { maxChars?: number } = {}, +): string { + /* + FNXC:TaskListOutput 2026-06-18-03:20: + FN-6629 requires stale-runtime fallback formatting to mirror the shared host-safe task-list budget; otherwise missing @fusion/core formatter exports can re-emit imageified column-filtered listings. + */ + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS)); + try { + const text = lines.join("\n"); + if (text.length <= maxChars) { + return text; + } + return text.slice(0, Math.max(0, maxChars - 1)) + "…"; + } catch { + return ""; + } +} + +export function resolveTaskListFormatter(core: { formatTaskListText?: unknown }): TaskListFormatter { + return typeof core.formatTaskListText === "function" + ? (core.formatTaskListText as TaskListFormatter) + : inlineTaskListFallback; +} + + /** #1403: display a column's label, falling back to the raw id for * workflow-defined custom columns that have no legacy label. */ function columnLabel(column: ColumnId): string { @@ -122,6 +157,23 @@ async function getStore(cwd: string): Promise { return store; } +/** @internal Exposed so tests and the extension shutdown hook can close cached stores deterministically; not a public CLI API contract. */ +export function closeCachedStores(): void { + /* + FNXC:CliTests 2026-06-17-23:58: + FN-6626 found the CLI extension cache cleared real TaskStore instances without closing them, leaving SQLite/WAL handles to survive module resets and making canonical-project-root task-tool tests timeout under suite load. + Close every cached store deterministically on extension shutdown and in tests; do not appease the load-sensitive seam with timeouts, retries, or worker changes. + */ + for (const store of storeCache.values()) { + try { + store.close(); + } catch (error) { + console.warn("[fusion-extension] cached TaskStore close skipped", error); + } + } + storeCache.clear(); +} + function getFusionDir(cwd: string): string { return join(resolveProjectRoot(cwd), ".fusion"); } @@ -802,9 +854,10 @@ export default function kbExtension(pi: ExtensionAPI) { } const perColumn = params.limit ?? 10; + const requestedColumn = params.column as ColumnId | undefined; const lines: string[] = []; for (const col of COLUMNS) { - if (params.column && params.column !== col) continue; + if (requestedColumn && requestedColumn !== col) continue; const colTasks = tasks.filter((t) => t.column === col); if (colTasks.length === 0) continue; @@ -821,8 +874,29 @@ export default function kbExtension(pi: ExtensionAPI) { lines.push(""); } + const emptyStateText = requestedColumn + ? `No tasks in ${columnLabel(requestedColumn)} (${requestedColumn}).` + : "No matching tasks."; + + /* + FNXC:TaskListOutput 2026-06-16-17:47: + FN-6492 routes CLI fn_task_list through the shared clamp so large column-filtered board reads remain text-only instead of being converted to host attachments. + + FNXC:TaskListOutput 2026-06-17-05:46: + FN-6570 resolves the clamp from the runtime @fusion/core namespace and lets formatTaskListText fall back when stale dist/interoperability paths omit clampTaskListText, preventing heartbeat board reads from crashing. + + FNXC:TaskListOutput 2026-06-17-07:25: + FN-6573 requires CLI fn_task_list to resolve formatTaskListText from the runtime @fusion/core namespace with a typeof guard and a self-contained bounded fallback. A stale @fusion/core dist missing the FN-6570 formatter export crashed ambient heartbeat agents as `(0 , _core.formatTaskListText) is not a function`; the tool must now return bounded text instead. + + FNXC:TaskListOutput 2026-06-18-04:46: + FN-6630 refines FN-6492 by requiring filtered fn_task_list calls against empty target columns to return explicit empty-state text. Host runtimes can imageify empty content blocks as `(see attached image)`, so this call site must never emit empty or whitespace-only text. + */ + const formatter = resolveTaskListFormatter(fusionCore); + const text = lines.length === 0 + ? emptyStateText + : formatter(lines, { clamp: fusionCore.clampTaskListText }).trimEnd(); return { - content: [{ type: "text", text: lines.join("\n").trimEnd() }], + content: [{ type: "text", text: text.trim().length > 0 ? text : emptyStateText }], details: { count: tasks.length }, }; }, @@ -3842,6 +3916,100 @@ export default function kbExtension(pi: ExtensionAPI) { }, }); + // ── fn_agent_set_instructions ─────────────────────────────────── + + /** + * FNXC:AgentManagement 2026-06-19-06:58: + * Managing agents need a scoped runtime tool for updating a direct or indirect report's operating instructions without granting peer, ancestor, or self-mutation rights. + * The no-agent caller path remains privileged for CLI/user control, while agent callers must appear as an ancestor in the target's chain of command so AgentStore config revisions preserve an auditable record of each instruction edit. + */ + pi.registerTool({ + name: "fn_agent_set_instructions", + label: "fn: Set Agent Instructions", + description: + "Set the instructionsText and/or instructionsPath of one of the caller's direct or indirect reports. " + + "At least one of instructions_text or instructions_path is required; pass an empty string to clear a field. " + + "The change is persisted and recorded as a config revision.", + promptSnippet: "Update operating instructions for an agent in your management subtree", + promptGuidelines: [ + "Use to update operating instructions for an agent in your management subtree", + "You can only target your own direct or indirect reports, not yourself, peers, or ancestors", + "Provide instructions_text, instructions_path, or both; use an explicit empty string to clear a field", + ], + parameters: Type.Object({ + agent_id: Type.String({ description: "Target agent whose instructions to set" }), + instructions_text: Type.Optional( + Type.String({ description: "Inline instructions. Pass an empty string to clear." }), + ), + instructions_path: Type.Optional( + Type.String({ description: "Path to a markdown instructions file. Pass an empty string to clear." }), + ), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const { AgentStore } = await import("@fusion/core"); + const agentStore = new AgentStore({ rootDir: getFusionDir(ctx.cwd) }); + await agentStore.init(); + + const hasInstructionsText = params.instructions_text !== undefined; + const hasInstructionsPath = params.instructions_path !== undefined; + if (!hasInstructionsText && !hasInstructionsPath) { + return { + content: [{ type: "text" as const, text: "ERROR: Provide instructions_text and/or instructions_path to update agent instructions." }], + isError: true, + details: { outcome: "invalid", error: "instructions_text or instructions_path is required" }, + }; + } + + const target = await agentStore.resolveAgent(params.agent_id); + if (!target) { + return { + content: [{ type: "text" as const, text: `Agent '${params.agent_id}' not found` }], + isError: true, + details: { outcome: "not_found", error: "Agent not found", agentId: params.agent_id }, + }; + } + + const fnCtx = ctx as typeof ctx & { agentId?: string }; + const callerAgentId = fnCtx.agentId; + if (callerAgentId) { + if (callerAgentId === target.id) { + return { + content: [{ type: "text" as const, text: "ERROR: You can only set instructions for your own direct or indirect reports, not yourself." }], + isError: true, + details: { outcome: "denied", agentId: target.id, callerAgentId, rule: "direct-or-indirect-reports-only" }, + }; + } + + const chain = await agentStore.getChainOfCommand(target.id); + const callerIndex = chain.findIndex((agent) => agent.id === callerAgentId); + if (callerIndex < 1) { + return { + content: [{ type: "text" as const, text: "ERROR: You can only set instructions for your own direct or indirect reports." }], + isError: true, + details: { outcome: "denied", agentId: target.id, callerAgentId, rule: "direct-or-indirect-reports-only" }, + }; + } + } + + const updatedFields: string[] = []; + if (hasInstructionsText) updatedFields.push("instructionsText"); + if (hasInstructionsPath) updatedFields.push("instructionsPath"); + + const updated = await agentStore.updateAgent(target.id, { + ...(hasInstructionsText ? { instructionsText: params.instructions_text } : {}), + ...(hasInstructionsPath ? { instructionsPath: params.instructions_path } : {}), + }); + + return { + content: [{ + type: "text" as const, + text: `Updated ${updated.name} (${updated.id}) instructions: ${updatedFields.join(", ")}`, + }], + details: { outcome: "updated", agentId: updated.id, updatedFields }, + }; + }, + }); + // ── fn_agent_delete ───────────────────────────────────────────── pi.registerTool({ @@ -4531,6 +4699,6 @@ export default function kbExtension(pi: ExtensionAPI) { dashboardProcess = null; dashboardPort = null; } - storeCache.clear(); + closeCachedStores(); }); } diff --git a/packages/cli/src/plugins/__tests__/bundled-plugin-freshness.test.ts b/packages/cli/src/plugins/__tests__/bundled-plugin-freshness.test.ts new file mode 100644 index 0000000000..9f07e847c2 --- /dev/null +++ b/packages/cli/src/plugins/__tests__/bundled-plugin-freshness.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { BUNDLED_PLUGIN_IDS } from "../bundled-plugin-install.js"; +import { findStaleBundledPlugins } from "../bundled-plugin-freshness.js"; +import { ALL_STAGED_BUNDLED_IDS } from "../staged-bundled-plugin-ids.js"; + +const older = new Date("2026-01-01T00:00:00.000Z"); +const newer = new Date("2026-01-01T00:01:00.000Z"); + +describe("bundled plugin build freshness", () => { + let tempRoot: string | null = null; + + afterEach(() => { + if (tempRoot) { + rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + function makeTempPluginsRoot(): string { + tempRoot = mkdtempSync(join(tmpdir(), "bundled-plugin-freshness-")); + return tempRoot; + } + + function writePluginFile(pluginsRoot: string, pluginId: string, relativePath: string, content = "// test fixture\n") { + const fullPath = join(pluginsRoot, pluginId, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, content); + return fullPath; + } + + it("reports stale compiled dist while allowing fresh and dist-absent plugins", () => { + const pluginsRoot = makeTempPluginsRoot(); + + const staleSrc = writePluginFile(pluginsRoot, "fixture-stale", "src/index.ts"); + const staleDist = writePluginFile(pluginsRoot, "fixture-stale", "dist/index.js"); + utimesSync(staleDist, older, older); + utimesSync(staleSrc, newer, newer); + + const freshSrc = writePluginFile(pluginsRoot, "fixture-fresh", "src/index.ts"); + const freshDist = writePluginFile(pluginsRoot, "fixture-fresh", "dist/index.js"); + utimesSync(freshSrc, older, older); + utimesSync(freshDist, newer, newer); + + writePluginFile(pluginsRoot, "fixture-dist-absent", "src/index.ts"); + + const stale = findStaleBundledPlugins(["fixture-stale", "fixture-fresh", "fixture-dist-absent"], { + pluginsRoot, + }); + + expect(stale).toHaveLength(1); + expect(stale[0]).toMatchObject({ id: "fixture-stale" }); + expect(stale[0]?.reason).toContain("run pnpm build"); + }); + + it("keeps the live staged bundled-plugin set fresh after build", () => { + expect(findStaleBundledPlugins(ALL_STAGED_BUNDLED_IDS)).toEqual([]); + }); + + it("keeps the auto-install list covered by the staged bundled-plugin set", () => { + const staged = new Set(ALL_STAGED_BUNDLED_IDS); + const missingFromStagedSet = BUNDLED_PLUGIN_IDS.filter((id) => !staged.has(id)); + + expect(missingFromStagedSet).toEqual([]); + + /* + * FNXC:BundledPlugins 2026-06-17-22:06: + * The staged set intentionally remains a superset today: droid/acp runtimes are shipped for explicit runtime selection but are not part of the default auto-install list. Use subset coverage, not equality, until product requirements say those runtimes should auto-install. + */ + expect(new Set(BUNDLED_PLUGIN_IDS)).not.toEqual(staged); + expect(ALL_STAGED_BUNDLED_IDS).toEqual(expect.arrayContaining(["fusion-plugin-droid-runtime", "fusion-plugin-acp-runtime"])); + }); +}); diff --git a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts index 90887da9af..a597259559 100644 --- a/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts +++ b/packages/cli/src/plugins/__tests__/bundled-plugin-install.test.ts @@ -3,17 +3,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // ── Mocks ──────────────────────────────────────────────────────────── // vi.mock factories are hoisted, so we use vi.hoisted() for mock references. -const { mockExistsSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } = vi.hoisted(() => ({ - mockExistsSync: vi.fn<(path: string) => boolean>(), - mockStatSync: vi.fn<(path: string) => { isDirectory: () => boolean }>(), - mockReadFile: vi.fn<(path: string, encoding: string) => Promise>(), - mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(), - mockCopyFile: vi.fn<(src: string, dest: string) => Promise>(), - mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(), -})); +const { mockExistsSync, mockReaddirSync, mockStatSync, mockReadFile, mockFsStat, mockCopyFile, mockValidatePluginManifest } = + vi.hoisted(() => ({ + mockExistsSync: vi.fn<(path: string) => boolean>(), + mockReaddirSync: vi.fn< + (path: string, options: { withFileTypes: true; encoding: "utf8" }) => Array<{ name: string; isDirectory: () => boolean }> + >(), + mockStatSync: vi.fn<(path: string) => { isDirectory: () => boolean; mtimeMs?: number }>(), + mockReadFile: vi.fn<(path: string, encoding: string) => Promise>(), + mockFsStat: vi.fn<(path: string) => Promise<{ isDirectory: () => boolean }>>(), + mockCopyFile: vi.fn<(src: string, dest: string) => Promise>(), + mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(), + })); vi.mock("node:fs", () => ({ existsSync: mockExistsSync, + readdirSync: mockReaddirSync, statSync: mockStatSync, })); @@ -201,7 +206,8 @@ async function getResolvedBundledPath(): Promise { beforeEach(() => { vi.clearAllMocks(); - mockStatSync.mockImplementation(() => ({ isDirectory: () => false })); + mockReaddirSync.mockReturnValue([{ name: "index.ts", isDirectory: () => false }]); + mockStatSync.mockImplementation(() => ({ isDirectory: () => false, mtimeMs: 0 })); mockFsStat.mockImplementation(async () => ({ isDirectory: () => false })); mockCopyFile.mockResolvedValue(); }); @@ -217,8 +223,27 @@ describe("resolvePluginEntryPath", () => { expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js"); }); - it("prefers dist/index.js when bundled.js is unavailable", () => { + it("prefers src/index.ts when bundled.js is unavailable and src is newer than dist", () => { mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js")); + mockStatSync.mockImplementation((p: string) => ({ + isDirectory: () => false, + mtimeMs: p.endsWith("/dist/index.js") ? 1 : 2, + })); + expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts"); + }); + + it("prefers dist/index.js when bundled.js is unavailable and dist is newer", () => { + mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js")); + mockStatSync.mockImplementation((p: string) => ({ + isDirectory: () => false, + mtimeMs: p.endsWith("/dist/index.js") ? 2 : 1, + })); + expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js"); + }); + + it("prefers dist/index.js when bundled.js is unavailable and mtimes are equal", () => { + mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js")); + mockStatSync.mockImplementation(() => ({ isDirectory: () => false, mtimeMs: 1 })); expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js"); }); @@ -252,6 +277,7 @@ describe("ensureBundledDependencyGraphPluginInstalled", () => { })); vi.doMock("node:fs", () => ({ existsSync: mockExistsSync, + readdirSync: mockReaddirSync, statSync: mockStatSync, })); vi.doMock("node:fs/promises", () => ({ diff --git a/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts b/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts index 0fa5e80258..525a27082f 100644 --- a/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts +++ b/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts @@ -12,8 +12,8 @@ * 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 { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { dirname, 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"; @@ -31,16 +31,53 @@ describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () function touch(relative: string) { const full = join(dir, relative); - mkdirSync(join(full, ".."), { recursive: true }); + mkdirSync(dirname(full), { recursive: true }); writeFileSync(full, "// entry\n"); } - const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [ + const older = new Date("2026-01-01T00:00:00.000Z"); + const newer = new Date("2026-01-01T00:01:00.000Z"); + + const layouts: Array<{ + name: string; + files: string[]; + expected: string | null; + mtimes?: Record; + }> = [ { 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: "dist + src, src newer → src/index.ts", + files: ["dist/index.js", "src/index.ts"], + expected: "src/index.ts", + mtimes: { "dist/index.js": older, "src/index.ts": newer }, + }, + { + name: "dist + src, dist newer → dist/index.js", + files: ["dist/index.js", "src/index.ts"], + expected: "dist/index.js", + mtimes: { "dist/index.js": newer, "src/index.ts": older }, + }, + { + name: "dist + src, equal mtimes → dist/index.js", + files: ["dist/index.js", "src/index.ts"], + expected: "dist/index.js", + mtimes: { "dist/index.js": older, "src/index.ts": older }, + }, + { + name: "dist + src, non-index src file newer → src/index.ts", + files: ["dist/index.js", "src/index.ts", "src/settings.ts"], + expected: "src/index.ts", + mtimes: { "dist/index.js": older, "src/index.ts": older, "src/settings.ts": newer }, + }, + { + name: "bundled.js + dist + src, src newer → bundled.js", + files: ["bundled.js", "dist/index.js", "src/index.ts"], + expected: "bundled.js", + mtimes: { "dist/index.js": older, "src/index.ts": newer }, + }, { 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 }, ]; @@ -48,6 +85,9 @@ describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () for (const layout of layouts) { it(`resolves identically for: ${layout.name}`, () => { for (const f of layout.files) touch(f); + for (const [file, mtime] of Object.entries(layout.mtimes ?? {})) { + utimesSync(join(dir, file), mtime, mtime); + } const expected = layout.expected === null ? null : join(dir, layout.expected); expect(cliResolve(dir)).toBe(expected); diff --git a/packages/cli/src/plugins/bundled-plugin-freshness.ts b/packages/cli/src/plugins/bundled-plugin-freshness.ts new file mode 100644 index 0000000000..e2a167f488 --- /dev/null +++ b/packages/cli/src/plugins/bundled-plugin-freshness.ts @@ -0,0 +1,118 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type StaleBundledPlugin = { + id: string; + pluginDir: string; + reason: string; + newestSrcMtimeMs: number; + oldestDistMtimeMs: number; +}; + +export type BundledPluginFreshnessOptions = { + pluginsRoot?: string; +}; + +const IGNORED_DIR_NAMES = new Set(["node_modules", "__tests__", ".git"]); + +/** + * FNXC:BundledPlugins 2026-06-17-21:50: + * Bundled plugin loaders resolve compiled entries before source entries in shipped installs, and prior work showed gitignored compiled output can drift from src and ship stale runtime behavior. Keep this guard generic and mtime-based so every staged bundled plugin gets the same stale-artifact protection that FN-6596 added for Compound Engineering after ce-debug regressed. + */ +export function findStaleBundledPlugins( + pluginIds: readonly string[], + opts: BundledPluginFreshnessOptions = {}, +): StaleBundledPlugin[] { + const pluginsRoot = opts.pluginsRoot ?? defaultPluginsRoot(); + const stalePlugins: StaleBundledPlugin[] = []; + + for (const id of pluginIds) { + const pluginDir = join(pluginsRoot, id); + const srcDir = join(pluginDir, "src"); + const distDir = join(pluginDir, "dist"); + const distIndexPath = join(distDir, "index.js"); + + if (!existsSync(distIndexPath)) { + continue; + } + + const newestSrcMtimeMs = newestFileMtimeMs(srcDir); + const oldestDistMtimeMs = oldestCompiledDistMtimeMs(distDir); + + if (newestSrcMtimeMs === null || oldestDistMtimeMs === null) { + continue; + } + + if (newestSrcMtimeMs > oldestDistMtimeMs) { + stalePlugins.push({ + id, + pluginDir, + newestSrcMtimeMs, + oldestDistMtimeMs, + reason: `${id} dist is stale relative to src — run pnpm build`, + }); + } + } + + return stalePlugins; +} + +export function assertBundledPluginsFresh( + pluginIds: readonly string[], + opts: BundledPluginFreshnessOptions = {}, +): void { + const stalePlugins = findStaleBundledPlugins(pluginIds, opts); + if (stalePlugins.length === 0) { + return; + } + + const details = stalePlugins.map((plugin) => `- ${plugin.reason} (${plugin.pluginDir})`).join("\n"); + throw new Error(`Stale bundled plugin compiled artifacts detected:\n${details}`); +} + +function defaultPluginsRoot(): string { + const moduleDir = dirname(fileURLToPath(import.meta.url)); + return resolve(moduleDir, "..", "..", "..", "..", "plugins"); +} + +function newestFileMtimeMs(rootDir: string): number | null { + let newest: number | null = null; + walkFiles(rootDir, (path) => { + const mtimeMs = statSync(path).mtimeMs; + newest = newest === null ? mtimeMs : Math.max(newest, mtimeMs); + }); + return newest; +} + +function oldestCompiledDistMtimeMs(rootDir: string): number | null { + let oldest: number | null = null; + walkFiles(rootDir, (path) => { + if (path.endsWith(".map")) { + return; + } + const mtimeMs = statSync(path).mtimeMs; + oldest = oldest === null ? mtimeMs : Math.min(oldest, mtimeMs); + }); + return oldest; +} + +function walkFiles(rootDir: string, visitFile: (path: string) => void): void { + if (!existsSync(rootDir)) { + return; + } + + const entries = readdirSync(rootDir, { withFileTypes: true, encoding: "utf8" }); + for (const entry of entries) { + const entryPath = join(rootDir, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIR_NAMES.has(entry.name)) { + walkFiles(entryPath, visitFile); + } + continue; + } + if (entry.isFile()) { + visitFile(entryPath); + } + } +} diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index 6f5beede25..6a9b5af702 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -1,4 +1,4 @@ -import { existsSync, statSync } from "node:fs"; +import { existsSync, readdirSync, statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -70,10 +70,17 @@ function resolveBundledPluginDir(pluginId: string): string | null { /** * Resolve the actual loadable entry FILE path for a plugin directory. Node ESM * does not allow directory imports, so we must register the explicit file the - * loader will dynamic-import. Preference order: - * 1. ./bundled.js (esbuild-bundled, shipped in npm tarball) - * 2. ./dist/index.js (legacy prebuilt fallback) - * 3. ./src/index.ts (workspace/dev fallback when no bundle exists) + * loader will dynamic-import. Resolution keeps ./bundled.js unconditional + * because production npm tarballs ship that esbuild-bundled entry. In + * dev/worktree contexts where no bundle exists, ./dist/index.js remains the + * prebuilt fallback unless any file under ./src/ is newer than dist/index.js; + * then ./src/index.ts wins so stale gitignored dist output cannot mask a source + * fix (FN-6615/FN-6596). + * + * FNXC:PluginLoader 2026-06-17-19:20: + * Prefer fresher src over stale dist only when bundled.js is absent. This keeps + * production tarballs on their bundled entry while preventing dev/worktree runs + * from silently loading old gitignored build output after a source fix. * * 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 @@ -82,16 +89,74 @@ function resolveBundledPluginDir(pluginId: string): string | null { * 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 = [ - join(pluginDir, "bundled.js"), - join(pluginDir, "dist", "index.js"), - join(pluginDir, "src", "index.ts"), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; +function newestSourceMtimeMs(srcDir: string): number | null { + let newest = Number.NEGATIVE_INFINITY; + + function visit(dir: string): boolean { + const entries = (() => { + try { + return readdirSync(dir, { withFileTypes: true, encoding: "utf8" }); + } catch { + return null; + } + })(); + if (!entries) return false; + + for (const entry of entries) { + const entryPath = join(dir, entry.name); + let entryStat: ReturnType; + try { + entryStat = statSync(entryPath); + } catch { + return false; + } + + if (entryStat.isDirectory()) { + if (!visit(entryPath)) return false; + continue; + } + + if (entryStat.mtimeMs > newest) { + newest = entryStat.mtimeMs; + } } + + return true; + } + + return visit(srcDir) && newest !== Number.NEGATIVE_INFINITY ? newest : null; +} + +function isSourceNewerThanDist(srcDir: string, distIndexPath: string): boolean { + try { + const distMtimeMs = statSync(distIndexPath).mtimeMs; + const srcMtimeMs = newestSourceMtimeMs(srcDir); + return srcMtimeMs !== null && srcMtimeMs > distMtimeMs; + } catch { + return false; + } +} + +export function resolvePluginEntryPath(pluginDir: string): string | null { + const bundledPath = join(pluginDir, "bundled.js"); + if (existsSync(bundledPath)) { + return bundledPath; + } + + const distIndexPath = join(pluginDir, "dist", "index.js"); + const srcDir = join(pluginDir, "src"); + const srcIndexPath = join(srcDir, "index.ts"); + const hasDist = existsSync(distIndexPath); + const hasSrc = existsSync(srcIndexPath); + + if (hasDist && hasSrc) { + return isSourceNewerThanDist(srcDir, distIndexPath) ? srcIndexPath : distIndexPath; + } + if (hasDist) { + return distIndexPath; + } + if (hasSrc) { + return srcIndexPath; } return null; } diff --git a/packages/cli/src/plugins/staged-bundled-plugin-ids.ts b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts new file mode 100644 index 0000000000..cc9e1c62b9 --- /dev/null +++ b/packages/cli/src/plugins/staged-bundled-plugin-ids.ts @@ -0,0 +1,22 @@ +export const RUNTIME_PLUGIN_IDS = [ + "fusion-plugin-hermes-runtime", + "fusion-plugin-openclaw-runtime", + "fusion-plugin-paperclip-runtime", + "fusion-plugin-cursor-runtime", + "fusion-plugin-droid-runtime", + "fusion-plugin-acp-runtime", +] as const; + +/** + * FNXC:BundledPlugins 2026-06-17-22:03: + * The published CLI stages more plugins than the auto-install subset: droid and ACP runtimes are bundled for explicit runtime use but are not auto-installed with the default plugin set. Keep one staged-id source shared by build assertions and freshness tests so a newly shipped plugin cannot bypass stale-dist checks. + */ +export const ALL_STAGED_BUNDLED_IDS = [ + ...RUNTIME_PLUGIN_IDS, + "fusion-plugin-dependency-graph", + "fusion-plugin-roadmap", + "fusion-plugin-compound-engineering", + "fusion-plugin-whatsapp-chat", + "fusion-plugin-reports", + "fusion-plugin-cli-printing-press", +] as const; diff --git a/packages/cli/src/type-guards/plugin-scaffold-fusion-plugin.ts b/packages/cli/src/type-guards/plugin-scaffold-fusion-plugin.ts new file mode 100644 index 0000000000..831642c716 --- /dev/null +++ b/packages/cli/src/type-guards/plugin-scaffold-fusion-plugin.ts @@ -0,0 +1,30 @@ +import type { FusionPlugin } from "@fusion/core"; + +function defineScaffoldPluginFixture(plugin: FusionPlugin): FusionPlugin { + return plugin; +} + +/** + * FNXC:PluginScaffold 2026-06-14-01:48: + * This fixture mirrors the standalone scaffold's emitted plugin object so the CLI build fails when the SDK-backed FusionPlugin contract adds a required field that `fn plugin new` must emit. + */ +const standaloneScaffoldPluginFixture: FusionPlugin = { + manifest: { + id: "hello-plugin", + name: "Hello Plugin", + version: "0.1.0", + description: "A standalone Fusion plugin", + }, + state: "installed", + hooks: { + onLoad: async (ctx) => { + ctx.logger.info("Hello Plugin plugin loaded"); + }, + }, +}; + +export function verifyStandaloneScaffoldPluginFixture(): FusionPlugin { + return defineScaffoldPluginFixture(standaloneScaffoldPluginFixture); +} + +export { standaloneScaffoldPluginFixture }; diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 3c608fbcb1..12b92f2dc6 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -3,19 +3,9 @@ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } fr import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { build as esbuildBuild } from "esbuild"; +import { ALL_STAGED_BUNDLED_IDS, RUNTIME_PLUGIN_IDS } from "./src/plugins/staged-bundled-plugin-ids"; -// Runtime plugin ids that ship inside the published CLI tarball. Each plugin's -// entry is esbuild-bundled into dist/plugins//bundled.js with workspace -// deps (@fusion/plugin-sdk) inlined, since npm publish strips node_modules -// directories. See ensureBundledPluginInstalled for the loader-side counterpart. -const RUNTIME_PLUGIN_IDS = [ - "fusion-plugin-hermes-runtime", - "fusion-plugin-openclaw-runtime", - "fusion-plugin-paperclip-runtime", - "fusion-plugin-cursor-runtime", - "fusion-plugin-droid-runtime", - "fusion-plugin-acp-runtime", -] as const; +export { ALL_STAGED_BUNDLED_IDS }; const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([ "fusion-plugin-openclaw-runtime", @@ -129,6 +119,27 @@ async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = fal console.log(`Bundled plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`); } +function assertAllStagedBundledPluginsLoadable() { + const missingEntries: string[] = []; + + for (const pluginId of ALL_STAGED_BUNDLED_IDS) { + const destDir = join(__dirname, "dist", "plugins", pluginId); + const manifestPath = join(destDir, "manifest.json"); + const bundledEntryPath = join(destDir, "bundled.js"); + const sourceEntryPath = join(destDir, "src", "index.ts"); + + if (!existsSync(manifestPath) || (!existsSync(bundledEntryPath) && !existsSync(sourceEntryPath))) { + missingEntries.push( + `${pluginId} (expected manifest.json plus bundled.js or src/index.ts under ${destDir})`, + ); + } + } + + if (missingEntries.length > 0) { + throw new Error(`[tsup] Missing loadable staged bundled plugin entries:\n${missingEntries.join("\n")}`); + } +} + const pluginSdkEntry = join(__dirname, "..", "plugin-sdk", "src", "index.ts"); const pluginSdkCoreRuntimeShim = join(__dirname, "src", "plugin-sdk-core-runtime-shim.ts"); @@ -291,6 +302,12 @@ const cliBuildConfig = { }); } + /* + * FNXC:BundledPlugins 2026-06-17-22:15: + * Build output must cover the complete staged plugin surface, including raw-src copied plugins that do not pass through bundlePluginEntry's per-plugin bundled.js assertion. Droid and ACP runtimes are intentionally staged but not auto-installed pending FN-6623, so this checks loadable staged entries rather than BUNDLED_PLUGIN_IDS equality. + */ + assertAllStagedBundledPluginsLoadable(); + if (existsSync(dashboardClientDest)) { rmSync(dashboardClientDest, { recursive: true, force: true }); } @@ -316,9 +333,18 @@ const pluginSdkBuildConfig = { target: "node22", tsconfig: join(__dirname, "..", "plugin-sdk", "tsconfig.json"), dts: { - resolve: true, + /* + * FNXC:PluginSDK 2026-06-13-12:00: + * FN-6409 requires the published @runfusion/fusion/plugin-sdk declaration entry to be self-contained. External plugin authors cannot resolve private @fusion/core types from scaffolded projects, so leaving @fusion/* imports in dist/plugin-sdk/index.d.ts makes tsc fail with TS2307 before ctx parameters can typecheck. + */ + resolve: [/^@fusion\//], compilerOptions: { rootDir: join(__dirname, ".."), + baseUrl: ".", + paths: { + "@fusion/core": ["../core/src/index.ts"], + }, + removeComments: true, }, }, noExternal: [/^@fusion\//], diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index eb1c0cebc6..76b9498c77 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -4,6 +4,29 @@ import { computeMaxWorkers } from "../core/src/__test-utils__/vitest-workers"; const maxWorkers = computeMaxWorkers(); +const quarantinedCliTests: string[] = [ + /* + FNXC:CliTests 2026-06-14-01:36: + The full @runfusion/fusion package lane timed out or leaked mock state across 24 CLI integration-heavy files under changed-test load, while the same files passed in smaller direct runs. + They were quarantined per the flaky-test deletion ratchet instead of raising the 5s test timeout or relaxing assertions. + + FNXC:CliTests 2026-06-14-05:50: + FN-6427 triaged all 24 quarantined CLI files and kept them in-window: 0 rescued, 0 deleted, 24 kept until the 2026-06-27 and 2026-06-28 deletion deadlines. + Fresh direct runs passed, and the shared package-load signature needed a broader fixture/concurrency rescue before these high-value suites could safely rejoin the default lane. + + FNXC:CliTests 2026-06-14-01:42: + FN-6430 rescued all 24 CLI quarantine entries after fixing shared test-isolation cleanup, rejecting inherited HOME roots from other invocations, removing pre-existing file-wide timeout bumps, and narrowing the mission real-store seam. + Keep this array as an explicit empty rescue ledger so future CLI quarantines add entries in lockstep with scripts/lib/test-quarantine.json instead of resurrecting stale excludes. + + FNXC:CliTests 2026-06-15-04:07: + FN-6483 observed extension-task-tools timing out only under the full @runfusion/fusion package lane while passing standalone immediately afterward. + Quarantine the suite for the 14-day deletion ratchet instead of appeasing the load-sensitive timeout with wider test timeouts, retries, or worker changes. + + FNXC:CliTests 2026-06-15-07:46: + FN-6486 rescued extension-task-tools by closing real TaskStore fixtures and replacing hoisted mock cleanup, then removed the quarantine in lockstep with scripts/lib/test-quarantine.json. Keep this array empty unless a future observed CLI flake is mirrored in the ledger in the same commit. + */ +]; + export default defineConfig({ resolve: { // Keep these aliases exact and ordered (subpaths before package roots). @@ -45,7 +68,7 @@ export default defineConfig({ // build-exe + build-exe-cross live in their own vitest project // (see vitest.build-exe.config.ts) so the rest of the CLI suite can // run with file parallelism enabled. - exclude: ["**/node_modules/**", "**/dist/**", "src/__tests__/build-exe*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**", "src/__tests__/build-exe*.test.ts", ...quarantinedCliTests], setupFiles: [ resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"), ], diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 5011826c91..e8a0e13aea 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @fusion/core +## 0.44.0 + +## 0.43.1 + +## 0.43.0 + ## 0.42.0 ## 0.41.0 diff --git a/packages/core/package.json b/packages/core/package.json index b7ec778846..b209f12d2b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@fusion/core", - "version": "0.42.0", + "version": "0.44.0", "license": "MIT", "description": "Fusion core: task store, scheduler, settings, and shared domain types backing the Fusion AI coding agent.", "homepage": "https://github.com/Runfusion/Fusion#readme", diff --git a/packages/core/src/__test-utils__/__tests__/core-dist.test.ts b/packages/core/src/__test-utils__/__tests__/core-dist.test.ts new file mode 100644 index 0000000000..7339f2c95d --- /dev/null +++ b/packages/core/src/__test-utils__/__tests__/core-dist.test.ts @@ -0,0 +1,46 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { hasBuiltCoreDistBarrel, requiredCoreDistFiles, tempWorkspace } from "../workspace.js"; + +describe("hasBuiltCoreDistBarrel", () => { + function makeDistDir() { + const root = tempWorkspace("fusion-core-dist-predicate-"); + const distDir = join(root, "dist"); + mkdirSync(distDir, { recursive: true }); + return distDir; + } + + function touchDistFile(distDir: string, file: (typeof requiredCoreDistFiles)[number]) { + writeFileSync(join(distDir, file), "export {};\n"); + } + + it("returns false when the dist barrel is absent", () => { + const distDir = makeDistDir(); + + expect(hasBuiltCoreDistBarrel(distDir)).toBe(false); + }); + + it("returns false when index.js exists without task-list-format.js", () => { + const distDir = makeDistDir(); + touchDistFile(distDir, "index.js"); + + expect(hasBuiltCoreDistBarrel(distDir)).toBe(false); + }); + + it("returns false when task-list-format.js exists without index.js", () => { + const distDir = makeDistDir(); + touchDistFile(distDir, "task-list-format.js"); + + expect(hasBuiltCoreDistBarrel(distDir)).toBe(false); + }); + + it("returns true only when all required core dist files exist", () => { + const distDir = makeDistDir(); + for (const file of requiredCoreDistFiles) { + touchDistFile(distDir, file); + } + + expect(hasBuiltCoreDistBarrel(distDir)).toBe(true); + }); +}); diff --git a/packages/core/src/__test-utils__/vitest-setup.ts b/packages/core/src/__test-utils__/vitest-setup.ts index 6aa4bb6c29..ea53e03acc 100644 --- a/packages/core/src/__test-utils__/vitest-setup.ts +++ b/packages/core/src/__test-utils__/vitest-setup.ts @@ -14,8 +14,9 @@ import { afterEach, expect } from "vitest"; import { createRequire, syncBuiltinESMExports } from "node:module"; +import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { promisify } from "node:util"; import { isMainThread } from "node:worker_threads"; import { assertOutsideRealFusionPath } from "../test-safety.js"; @@ -74,6 +75,8 @@ function installWarningFilter(): void { installWarningFilter(); const TEST_HOME_PREFIX = "fn-test-home-"; +const WORKER_ROOT_OWNER_FILE = ".fusion-test-worker-root-owner"; +const FUSION_TEST_RUN_TOKEN_ENV = "FUSION_TEST_RUN_TOKEN"; const DEFAULT_TEST_SUBPROCESS_TIMEOUT_MS = Math.max( 1_000, Number.parseInt(process.env.FUSION_TEST_SUBPROCESS_TIMEOUT_MS ?? "30000", 10) || 30_000, @@ -170,14 +173,43 @@ if (!process.env.FUSION_MASTER_KEY_DISABLE_KEYCHAIN) { // bounded one-level sweep of WORKER_ROOT, and a static root can accumulate enough // stale worker/home dirs after interrupted runs to make every mkdtempSync call // take seconds. -const WORKER_ROOT = (() => { +function ensureTestRunToken(): string { + const existing = process.env[FUSION_TEST_RUN_TOKEN_ENV]; + if (existing && existing.trim().length > 0) return existing; + const token = randomUUID(); + process.env[FUSION_TEST_RUN_TOKEN_ENV] = token; + return token; +} + +function writeWorkerRootOwnerMarker(root: string): void { + try { + writeFileSync( + join(root, WORKER_ROOT_OWNER_FILE), + `${process.pid}\nrunToken=${ensureTestRunToken()}\n`, + ); + } catch { + // Best effort only. The marker helps the pnpm-test runner distinguish a + // live same-run root from stale pid reuse; local exit cleanup still owns + // self-minted fallback roots by absolute path. + } +} + +const { root: WORKER_ROOT, selfMinted: SELF_MINTED_WORKER_ROOT } = (() => { const fromEnv = process.env.FUSION_TEST_WORKER_ROOT; - const root = fromEnv && fromEnv.trim().length > 0 - ? resolve(fromEnv) - : realpathSync(mkdtempSync(join(tmpdir(), "fusion-test-workers-"))); + const selfMinted = !(fromEnv && fromEnv.trim().length > 0); + const root = selfMinted + ? realpathSync(mkdtempSync(join(tmpdir(), "fusion-test-workers-"))) + : resolve(fromEnv); try { mkdirSync(root, { recursive: true }); } catch { /* ignore */ } process.env.FUSION_TEST_WORKER_ROOT = root; - return root; + ensureTestRunToken(); + if (selfMinted) { + // FN-6396/FN-6360 recurrence: without globalSetup there is no teardown + // owner for this fallback root. Mark it and remove the root itself on exit + // so an empty fusion-test-workers-* shell cannot trip check-test-isolation. + writeWorkerRootOwnerMarker(root); + } + return { root, selfMinted }; })(); const REAL_TMPDIR = (() => { @@ -187,12 +219,32 @@ const REAL_TMPDIR = (() => { return resolve(tmpdir()); } })(); +const REAL_WORKER_ROOT = (() => { + try { + return realpathSync(WORKER_ROOT); + } catch { + return resolve(WORKER_ROOT); + } +})(); const TMPDIR_REDIRECT_REGISTRY = join(WORKER_ROOT, ".redir-pids"); let tmpdirRedirectSink: string | null = null; let tmpdirRedirectExitCleanupInstalled = false; let tmpdirRedirectSweepComplete = false; +function ensureWorkerRoot(): void { + /* + FNXC:TestIsolation 2026-06-14-01:55: + Concurrent Vitest lanes can observe a worker-root cleanup race where the per-invocation root disappears after module initialization but before a worker creates HOME or cwd directories. + Recreate the root immediately before every mkdtemp under it so a transient sibling teardown cannot fail suite startup with ENOENT. + + FNXC:TestIsolation 2026-06-14-02:08: + When this helper recreates a removed root, it must also restore the owner marker; otherwise the post-test isolation guard reports the still-active rebuilt root as an unowned leak. + */ + mkdirSync(WORKER_ROOT, { recursive: true }); + writeWorkerRootOwnerMarker(WORKER_ROOT); +} + function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); @@ -279,6 +331,7 @@ export const __fusionTmpdirRedirectTestHooks = { }; function ensureTmpdirRedirectSink(): string { + ensureWorkerRoot(); if (tmpdirRedirectSink) { // FN-6310: recovery-timeout cleanup can remove a live worker's cached // redirect sink; recreate it on demand so later mkdtemp calls don't ENOENT. @@ -324,13 +377,35 @@ function redirectTmpdirPrefix(prefix: T): T { return join(ensureTmpdirRedirectSink(), basename(prefix)) as T; } -function ensureIsolatedHome(): void { - const existingHome = process.env.HOME ?? process.env.USERPROFILE; - if (existingHome && existingHome.includes(tmpdir()) && existingHome.includes(TEST_HOME_PREFIX)) { - return; - } +function isWorkerHomePath(path: string | undefined): boolean { + if (!path) return false; + const resolved = (() => { + try { + return realpathSync(path); + } catch { + return resolve(path); + } + })(); + const workerRoots = Array.from(new Set([resolve(WORKER_ROOT), REAL_WORKER_ROOT])); + return workerRoots.some((root) => { + const relativeHome = relative(root, resolved); + return Boolean(relativeHome) + && !relativeHome.startsWith("..") + && !isAbsolute(relativeHome) + && basename(resolved).startsWith(TEST_HOME_PREFIX); + }); +} - const tempHome = realpathSync(mkdtempSync(join(WORKER_ROOT, `${TEST_HOME_PREFIX}${process.pid}-`))); +function isCurrentWorkerHome(path: string | undefined): boolean { + if (!isWorkerHomePath(path)) return false; + if (!existsSync(path!)) { + ensureWorkerRoot(); + mkdirSync(path!, { recursive: true }); + } + return existsSync(path!); +} + +function assignHomeEnv(tempHome: string): void { process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; if (process.platform === "win32") { @@ -342,16 +417,65 @@ function ensureIsolatedHome(): void { } } +function ensureIsolatedHome(): void { + const existingHome = process.env.HOME ?? process.env.USERPROFILE; + if (isCurrentWorkerHome(existingHome)) { + return; + } + + ensureWorkerRoot(); + /* + FNXC:TestIsolation 2026-06-14-00:31: + Nested or recursive Vitest lanes may inherit a parent worker's `fn-test-home-*` HOME value, which shares global settings/cache state across files and keeps CLI suites load-sensitive. + Reuse HOME only when it belongs to this invocation's worker root; otherwise mint a fresh per-run HOME under `fusion-test-workers-*` so teardown removes it with the worker root. + + FNXC:TestIsolation 2026-06-18-07:22: + FN-6610 requires a live worker's HOME redirect to survive sibling teardown without leaking a new `fn-test-home-*` directory per subprocess. + Recreate the owned HOME path when it was swept so repeated git/config subprocesses keep one stable per-worker HOME. + */ + const tempHome = realpathSync(mkdtempSync(join(WORKER_ROOT, `${TEST_HOME_PREFIX}${process.pid}-`))); + assignHomeEnv(tempHome); +} + ensureIsolatedHome(); let workerTempDir: string | null = null; if (isMainThread) { + ensureWorkerRoot(); workerTempDir = realpathSync( mkdtempSync(join(WORKER_ROOT, `w-${process.pid}-`)) ); process.chdir(workerTempDir); } +function ensureWorkerCwdForSubprocess(): void { + if (!isMainThread) return; + try { + originalCwd(); + return; + } catch { + // Recreate below. A child process launched while uv_cwd is invalid fails + // before its own command can run, so the subprocess seam must repair cwd. + } + + ensureWorkerRoot(); + if (!workerTempDir || !existsSync(workerTempDir)) { + workerTempDir = realpathSync(mkdtempSync(join(WORKER_ROOT, `w-${process.pid}-`))); + } + process.chdir(workerTempDir); +} + +function ensureRuntimeIsolationForSubprocess(): void { + /* + FNXC:TestIsolation 2026-06-18-07:22: + FN-6610 traced engine-lane git/config failures to live workers inheriting a swept cwd or `fn-test-home-*` directory after setup. + Revalidate cwd and HOME immediately before subprocess launch so real-git tests do not depend on setup-time paths surviving sibling teardown or recovery cleanup. + */ + ensureWorkerRoot(); + ensureIsolatedHome(); + ensureWorkerCwdForSubprocess(); +} + function installFsGuards(): void { const guardState = globalThis as typeof globalThis & { __fusionTestFsGuardInstalled?: boolean }; if (guardState.__fusionTestFsGuardInstalled) return; @@ -813,6 +937,7 @@ function installChildProcessGuards(): void { if (shouldBlockRealTestCli(commandLine)) { throw blockedCliError(commandLine); } + ensureRuntimeIsolationForSubprocess(); const proc = originalChildProcess.spawn(command, args, options); registerTrackedSubprocess(proc, commandLine); return proc; @@ -828,6 +953,7 @@ function installChildProcessGuards(): void { if (shouldBlockRealTestCli(commandLine)) { throw blockedCliError(commandLine); } + ensureRuntimeIsolationForSubprocess(); return originalChildProcess.spawnSync(command, args, options); }) as ChildProcessModule["spawnSync"]; @@ -838,6 +964,7 @@ function installChildProcessGuards(): void { if (shouldBlockRealTestCli(command)) { throw blockedCliError(command); } + ensureRuntimeIsolationForSubprocess(); return originalChildProcess.execSync(command, withDefaultTimeout(options)); }) as ChildProcessModule["execSync"]; @@ -851,6 +978,7 @@ function installChildProcessGuards(): void { if (shouldBlockRealTestCli(commandLine)) { throw blockedCliError(commandLine); } + ensureRuntimeIsolationForSubprocess(); return originalChildProcess.execFileSync(file, args, options); }) as ChildProcessModule["execFileSync"]; @@ -866,6 +994,7 @@ function installChildProcessGuards(): void { } const options = typeof optionsOrCallback === "function" ? undefined : optionsOrCallback; const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback; + ensureRuntimeIsolationForSubprocess(); const proc = originalChildProcess.exec(command, withDefaultTimeout(options), callback); registerTrackedSubprocess(proc, command); return proc; @@ -899,6 +1028,7 @@ function installChildProcessGuards(): void { const callback = Array.isArray(argsOrOptions) ? (typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback) : (typeof argsOrOptions === "function" ? argsOrOptions : typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback); + ensureRuntimeIsolationForSubprocess(); const proc = originalChildProcess.execFile(file, args, withDefaultTimeout(options), callback); registerTrackedSubprocess(proc, commandLine); return proc; @@ -927,6 +1057,7 @@ function installChildProcessGuards(): void { if (shouldBlockRealTestCli(commandLine)) { throw blockedCliError(commandLine); } + ensureRuntimeIsolationForSubprocess(); const proc = originalChildProcess.fork(modulePath, args, options); registerTrackedSubprocess(proc, commandLine); return proc; @@ -1036,6 +1167,32 @@ afterEach(async () => { } }); +function sleepMsSync(ms: number): void { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function removeSelfMintedWorkerRootWithRetry( + workerRoot = WORKER_ROOT, + selfMinted = SELF_MINTED_WORKER_ROOT, + delayMs = 25, +): void { + if (!selfMinted) return; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + rmSync(workerRoot, { recursive: true, force: true }); + return; + } catch { + if (attempt < 3) sleepMsSync(delayMs); + } + } +} + +export const __fusionWorkerRootCleanupTestHooks = { + removeSelfMintedWorkerRootWithRetry, + writeWorkerRootOwnerMarker, +}; + process.on("exit", () => { for (const [proc] of trackedSubprocesses) { try { @@ -1045,11 +1202,14 @@ process.on("exit", () => { } cleanupTrackedSubprocess(proc); } - if (!workerTempDir) return; - try { - originalChdir(tmpdir()); - rmSync(workerTempDir, { recursive: true, force: true }); - } catch { - // Ignore — globalTeardown sweeps WORKER_ROOT anyway. + if (workerTempDir) { + try { + originalChdir(tmpdir()); + rmSync(workerTempDir, { recursive: true, force: true }); + } catch { + // Ignore — globalTeardown sweeps env-owned WORKER_ROOT; self-minted roots + // get their own bounded best-effort removal below. + } } + removeSelfMintedWorkerRootWithRetry(); }); diff --git a/packages/core/src/__test-utils__/vitest-teardown.ts b/packages/core/src/__test-utils__/vitest-teardown.ts index 20f52e3482..2b1e7a8445 100644 --- a/packages/core/src/__test-utils__/vitest-teardown.ts +++ b/packages/core/src/__test-utils__/vitest-teardown.ts @@ -6,11 +6,13 @@ * the run-local worker/home directories as leaks. */ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; export const WORKER_ROOT_OWNER_FILE = ".fusion-test-worker-root-owner"; +const FUSION_TEST_RUN_TOKEN_ENV = "FUSION_TEST_RUN_TOKEN"; +const LEGACY_TEST_HOME_PREFIX = "fn-test-home-"; let workerRootRmSync = rmSync; let workerRootSleepMsSync = sleepMsSync; @@ -32,7 +34,35 @@ function isEnoent(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); } -export function removeWorkerRootWithRetry(workerRoot: string, retries = 3, delayMs = 75): void { +export function removeLegacyTopLevelHomeRoots(tempRoot = tmpdir()): void { + /* + FNXC:TestIsolation 2026-06-14-00:36: + FN-6430 found stale top-level `fn-test-home-*` roots after CLI package-load runs; current workers create HOME under `fusion-test-workers-*`, so top-level homes are legacy leftovers that can bleed settings/cache state into nested lanes. + Sweep only a single temp-root level by prefix during setup/teardown, never a recursive temp-tree walk. + */ + let entries: string[] = []; + try { + entries = readdirSync(tempRoot); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.startsWith(LEGACY_TEST_HOME_PREFIX)) continue; + try { + workerRootRmSync(join(tempRoot, entry), { recursive: true, force: true }); + } catch { + // Best effort only. A future invocation will retry the bounded prefix sweep. + } + } +} + +export function removeWorkerRootWithRetry(workerRoot: string, retries = 8, delayMs = 75): void { + /* + FNXC:TestIsolation 2026-06-17-19:02: + Broad core/package runs can finish workers while macOS still drains redirected temp files or SQLite WAL handles under `fusion-test-workers-*`. + Keep teardown bounded but long enough to absorb transient ENOTEMPTY/EBUSY cleanup races rather than leaking a per-invocation worker root. + */ let lastError: unknown = null; for (let attempt = 1; attempt <= retries; attempt++) { try { @@ -52,15 +82,19 @@ export function removeWorkerRootWithRetry(workerRoot: string, retries = 3, delay } export default function setup(): () => Promise { + removeLegacyTopLevelHomeRoots(); // Use a fresh root for each Vitest invocation. A static shared root makes the // setup-time redirect sweep proportional to stale directories left by every // prior interrupted run. const workerRoot = resolve(mkdtempSync(join(tmpdir(), "fusion-test-workers-"))); try { - writeFileSync(join(workerRoot, WORKER_ROOT_OWNER_FILE), `${process.pid}\n`); + const runToken = process.env[FUSION_TEST_RUN_TOKEN_ENV]; + const tokenLine = runToken && runToken.trim().length > 0 ? `runToken=${runToken}\n` : ""; + writeFileSync(join(workerRoot, WORKER_ROOT_OWNER_FILE), `${process.pid}\n${tokenLine}`); } catch { // Best effort only. The marker protects active roots from external orphan - // pruning; teardown still owns this root by absolute path. + // pruning; FN-6396 adds the runner token so stale pid reuse cannot keep an + // orphaned root alive. Teardown still owns this root by absolute path. } process.env.FUSION_TEST_WORKER_ROOT = workerRoot; @@ -74,5 +108,6 @@ export default function setup(): () => Promise { // redirected temp dirs are still closing. Retry boundedly so a brief busy-fd // race does not leak the per-invocation fusion-test-workers-* root. removeWorkerRootWithRetry(workerRoot); + removeLegacyTopLevelHomeRoots(); }; } diff --git a/packages/core/src/__test-utils__/workspace.ts b/packages/core/src/__test-utils__/workspace.ts index 91da16c4a0..e8b7a841a4 100644 --- a/packages/core/src/__test-utils__/workspace.ts +++ b/packages/core/src/__test-utils__/workspace.ts @@ -16,6 +16,17 @@ import { join, resolve } from "node:path"; import { afterEach } from "vitest"; import { assertOutsideRealFusionPath } from "../test-safety.js"; +/** + * FNXC:CoreTests 2026-06-18-01:30: + * FN-6627 requires built-dist-barrel regression guards to skip cleanly when @fusion/core/dist is absent or partial, and to run with full FN-6515/FN-6535 signal only when the same artifacts loaded by the test are present. + * Keep new dist-dependent guards on this predicate instead of checking index.js separately from task-list-format.js body assertions. + */ +export const requiredCoreDistFiles = ["index.js", "task-list-format.js"] as const; + +export function hasBuiltCoreDistBarrel(distDir: string): boolean { + return requiredCoreDistFiles.every((file) => existsSync(resolve(distDir, file))); +} + export function assertOutsideRealFusion(path: string, context = "operation"): void { assertOutsideRealFusionPath(path, context); } diff --git a/packages/core/src/__tests__/activity-analytics.test.ts b/packages/core/src/__tests__/activity-analytics.test.ts new file mode 100644 index 0000000000..e2b915a0e3 --- /dev/null +++ b/packages/core/src/__tests__/activity-analytics.test.ts @@ -0,0 +1,442 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { emitUsageEvent } from "../usage-events.js"; +import { + aggregateActivityAnalytics, + aggregateMonitorMetrics, + aggregateSdlcFunnel, + buildColumnStageMap, + stageForTraits, +} from "../activity-analytics.js"; + +let incidentSeq = 0; +function insertIncident( + db: Database, + fields: { + groupingKey: string; + status: "open" | "resolved"; + openedAt: string; + resolvedAt?: string | null; + severity?: string; + }, +): string { + const incidentId = `inc-${incidentSeq++}`; + const now = "2026-03-01T00:00:00.000Z"; + db.prepare( + `INSERT INTO incidents + (incidentId, groupingKey, title, severity, status, source, openedAt, resolvedAt, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + incidentId, + fields.groupingKey, + `Incident ${incidentId}`, + fields.severity ?? "error", + fields.status, + "webhook", + fields.openedAt, + fields.resolvedAt ?? null, + now, + now, + ); + return incidentId; +} + +let deploySeq = 0; +function insertDeployment(db: Database, deployedAt: string): void { + const id = `dep-${deploySeq++}`; + db.prepare( + `INSERT INTO deployments (deploymentId, service, environment, deployedAt, createdAt) + VALUES (?, ?, ?, ?, ?)`, + ).run(id, "svc", "prod", deployedAt, deployedAt); +} + +let moveSeq = 0; +function insertMove( + db: Database, + taskId: string, + from: string, + to: string, + timestamp: string, +): void { + db.prepare( + `INSERT INTO activityLog (id, timestamp, type, taskId, taskTitle, details, metadata) + VALUES (?, ?, 'task:moved', ?, ?, ?, ?)`, + ).run( + `mv-${moveSeq++}`, + timestamp, + taskId, + `Task ${taskId}`, + `Task ${taskId} moved: ${from} → ${to}`, + JSON.stringify({ from, to }), + ); +} + +function insertCliSession(db: Database, id: string, createdAt: string): void { + db.prepare( + `INSERT INTO cli_sessions + (id, purpose, projectId, adapterId, agentState, createdAt, updatedAt) + VALUES (?, 'task', 'proj-1', 'claude-local', 'running', ?, ?)`, + ).run(id, createdAt, createdAt); +} + +let agentRunSeq = 0; +function insertAgentRun( + db: Database, + fields: { + agentId?: string; + startedAt: string; + endedAt?: string | null; + status: string; + }, +): string { + const id = `run-${agentRunSeq++}`; + const agentId = fields.agentId ?? "agent-1"; + db.prepare( + `INSERT OR IGNORE INTO agents (id, name, role, state, createdAt, updatedAt) + VALUES (?, ?, 'executor', 'idle', ?, ?)`, + ).run(agentId, agentId, fields.startedAt, fields.startedAt); + db.prepare( + `INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, agentId, JSON.stringify({ taskId: `task-${id}` }), fields.startedAt, fields.endedAt ?? null, fields.status); + return id; +} + +describe("activity-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + incidentSeq = 0; + deploySeq = 0; + moveSeq = 0; + agentRunSeq = 0; + tmpDir = mkdtempSync(join(tmpdir(), "kb-activity-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("counts sessions, messages, and distinct active nodes/agents over a range", () => { + insertCliSession(db, "s1", "2026-03-01T00:00:00.000Z"); + insertCliSession(db, "s2", "2026-03-02T00:00:00.000Z"); + // session outside range + insertCliSession(db, "s-old", "2025-01-01T00:00:00.000Z"); + + emitUsageEvent(db, { kind: "user_message", agentId: "agent-1", nodeId: "node-1", ts: "2026-03-01T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "user_message", agentId: "agent-2", nodeId: "node-1", ts: "2026-03-01T01:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", agentId: "agent-2", nodeId: "node-2", ts: "2026-03-02T00:00:00.000Z" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.sessions).toBe(2); + expect(result.messages).toBe(2); + expect(result.activeNodes).toBe(2); // node-1, node-2 + expect(result.activeAgents).toBe(2); // agent-1, agent-2 + }); + + it("produces a per-day breakdown ascending by day", () => { + emitUsageEvent(db, { kind: "user_message", agentId: "agent-1", nodeId: "node-1", ts: "2026-03-01T08:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", agentId: "agent-1", nodeId: "node-1", ts: "2026-03-01T09:00:00.000Z" }); + emitUsageEvent(db, { kind: "user_message", agentId: "agent-2", nodeId: "node-2", ts: "2026-03-02T08:00:00.000Z" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.daily.map((d) => d.day)).toEqual(["2026-03-01", "2026-03-02"]); + expect(result.daily[0]).toMatchObject({ day: "2026-03-01", activeNodes: 1, activeAgents: 1, messages: 1 }); + expect(result.daily[1]).toMatchObject({ day: "2026-03-02", activeNodes: 1, activeAgents: 1, messages: 1 }); + }); + + it("counts agent runs by status over startedAt range and includes unknown statuses only in total", () => { + insertAgentRun(db, { agentId: "agent-a", startedAt: "2026-03-01T00:00:00.000Z", status: "active" }); + insertAgentRun(db, { agentId: "agent-b", startedAt: "2026-03-02T00:00:00.000Z", endedAt: "2026-03-02T00:10:00.000Z", status: "completed" }); + insertAgentRun(db, { agentId: "agent-c", startedAt: "2026-03-03T00:00:00.000Z", endedAt: "2026-03-03T00:05:00.000Z", status: "failed" }); + insertAgentRun(db, { agentId: "agent-d", startedAt: "2026-03-04T00:00:00.000Z", endedAt: "2026-03-04T00:01:00.000Z", status: "cancelled" }); + insertAgentRun(db, { agentId: "agent-old", startedAt: "2026-02-28T23:59:59.000Z", status: "completed" }); + insertAgentRun(db, { agentId: "agent-new", startedAt: "2026-04-01T00:00:00.000Z", status: "failed" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" }); + + expect(result.agentRuns).toEqual({ total: 4, active: 1, completed: 1, failed: 1 }); + }); + + it("aligns per-day agent run counts with usage days and run-only days", () => { + emitUsageEvent(db, { kind: "user_message", agentId: "a", nodeId: "n1", ts: "2026-03-01T08:00:00.000Z" }); + emitUsageEvent(db, { kind: "user_message", agentId: "b", nodeId: "n2", ts: "2026-03-03T08:00:00.000Z" }); + insertAgentRun(db, { startedAt: "2026-03-02T00:00:00.000Z", status: "completed" }); + insertAgentRun(db, { startedAt: "2026-03-03T00:00:00.000Z", status: "failed" }); + insertAgentRun(db, { startedAt: "2026-03-03T02:00:00.000Z", status: "active" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + + expect(result.daily).toEqual([ + { day: "2026-03-01", activeNodes: 1, activeAgents: 1, messages: 1, agentRuns: 0 }, + { day: "2026-03-02", activeNodes: 0, activeAgents: 0, messages: 0, agentRuns: 1 }, + { day: "2026-03-03", activeNodes: 1, activeAgents: 1, messages: 1, agentRuns: 2 }, + ]); + }); + + it("returns zero agent-run metrics when the agentRuns table is absent", () => { + db.prepare("DROP TABLE agentRuns").run(); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + + expect(result.agentRuns).toEqual({ total: 0, active: 0, completed: 0, failed: 0 }); + expect(result.daily).toEqual([]); + }); + + it("computes stickiness = DAU/MAU", () => { + // Day 1: agents a,b active. Day 2: agent a active. MAU = {a,b} = 2. + // DAU = mean(2, 1) = 1.5. stickiness = 1.5 / 2 = 0.75. + emitUsageEvent(db, { kind: "tool_call", agentId: "a", nodeId: "n1", ts: "2026-03-01T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", agentId: "b", nodeId: "n1", ts: "2026-03-01T01:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", agentId: "a", nodeId: "n1", ts: "2026-03-02T00:00:00.000Z" }); + + const result = aggregateActivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.activeAgents).toBe(2); + expect(result.stickiness).toBeCloseTo(0.75, 5); + }); + + it("empty range returns zeroed structures, not nulls", () => { + insertCliSession(db, "s1", "2026-03-01T00:00:00.000Z"); + emitUsageEvent(db, { kind: "user_message", agentId: "a", nodeId: "n1", ts: "2026-03-01T00:00:00.000Z" }); + + const result = aggregateActivityAnalytics(db, { from: "2027-01-01T00:00:00.000Z", to: "2027-12-31T00:00:00.000Z" }); + expect(result.sessions).toBe(0); + expect(result.messages).toBe(0); + expect(result.activeNodes).toBe(0); + expect(result.activeAgents).toBe(0); + expect(result.agentRuns).toEqual({ total: 0, active: 0, completed: 0, failed: 0 }); + expect(result.daily).toEqual([]); + expect(result.stickiness).toBe(0); + }); + + it("MTTR is unavailable (not 0) when no incident has been resolved", () => { + const result = aggregateActivityAnalytics(db, {}); + expect(result.mttr).toEqual({ value: null, unavailable: true, sampleCount: 0 }); + expect(result.monitor.openIncidents).toBe(0); + expect(result.monitor.deployments).toBe(0); + }); + + describe("SDLC funnel (U7)", () => { + const RANGE = { from: "2026-03-01T00:00:00.000Z", to: "2026-03-08T00:00:00.000Z" }; + + function stage(result: ReturnType, name: string) { + return result.stages.find((s) => s.stage === name); + } + + it("maps the built-in workflow columns to stages by trait", () => { + expect(stageForTraits(["intake"])).toBe("triage"); + expect(stageForTraits(["hold", "reset-on-entry"])).toBe("todo"); + expect(stageForTraits(["wip", "timing"])).toBe("in-progress"); + expect(stageForTraits(["merge-blocker", "human-review", "merge"])).toBe("in-review"); + expect(stageForTraits(["complete"])).toBe("done"); + // No recognized trait -> other. + expect(stageForTraits(["archived"])).toBe("other"); + expect(stageForTraits([])).toBe("other"); + }); + + it("renders correct per-stage counts for tasks distributed across columns", () => { + // t1: triage -> todo -> in-progress -> in-review -> done (full funnel) + insertMove(db, "t1", "triage", "todo", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t1", "todo", "in-progress", "2026-03-02T01:00:00.000Z"); + insertMove(db, "t1", "in-progress", "in-review", "2026-03-02T02:00:00.000Z"); + insertMove(db, "t1", "in-review", "done", "2026-03-02T03:00:00.000Z"); + // t2: triage -> todo -> in-progress (stalls) + insertMove(db, "t2", "triage", "todo", "2026-03-03T00:00:00.000Z"); + insertMove(db, "t2", "todo", "in-progress", "2026-03-03T01:00:00.000Z"); + // t3: triage -> todo (stalls earlier) + insertMove(db, "t3", "triage", "todo", "2026-03-04T00:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + // Entry counts destination columns of moves. Nothing moved INTO triage + // here, so triage entered = 0; todo = 3, in-progress = 2, in-review = 1, + // done = 1. + expect(stage(result, "triage")?.entered).toBe(0); + expect(stage(result, "todo")?.entered).toBe(3); + expect(stage(result, "in-progress")?.entered).toBe(2); + expect(stage(result, "in-review")?.entered).toBe(1); + expect(stage(result, "done")?.entered).toBe(1); + }); + + it("counts a task once per stage even if it re-enters", () => { + insertMove(db, "t1", "in-review", "in-progress", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t1", "in-progress", "in-review", "2026-03-02T01:00:00.000Z"); + insertMove(db, "t1", "in-review", "in-progress", "2026-03-02T02:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + expect(stage(result, "in-progress")?.entered).toBe(1); + expect(stage(result, "in-review")?.entered).toBe(1); + }); + + it("maps custom workflow columns by trait, folding unknown into other", () => { + // Custom column ids that are NOT the builtin names, carrying standard traits. + const columns = [ + { id: "backlog", traits: [{ trait: "intake" }] }, + { id: "ready", traits: [{ trait: "reset-on-entry" }] }, + { id: "doing", traits: [{ trait: "wip" }] }, + { id: "shipped", traits: [{ trait: "complete" }] }, + { id: "icebox", traits: [{ trait: "some-unknown-trait" }] }, + ]; + insertMove(db, "c1", "backlog", "ready", "2026-03-02T00:00:00.000Z"); + insertMove(db, "c1", "ready", "doing", "2026-03-02T01:00:00.000Z"); + insertMove(db, "c1", "doing", "shipped", "2026-03-02T02:00:00.000Z"); + insertMove(db, "c2", "ready", "icebox", "2026-03-03T00:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, { ...RANGE, columns }); + expect(stage(result, "todo")?.entered).toBe(1); // moved into "ready" + expect(stage(result, "in-progress")?.entered).toBe(1); // "doing" + expect(stage(result, "done")?.entered).toBe(1); // "shipped" + expect(stage(result, "other")?.entered).toBe(1); // "icebox" (unknown trait) + + // Map helper resolves by trait, not name. + const map = buildColumnStageMap(columns); + expect(map.get("backlog")).toBe("triage"); + expect(map.get("shipped")).toBe("done"); + expect(map.get("icebox")).toBe("other"); + }); + + it("completion rate is cohort completed triage entrants / entered-in-range", () => { + // 4 tasks enter triage; 2 of those in-range entrants reach done. + insertMove(db, "t1", "todo", "triage", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t2", "todo", "triage", "2026-03-02T01:00:00.000Z"); + insertMove(db, "t3", "todo", "triage", "2026-03-02T02:00:00.000Z"); + insertMove(db, "t4", "todo", "triage", "2026-03-02T03:00:00.000Z"); + insertMove(db, "t1", "in-review", "done", "2026-03-03T00:00:00.000Z"); + insertMove(db, "t2", "in-review", "done", "2026-03-03T01:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.enteredInRange).toBe(4); + expect(result.doneInRange).toBe(2); + expect(result.completionRate).toBe(0.5); + }); + + it("keeps completion rate bounded when older triage entrants finish in range", () => { + insertMove(db, "old-1", "todo", "triage", "2026-02-20T00:00:00.000Z"); + insertMove(db, "old-2", "todo", "triage", "2026-02-21T00:00:00.000Z"); + insertMove(db, "new-1", "todo", "triage", "2026-03-02T00:00:00.000Z"); + insertMove(db, "old-1", "in-review", "done", "2026-03-03T00:00:00.000Z"); + insertMove(db, "old-2", "in-review", "done", "2026-03-03T01:00:00.000Z"); + insertMove(db, "new-1", "in-review", "done", "2026-03-03T02:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.enteredInRange).toBe(1); + expect(result.doneInRange).toBe(3); + expect(result.completionRate).toBe(1); + expect(result.completionRate).toBeLessThanOrEqual(1); + expect(result.completionRate).not.toBeGreaterThan(1); + }); + + it("reports exactly 100 percent when all in-range triage entrants reach done", () => { + insertMove(db, "t1", "todo", "triage", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t2", "todo", "triage", "2026-03-02T01:00:00.000Z"); + insertMove(db, "t1", "in-review", "done", "2026-03-03T00:00:00.000Z"); + insertMove(db, "t2", "in-review", "done", "2026-03-03T01:00:00.000Z"); + + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.enteredInRange).toBe(2); + expect(result.doneInRange).toBe(2); + expect(result.completionRate).toBe(1); + }); + + it("handles the zero-denominator completion rate as null, not NaN", () => { + // No triage entrants in range; one done move. + insertMove(db, "t1", "in-review", "done", "2026-03-02T00:00:00.000Z"); + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.enteredInRange).toBe(0); + expect(result.completionRate).toBeNull(); + expect(result.doneInRange).toBe(1); + }); + + it("computes throughput per day over the range", () => { + insertMove(db, "t1", "in-review", "done", "2026-03-02T00:00:00.000Z"); + insertMove(db, "t2", "in-review", "done", "2026-03-03T00:00:00.000Z"); + // 7-day range, 2 done -> ~0.2857/day + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.rangeDays).toBe(7); + expect(result.throughputPerDay).toBeCloseTo(2 / 7, 5); + }); + + it("is exposed on the aggregated activity analytics payload (rides /activity)", () => { + insertMove(db, "t1", "todo", "in-progress", "2026-03-02T00:00:00.000Z"); + const result = aggregateActivityAnalytics(db, RANGE); + expect(result.funnel).toBeDefined(); + expect(result.funnel.stages.find((s) => s.stage === "in-progress")?.entered).toBe(1); + }); + + it("empty range yields zeroed funnel, not nulls in counts", () => { + const result = aggregateSdlcFunnel(db, RANGE); + expect(result.doneInRange).toBe(0); + expect(result.enteredInRange).toBe(0); + expect(result.completionRate).toBeNull(); + for (const s of result.stages) { + expect(s.entered).toBe(0); + } + }); + }); + + describe("monitor metrics / MTTR (U13)", () => { + const RANGE = { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" }; + + it("incident opened then resolved yields correct MTTR (minutes)", () => { + // Opened 10:00, resolved 10:30 → 30 minutes. + insertIncident(db, { + groupingKey: "g1", + status: "resolved", + openedAt: "2026-03-02T10:00:00.000Z", + resolvedAt: "2026-03-02T10:30:00.000Z", + }); + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.mttr).toEqual({ value: 30, unavailable: false, sampleCount: 1 }); + expect(m.incidentsResolved).toBe(1); + expect(m.openIncidents).toBe(0); + }); + + it("averages MTTR across multiple resolved incidents", () => { + insertIncident(db, { groupingKey: "g1", status: "resolved", openedAt: "2026-03-02T10:00:00.000Z", resolvedAt: "2026-03-02T10:20:00.000Z" }); // 20m + insertIncident(db, { groupingKey: "g2", status: "resolved", openedAt: "2026-03-03T10:00:00.000Z", resolvedAt: "2026-03-03T11:00:00.000Z" }); // 60m + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.mttr.value).toBe(40); + expect(m.mttr.sampleCount).toBe(2); + }); + + it("unresolved incident contributes to open incidents, NOT to MTTR", () => { + insertIncident(db, { groupingKey: "g1", status: "open", openedAt: "2026-03-02T10:00:00.000Z" }); + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.mttr).toEqual({ value: null, unavailable: true, sampleCount: 0 }); + expect(m.openIncidents).toBe(1); + expect(m.incidentsOpened).toBe(1); + expect(m.incidentsResolved).toBe(0); + }); + + it("a resolution outside the range does not count toward MTTR", () => { + insertIncident(db, { groupingKey: "g1", status: "resolved", openedAt: "2026-02-01T10:00:00.000Z", resolvedAt: "2026-02-01T10:30:00.000Z" }); + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.mttr.unavailable).toBe(true); + expect(m.incidentsResolved).toBe(0); + }); + + it("deploy with no incident counts toward deploy frequency", () => { + insertDeployment(db, "2026-03-05T12:00:00.000Z"); + insertDeployment(db, "2026-03-06T12:00:00.000Z"); + const m = aggregateMonitorMetrics(db, RANGE); + expect(m.deployments).toBe(2); + expect(m.incidentsOpened).toBe(0); + expect(m.mttr.unavailable).toBe(true); + }); + + it("rides the aggregated activity payload (mttr + monitor surfaced)", () => { + insertIncident(db, { groupingKey: "g1", status: "resolved", openedAt: "2026-03-02T10:00:00.000Z", resolvedAt: "2026-03-02T10:30:00.000Z" }); + const result = aggregateActivityAnalytics(db, RANGE); + expect(result.mttr.value).toBe(30); + expect(result.monitor.mttr.value).toBe(30); + }); + }); +}); diff --git a/packages/core/src/__tests__/agent-store.test.ts b/packages/core/src/__tests__/agent-store.test.ts index 16905f744c..841d1c52b8 100644 --- a/packages/core/src/__tests__/agent-store.test.ts +++ b/packages/core/src/__tests__/agent-store.test.ts @@ -1249,7 +1249,7 @@ describe("AgentStore", () => { }); it("blocks delete when checked-out assigned task exists unless force=true", async () => { - const taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); + const taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"), { inMemoryDb: true }); await taskStore.init(); const linkedStore = new AgentStore({ rootDir, inMemoryDb: true, taskStore }); await linkedStore.init(); @@ -1843,12 +1843,17 @@ describe("AgentStore", () => { let taskId: string; beforeEach(async () => { - taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); + /* + FNXC:AgentStoreTests 2026-06-13-17:49: + Checkout leasing tests validate AgentStore and TaskStore behavior through one live TaskStore instance, not disk re-open durability. + Keep the TaskStore database in memory so the full agent-store suite does not spend most of its wall time in repeated SQLite file setup and teardown. + */ + taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"), { inMemoryDb: true }); await taskStore.init(); // Mirror the top-level AgentStore setup: checkout-leasing assertions need - // the disk-backed TaskStore for task persistence, but not a disk-backed - // AgentStore SQLite database in a shared hook. + // task persistence through this TaskStore instance, but not a disk-backed + // SQLite database in a shared hook. store.close(); store = new AgentStore({ rootDir, inMemoryDb: true, taskStore }); await store.init(); @@ -1989,85 +1994,40 @@ describe("AgentStore", () => { expect(claimedAgent?.taskId).toBe(taskId); }); - it("claimTaskForAgent rejects non-executor agents for implementation tasks", async () => { + it("claimTaskForAgent enforces role, task-state, assignment, and checkout guards", async () => { const reviewer = await store.createAgent({ name: "Reviewer", role: "reviewer" }); - - const result = await store.claimTaskForAgent(reviewer.id, taskId); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toMatch(/requires an "executor"-role agent/); - expect(result.reason).toMatch(/durable "engineer" supported only for explicit routing/); - - const claimedTask = await taskStore.getTask(taskId); - expect(claimedTask?.assignedAgentId).toBeUndefined(); - }); - - it("claimTaskForAgent allows engineer claim for explicitly assigned implementation tasks", async () => { const engineer = await store.createAgent({ name: "Engineer", role: "engineer" }); - await taskStore.updateTask(taskId, { assignedAgentId: engineer.id }); - - const result = await store.claimTaskForAgent(engineer.id, taskId); - expect(result.ok).toBe(true); - if (!result.ok) return; - - const claimedTask = await taskStore.getTask(taskId); - expect(claimedTask?.assignedAgentId).toBe(engineer.id); - expect(claimedTask?.checkedOutBy).toBe(engineer.id); - }); - - it("claimTaskForAgent rejects engineer auto-claim for unassigned implementation tasks", async () => { - const engineer = await store.createAgent({ name: "Engineer", role: "engineer" }); - - const result = await store.claimTaskForAgent(engineer.id, taskId); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toMatch(/requires an "executor"-role agent/); - - const claimedTask = await taskStore.getTask(taskId); - expect(claimedTask?.assignedAgentId).toBeUndefined(); - }); - - it("claimTaskForAgent rejects paused task", async () => { - await taskStore.updateTask(taskId, { paused: true }); - - const result = await store.claimTaskForAgent(holderId, taskId); - expect(result).toMatchObject({ ok: false, reason: "paused" }); - - const claimedAgent = await store.getAgent(holderId); - expect(claimedAgent?.taskId).toBeUndefined(); - }); - - it("claimTaskForAgent rejects tasks in terminal columns", async () => { + const assignedToEngineer = await taskStore.createTask({ description: "explicit engineer task", assignedAgentId: engineer.id }); + const pausedTask = await taskStore.createTask({ description: "paused task" }); + await taskStore.updateTask(pausedTask.id, { paused: true }); const doneTask = await taskStore.createTask({ description: "done task", column: "done" }); + const assignedElsewhere = await taskStore.createTask({ description: "assigned elsewhere", assignedAgentId: otherAgentId }); + const checkedOutElsewhere = await taskStore.createTask({ description: "checked out elsewhere" }); + await store.checkoutTask(otherAgentId, checkedOutElsewhere.id); - const result = await store.claimTaskForAgent(holderId, doneTask.id); - expect(result).toMatchObject({ ok: false, reason: "terminal" }); + const reviewerResult = await store.claimTaskForAgent(reviewer.id, taskId); + expect(reviewerResult.ok).toBe(false); + if (!reviewerResult.ok) { + expect(reviewerResult.reason).toMatch(/requires an "executor"-role agent/); + expect(reviewerResult.reason).toMatch(/durable "engineer" supported only for explicit routing/); + } + expect((await taskStore.getTask(taskId))?.assignedAgentId).toBeUndefined(); - const claimedAgent = await store.getAgent(holderId); - expect(claimedAgent?.taskId).toBeUndefined(); - }); + const explicitEngineerResult = await store.claimTaskForAgent(engineer.id, assignedToEngineer.id); + expect(explicitEngineerResult.ok).toBe(true); + expect((await taskStore.getTask(assignedToEngineer.id))?.checkedOutBy).toBe(engineer.id); - it("claimTaskForAgent returns task_not_found when task is missing", async () => { - const result = await store.claimTaskForAgent(holderId, "FN-404"); - expect(result).toMatchObject({ ok: false, reason: "task_not_found" }); - expect("task" in result).toBe(false); + const autoEngineerResult = await store.claimTaskForAgent(engineer.id, taskId); + expect(autoEngineerResult.ok).toBe(false); + if (!autoEngineerResult.ok) { + expect(autoEngineerResult.reason).toMatch(/requires an "executor"-role agent/); + } - const claimedAgent = await store.getAgent(holderId); - expect(claimedAgent?.taskId).toBeUndefined(); - }); - - it("claimTaskForAgent rejects task already assigned to another agent", async () => { - await taskStore.updateTask(taskId, { assignedAgentId: otherAgentId }); - - const result = await store.claimTaskForAgent(holderId, taskId); - expect(result).toMatchObject({ ok: false, reason: "assigned_to_other" }); - }); - - it("claimTaskForAgent rejects checkout conflicts", async () => { - await store.checkoutTask(otherAgentId, taskId); - - const result = await store.claimTaskForAgent(holderId, taskId); - expect(result).toMatchObject({ ok: false, reason: "checkout_conflict" }); + expect(await store.claimTaskForAgent(holderId, pausedTask.id)).toMatchObject({ ok: false, reason: "paused" }); + expect(await store.claimTaskForAgent(holderId, doneTask.id)).toMatchObject({ ok: false, reason: "terminal" }); + expect(await store.claimTaskForAgent(holderId, "FN-404")).toMatchObject({ ok: false, reason: "task_not_found" }); + expect(await store.claimTaskForAgent(holderId, assignedElsewhere.id)).toMatchObject({ ok: false, reason: "assigned_to_other" }); + expect(await store.claimTaskForAgent(holderId, checkedOutElsewhere.id)).toMatchObject({ ok: false, reason: "checkout_conflict" }); const claimedAgent = await store.getAgent(holderId); expect(claimedAgent?.taskId).toBeUndefined(); diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index 2a7ab7dd57..4db2db1490 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -268,11 +268,42 @@ describe("built-in workflows", () => { it("compound-engineering compiles its skill nodes to steps", () => { const ce = getBuiltinWorkflow("builtin:compound-engineering")!; const steps = compileWorkflowToSteps(ce.ir); - // plan + code-review (pre-merge) + document (post-merge) — seams are skipped. - expect(steps.length).toBeGreaterThanOrEqual(3); + // plan + execute (ce-work) + code-review (pre-merge) + document (post-merge) + // — review/merge seams are skipped. + expect(steps.length).toBeGreaterThanOrEqual(4); expect(steps.some((s) => s.name === "Plan")).toBe(true); }); + it("compound-engineering runs ce-work for the execute step in coding mode", () => { + const ce = getBuiltinWorkflow("builtin:compound-engineering")!; + // The IR node declares the ce-work skill executor (engine wraps the prompt + // with the invoke-skill preamble on the graph-interpreter path). + const executeNode = ce.ir.nodes.find((n) => n.id === "execute"); + expect(executeNode?.config?.executor).toBe("skill"); + expect(executeNode?.config?.skillName).toBe("compound-engineering:ce-work"); + // The compiled step runs in coding mode so write/spawn tools are available. + const steps = compileWorkflowToSteps(ce.ir); + const execute = steps.find((s) => s.name === "Execute"); + expect(execute).toBeDefined(); + expect(execute!.toolMode).toBe("coding"); + }); + + it("compound-engineering merge stage uses the CE commit/PR + resolve-feedback skills", () => { + const ce = getBuiltinWorkflow("builtin:compound-engineering")!; + const byId = (id: string) => ce.ir.nodes.find((n) => n.id === id); + expect(byId("commit-pr")?.config?.skillName).toBe("compound-engineering:ce-commit-push-pr"); + expect(byId("commit-pr")?.config?.toolMode).toBe("coding"); + expect(byId("resolve-feedback")?.config?.skillName).toBe("compound-engineering:ce-resolve-pr-feedback"); + // KTD-6: the Fusion board-merge seam is preserved (CE prepares the PR, Fusion + // owns the merge transition). + expect(byId("merge")?.config?.seam).toBe("merge"); + // Ordering: commit-pr → resolve-feedback → merge → document. + const ids = ce.ir.nodes.map((n) => n.id); + expect(ids.indexOf("commit-pr")).toBeLessThan(ids.indexOf("resolve-feedback")); + expect(ids.indexOf("resolve-feedback")).toBeLessThan(ids.indexOf("merge")); + expect(ids.indexOf("merge")).toBeLessThan(ids.indexOf("document")); + }); + describe("store integration", () => { const harness = createTaskStoreTestHarness(); let store: ReturnType; diff --git a/packages/core/src/__tests__/command-center-live.test.ts b/packages/core/src/__tests__/command-center-live.test.ts new file mode 100644 index 0000000000..2cb71fc074 --- /dev/null +++ b/packages/core/src/__tests__/command-center-live.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { composeLiveSnapshot } from "../command-center-live.js"; + +function insertSession( + db: Database, + opts: { + id: string; + taskId?: string | null; + agentState: string; + terminationReason?: string | null; + worktreePath?: string | null; + purpose?: string; + }, +): void { + db.prepare( + `INSERT INTO cli_sessions + (id, taskId, purpose, projectId, adapterId, agentState, terminationReason, worktreePath, createdAt, updatedAt) + VALUES (?, ?, ?, 'proj-1', 'claude-local', ?, ?, ?, ?, ?)`, + ).run( + opts.id, + opts.taskId ?? null, + opts.purpose ?? "execute", + opts.agentState, + opts.terminationReason ?? null, + opts.worktreePath ?? null, + "2026-03-01T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + ); +} + +function insertAgent(db: Database, id: string): void { + db.prepare( + `INSERT INTO agents (id, name, role, state, createdAt, updatedAt) + VALUES (?, ?, 'executor', 'idle', ?, ?)`, + ).run(id, id, "2026-03-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z"); +} + +function insertRun( + db: Database, + opts: { id: string; agentId: string; status: string; taskId?: string }, +): void { + db.prepare( + `INSERT INTO agentRuns (id, agentId, data, startedAt, endedAt, status) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + opts.id, + opts.agentId, + JSON.stringify(opts.taskId ? { taskId: opts.taskId } : {}), + "2026-03-01T00:00:00.000Z", + opts.status === "active" ? null : "2026-03-01T01:00:00.000Z", + opts.status, + ); +} + +function insertTask(db: Database, id: string, column: string): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt) + VALUES (?, 'desc', ?, ?, ?)`, + ).run(id, column, "2026-03-01T00:00:00.000Z", "2026-03-01T00:00:00.000Z"); +} + +describe("command-center-live", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-cc-live-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("composes an empty snapshot with zeroed counts (not nulls)", () => { + const snap = composeLiveSnapshot(db, Date.parse("2026-03-01T12:00:00.000Z")); + expect(snap.capturedAt).toBe("2026-03-01T12:00:00.000Z"); + expect(snap.activeSessions).toBe(0); + expect(snap.activeRuns).toBe(0); + expect(snap.activeNodes).toBe(0); + expect(snap.sessions).toEqual([]); + expect(snap.runs).toEqual([]); + expect(snap.columns).toEqual([]); + }); + + it("counts active sessions and active nodes, excluding terminal/terminated", () => { + insertSession(db, { id: "s1", agentState: "busy", worktreePath: "/wt/node-a" }); + insertSession(db, { id: "s2", agentState: "ready", worktreePath: "/wt/node-b" }); + // same worktree as s1 → one distinct node + insertSession(db, { id: "s3", agentState: "waitingOnInput", worktreePath: "/wt/node-a" }); + // terminal state → excluded + insertSession(db, { id: "s4", agentState: "done", worktreePath: "/wt/node-c" }); + // terminated → excluded even though state is non-terminal + insertSession(db, { + id: "s5", + agentState: "busy", + terminationReason: "userExited", + worktreePath: "/wt/node-d", + }); + + const snap = composeLiveSnapshot(db); + expect(snap.activeSessions).toBe(3); // s1, s2, s3 + expect(snap.activeNodes).toBe(2); // /wt/node-a, /wt/node-b + expect(snap.sessions.map((s) => s.id).sort()).toEqual(["s1", "s2", "s3"]); + }); + + it("counts active runs only and extracts taskId from run data", () => { + insertAgent(db, "agent-1"); + insertRun(db, { id: "r1", agentId: "agent-1", status: "active", taskId: "FN-1" }); + insertRun(db, { id: "r2", agentId: "agent-1", status: "completed", taskId: "FN-2" }); + insertRun(db, { id: "r3", agentId: "agent-1", status: "active" }); + + const snap = composeLiveSnapshot(db); + expect(snap.activeRuns).toBe(2); + expect(snap.runs.map((r) => r.id).sort()).toEqual(["r1", "r3"]); + const r1 = snap.runs.find((r) => r.id === "r1"); + expect(r1?.taskId).toBe("FN-1"); + const r3 = snap.runs.find((r) => r.id === "r3"); + expect(r3?.taskId).toBeNull(); + }); + + it("produces current per-column task counts", () => { + insertTask(db, "FN-1", "todo"); + insertTask(db, "FN-2", "todo"); + insertTask(db, "FN-3", "in-progress"); + insertTask(db, "FN-4", "done"); + + const snap = composeLiveSnapshot(db); + const byColumn = Object.fromEntries(snap.columns.map((c) => [c.column, c.count])); + expect(byColumn).toEqual({ todo: 2, "in-progress": 1, done: 1 }); + }); + + it("is a pure read — does not mutate the database", () => { + insertTask(db, "FN-1", "todo"); + composeLiveSnapshot(db); + composeLiveSnapshot(db); + const count = ( + db.prepare(`SELECT COUNT(*) AS count FROM tasks`).get() as { count: number } + ).count; + expect(count).toBe(1); + }); +}); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index 8deb30c15b..f85bccb841 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -1,3 +1,7 @@ +/* +FNXC:Database 2026-06-16-09:40: +Command Center / SDLC work (PR #1683) added usage_events, knowledge_pages, deployments, and incidents tables behind schema migrations 118-120. These legacy-data migration tests guard the separate legacy-import path so the in-DB schema migrations and the legacy importer stay independent. +*/ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { detectLegacyData, migrateFromLegacy, getMigrationStatus } from "../db-migrate.js"; import { Database, SCHEMA_VERSION } from "../db.js"; @@ -592,6 +596,7 @@ describe("migrateFromLegacy", () => { externalIssueId: "I_kgDOExample", issueNumber: 10, url: "https://github.com/test/issues/1", + closedAt: "2026-06-18T12:00:00.000Z", }, breakIntoSubtasks: true, enabledWorkflowSteps: ["WS-001", "WS-002"], @@ -641,6 +646,7 @@ describe("migrateFromLegacy", () => { expect(row.sourceIssueExternalIssueId).toBe("I_kgDOExample"); expect(row.sourceIssueNumber).toBe(10); expect(row.sourceIssueUrl).toBe("https://github.com/test/issues/1"); + expect(row.sourceIssueClosedAt).toBe("2026-06-18T12:00:00.000Z"); expect(row.breakIntoSubtasks).toBe(1); expect(JSON.parse(row.enabledWorkflowSteps)).toEqual(["WS-001", "WS-002"]); }); @@ -720,6 +726,68 @@ describe("schema migration", () => { db.close(); }); + it("adds sourceIssueClosedAt when migrating from schema version 121 without data loss", () => { + const db = new Database(fusionDir); + db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); + 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, + sourceIssueProvider TEXT, + sourceIssueRepository TEXT, + sourceIssueExternalIssueId TEXT, + sourceIssueNumber INTEGER, + sourceIssueUrl TEXT, + tokenUsageModelProvider TEXT, + tokenUsageModelId TEXT + ) + `); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '121')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec(` + INSERT INTO tasks ( + id, description, "column", createdAt, updatedAt, + sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, + sourceIssueNumber, sourceIssueUrl + ) VALUES ( + 'FN-source', 'legacy source issue', 'done', '2025-01-01T00:00:00.000Z', '2025-01-02T00:00:00.000Z', + 'github', 'runfusion/fusion', 'I_kgDOExample', 10, 'https://github.com/runfusion/fusion/issues/10' + ) + `); + + db.init(); + + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.map((column) => column.name)).toContain("sourceIssueClosedAt"); + + const row = db.prepare(` + SELECT sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, + sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt + FROM tasks WHERE id = 'FN-source' + `).get() as { + sourceIssueProvider: string; + sourceIssueRepository: string; + sourceIssueExternalIssueId: string; + sourceIssueNumber: number; + sourceIssueUrl: string; + sourceIssueClosedAt: string | null; + }; + expect(row).toEqual({ + sourceIssueProvider: "github", + sourceIssueRepository: "runfusion/fusion", + sourceIssueExternalIssueId: "I_kgDOExample", + sourceIssueNumber: 10, + sourceIssueUrl: "https://github.com/runfusion/fusion/issues/10", + sourceIssueClosedAt: null, + }); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + + db.close(); + }); + it("adds workflow_steps.gateMode and backfills legacy rows by mode", () => { const db = new Database(fusionDir); db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index aa49d59aa6..b207c76b01 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,15 +1488,15 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); db.close(); }); @@ -1531,7 +1531,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1572,7 +1572,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1644,7 +1644,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1653,6 +1653,7 @@ describe("schema migrations", () => { expect(colNames).toContain("sourceIssueExternalIssueId"); expect(colNames).toContain("sourceIssueNumber"); expect(colNames).toContain("sourceIssueUrl"); + expect(colNames).toContain("sourceIssueClosedAt"); const task = db.prepare(` SELECT @@ -1660,7 +1661,8 @@ describe("schema migrations", () => { sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, - sourceIssueUrl + sourceIssueUrl, + sourceIssueClosedAt FROM tasks WHERE id = 'FN-3' `).get() as Record; @@ -1670,10 +1672,47 @@ describe("schema migrations", () => { expect(task.sourceIssueExternalIssueId).toBeNull(); expect(task.sourceIssueNumber).toBeNull(); expect(task.sourceIssueUrl).toBeNull(); + expect(task.sourceIssueClosedAt).toBeNull(); db.close(); }); + it("round-trips source issue closedAt through TaskStore serialization", async () => { + const rootDir = makeTmpDir(); + const globalDir = join(rootDir, ".fusion-global"); + const store = new TaskStore(rootDir, globalDir); + await store.init(); + try { + const closedAt = "2026-06-18T15:30:00.000Z"; + const created = await store.createTask({ + description: "source issue closedAt round trip", + sourceIssue: { + provider: "github", + repository: "runfusion/fusion", + externalIssueId: "I_kwDOBogus", + issueNumber: 42, + url: "https://github.com/runfusion/fusion/issues/42", + closedAt, + }, + }); + + const row = store.getDatabase().prepare("SELECT sourceIssueClosedAt FROM tasks WHERE id = ?").get(created.id) as { sourceIssueClosedAt: string | null }; + expect(row.sourceIssueClosedAt).toBe(closedAt); + + const reloaded = await store.getTask(created.id); + expect(reloaded.sourceIssue).toEqual({ + provider: "github", + repository: "runfusion/fusion", + externalIssueId: "I_kwDOBogus", + issueNumber: 42, + url: "https://github.com/runfusion/fusion/issues/42", + closedAt, + }); + } finally { + store.close(); + } + }); + it("reconciles missing columns across all SCHEMA_SQL tables even when schemaVersion is current", () => { tmpDir = makeTmpDir(); const fusionDir = join(tmpDir, ".fusion"); @@ -1884,7 +1923,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1958,7 +1997,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); 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" }]); @@ -1982,7 +2021,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); 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" }]); @@ -2086,7 +2125,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2269,6 +2308,48 @@ describe("schema migrations", () => { db.close(); }); + it("migration v123 adds nullable task commit association diff-stat columns", () => { + tmpDir = makeTmpDir(); + const fusionDir = join(tmpDir, ".fusion"); + const localDb = new Database(fusionDir); + + localDb.exec(` + CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE IF NOT EXISTS task_commit_associations ( + id TEXT PRIMARY KEY, + taskLineageId TEXT NOT NULL, + taskIdSnapshot TEXT NOT NULL, + commitSha TEXT NOT NULL, + commitSubject TEXT NOT NULL, + authoredAt TEXT NOT NULL, + matchedBy TEXT NOT NULL, + confidence TEXT NOT NULL, + note TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + UNIQUE(taskLineageId, commitSha, matchedBy) + ); + `); + localDb.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '122')"); + localDb.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + localDb.exec(`INSERT INTO task_commit_associations + (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, createdAt, updatedAt) + VALUES ('assoc-1', 'lin-1', 'FN-6704', 'abc123', 'subject', '2026-06-19T00:00:00.000Z', 'canonical-lineage-trailer', 'canonical', '2026-06-19T00:00:00.000Z', '2026-06-19T00:00:00.000Z')`); + + localDb.init(); + + expect(localDb.getSchemaVersion()).toBe(123); + const columns = localDb.prepare("PRAGMA table_info(task_commit_associations)").all() as Array<{ name: string; notnull: number; dflt_value: string | null }>; + const additions = columns.find((column) => column.name === "additions"); + const deletions = columns.find((column) => column.name === "deletions"); + expect(additions).toMatchObject({ notnull: 0, dflt_value: null }); + expect(deletions).toMatchObject({ notnull: 0, dflt_value: null }); + const row = localDb.prepare("SELECT additions, deletions FROM task_commit_associations WHERE id = 'assoc-1'").get() as { additions: number | null; deletions: number | null }; + expect(row).toEqual({ additions: null, deletions: null }); + + localDb.close(); + }); + it("migration v74 adds tokenUsageCacheWriteTokens without data loss", () => { tmpDir = makeTmpDir(); const fusionDir = join(tmpDir, ".fusion"); @@ -2305,7 +2386,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(118); + expect(localDb.getSchemaVersion()).toBe(124); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2616,7 +2697,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2770,7 +2851,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(118); + expect(migrated.getSchemaVersion()).toBe(124); 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); @@ -2801,7 +2882,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(118); + expect(fresh.getSchemaVersion()).toBe(124); const names = new Set( (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2829,7 +2910,7 @@ describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(118); + expect(migrated.getSchemaVersion()).toBe(124); const names = new Set( (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), ); @@ -2855,7 +2936,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { const fresh = new Database(fusion); try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(118); + expect(fresh.getSchemaVersion()).toBe(124); const table = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2889,7 +2970,7 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(118); + expect(migrated.getSchemaVersion()).toBe(124); const table = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") .get() as { name: string } | undefined; @@ -2908,6 +2989,103 @@ describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { }); }); +describe("migration v120 adds deployments + incidents tables (U13)", () => { + it("creates the deployments and incidents tables + indexes on fresh init", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const fresh = new Database(fusion); + try { + fresh.init(); + expect(fresh.getSchemaVersion()).toBe(123); + const tables = new Set( + ( + fresh + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('deployments','incidents')", + ) + .all() as Array<{ name: string }> + ).map((t) => t.name), + ); + expect(tables.has("deployments")).toBe(true); + expect(tables.has("incidents")).toBe(true); + const indexes = new Set( + ( + fresh + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND (tbl_name='deployments' OR tbl_name='incidents')", + ) + .all() as Array<{ name: string }> + ).map((i) => i.name), + ); + expect(indexes.has("idxDeploymentsDeployedAt")).toBe(true); + expect(indexes.has("idxIncidentsGroupingKey")).toBe(true); + } finally { + try { fresh.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); + + it("from v119 → init() adds deployments + incidents without dropping existing rows", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const localDb = new Database(fusion); + let migrated: Database | undefined; + try { + localDb.init(); + localDb + .prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)') + .run("FN-V119", "pre-120 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); + // Roll back to v119 and drop the tables the v120 migration creates. + localDb.exec("DROP TABLE IF EXISTS deployments"); + localDb.exec("DROP TABLE IF EXISTS incidents"); + localDb.prepare("UPDATE __meta SET value = '119' WHERE key = 'schemaVersion'").run(); + localDb.close(); + + migrated = new Database(fusion); + migrated.init(); + // FNXC:Database 2026-06-16-14:30: + // The v119→init migration path must restore not just the deployments + + // incidents tables but their indexes too — a migration could regress index + // creation while table + row assertions still pass. Assert the real index + // names the v120 migration creates (idxDeployments*, idxIncidents*) so that + // regression is caught. + expect(migrated.getSchemaVersion()).toBe(123); + const tables = new Set( + ( + migrated + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('deployments','incidents')", + ) + .all() as Array<{ name: string }> + ).map((t) => t.name), + ); + expect(tables.has("deployments")).toBe(true); + expect(tables.has("incidents")).toBe(true); + const indexes = new Set( + ( + migrated + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND (tbl_name='deployments' OR tbl_name='incidents')", + ) + .all() as Array<{ name: string }> + ).map((i) => i.name), + ); + expect(indexes.has("idxDeploymentsDeployedAt")).toBe(true); + expect(indexes.has("idxDeploymentsService")).toBe(true); + expect(indexes.has("idxIncidentsGroupingKey")).toBe(true); + expect(indexes.has("idxIncidentsStatus")).toBe(true); + expect(indexes.has("idxIncidentsOpenedAt")).toBe(true); + expect(indexes.has("idxIncidentsResolvedAt")).toBe(true); + const task = migrated.prepare("SELECT id FROM tasks WHERE id = ?").get("FN-V119") as { id: string } | undefined; + expect(task?.id).toBe("FN-V119"); + } finally { + try { migrated?.close(); } catch { /* already closed */ } + try { localDb.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); +}); + describe("migration v67 drops orphan project auth tables", () => { it("drops project_auth_* tables left over from the removed pluggable auth feature", () => { const temp = makeTmpDir(); @@ -2930,7 +3108,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(118); + expect(migrated.getSchemaVersion()).toBe(124); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2957,7 +3135,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(118); + expect(fresh.getSchemaVersion()).toBe(124); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/github-issue-analytics.test.ts b/packages/core/src/__tests__/github-issue-analytics.test.ts new file mode 100644 index 0000000000..c141cf8456 --- /dev/null +++ b/packages/core/src/__tests__/github-issue-analytics.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { aggregateGithubIssueAnalytics } from "../github-issue-analytics.js"; + +function insertTrackedIssue( + db: Database, + id: string, + issue: Record, + updatedAt = "2026-04-01T00:00:00.000Z", +): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, githubTracking) + VALUES (?, 'desc', 'todo', ?, ?, ?)`, + ).run(id, updatedAt, updatedAt, JSON.stringify({ issue })); +} + +function insertRawGithubTracking(db: Database, id: string, githubTracking: string): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, githubTracking) + VALUES (?, 'desc', 'todo', '2026-04-01T00:00:00.000Z', '2026-04-01T00:00:00.000Z', ?)`, + ).run(id, githubTracking); +} + +function insertSourceIssueTask( + db: Database, + id: string, + opts: { + provider: string; + repository: string; + column: string; + updatedAt: string; + closedAt?: string | null; + issueNumber?: number; + }, +): void { + db.prepare( + `INSERT INTO tasks ( + id, description, "column", createdAt, updatedAt, + sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, + sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt + ) VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, + opts.column, + opts.updatedAt, + opts.updatedAt, + opts.provider, + opts.repository, + String(opts.issueNumber ?? 1), + opts.issueNumber ?? 1, + `https://example.test/${id}`, + opts.closedAt ?? null, + ); +} + +describe("github-issue-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-github-issue-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("aggregates filed and fixed issue totals, daily buckets, and repositories", () => { + insertTrackedIssue(db, "filed-a-1", { + owner: "acme", + repo: "alpha", + number: 10, + url: "https://github.com/acme/alpha/issues/10", + createdAt: "2026-04-01T12:00:00.000Z", + }); + insertTrackedIssue(db, "filed-a-2", { + owner: "acme", + repo: "alpha", + number: 11, + url: "https://github.com/acme/alpha/issues/11", + createdAt: "2026-04-02T12:00:00.000Z", + }); + insertTrackedIssue(db, "filed-b-1", { + owner: "acme", + repo: "beta", + number: 12, + url: "https://github.com/acme/beta/issues/12", + createdAt: "2026-04-02T13:00:00.000Z", + }); + insertTrackedIssue(db, "filed-old", { + owner: "acme", + repo: "old", + number: 9, + url: "https://github.com/acme/old/issues/9", + createdAt: "2026-03-01T00:00:00.000Z", + }); + + insertSourceIssueTask(db, "fixed-a", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-02T20:00:00.000Z", + issueNumber: 20, + }); + insertSourceIssueTask(db, "fixed-b", { + provider: "github", + repository: "acme/beta", + column: "done", + updatedAt: "2026-04-03T20:00:00.000Z", + issueNumber: 21, + }); + insertSourceIssueTask(db, "not-done", { + provider: "github", + repository: "acme/alpha", + column: "todo", + updatedAt: "2026-04-02T20:00:00.000Z", + issueNumber: 22, + }); + insertSourceIssueTask(db, "not-github", { + provider: "gitlab", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-02T20:00:00.000Z", + issueNumber: 23, + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2026-04-01T00:00:00.000Z", + to: "2026-04-03T23:59:59.999Z", + }); + + expect(result.filed).toBe(3); + expect(result.fixed).toBe(2); + expect(result.net).toBe(1); + expect(result.daily).toEqual([ + { date: "2026-04-01", filed: 1, fixed: 0 }, + { date: "2026-04-02", filed: 2, fixed: 1 }, + { date: "2026-04-03", filed: 0, fixed: 1 }, + ]); + expect(result.byRepo).toEqual([ + { repo: "acme/alpha", filed: 2, fixed: 1 }, + { repo: "acme/beta", filed: 1, fixed: 1 }, + ]); + }); + + it("treats range bounds as inclusive", () => { + insertTrackedIssue(db, "filed-from", { + owner: "acme", + repo: "alpha", + number: 1, + url: "https://github.com/acme/alpha/issues/1", + createdAt: "2026-04-01T00:00:00.000Z", + }); + insertSourceIssueTask(db, "fixed-to", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-03T00:00:00.000Z", + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2026-04-01T00:00:00.000Z", + to: "2026-04-03T00:00:00.000Z", + }); + + expect(result.filed).toBe(1); + expect(result.fixed).toBe(1); + expect(result.daily).toEqual([ + { date: "2026-04-01", filed: 1, fixed: 0 }, + { date: "2026-04-03", filed: 0, fixed: 1 }, + ]); + }); + + it("prefers source issue closedAt over updatedAt for fixed range and daily buckets", () => { + insertSourceIssueTask(db, "closed-in-range-updated-outside", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-03-01T00:00:00.000Z", + closedAt: "2026-04-02T10:00:00.000Z", + issueNumber: 31, + }); + insertSourceIssueTask(db, "closed-outside-updated-in-range", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-03T10:00:00.000Z", + closedAt: "2026-03-31T23:59:59.999Z", + issueNumber: 32, + }); + insertSourceIssueTask(db, "no-closedAt-falls-back", { + provider: "github", + repository: "acme/beta", + column: "done", + updatedAt: "2026-04-03T10:00:00.000Z", + issueNumber: 33, + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2026-04-01T00:00:00.000Z", + to: "2026-04-03T23:59:59.999Z", + }); + + expect(result.fixed).toBe(2); + expect(result.daily).toEqual([ + { date: "2026-04-02", filed: 0, fixed: 1 }, + { date: "2026-04-03", filed: 0, fixed: 1 }, + ]); + expect(result.byRepo).toEqual([ + { repo: "acme/alpha", filed: 0, fixed: 1 }, + { repo: "acme/beta", filed: 0, fixed: 1 }, + ]); + }); + + it("returns zeroed structures for an empty range", () => { + insertTrackedIssue(db, "filed", { + owner: "acme", + repo: "alpha", + number: 1, + url: "https://github.com/acme/alpha/issues/1", + createdAt: "2026-04-01T00:00:00.000Z", + }); + insertSourceIssueTask(db, "fixed", { + provider: "github", + repository: "acme/alpha", + column: "done", + updatedAt: "2026-04-01T00:00:00.000Z", + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2027-01-01T00:00:00.000Z", + to: "2027-01-31T00:00:00.000Z", + }); + + expect(result).toMatchObject({ + from: "2027-01-01T00:00:00.000Z", + to: "2027-01-31T00:00:00.000Z", + filed: 0, + fixed: 0, + net: 0, + daily: [], + byRepo: [], + }); + }); + + it("skips malformed tracking JSON and issue-less rows without throwing", () => { + insertRawGithubTracking(db, "bad-json", "{not json"); + insertRawGithubTracking(db, "empty-object", "{}"); + insertRawGithubTracking(db, "no-issue", JSON.stringify({ enabled: true })); + + expect(() => aggregateGithubIssueAnalytics(db, {})).not.toThrow(); + expect(aggregateGithubIssueAnalytics(db, {})).toMatchObject({ + filed: 0, + fixed: 0, + daily: [], + byRepo: [], + }); + }); + + it("counts undated filed issues in totals without fabricating a daily date", () => { + insertTrackedIssue(db, "undated", { + owner: "acme", + repo: "alpha", + number: 1, + url: "https://github.com/acme/alpha/issues/1", + }); + + const result = aggregateGithubIssueAnalytics(db, { + from: "2026-04-01T00:00:00.000Z", + to: "2026-04-30T00:00:00.000Z", + }); + + expect(result.filed).toBe(1); + expect(result.daily).toEqual([]); + expect(result.byRepo).toEqual([{ repo: "acme/alpha", filed: 1, fixed: 0 }]); + }); +}); diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index 75cf8c431e..c5e41c98c3 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 84f57c78a3..fa5f579684 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -8,6 +8,10 @@ * - Stable identity on upsert (id/createdAt preserved) * - Deterministic ordering under timestamp ties * - Migration: pre-33 DB upgrades to include insight tables + * + * FNXC:Insights 2026-06-16-09:40: + * Touched alongside the Command Center schema work (PR #1683, migrations 118-120) so the insight-store + * migration coverage stays valid as later schema versions land; assertions pin the pre-33 upgrade path. */ import { describe, it, expect, beforeEach, vi } from "vitest"; @@ -1000,7 +1004,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(118); + expect(db1.getSchemaVersion()).toBe(124); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1039,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(118); + expect(db3.getSchemaVersion()).toBe(124); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1070,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(118); + expect(db1.getSchemaVersion()).toBe(124); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(118); + expect(db2.getSchemaVersion()).toBe(124); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1089,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(118); + expect(db1.getSchemaVersion()).toBe(124); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index b3e4291825..6c988d1d03 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); }); it("upserts merge request records", async () => { @@ -222,4 +222,145 @@ describe("TaskStore merge request record + completion handoff marker", () => { lastError: "cancelled-by-user-hard-cancel", }); }); + + it("creates idempotent workflow merge work during completion handoff", async () => { + const taskId = await createTask(); + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-handoff", agentId: "agent-test" }, + now: "2026-05-30T00:00:00.000Z", + }); + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-handoff", agentId: "agent-test" }, + now: "2026-05-30T00:00:01.000Z", + }); + + expect(store.listWorkflowWorkItemsForTask(taskId, { kinds: ["merge"] })).toEqual([ + expect.objectContaining({ + runId: "run-handoff", + taskId, + nodeId: "merge-gate", + kind: "merge", + state: "runnable", + }), + ]); + }); + + it("cancels previous active handoff work when a re-handoff uses a new run id", async () => { + const taskId = await createTask(); + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-handoff-1", agentId: "agent-test" }, + now: "2026-05-30T00:00:00.000Z", + }); + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-handoff-2", agentId: "agent-test" }, + now: "2026-05-30T00:00:01.000Z", + }); + + expect(store.listWorkflowWorkItemsForTask(taskId, { kinds: ["merge"] })).toEqual([ + expect.objectContaining({ + runId: "run-handoff-1", + state: "cancelled", + lastError: "superseded-by-completion-handoff", + }), + expect.objectContaining({ + runId: "run-handoff-2", + state: "runnable", + }), + ]); + }); + + it("cancels opposite handoff kind when autoMerge flips between handoffs", async () => { + const taskId = await createTask(); + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-merge", agentId: "agent-test" }, + now: "2026-05-30T00:00:00.000Z", + }); + await store.updateTask(taskId, { autoMerge: false }); + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-manual", agentId: "agent-test" }, + now: "2026-05-30T00:00:01.000Z", + }); + + expect(store.listWorkflowWorkItemsForTask(taskId)).toEqual([ + expect.objectContaining({ + runId: "run-merge", + kind: "merge", + state: "cancelled", + lastError: "superseded-by-completion-handoff", + }), + expect.objectContaining({ + runId: "run-manual", + kind: "manual-hold", + state: "manual-required", + }), + ]); + }); + + it("does not reset running handoff work to runnable on same-run replay", async () => { + const taskId = await createTask(); + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-handoff", agentId: "agent-test" }, + now: "2026-05-30T00:00:00.000Z", + }); + const [mergeWork] = store.listWorkflowWorkItemsForTask(taskId, { kinds: ["merge"] }); + store.transitionWorkflowWorkItem(mergeWork.id, "running", { + leaseOwner: "worker-a", + leaseExpiresAt: "2026-05-30T00:05:00.000Z", + now: "2026-05-30T00:00:01.000Z", + }); + + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-handoff", agentId: "agent-test" }, + now: "2026-05-30T00:00:02.000Z", + }); + + expect(store.getWorkflowWorkItem(mergeWork.id)).toMatchObject({ + state: "running", + leaseOwner: "worker-a", + leaseExpiresAt: "2026-05-30T00:05:00.000Z", + }); + }); + + it("creates manual hold workflow work instead of merge work when autoMerge is false", async () => { + const taskId = await createTask(); + await store.updateTask(taskId, { autoMerge: false }); + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + + await store.handoffToReview(taskId, { + ownerAgentId: "agent-test", + evidence: { reason: "fn_task_done", runId: "run-manual", agentId: "agent-test" }, + }); + + expect(store.listWorkflowWorkItemsForTask(taskId)).toEqual([ + expect.objectContaining({ + runId: "run-manual", + taskId, + nodeId: "merge-manual-hold", + kind: "manual-hold", + state: "manual-required", + blockedReason: "autoMerge:false", + }), + ]); + }); }); diff --git a/packages/core/src/__tests__/mission-integration.test.ts b/packages/core/src/__tests__/mission-integration.test.ts index bbe2632ea9..38473e530f 100644 --- a/packages/core/src/__tests__/mission-integration.test.ts +++ b/packages/core/src/__tests__/mission-integration.test.ts @@ -82,17 +82,45 @@ async function createHierarchy(store: TaskStore) { describe("MissionStore integration with TaskStore", () => { let rootDir: string; let taskStore: TaskStore; + let storesToClose: TaskStore[]; + + /** + * FNXC:CoreTests 2026-06-17-14:36: + * Restart-fidelity coverage must prove committed mission rows survive TaskStore.close() and a fresh + * TaskStore(rootDir).init() across every mission read path, including empty and populated hierarchies. + * Register reopened stores so fake timers and WAL-backed SQLite handles are closed before temp-root + * cleanup instead of leaking across package fan-out. + */ + function registerStore(store: TaskStore): TaskStore { + storesToClose.push(store); + return store; + } + + async function openRestartedStore(): Promise { + taskStore.close(); + const restarted = registerStore( + new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")), + ); + await restarted.init(); + taskStore = restarted; + return restarted; + } beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z")); rootDir = makeTmpDir(); - taskStore = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); + storesToClose = []; + taskStore = registerStore(new TaskStore(rootDir, join(rootDir, ".fusion-global-settings"))); await taskStore.init(); }); afterEach(async () => { + for (const store of [...storesToClose].reverse()) { + store.close(); + } + storesToClose = []; vi.useRealTimers(); await rm(rootDir, { recursive: true, force: true }); }); @@ -409,9 +437,7 @@ describe("MissionStore integration with TaskStore", () => { missionStore.updateMission(mission.id, { status: "active", autopilotEnabled: true }); // Restart store - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); + const taskStore2 = await openRestartedStore(); const missionStore2 = taskStore2.getMissionStore(); const retrieved = missionStore2.getMission(mission.id); @@ -435,9 +461,7 @@ describe("MissionStore integration with TaskStore", () => { missionStore.updateMission(mission.id, { autopilotState: "inactive" }); // Restart store - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); + const taskStore2 = await openRestartedStore(); const missionStore2 = taskStore2.getMissionStore(); const retrieved = missionStore2.getMission(mission.id); @@ -460,9 +484,7 @@ describe("MissionStore integration with TaskStore", () => { missionStore.linkFeatureToTask(feature.id, task.id); // Restart store - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); + const taskStore2 = await openRestartedStore(); const missionStore2 = taskStore2.getMissionStore(); const retrieved = missionStore2.getFeature(feature.id); @@ -483,9 +505,7 @@ describe("MissionStore integration with TaskStore", () => { missionStore.updateFeatureStatus(feature.id, "blocked"); // Restart store - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); + const taskStore2 = await openRestartedStore(); const missionStore2 = taskStore2.getMissionStore(); const hierarchy = missionStore2.getMissionWithHierarchy(mission.id); @@ -505,9 +525,7 @@ describe("MissionStore integration with TaskStore", () => { missionStore.logMissionEvent(mission.id, "feature_triaged", "Feature triaged"); // Restart store - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); + const taskStore2 = await openRestartedStore(); const missionStore2 = taskStore2.getMissionStore(); const events = missionStore2.getMissionEvents(mission.id); @@ -529,9 +547,7 @@ describe("MissionStore integration with TaskStore", () => { ]); // Restart store - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); + const taskStore2 = await openRestartedStore(); const missionStore2 = taskStore2.getMissionStore(); const hierarchy = missionStore2.getMissionWithHierarchy(mission.id); @@ -540,6 +556,88 @@ describe("MissionStore integration with TaskStore", () => { expect(hierarchy!.milestones[2].id).toBe(milestones[1].id); }); + it("persists all mission hierarchy read paths across store restart", async () => { + const missionStore = taskStore.getMissionStore(); + const emptyMission = missionStore.createMission({ title: "Empty Restart Mission" }); + vi.advanceTimersByTime(1); + missionStore.logMissionEvent(emptyMission.id, "mission_created", "Empty mission event"); + + const { mission, milestones } = await createHierarchy(taskStore); + const firstMilestone = milestones[0]; + const firstSlice = firstMilestone.slices[0]; + const firstFeature = firstSlice.features[0]; + + missionStore.updateMission(mission.id, { status: "active" }); + missionStore.updateMilestone(firstMilestone.id, { + planningNotes: "Persist milestone planning", + verification: "Persist milestone verification", + }); + missionStore.updateSlice(firstSlice.id, { + planningNotes: "Persist slice planning", + verification: "Persist slice verification", + }); + missionStore.updateFeature(firstFeature.id, { + status: "in-progress", + lastValidatorStatus: "running", + }); + missionStore.reorderMilestones(mission.id, [ + milestones[2].id, + milestones[0].id, + milestones[1].id, + ]); + vi.advanceTimersByTime(1); + missionStore.logMissionEvent(mission.id, "mission_started", "Populated mission event"); + + const taskStore2 = await openRestartedStore(); + const missionStore2 = taskStore2.getMissionStore(); + + const emptyHierarchy = missionStore2.getMissionWithHierarchy(emptyMission.id); + expect(emptyHierarchy).toBeDefined(); + expect(emptyHierarchy?.milestones).toEqual([]); + expect(missionStore2.getMissionEvents(emptyMission.id).events).toHaveLength(1); + + const retrievedMission = missionStore2.getMission(mission.id); + const retrievedMilestone = missionStore2.getMilestone(firstMilestone.id); + const retrievedSlice = missionStore2.getSlice(firstSlice.id); + const retrievedFeature = missionStore2.getFeature(firstFeature.id); + const hierarchy = missionStore2.getMissionWithHierarchy(mission.id); + const events = missionStore2.getMissionEvents(mission.id); + + expect(retrievedMission).toMatchObject({ id: mission.id, status: "active" }); + expect(retrievedMilestone).toMatchObject({ + id: firstMilestone.id, + planningNotes: "Persist milestone planning", + verification: "Persist milestone verification", + }); + expect(retrievedSlice).toMatchObject({ + id: firstSlice.id, + planningNotes: "Persist slice planning", + verification: "Persist slice verification", + }); + expect(retrievedFeature).toMatchObject({ + id: firstFeature.id, + status: "in-progress", + lastValidatorStatus: "running", + }); + expect(hierarchy).toBeDefined(); + expect(hierarchy?.milestones).toHaveLength(3); + expect(hierarchy?.milestones[0].id).toBe(milestones[2].id); + expect(hierarchy?.milestones.every((milestone) => milestone.slices.length === 2)).toBe(true); + expect( + hierarchy?.milestones.every((milestone) => + milestone.slices.every((slice) => { + const hierarchySlice = slice as typeof slice & { features: Array<{ id: string }> }; + return hierarchySlice.features.length === 3; + }), + ), + ).toBe(true); + expect(events.events).toHaveLength(1); + expect(events.events[0]).toMatchObject({ + eventType: "mission_started", + description: "Populated mission event", + }); + }); + it("persists planning notes and verification across store restart", async () => { const missionStore = taskStore.getMissionStore(); const mission = missionStore.createMission({ title: "Planning Context Test" }); @@ -555,9 +653,7 @@ describe("MissionStore integration with TaskStore", () => { }); // Restart store - taskStore.close(); - const taskStore2 = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); - await taskStore2.init(); + const taskStore2 = await openRestartedStore(); const missionStore2 = taskStore2.getMissionStore(); const retrievedMilestone = missionStore2.getMilestone(milestone.id); diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts new file mode 100644 index 0000000000..ea116b31fe --- /dev/null +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; + +import { + costFor, + lookupPricing, + MODEL_PRICING, + pricingAsOf, + PRICING_STALE_AFTER_MS, +} from "../model-pricing.js"; + +const ZERO = { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, +}; + +describe("model-pricing", () => { + it("exposes a pricingAsOf ISO date and a staleness threshold", () => { + expect(pricingAsOf).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(Number.isNaN(Date.parse(pricingAsOf))).toBe(false); + expect(PRICING_STALE_AFTER_MS).toBeGreaterThan(0); + }); + + it("prices a known model + token counts to cent precision", () => { + // claude-opus-4-8: input $5/1M, output $25/1M. + // 1,000,000 input + 200,000 output = 5.00 + 5.00 = 10.00 + const result = costFor( + { ...ZERO, inputTokens: 1_000_000, outputTokens: 200_000 }, + { provider: "anthropic", model: "claude-opus-4-8" }, + ); + expect(result.unavailable).toBe(false); + expect(result.usd).not.toBeNull(); + expect(result.usd).toBeCloseTo(10.0, 2); + }); + + it("returns unavailable + null usd for an unknown model (never guesses)", () => { + const result = costFor( + { ...ZERO, inputTokens: 1_000_000 }, + { provider: "acme", model: "totally-made-up-model" }, + ); + expect(result.unavailable).toBe(true); + expect(result.usd).toBeNull(); + }); + + it("prices cache tokens at the cache rate, not the input rate", () => { + // claude-opus-4-8: input $5/1M, cacheRead $0.5/1M, cacheWrite $6.25/1M. + const model = { provider: "anthropic", model: "claude-opus-4-8" }; + + const cacheRead = costFor( + { ...ZERO, cachedTokens: 1_000_000 }, + model, + ); + // At cache-read rate ($0.5), NOT the input rate ($5). + expect(cacheRead.usd).toBeCloseTo(0.5, 2); + expect(cacheRead.usd).not.toBeCloseTo(5.0, 2); + + const cacheWrite = costFor( + { ...ZERO, cacheWriteTokens: 1_000_000 }, + model, + ); + expect(cacheWrite.usd).toBeCloseTo(6.25, 2); + + // A pure-input baseline confirms input is the more expensive rate. + const input = costFor({ ...ZERO, inputTokens: 1_000_000 }, model); + expect(input.usd).toBeCloseTo(5.0, 2); + }); + + it("sums all four token kinds at their respective rates", () => { + // 100k input(5) + 100k output(25) + 100k cacheRead(0.5) + 100k cacheWrite(6.25) + // = 0.5 + 2.5 + 0.05 + 0.625 = 3.675 + const result = costFor( + { + inputTokens: 100_000, + outputTokens: 100_000, + cachedTokens: 100_000, + cacheWriteTokens: 100_000, + }, + { provider: "anthropic", model: "claude-opus-4-8" }, + ); + expect(result.usd).toBeCloseTo(3.675, 3); + }); + + it("flags stale when now is past the staleness threshold", () => { + const asOf = Date.parse(pricingAsOf); + const wayLater = asOf + PRICING_STALE_AFTER_MS + 24 * 60 * 60 * 1000; + const result = costFor( + { ...ZERO, inputTokens: 1_000_000 }, + { provider: "anthropic", model: "claude-opus-4-8" }, + wayLater, + ); + expect(result.stale).toBe(true); + // Cost is still computed for a stale-but-present entry. + expect(result.usd).toBeCloseTo(5.0, 2); + }); + + it("does not flag stale within the threshold or when now is omitted", () => { + const asOf = Date.parse(pricingAsOf); + const model = { provider: "anthropic", model: "claude-opus-4-8" }; + const usage = { ...ZERO, inputTokens: 1_000_000 }; + + // Just inside the window. + const fresh = costFor(usage, model, asOf + PRICING_STALE_AFTER_MS - 1000); + expect(fresh.stale).toBe(false); + + // No `now` → never stale (pure: module never reads the clock). + const noNow = costFor(usage, model); + expect(noNow.stale).toBe(false); + }); + + it("still reports stale for an unknown model when now is past threshold", () => { + const asOf = Date.parse(pricingAsOf); + const wayLater = asOf + PRICING_STALE_AFTER_MS + 1000; + const result = costFor( + { ...ZERO, inputTokens: 1_000_000 }, + { provider: "acme", model: "nope" }, + wayLater, + ); + expect(result.unavailable).toBe(true); + expect(result.usd).toBeNull(); + expect(result.stale).toBe(true); + }); + + describe("lookupPricing", () => { + it("resolves by provider:model", () => { + expect( + lookupPricing({ provider: "openai", model: "gpt-4o" }), + ).toBe(MODEL_PRICING["openai:gpt-4o"]); + }); + + it("is case-insensitive and trims", () => { + expect( + lookupPricing({ provider: " OpenAI ", model: " GPT-4o " }), + ).toBe(MODEL_PRICING["openai:gpt-4o"]); + }); + + it("falls back to a bare model id when provider is unset", () => { + expect(lookupPricing({ model: "gemini-2.5-pro" })).toBe( + MODEL_PRICING["google:gemini-2.5-pro"], + ); + }); + + it("returns undefined for empty / unknown input", () => { + expect(lookupPricing({})).toBeUndefined(); + expect(lookupPricing({ model: "" })).toBeUndefined(); + expect(lookupPricing({ provider: "x", model: "y" })).toBeUndefined(); + }); + }); + + it("seeds Anthropic, OpenAI, and Google providers", () => { + const providers = new Set( + Object.keys(MODEL_PRICING).map((k) => k.split(":")[0]), + ); + expect(providers).toContain("anthropic"); + expect(providers).toContain("openai"); + expect(providers).toContain("google"); + }); + + it("every entry has all four rates and a source", () => { + for (const [key, entry] of Object.entries(MODEL_PRICING)) { + expect(typeof entry.inputPer1M, key).toBe("number"); + expect(typeof entry.outputPer1M, key).toBe("number"); + expect(typeof entry.cacheReadPer1M, key).toBe("number"); + expect(typeof entry.cacheWritePer1M, key).toBe("number"); + expect(entry.source.length, key).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/src/__tests__/model-router.test.ts b/packages/core/src/__tests__/model-router.test.ts new file mode 100644 index 0000000000..fbb1dc19f7 --- /dev/null +++ b/packages/core/src/__tests__/model-router.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { queryUsageEvents } from "../usage-events.js"; +import { + routeModel, + routeModelAndEmit, + isMechanicalRoutableContext, + type RouteModelInput, +} from "../model-router.js"; +import { + resolveTaskExecutionModel, + resolveTaskPlanningModel, + resolveTaskValidatorModel, + routeTaskExecutionModel, + routeTaskPlanningModel, + routeTaskValidatorModel, +} from "../model-resolution.js"; +import type { Settings } from "../types.js"; + +const DEFAULT = { provider: "anthropic", modelId: "claude-opus-4-8" } as const; +const CHEAP = { provider: "anthropic", modelId: "claude-haiku-4-5" } as const; + +const routerSettings: Partial = { + modelRouterEnabled: true, + modelRouterCheapProvider: CHEAP.provider, + modelRouterCheapModelId: CHEAP.modelId, + // give the default-pair lanes a concrete value + defaultProvider: DEFAULT.provider, + defaultModelId: DEFAULT.modelId, +}; + +function baseInput(overrides: Partial = {}): RouteModelInput { + return { + lane: "execution", + defaultPair: { ...DEFAULT }, + settings: routerSettings, + context: { traits: ["dependabot"] }, + ...overrides, + }; +} + +describe("isMechanicalRoutableContext", () => { + it("matches dependabot/renovate sources", () => { + expect(isMechanicalRoutableContext({ source: "dependabot" })).toBe(true); + expect(isMechanicalRoutableContext({ source: "renovate" })).toBe(true); + }); + it("matches mechanical traits and labels", () => { + expect(isMechanicalRoutableContext({ traits: ["lint-only"] })).toBe(true); + expect(isMechanicalRoutableContext({ labels: ["dependencies"] })).toBe(true); + }); + it("matches conservative title keywords", () => { + expect(isMechanicalRoutableContext({ title: "Bump lodash from 4.17.20 to 4.17.21" })).toBe(true); + expect(isMechanicalRoutableContext({ title: "chore(deps): update eslint" })).toBe(true); + expect(isMechanicalRoutableContext({ title: "Lint-only fix for unused imports" })).toBe(true); + }); + it("does NOT match normal work (conservative default)", () => { + expect(isMechanicalRoutableContext({ title: "Implement OAuth login flow" })).toBe(false); + expect(isMechanicalRoutableContext({ traits: ["needs-review"] })).toBe(false); + expect(isMechanicalRoutableContext(undefined)).toBe(false); + expect(isMechanicalRoutableContext({})).toBe(false); + }); +}); + +describe("routeModel — core selection layer", () => { + it("allowlisted step → cheap tier with escalation seam to the default pair", () => { + const d = routeModel(baseInput()); + expect(d.routed).toBe(true); + expect(d.reason).toBe("cheap-tier"); + expect(d.selection).toEqual(CHEAP); + expect(d.counterfactual).toEqual(DEFAULT); + expect(d.escalation).toEqual(DEFAULT); + }); + + it("normal task → default pair (not routable)", () => { + const d = routeModel(baseInput({ context: { title: "Build a feature" } })); + expect(d.routed).toBe(false); + expect(d.reason).toBe("not-routable"); + expect(d.selection).toEqual(DEFAULT); + expect(d.counterfactual).toEqual(DEFAULT); + }); + + it("column-agent override wins — router defers even for an allowlisted step", () => { + const override = { provider: "openai", modelId: "gpt-5" }; + const d = routeModel(baseInput({ overridePair: override })); + expect(d.routed).toBe(false); + expect(d.reason).toBe("override"); + expect(d.selection).toEqual(override); + // counterfactual is still the default-pair, not the override + expect(d.counterfactual).toEqual(DEFAULT); + }); + + it("a project-policy-restricted model is NEVER selected even if it is the best pick", () => { + const isPermitted = (p: { provider?: string; modelId?: string }) => + !(p.provider === CHEAP.provider && p.modelId === CHEAP.modelId); + const d = routeModel(baseInput({ isPermitted })); + expect(d.routed).toBe(false); + expect(d.reason).toBe("cheap-forbidden"); + expect(d.selection).toEqual(DEFAULT); // fallback path also respects governance + }); + + it("governance is absolute — a forbidden override is NOT honored, falls through", () => { + const override = { provider: "openai", modelId: "gpt-5" }; + const isPermitted = (p: { provider?: string }) => p.provider !== "openai"; + // override forbidden + not routable → default + const d = routeModel(baseInput({ overridePair: override, isPermitted, context: { title: "x" } })); + expect(d.reason).toBe("not-routable"); + expect(d.selection).toEqual(DEFAULT); + }); + + it("router disabled → byte-identical to the default pair", () => { + const d = routeModel(baseInput({ settings: { ...routerSettings, modelRouterEnabled: false } })); + expect(d.routed).toBe(false); + expect(d.reason).toBe("disabled"); + expect(d.selection).toEqual(DEFAULT); + expect(d.escalation).toBeUndefined(); + }); + + it("cheap tier unconfigured → default pair", () => { + const d = routeModel( + baseInput({ settings: { modelRouterEnabled: true } }), + ); + expect(d.reason).toBe("cheap-unconfigured"); + expect(d.selection).toEqual(DEFAULT); + }); + + it("no usable default pair → reason no-default", () => { + const d = routeModel(baseInput({ defaultPair: {}, context: { title: "x" } })); + expect(d.reason).toBe("no-default"); + expect(d.selection).toEqual({}); + }); +}); + +describe("governed lanes vs ungoverned lanes (model-resolution wrappers)", () => { + const task = {}; + + it("execution lane: disabled router === resolveTaskExecutionModel (no regression)", () => { + const settings = { ...routerSettings, modelRouterEnabled: false }; + const direct = resolveTaskExecutionModel(task, settings); + const routed = routeTaskExecutionModel(task, settings).selection; + expect(routed).toEqual(direct); + }); + + it("planning lane: disabled router === resolveTaskPlanningModel", () => { + const settings = { ...routerSettings, modelRouterEnabled: false }; + expect(routeTaskPlanningModel(task, settings).selection).toEqual( + resolveTaskPlanningModel(task, settings), + ); + }); + + it("validation lane: disabled router === resolveTaskValidatorModel", () => { + const settings = { ...routerSettings, modelRouterEnabled: false }; + expect(routeTaskValidatorModel(task, settings).selection).toEqual( + resolveTaskValidatorModel(task, settings), + ); + }); + + it("each governed lane down-routes an allowlisted step and reports its lane", () => { + const opts = { context: { traits: ["dependabot"] } }; + const exec = routeTaskExecutionModel(task, routerSettings, opts); + const plan = routeTaskPlanningModel(task, routerSettings, opts); + const val = routeTaskValidatorModel(task, routerSettings, opts); + expect(exec.lane).toBe("execution"); + expect(plan.lane).toBe("planning"); + expect(val.lane).toBe("validation"); + for (const d of [exec, plan, val]) { + expect(d.routed).toBe(true); + expect(d.selection).toEqual(CHEAP); + } + }); + + it("each governed lane never returns a forbidden pair", () => { + const opts = { + context: { traits: ["dependabot"] }, + isPermitted: (p: { modelId?: string }) => p.modelId !== CHEAP.modelId, + }; + for (const fn of [routeTaskExecutionModel, routeTaskPlanningModel, routeTaskValidatorModel]) { + const d = fn(task, routerSettings, opts); + expect(d.selection.modelId).not.toBe(CHEAP.modelId); + } + }); + + it("ungoverned lanes (settings-only / title summarizer / project default) are untouched — no router wrappers exist for them", async () => { + const mod = await import("../model-resolution.js"); + // Only the three task lanes get router wrappers; ensure no extra ones leaked in. + expect(typeof mod.routeTaskExecutionModel).toBe("function"); + expect(typeof mod.routeTaskPlanningModel).toBe("function"); + expect(typeof mod.routeTaskValidatorModel).toBe("function"); + expect((mod as Record).routeProjectDefaultModel).toBeUndefined(); + expect((mod as Record).routeExecutionSettingsModel).toBeUndefined(); + expect((mod as Record).routeTitleSummarizerSettingsModel).toBeUndefined(); + }); +}); + +describe("routeModelAndEmit — telemetry with counterfactual", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-model-router-test-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("emits a routing decision with the counterfactual model into usage_events", () => { + const d = routeModelAndEmit(db, { ...baseInput(), taskId: "t1", nodeId: "n1" }); + expect(d.routed).toBe(true); + + const rows = queryUsageEvents(db, { kind: "session_start" }); + expect(rows).toHaveLength(1); + const row = rows[0]; + expect(row.category).toBe("model-router"); + expect(row.provider).toBe(CHEAP.provider); + expect(row.model).toBe(CHEAP.modelId); + expect(row.taskId).toBe("t1"); + expect(row.nodeId).toBe("n1"); + // The counterfactual model that WOULD have run absent the router: + expect(row.meta?.routed).toBe(true); + expect(row.meta?.reason).toBe("cheap-tier"); + expect(row.meta?.counterfactualProvider).toBe(DEFAULT.provider); + expect(row.meta?.counterfactualModelId).toBe(DEFAULT.modelId); + }); + + it("emits the counterfactual even when not routed (default pair selected)", () => { + routeModelAndEmit(db, { ...baseInput({ context: { title: "real work" } }), taskId: "t2" }); + const rows = queryUsageEvents(db, { kind: "session_start" }); + expect(rows).toHaveLength(1); + expect(rows[0].provider).toBe(DEFAULT.provider); + expect(rows[0].meta?.routed).toBe(false); + expect(rows[0].meta?.counterfactualModelId).toBe(DEFAULT.modelId); + }); + + it("emission is fail-soft and does not alter the decision when db is undefined", () => { + const d = routeModelAndEmit(undefined, baseInput()); + expect(d.selection).toEqual(CHEAP); + }); +}); diff --git a/packages/core/src/__tests__/near-duplicate-stale-flag-clear.test.ts b/packages/core/src/__tests__/near-duplicate-stale-flag-clear.test.ts new file mode 100644 index 0000000000..a1cb1ce348 --- /dev/null +++ b/packages/core/src/__tests__/near-duplicate-stale-flag-clear.test.ts @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { TaskStore } from "../store.js"; +import type { Task } from "../types.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +describe("near-duplicate stale flag clearing", () => { + const harness = createTaskStoreTestHarness(); + let store: TaskStore; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + async function createCanonical(): Promise { + return store.createTask({ title: "Canonical task", description: "Canonical intent" }); + } + + async function createReferencingTask(canonicalId: string, title = "Referencing task"): Promise { + return store.createTask({ + title, + description: "Similar intent that should stop asking for a duplicate decision", + source: { + sourceType: "automation", + sourceMetadata: { + nearDuplicateOf: canonicalId, + nearDuplicateScore: 0.92, + nearDuplicateSharedTokens: ["packages/core/src/store.ts", "nearDuplicateOf"], + nearDuplicateDismissed: true, + retainedMetadata: "kept", + }, + }, + }); + } + + async function moveCanonicalToDone(taskId: string): Promise { + await store.moveTask(taskId, "todo"); + await store.moveTask(taskId, "in-progress"); + await store.moveTask(taskId, "in-review", { allowDirectInReviewMove: true }); + await store.moveTask(taskId, "done", { skipMergeBlocker: true }); + } + + async function expectFlagCleared(taskId: string, canonicalId: string, reason: string): Promise { + const updated = await store.getTask(taskId); + expect(updated.sourceMetadata).toEqual({ retainedMetadata: "kept" }); + expect(updated.paused).not.toBe(true); + expect(updated.status).not.toBe("failed"); + expect(updated.log.some((entry) => entry.action.includes(`Near-duplicate canonical ${canonicalId} is now inactive (${reason}); cleared duplicate flag`))).toBe(true); + } + + it("clears active referrers when the canonical is archived without cleanup", async () => { + const canonical = await createCanonical(); + const referrer = await createReferencingTask(canonical.id); + + await store.archiveTask(canonical.id, { cleanup: false }); + + await expectFlagCleared(referrer.id, canonical.id, "archived"); + }); + + it("clears multiple active referrers when the canonical is archived with cleanup", async () => { + const canonical = await createCanonical(); + const first = await createReferencingTask(canonical.id, "First referrer"); + const second = await createReferencingTask(canonical.id, "Second referrer"); + + await store.archiveTask(canonical.id, { cleanup: true }); + + await expectFlagCleared(first.id, canonical.id, "archived"); + await expectFlagCleared(second.id, canonical.id, "archived"); + }); + + it("clears active referrers when the canonical is soft-deleted", async () => { + const canonical = await createCanonical(); + const referrer = await createReferencingTask(canonical.id); + + await store.deleteTask(canonical.id); + + await expectFlagCleared(referrer.id, canonical.id, "deleted"); + }); + + it("clears active referrers when the canonical moves to done", async () => { + const canonical = await createCanonical(); + const referrer = await createReferencingTask(canonical.id); + + await moveCanonicalToDone(canonical.id); + + await expectFlagCleared(referrer.id, canonical.id, "done"); + }); + + it("does not fail canonical inactive transitions when there are no referrers", async () => { + const archived = await createCanonical(); + await expect(store.archiveTask(archived.id, { cleanup: false })).resolves.toMatchObject({ id: archived.id, column: "archived" }); + + const deleted = await createCanonical(); + await expect(store.deleteTask(deleted.id)).resolves.toMatchObject({ id: deleted.id }); + + const done = await createCanonical(); + await expect(moveCanonicalToDone(done.id)).resolves.toBeUndefined(); + await expect(store.getTask(done.id)).resolves.toMatchObject({ id: done.id, column: "done" }); + }); +}); diff --git a/packages/core/src/__tests__/near-duplicate.test.ts b/packages/core/src/__tests__/near-duplicate.test.ts index 53b83b4913..38c10d376f 100644 --- a/packages/core/src/__tests__/near-duplicate.test.ts +++ b/packages/core/src/__tests__/near-duplicate.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { extractIntentSignature, findNearDuplicates } from "../near-duplicate.js"; +import { extractIntentSignature, findNearDuplicates, isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "../near-duplicate.js"; const fn5144Title = "Create PR dialog missing /pr/options /pr/preflight /pr/generate-metadata routes"; const fn5144Description = @@ -50,6 +50,23 @@ describe("extractIntentSignature", () => { }); }); +describe("near-duplicate canonical activity predicates", () => { + it("treats non-terminal live columns as active", () => { + expect(isActiveNearDuplicateColumn("triage")).toBe(true); + expect(isActiveNearDuplicateColumn("todo")).toBe(true); + expect(isActiveNearDuplicateColumn("in-progress")).toBe(true); + expect(isActiveNearDuplicateColumn("in-review")).toBe(true); + }); + + it("treats archived, done, soft-deleted, and missing canonicals as inactive", () => { + expect(isNearDuplicateCanonicalInactive(undefined)).toBe(true); + expect(isNearDuplicateCanonicalInactive({ column: "archived" })).toBe(true); + expect(isNearDuplicateCanonicalInactive({ column: "done" })).toBe(true); + expect(isNearDuplicateCanonicalInactive({ column: "todo", deletedAt: "2026-06-14T00:00:00.000Z" })).toBe(true); + expect(isNearDuplicateCanonicalInactive({ column: "todo", deletedAt: null })).toBe(false); + }); +}); + describe("findNearDuplicates", () => { it("flags FN-5144 and FN-5149 pair via shared PR route tokens", () => { const matches = findNearDuplicates( diff --git a/packages/core/src/__tests__/no-commits-finalize-guard.test.ts b/packages/core/src/__tests__/no-commits-finalize-guard.test.ts new file mode 100644 index 0000000000..74693b201e --- /dev/null +++ b/packages/core/src/__tests__/no-commits-finalize-guard.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { evaluateNoCommitsNoOpFinalize, type TaskStep } from "../index.js"; + +function steps(statuses: Array): TaskStep[] { + return statuses.map((status, index) => ({ name: `Step ${index}`, status })); +} + +describe("evaluateNoCommitsNoOpFinalize", () => { + it("blocks the FN-6455 skipped-release shape", () => { + const result = evaluateNoCommitsNoOpFinalize({ + noCommitsExpected: true, + steps: steps(["done", "skipped", "skipped", "skipped", "skipped", "skipped"]), + }); + + expect(result).toMatchObject({ blocked: true, doneCount: 1, incompleteCount: 5 }); + expect(result.reason).toContain("done=1, incomplete=5"); + }); + + it("allows legitimate all-done no-op tasks", () => { + expect(evaluateNoCommitsNoOpFinalize({ + noCommitsExpected: true, + steps: steps(["done", "done", "done"]), + })).toEqual({ blocked: false, doneCount: 3, incompleteCount: 0 }); + }); + + it("allows mostly-done no-op tasks with only a minor skipped tail", () => { + expect(evaluateNoCommitsNoOpFinalize({ + noCommitsExpected: true, + steps: steps(["done", "done", "done", "done", "done", "skipped"]), + })).toEqual({ blocked: false, doneCount: 5, incompleteCount: 1 }); + }); + + it("blocks pending or in-progress work on no-commits tasks", () => { + expect(evaluateNoCommitsNoOpFinalize({ + noCommitsExpected: true, + steps: steps(["done", "pending"]), + })).toMatchObject({ blocked: true, doneCount: 1, incompleteCount: 1 }); + expect(evaluateNoCommitsNoOpFinalize({ + noCommitsExpected: true, + steps: steps(["in-progress"]), + })).toMatchObject({ blocked: true, doneCount: 0, incompleteCount: 1 }); + }); + + it("preserves zero-step behavior", () => { + expect(evaluateNoCommitsNoOpFinalize({ noCommitsExpected: true, steps: [] })) + .toEqual({ blocked: false, doneCount: 0, incompleteCount: 0 }); + }); + + it("does not block ordinary tasks", () => { + expect(evaluateNoCommitsNoOpFinalize({ + noCommitsExpected: false, + steps: steps(["done", "skipped", "skipped"]), + })).toEqual({ blocked: false, doneCount: 1, incompleteCount: 2 }); + expect(evaluateNoCommitsNoOpFinalize({ + steps: steps(["pending"]), + })).toEqual({ blocked: false, doneCount: 0, incompleteCount: 1 }); + }); +}); diff --git a/packages/core/src/__tests__/oauth-credential-interop.test.ts b/packages/core/src/__tests__/oauth-credential-interop.test.ts index c3a1768307..37adab7f9d 100644 --- a/packages/core/src/__tests__/oauth-credential-interop.test.ts +++ b/packages/core/src/__tests__/oauth-credential-interop.test.ts @@ -94,6 +94,7 @@ describe("oauth credential interop", () => { accessToken: "claude-access", refreshToken: "claude-refresh", expiresAt: Date.now() + 3600_000, + scopes: ["user:profile", "org:create_api_key"], }, }); @@ -102,6 +103,7 @@ describe("oauth credential interop", () => { access: "claude-access", refresh: "claude-refresh", expires: expect.any(Number), + scopes: ["user:profile", "org:create_api_key"], }); }); diff --git a/packages/core/src/__tests__/otel-metrics.test.ts b/packages/core/src/__tests__/otel-metrics.test.ts new file mode 100644 index 0000000000..bd1b8a9fde --- /dev/null +++ b/packages/core/src/__tests__/otel-metrics.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect } from "vitest"; + +import { mapAnalyticsToOtlp, OTEL_METRIC_PREFIX } from "../otel-metrics.js"; +import type { TokenAnalytics } from "../token-analytics.js"; +import type { ActivityAnalytics } from "../activity-analytics.js"; + +const TIME_NANO = "1700000000000000000"; + +function tokenFixture(): TokenAnalytics { + return { + from: null, + to: null, + groupBy: "model", + totals: { + inputTokens: 1000, + outputTokens: 500, + cachedTokens: 200, + cacheWriteTokens: 50, + totalTokens: 1750, + nTasks: 3, + }, + cost: { usd: 12.34, unavailable: false, stale: false }, + groups: [ + { + key: "claude-opus-4-8", + inputTokens: 600, + outputTokens: 300, + cachedTokens: 100, + cacheWriteTokens: 25, + totalTokens: 1025, + nTasks: 2, + cost: { usd: 9.0, unavailable: false, stale: false }, + }, + { + key: "gpt-5", + inputTokens: 400, + outputTokens: 200, + cachedTokens: 100, + cacheWriteTokens: 25, + totalTokens: 725, + nTasks: 1, + // Unpriced group → cost must be omitted, not reported as $0. + cost: { usd: null, unavailable: true, stale: false }, + }, + ], + }; +} + +function activityFixture(): ActivityAnalytics { + // Focused fixture: the OTLP mapping only reads the activity gauge fields below, + // so funnel/monitor (U7/U13 additions) are intentionally omitted via the cast. + return { + from: null, + to: null, + sessions: 7, + messages: 42, + activeNodes: 3, + activeAgents: 5, + daily: [], + stickiness: 0.6, + mttr: { value: null, unavailable: true, sampleCount: 0 }, + } as unknown as ActivityAnalytics; +} + +function findMetric(payload: ReturnType, name: string) { + const metrics = payload.resourceMetrics[0].scopeMetrics[0].metrics; + const m = metrics.find((x) => x.name === name); + expect(m, `metric ${name} present`).toBeDefined(); + return m!; +} + +describe("mapAnalyticsToOtlp", () => { + it("maps token totals to a monotonic Sum counter with a grand-total point", () => { + const payload = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const total = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.total`); + expect(total.sum?.isMonotonic).toBe(true); + expect(total.sum?.aggregationTemporality).toBe(2); + // Grand total point (no attributes) carries the totals value. + const grand = total.sum?.dataPoints.find((p) => p.attributes.length === 0); + expect(grand?.asInt).toBe("1750"); + }); + + it("emits one attributed data point per group (model/provider/node/agent)", () => { + const payload = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const input = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.input`); + const modelPoints = input.sum!.dataPoints.filter((p) => + p.attributes.some((a) => a.key === "model"), + ); + const models = modelPoints + .map((p) => p.attributes.find((a) => a.key === "model")!.value.stringValue) + .sort(); + expect(models).toEqual(["claude-opus-4-8", "gpt-5"]); + const opus = modelPoints.find( + (p) => + p.attributes.find((a) => a.key === "model")!.value.stringValue === + "claude-opus-4-8", + ); + expect(opus?.asInt).toBe("600"); + }); + + it("uses provider/node/agent attribute keys per groupBy", () => { + const base = tokenFixture(); + for (const [groupBy, attrKey] of [ + ["provider", "provider"], + ["node", "node.id"], + ["agent", "agent.id"], + ] as const) { + const payload = mapAnalyticsToOtlp({ + tokens: { ...base, groupBy, groups: [{ ...base.groups[0], key: "k" }] }, + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const input = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.input`); + const attributed = input.sum!.dataPoints.find((p) => p.attributes.length > 0); + expect(attributed?.attributes[0].key).toBe(attrKey); + } + }); + + it("omits cost data points for unpriced groups (never reports $0)", () => { + const payload = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const cost = findMetric(payload, `${OTEL_METRIC_PREFIX}.cost.usd`); + // Grand total (12.34) + opus (9.0); gpt-5 (null) omitted ⇒ 2 points. + expect(cost.sum?.dataPoints.length).toBe(2); + const grand = cost.sum?.dataPoints.find((p) => p.attributes.length === 0); + expect(grand?.asDouble).toBeCloseTo(12.34, 5); + const hasGpt5 = cost.sum?.dataPoints.some((p) => + p.attributes.some((a) => a.value.stringValue === "gpt-5"), + ); + expect(hasGpt5).toBe(false); + }); + + it("maps activity to gauges (active nodes/agents/sessions/messages/stickiness)", () => { + const payload = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.active_nodes`).gauge + ?.dataPoints[0].asInt, + ).toBe("3"); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.active_agents`).gauge + ?.dataPoints[0].asInt, + ).toBe("5"); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.sessions`).gauge + ?.dataPoints[0].asInt, + ).toBe("7"); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.messages`).gauge + ?.dataPoints[0].asInt, + ).toBe("42"); + expect( + findMetric(payload, `${OTEL_METRIC_PREFIX}.activity.stickiness`).gauge + ?.dataPoints[0].asDouble, + ).toBeCloseTo(0.6, 5); + }); + + it("applies resource attributes and a default service.name", () => { + const dflt = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const defaultAttrs = dflt.resourceMetrics[0].resource.attributes; + expect( + defaultAttrs.find((a) => a.key === "service.name")?.value.stringValue, + ).toBe("fusion-dashboard"); + + const custom = mapAnalyticsToOtlp({ + tokens: tokenFixture(), + activity: activityFixture(), + timeUnixNano: TIME_NANO, + resourceAttributes: { "service.name": "my-svc", env: "staging" }, + }); + const attrs = custom.resourceMetrics[0].resource.attributes; + expect(attrs.find((a) => a.key === "env")?.value.stringValue).toBe("staging"); + }); + + it("coerces non-finite / negative counts to 0 (no NaN on the wire)", () => { + const bad = tokenFixture(); + bad.totals.inputTokens = Number.NaN; + bad.totals.outputTokens = -5; + const payload = mapAnalyticsToOtlp({ + tokens: bad, + activity: activityFixture(), + timeUnixNano: TIME_NANO, + }); + const input = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.input`); + const grand = input.sum!.dataPoints.find((p) => p.attributes.length === 0); + expect(grand?.asInt).toBe("0"); + const output = findMetric(payload, `${OTEL_METRIC_PREFIX}.tokens.output`); + const grandOut = output.sum!.dataPoints.find((p) => p.attributes.length === 0); + expect(grandOut?.asInt).toBe("0"); + }); +}); diff --git a/packages/core/src/__tests__/plugin-loader.test.ts b/packages/core/src/__tests__/plugin-loader.test.ts index bb011e0a1b..0bf99618e1 100644 --- a/packages/core/src/__tests__/plugin-loader.test.ts +++ b/packages/core/src/__tests__/plugin-loader.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { mkdtempSync, existsSync } from "node:fs"; +import { mkdtempSync, existsSync, rmSync, utimesSync } from "node:fs"; import { tmpdir } from "node:os"; -import { PluginLoader } from "../plugin-loader.js"; +import { PluginLoader, resolvePluginEntryPath } from "../plugin-loader.js"; import * as loggerModule from "../logger.js"; const scanPluginSecurityMock = vi.fn(); @@ -126,6 +126,73 @@ function droidPluginModulePath(): string { ); } +describe("resolvePluginEntryPath", () => { + let pluginDir: string; + + beforeEach(() => { + pluginDir = makeTmpDir(); + }); + + afterEach(() => { + rmSync(pluginDir, { recursive: true, force: true }); + }); + + async function writeEntry(relative: string): Promise { + const path = join(pluginDir, relative); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, "// entry\n"); + return path; + } + + it("prefers fresher src/index.ts over stale dist when no bundle exists", async () => { + const dist = await writeEntry("dist/index.js"); + const src = await writeEntry("src/index.ts"); + const older = new Date("2026-01-01T00:00:00.000Z"); + const newer = new Date("2026-01-01T00:01:00.000Z"); + utimesSync(dist, older, older); + utimesSync(src, newer, newer); + + expect(resolvePluginEntryPath(pluginDir)).toBe(src); + }); + + it("keeps dist/index.js when dist is newer than src", async () => { + const dist = await writeEntry("dist/index.js"); + const src = await writeEntry("src/index.ts"); + const older = new Date("2026-01-01T00:00:00.000Z"); + const newer = new Date("2026-01-01T00:01:00.000Z"); + utimesSync(dist, newer, newer); + utimesSync(src, older, older); + + expect(resolvePluginEntryPath(pluginDir)).toBe(dist); + }); + + it("uses newest non-index src file for freshness and still returns src/index.ts", async () => { + const dist = await writeEntry("dist/index.js"); + const src = await writeEntry("src/index.ts"); + const settings = await writeEntry("src/settings.ts"); + const older = new Date("2026-01-01T00:00:00.000Z"); + const newer = new Date("2026-01-01T00:01:00.000Z"); + utimesSync(dist, older, older); + utimesSync(src, older, older); + utimesSync(settings, newer, newer); + + expect(resolvePluginEntryPath(pluginDir)).toBe(src); + }); + + it("always keeps bundled.js first regardless of dist or src freshness", async () => { + const bundled = await writeEntry("bundled.js"); + const dist = await writeEntry("dist/index.js"); + const src = await writeEntry("src/index.ts"); + const older = new Date("2026-01-01T00:00:00.000Z"); + const newer = new Date("2026-01-01T00:01:00.000Z"); + utimesSync(bundled, older, older); + utimesSync(dist, older, older); + utimesSync(src, newer, newer); + + expect(resolvePluginEntryPath(pluginDir)).toBe(bundled); + }); +}); + // Mock TaskStore for testing const mockTaskStore = { logActivity: vi.fn(), diff --git a/packages/core/src/__tests__/pr-entity.test.ts b/packages/core/src/__tests__/pr-entity.test.ts index 11d2c4b512..76fc53d452 100644 --- a/packages/core/src/__tests__/pr-entity.test.ts +++ b/packages/core/src/__tests__/pr-entity.test.ts @@ -5,8 +5,19 @@ import { isPrEntityActionable, isPrEntityActive, isPrEntityAutoMergeReady, + summarizePrThreadActivity, } from "../pr-entity.js"; -import type { PrEntity } from "../types.js"; +import type { PrEntity, PrThreadState } from "../types.js"; + +function thread(outcome: PrThreadState["outcome"], threadId = "th"): PrThreadState { + return { + prEntityId: "PR-1", + threadId, + headOid: "deadbeef", + outcome, + updatedAt: 1, + }; +} function entity(overrides: Partial = {}): PrEntity { return { @@ -88,3 +99,32 @@ describe("PR entity predicates", () => { expect(autoMergeGateReason({ ...ready, mergeable: "unknown" })).toBe("Waiting for checks"); }); }); + +describe("summarizePrThreadActivity (U18, R15)", () => { + it("counts fixed vs disagreed vs pending and derives acted/total", () => { + const activity = summarizePrThreadActivity([ + thread("fixed", "a"), + thread("fixed", "b"), + thread("disagreed", "c"), + thread("pending", "d"), + ]); + expect(activity).toEqual({ total: 4, acted: 3, fixed: 2, disagreed: 1, pending: 1 }); + }); + + it("empty input returns zeroed counts, not nulls", () => { + expect(summarizePrThreadActivity([])).toEqual({ + total: 0, + acted: 0, + fixed: 0, + disagreed: 0, + pending: 0, + }); + }); + + it("acted excludes pending (in-flight, not yet GitHub-confirmed)", () => { + const activity = summarizePrThreadActivity([thread("pending"), thread("pending", "x")]); + expect(activity.acted).toBe(0); + expect(activity.total).toBe(2); + expect(activity.pending).toBe(2); + }); +}); diff --git a/packages/core/src/__tests__/productivity-analytics.test.ts b/packages/core/src/__tests__/productivity-analytics.test.ts new file mode 100644 index 0000000000..5db312da2e --- /dev/null +++ b/packages/core/src/__tests__/productivity-analytics.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { aggregateProductivityAnalytics } from "../productivity-analytics.js"; + +function insertTaskWithFiles(db: Database, id: string, files: string[], updatedAt: string): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, modifiedFiles) + VALUES (?, 'desc', 'todo', ?, ?, ?)`, + ).run(id, updatedAt, updatedAt, JSON.stringify(files)); +} + +function insertCommit( + db: Database, + id: string, + sha: string, + authoredAt: string, + stats: { additions?: number | null; deletions?: number | null } = {}, +): void { + db.prepare( + `INSERT INTO task_commit_associations + (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, + matchedBy, confidence, additions, deletions, createdAt, updatedAt) + VALUES (?, 'lin-1', 't-1', ?, 'subj', ?, 'canonical-lineage-trailer', 'canonical', ?, ?, ?, ?)`, + ).run(id, sha, authoredAt, stats.additions ?? null, stats.deletions ?? null, authoredAt, authoredAt); +} + +function insertPr(db: Database, id: string, createdAtMs: number): void { + db.prepare( + `INSERT INTO pull_requests + (id, sourceType, sourceId, repo, headBranch, state, createdAt, updatedAt) + VALUES (?, 'task', ?, 'org/repo', ?, 'open', ?, ?)`, + ).run(id, `src-${id}`, `branch-${id}`, createdAtMs, createdAtMs); +} + +describe("productivity-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-productivity-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("counts modified files and language distribution", () => { + insertTaskWithFiles(db, "t1", ["src/a.ts", "src/b.ts", "README.md"], "2026-03-01T00:00:00.000Z"); + insertTaskWithFiles(db, "t2", ["src/c.ts", "style.css"], "2026-03-02T00:00:00.000Z"); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.modifiedFiles).toBe(5); + const byLang = new Map(result.byLanguage.map((l) => [l.language, l.count])); + expect(byLang.get("ts")).toBe(3); + expect(byLang.get("md")).toBe(1); + expect(byLang.get("css")).toBe(1); + // sorted descending by count + expect(result.byLanguage[0]).toEqual({ language: "ts", count: 3 }); + }); + + it("counts commit associations and pull requests in range", () => { + insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z"); + insertCommit(db, "c-old", "sha-old", "2025-01-01T00:00:00.000Z"); + + insertPr(db, "pr1", Date.parse("2026-03-01T00:00:00.000Z")); + insertPr(db, "pr2", Date.parse("2026-03-10T00:00:00.000Z")); + insertPr(db, "pr-old", Date.parse("2025-01-01T00:00:00.000Z")); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.commits).toBe(2); + expect(result.pullRequests).toBe(2); + }); + + it("reports LOC as unavailable (null + unavailable:true), never 0 when no stats exist", () => { + insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c-null", "sha-null", "2026-03-01T00:00:00.000Z"); + const result = aggregateProductivityAnalytics(db, {}); + expect(result.loc).toEqual({ value: null, unavailable: true }); + expect(result.loc.value).not.toBe(0); + }); + + it("sums additions and deletions into LOC when commit stats exist", () => { + insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z", { additions: 10, deletions: 5 }); + insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z", { additions: 3, deletions: 2 }); + insertCommit(db, "c-old", "sha-old", "2025-01-01T00:00:00.000Z", { additions: 100, deletions: 100 }); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.commits).toBe(2); + expect(result.loc).toEqual({ value: 20, unavailable: false }); + }); + + it("keeps the LOC sentinel when in-range commit rows have only null stats", () => { + insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c2", "sha2", "2026-03-02T00:00:00.000Z", { additions: null, deletions: null }); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.commits).toBe(2); + expect(result.loc).toEqual({ value: null, unavailable: true }); + expect(result.loc.value).not.toBe(0); + }); + + it("sums only valued LOC rows while allowing partial commit-stat coverage", () => { + insertCommit(db, "c-null", "sha-null", "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c-additions", "sha-additions", "2026-03-02T00:00:00.000Z", { additions: 7 }); + insertCommit(db, "c-deletions", "sha-deletions", "2026-03-03T00:00:00.000Z", { deletions: 4 }); + + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.commits).toBe(3); + expect(result.loc).toEqual({ value: 11, unavailable: false }); + }); + + it("empty range returns zeroed structures, not nulls", () => { + insertTaskWithFiles(db, "t1", ["src/a.ts"], "2026-03-01T00:00:00.000Z"); + insertCommit(db, "c1", "sha1", "2026-03-01T00:00:00.000Z"); + insertPr(db, "pr1", Date.parse("2026-03-01T00:00:00.000Z")); + + const result = aggregateProductivityAnalytics(db, { from: "2027-01-01T00:00:00.000Z", to: "2027-12-31T00:00:00.000Z" }); + expect(result.modifiedFiles).toBe(0); + expect(result.byLanguage).toEqual([]); + expect(result.commits).toBe(0); + expect(result.pullRequests).toBe(0); + // LOC unavailable regardless of range + expect(result.loc).toEqual({ value: null, unavailable: true }); + }); + + it("includes a boundary task exactly at `from`", () => { + insertTaskWithFiles(db, "boundary", ["x.ts"], "2026-03-01T00:00:00.000Z"); + const result = aggregateProductivityAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.modifiedFiles).toBe(1); + }); +}); diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 68faeb4cdb..309142e352 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -583,8 +583,8 @@ describe("Run Audit", () => { expect(indexNames).toContain("idxRunAuditEventsTimestamp"); }); - it("schema version is bumped to 118", () => { - expect(db.getSchemaVersion()).toBe(118); + it("schema version is bumped to 124", () => { + expect(db.getSchemaVersion()).toBe(124); }); }); }); diff --git a/packages/core/src/__tests__/settings-consistency.test.ts b/packages/core/src/__tests__/settings-consistency.test.ts index dc829a494f..0b60df72e3 100644 --- a/packages/core/src/__tests__/settings-consistency.test.ts +++ b/packages/core/src/__tests__/settings-consistency.test.ts @@ -76,6 +76,11 @@ describe("settings consistency (U5)", () => { expect(isGlobalSettingsKey(key), `isGlobalSettingsKey('${key}') must be false`).toBe(false); expect(isProjectSettingsKey(key), `isProjectSettingsKey('${key}') must be false`).toBe(false); } + + expect(projectKeys, "verificationCommandTimeoutMs remains a project setting, not a moved workflow setting").toContain("verificationCommandTimeoutMs"); + expect(DEFAULT_PROJECT_SETTINGS.verificationCommandTimeoutMs).toBeUndefined(); + expect(isProjectSettingsKey("verificationCommandTimeoutMs")).toBe(true); + expect(isGlobalSettingsKey("verificationCommandTimeoutMs")).toBe(false); }); it("(d) settings-export v2 global/project section keys never overlap moved keys", async () => { diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 708839b27f..81c92ecce4 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_MAX_AUTO_MERGE_RETRIES, resolveMaxAutoMergeRetries } from "../in-review-stall.js"; import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS } from "../settings-schema.js"; import { __resetLegacyCwdMainWarningForTests, @@ -25,6 +26,17 @@ describe("settings defaults invariants", () => { expect(DEFAULT_PROJECT_SETTINGS.worktreesDir).toBeUndefined(); }); + it("defaults maxAutoMergeRetries to the historical project-scoped cap", () => { + expect(DEFAULT_PROJECT_SETTINGS.maxAutoMergeRetries).toBe(DEFAULT_MAX_AUTO_MERGE_RETRIES); + expect("maxAutoMergeRetries" in DEFAULT_GLOBAL_SETTINGS).toBe(false); + expect(resolveMaxAutoMergeRetries(undefined)).toBe(3); + expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: 1 })).toBe(1); + expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: 5 })).toBe(5); + expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: 0 })).toBe(3); + expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: -1 })).toBe(3); + expect(resolveMaxAutoMergeRetries({ maxAutoMergeRetries: Number.NaN })).toBe(3); + }); + it("resolves worktrunk as disabled when both scopes are unset or empty", () => { expect(resolveWorktrunkSettings(undefined, undefined).enabled).toBe(false); expect(resolveWorktrunkSettings({}, {}).enabled).toBe(false); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index f860085c85..1d7cfb7d88 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -9,6 +9,7 @@ import { isGlobalSettingsKey, isProjectSettingsKey, } from "../types.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "../builtin-workflow-settings.js"; function assertExactKeyCoverage(scopeName: string, actual: readonly string[], expected: readonly string[]): void { const uniqueActual = [...new Set(actual)]; @@ -469,6 +470,11 @@ describe("model lane key parity regression (FN-1729)", () => { }, ); + it("does not declare title summarizer keys as built-in workflow settings", () => { + const declaredIds = BUILTIN_WORKFLOW_SETTINGS.map((setting) => setting.id); + expect(declaredIds.filter((id) => /^titleSummarizer/.test(id))).toEqual([]); + }); + 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[]); diff --git a/packages/core/src/__tests__/signals-analytics.test.ts b/packages/core/src/__tests__/signals-analytics.test.ts new file mode 100644 index 0000000000..ede56ccf9d --- /dev/null +++ b/packages/core/src/__tests__/signals-analytics.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { aggregateSignalsAnalytics } from "../signals-analytics.js"; + +const RANGE = { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T23:59:59.999Z" }; + +let incidentSeq = 0; +function insertIncident( + db: Database, + fields: { + status: "open" | "resolved"; + openedAt: string; + resolvedAt?: string | null; + source?: string | null; + severity?: string | null; + }, +): void { + const incidentId = `sig-${incidentSeq++}`; + const now = "2026-03-01T00:00:00.000Z"; + db.prepare( + `INSERT INTO incidents + (incidentId, groupingKey, title, severity, status, source, openedAt, resolvedAt, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + incidentId, + `group-${incidentId}`, + `Signal ${incidentId}`, + fields.severity ?? "error", + fields.status, + fields.source ?? "webhook", + fields.openedAt, + fields.resolvedAt ?? null, + now, + now, + ); +} + +describe("signals-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + incidentSeq = 0; + tmpDir = mkdtempSync(join(tmpdir(), "kb-signals-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("aggregates real incident signals by source, severity, status, and MTTR", () => { + insertIncident(db, { + status: "open", + openedAt: "2026-03-02T10:00:00.000Z", + source: "sentry", + severity: "critical", + }); + insertIncident(db, { + status: "resolved", + openedAt: "2026-03-03T10:00:00.000Z", + resolvedAt: "2026-03-03T10:45:00.000Z", + source: "pagerduty", + severity: "warning", + }); + insertIncident(db, { + status: "resolved", + openedAt: "2026-02-01T10:00:00.000Z", + resolvedAt: "2026-02-01T10:30:00.000Z", + source: "outside", + severity: "info", + }); + + const result = aggregateSignalsAnalytics(db, RANGE); + + expect(result.totalSignals).toBe(2); + expect(result.open).toBe(1); + expect(result.resolved).toBe(1); + expect(result.mttr).toEqual({ value: 45, unavailable: false, sampleCount: 1 }); + expect(result.bySource).toEqual([ + { source: "pagerduty", count: 1 }, + { source: "sentry", count: 1 }, + ]); + expect(result.bySeverity).toEqual([ + { severity: "critical", count: 1 }, + { severity: "warning", count: 1 }, + ]); + expect(result.byStatus).toEqual([ + { status: "open", count: 1 }, + { status: "resolved", count: 1 }, + ]); + }); + + it("keeps MTTR as the unavailable sentinel when no incident resolved in range", () => { + insertIncident(db, { + status: "open", + openedAt: "2026-03-02T10:00:00.000Z", + source: "webhook", + severity: "error", + }); + + const result = aggregateSignalsAnalytics(db, RANGE); + + expect(result.totalSignals).toBe(1); + expect(result.mttr).toEqual({ value: null, unavailable: true, sampleCount: 0 }); + }); +}); diff --git a/packages/core/src/__tests__/store-concurrent-writes.test.ts b/packages/core/src/__tests__/store-concurrent-writes.test.ts index bc61e13ea0..5a20eebb78 100644 --- a/packages/core/src/__tests__/store-concurrent-writes.test.ts +++ b/packages/core/src/__tests__/store-concurrent-writes.test.ts @@ -36,7 +36,13 @@ async function holdWriteLock( process.exit(0); }; if (${JSON.stringify(releaseMode)} === "timer") { - setTimeout(release, ${holdMs}); + /* + FNXC:CoreTests 2026-06-15-07:38: + FN-6486 rescues this WAL lock-recovery regression by removing the helper's event-loop timer dependency. Under package-lane load, a delayed setTimeout could keep the external writer lock past the recovery window and mimic a product failure; a synchronous child-process sleep preserves the transient lock invariant without widening test or SQLite retry timeouts. + */ + const signal = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(signal, 0, 0, ${holdMs}); + release(); } else { process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index f4311184d8..f908a6b971 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(118); + expect(store.getDatabase().getSchemaVersion()).toBe(124); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/store-token-usage.test.ts b/packages/core/src/__tests__/store-token-usage.test.ts index 368f7e308e..1046ad391a 100644 --- a/packages/core/src/__tests__/store-token-usage.test.ts +++ b/packages/core/src/__tests__/store-token-usage.test.ts @@ -28,6 +28,8 @@ describe("TaskStore", () => { totalTokens: 204, firstUsedAt: "2026-04-23T10:00:00.000Z", lastUsedAt: "2026-04-23T10:05:00.000Z", + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", }; const task = await harness.store().createTask({ @@ -52,6 +54,8 @@ describe("TaskStore", () => { totalTokens: 345, firstUsedAt: "2026-04-23T12:00:00.000Z", lastUsedAt: "2026-04-23T12:30:00.000Z", + modelProvider: "openai", + modelId: "gpt-5", }; const updated = await harness.store().updateTask(task.id, { tokenUsage }); diff --git a/packages/core/src/__tests__/store-upsert.test.ts b/packages/core/src/__tests__/store-upsert.test.ts index b66a667b00..b753c95610 100644 --- a/packages/core/src/__tests__/store-upsert.test.ts +++ b/packages/core/src/__tests__/store-upsert.test.ts @@ -35,6 +35,46 @@ describe("TaskStore", () => { const insertLogEntryWithTimestamp = (...args: any[]) => (harness as any).insertLogEntryWithTimestamp(...args); const taskDir = (taskId: string) => join(rootDir, ".fusion", "tasks", taskId); + describe("task commit association diff stats", () => { + it("round-trips nullable additions and deletions without coercing unknown stats to zero", async () => { + const withStats = await store.upsertTaskCommitAssociation({ + taskLineageId: "lineage-loc-stats", + taskIdSnapshot: "FN-6704", + commitSha: "abc123", + commitSubject: "feat: capture stats", + authoredAt: "2026-06-19T00:00:00.000Z", + matchedBy: "canonical-lineage-trailer", + confidence: "canonical", + additions: 12, + deletions: 3, + }); + expect(withStats.additions).toBe(12); + expect(withStats.deletions).toBe(3); + + await store.upsertTaskCommitAssociation({ + taskLineageId: "lineage-loc-stats", + taskIdSnapshot: "FN-6704", + commitSha: "def456", + commitSubject: "fix: unknown stats", + authoredAt: "2026-06-19T01:00:00.000Z", + matchedBy: "canonical-lineage-trailer", + confidence: "canonical", + }); + + const associations = await store.getTaskCommitAssociationsByLineageId("lineage-loc-stats"); + const persistedWithStats = associations.find((association) => association.commitSha === "abc123"); + const persistedUnknownStats = associations.find((association) => association.commitSha === "def456"); + expect(persistedWithStats).toMatchObject({ additions: 12, deletions: 3 }); + expect(persistedUnknownStats?.additions).toBeUndefined(); + expect(persistedUnknownStats?.deletions).toBeUndefined(); + + const rawUnknown = (store as any).db.prepare( + `SELECT additions, deletions FROM task_commit_associations WHERE commitSha = ?`, + ).get("def456") as { additions: number | null; deletions: number | null }; + expect(rawUnknown).toEqual({ additions: null, deletions: null }); + }); + }); + describe("upsertTask regression coverage", () => { it("creates tasks successfully on a fresh database schema", async () => { const freshRoot = makeTmpDir(); diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index ec92575074..30fa58cd90 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(118); + expect(db.getSchemaVersion()).toBe(124); const index = db .prepare( diff --git a/packages/core/src/__tests__/task-list-format.test.ts b/packages/core/src/__tests__/task-list-format.test.ts new file mode 100644 index 0000000000..b917045f11 --- /dev/null +++ b/packages/core/src/__tests__/task-list-format.test.ts @@ -0,0 +1,299 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + clampTaskListText as sourceBarrelClampTaskListText, + MAX_TASK_LIST_TEXT_CHARS as SOURCE_BARREL_MAX_TASK_LIST_TEXT_CHARS, + formatTaskListText as sourceBarrelFormatTaskListText, +} from "../index.js"; +import { hasBuiltCoreDistBarrel } from "@fusion/test-utils"; +import { clampTaskListText, formatTaskListText, MAX_TASK_LIST_TEXT_CHARS } from "../task-list-format.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +type RuntimeCoreTaskListModule = { + COLUMNS: readonly string[]; + COLUMN_LABELS: Record; + MAX_TASK_LIST_TEXT_CHARS: number; + clampTaskListText: (lines: string[]) => string; + formatTaskListText?: (lines: string[]) => string; +}; + +type RuntimeTask = { + id: string; + title?: string; + description: string; + column: string; + dependencies?: string[]; +}; + +function formatRuntimeTaskLine(task: RuntimeTask): string { + const dependencySuffix = task.dependencies?.length ? ` [deps: ${task.dependencies.join(", ")}]` : ""; + return `${task.id} ${task.title || task.description}${dependencySuffix}`; +} + +function executeRuntimeTaskList( + core: RuntimeCoreTaskListModule, + tasks: RuntimeTask[], + params: { column?: string; limit?: number } = {}, +) { + if (tasks.length === 0) { + return { + content: [{ type: "text", text: "No tasks yet." }], + details: { count: 0 }, + }; + } + + const perColumn = params.limit ?? 10; + const lines: string[] = []; + for (const col of core.COLUMNS) { + if (params.column && params.column !== col) continue; + + const colTasks = tasks.filter((task) => task.column === col); + if (colTasks.length === 0) continue; + + lines.push(`${core.COLUMN_LABELS[col] ?? col} (${colTasks.length}):`); + const shown = colTasks.slice(0, perColumn); + for (const task of shown) { + lines.push(` ${formatRuntimeTaskLine(task)}`); + } + const hidden = colTasks.length - shown.length; + if (hidden > 0) { + lines.push(` ... and ${hidden} more`); + } + lines.push(""); + } + + return { + content: [{ type: "text", text: core.clampTaskListText(lines).trimEnd() }], + details: { count: tasks.length }, + }; +} + +/** + * FNXC:TaskListOutput 2026-06-16-23:20: + * FN-6515 requires the @fusion/core dist barrel to export clampTaskListText and MAX_TASK_LIST_TEXT_CHARS because heartbeat fn_task_list and other runtime surfaces load the built dist, not src/index.ts. Source-aliased tests alone can pass while a stale or missing dist export still crashes ambient agents. + * + * FNXC:TaskListOutput 2026-06-17-02:30: + * FN-6535 requires this guard to execute a fn_task_list-shaped runtime call through the built dist module, not just assert the barrel types. The recurring crash was a post-FN-6492 tool call resolving @fusion/core through exports.import to stale dist, so the regression must fail when that dist omits the helper. + */ +describe("@fusion/core dist barrel export wiring (FN-6515/FN-6535)", () => { + const distDir = resolve(__dirname, "../../dist"); + const hasCompleteDistBarrel = hasBuiltCoreDistBarrel(distDir); + const distIndex = resolve(distDir, "index.js"); + let builtDistCore: RuntimeCoreTaskListModule | undefined; + + /* + FNXC:CoreTests 2026-06-17-13:40: + FN-6591 requires the FN-6515/FN-6535 dist-barrel guard to settle under broad @fusion/core suite load without timeout, retry, or worker appeasement. + Load the built dist barrel once for every dist assertion so heartbeat fn_task_list coverage still exercises the real runtime export path while avoiding duplicate dynamic-import pressure in the timed test bodies. + + FNXC:CoreTests 2026-06-18-01:35: + FN-6627 requires this guard to skip when the built @fusion/core dist barrel is absent or partial, because the runtime import path depends on both index.js and task-list-format.js. + */ + beforeAll(async () => { + if (!hasCompleteDistBarrel) return; + builtDistCore = await import(pathToFileURL(distIndex).href) as RuntimeCoreTaskListModule; + }); + + it("re-exports task-list formatting helpers from the source barrel", () => { + expect(typeof sourceBarrelClampTaskListText).toBe("function"); + expect(typeof sourceBarrelFormatTaskListText).toBe("function"); + expect(typeof SOURCE_BARREL_MAX_TASK_LIST_TEXT_CHARS).toBe("number"); + }); + + it.skipIf(!hasCompleteDistBarrel)("re-exports task-list formatting helpers from the built dist barrel", () => { + const mod = builtDistCore; + + expect(mod).toBeDefined(); + expect(typeof mod?.clampTaskListText).toBe("function"); + expect(typeof mod?.formatTaskListText).toBe("function"); + expect(typeof mod?.MAX_TASK_LIST_TEXT_CHARS).toBe("number"); + }); + + it.skipIf(!hasCompleteDistBarrel)("executes the fn_task_list surface through the built dist core module", () => { + const mod = builtDistCore as RuntimeCoreTaskListModule; + const todoAnchor: RuntimeTask = { + id: "FN-001", + title: `Runtime todo task 001 ${"x".repeat(260)}`, + description: "Runtime todo task 001", + column: "todo", + }; + const tasks: RuntimeTask[] = [ + ...Array.from({ length: 35 }, (_, index) => ({ + id: `FN-${String(index + 101).padStart(3, "0")}`, + title: `Runtime planning task ${String(index + 1).padStart(3, "0")} ${"x".repeat(380)}`, + description: `Runtime planning task ${String(index + 1).padStart(3, "0")}`, + column: "triage", + })), + todoAnchor, + ...Array.from({ length: 59 }, (_, index) => ({ + id: `FN-${String(index + 2).padStart(3, "0")}`, + title: `Runtime todo task ${String(index + 2).padStart(3, "0")} ${"x".repeat(260)}`, + description: `Runtime todo task ${String(index + 2).padStart(3, "0")}`, + column: "todo", + dependencies: [todoAnchor.id], + })), + ]; + + const broadResult = executeRuntimeTaskList(mod, tasks, { limit: 20 }); + const broadText = broadResult.content[0].text; + expect(broadResult.content).toEqual([{ type: "text", text: expect.any(String) }]); + expect(broadText.length).toBeLessThanOrEqual(mod.MAX_TASK_LIST_TEXT_CHARS); + expect(broadText).toContain("Planning (35):"); + expect(broadText).toContain("FN-101"); + expect(broadText).toContain("truncated to fit; narrow with column/limit"); + expect(broadResult.details.count).toBe(95); + + const todoResult = executeRuntimeTaskList(mod, tasks, { column: "todo", limit: 50 }); + const todoText = todoResult.content[0].text; + expect(todoResult.content).toEqual([{ type: "text", text: expect.any(String) }]); + expect(todoText.length).toBeLessThanOrEqual(mod.MAX_TASK_LIST_TEXT_CHARS); + expect(todoText).toContain("Todo (60):"); + expect(todoText).toContain("FN-001"); + expect(todoText).toContain("[deps: FN-001]"); + expect(todoText).toContain("truncated to fit; narrow with column/limit"); + expect(todoResult.details.count).toBe(95); + }); +}); + +describe("formatTaskListText", () => { + it("returns an empty string for empty input", () => { + expect(formatTaskListText([])).toBe(""); + }); + + it("uses the canonical clamp path for small input without a marker", () => { + const lines = ["Todo (2):", " FN-001 First task", " FN-002 Second task"]; + + expect(formatTaskListText(lines)).toBe(lines.join("\n")); + expect(formatTaskListText(lines)).not.toContain("truncated to fit"); + }); + + it("uses the canonical clamp path for large input with the FN-6492 marker", () => { + const lines = Array.from({ length: 20 }, (_, index) => `FN-${String(index + 1).padStart(3, "0")} ${"x".repeat(20)}`); + + const text = formatTaskListText(lines, { maxChars: 150 }); + + expect(text.length).toBeLessThanOrEqual(150); + expect(text).toContain("truncated to fit; narrow with column/limit"); + }); + + it("falls back to bounded text when the clamp helper is missing", () => { + const lines = Array.from({ length: 500 }, (_, index) => `FN-${String(index + 1).padStart(3, "0")} ${"x".repeat(80)}`); + + const text = formatTaskListText(lines, { clamp: undefined }); + + expect(text).toBeTruthy(); + expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + }); + + it("falls back to bounded text when the clamp binding is not a function", () => { + const lines = ["FN-001 " + "x".repeat(200)]; + const text = formatTaskListText(lines, { + maxChars: 40, + clamp: "not-a-function" as unknown as (lines: string[], opts?: { maxChars?: number }) => string, + }); + + expect(text).toBeTruthy(); + expect(text.length).toBeLessThanOrEqual(40); + expect(text.endsWith("…")).toBe(true); + }); +}); + +describe("clampTaskListText", () => { + it("documents the host-safe default budget", () => { + expect(MAX_TASK_LIST_TEXT_CHARS).toBe(3_000); + }); + + it("returns an empty string for empty input", () => { + expect(clampTaskListText([])).toBe(""); + }); + + it("returns small input unchanged without a marker", () => { + const lines = ["Todo (2):", " FN-001 First task", " FN-002 Second task"]; + + expect(clampTaskListText(lines)).toBe(lines.join("\n")); + expect(clampTaskListText(lines)).not.toContain("truncated to fit"); + }); + + it("truncates large input to the budget with an accurate dropped-line marker", () => { + const lines = [ + "Todo (5):", + " FN-001 Task one", + " FN-002 Task two", + " FN-003 Task three", + " FN-004 Task four", + " FN-005 Task five", + ]; + + const text = clampTaskListText(lines, { maxChars: 95 }); + + expect(text.length).toBeLessThanOrEqual(95); + expect(text).toContain("Todo (5):"); + expect(text).toContain("FN-001"); + expect(text).toContain("... and 4 more tasks (truncated to fit; narrow with column/limit)"); + }); + + it("never splits retained lines mid-line", () => { + const lines = [ + "Todo (4):", + " FN-001 Retain me whole", + " FN-002 Retain me whole too", + " FN-003 Drop me whole", + " FN-004 Drop me whole too", + ]; + + const text = clampTaskListText(lines, { maxChars: 105 }); + const outputLines = text.split("\n"); + + expect(outputLines).toEqual([ + "Todo (4):", + " FN-001 Retain me whole", + "... and 3 more tasks (truncated to fit; narrow with column/limit)", + ]); + }); + + it("honors a custom maxChars budget", () => { + const lines = Array.from({ length: 20 }, (_, index) => `FN-${String(index + 1).padStart(3, "0")} ${"x".repeat(20)}`); + + const text = clampTaskListText(lines, { maxChars: 150 }); + + expect(text.length).toBeLessThanOrEqual(150); + expect(text).toContain("truncated to fit"); + }); + + it("keeps default output within the exported budget", () => { + const lines = Array.from({ length: 500 }, (_, index) => `FN-${String(index + 1).padStart(3, "0")} ${"x".repeat(80)}`); + + expect(clampTaskListText(lines).length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + }); + + it("truncates realistic large listings under the host-safe budget", () => { + const lines = [ + "Todo (60):", + ...Array.from( + { length: 50 }, + (_, index) => + ` FN-${String(index + 1).padStart(3, "0")} Realistic todo task ${String(index + 1).padStart(3, "0")} keeps descriptive context for text agents without artificial padding`, + ), + " ... and 10 more", + "", + ]; + + const text = clampTaskListText(lines); + + expect(lines.join("\n").length).toBeGreaterThan(MAX_TASK_LIST_TEXT_CHARS); + expect(text.length).toBeLessThanOrEqual(MAX_TASK_LIST_TEXT_CHARS); + expect(text).toContain("Todo (60):"); + expect(text).toContain("FN-001"); + expect(text).toContain("truncated to fit; narrow with column/limit"); + }); + + it("handles a single over-budget line by returning a bounded truncation marker", () => { + const text = clampTaskListText(["FN-001 " + "x".repeat(200)], { maxChars: 40 }); + + expect(text.length).toBeLessThanOrEqual(40); + expect(text).toMatch(/^\.\.\. and 1 more tas/); + expect(text.endsWith("…")).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/team-analytics.test.ts b/packages/core/src/__tests__/team-analytics.test.ts new file mode 100644 index 0000000000..666f344f2a --- /dev/null +++ b/packages/core/src/__tests__/team-analytics.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { aggregateTeamAnalytics } from "../team-analytics.js"; + +interface TaskSeed { + id: string; + agentId?: string | null; + column?: string; + columnMovedAt?: string | null; + updatedAt?: string; + modifiedFiles?: unknown; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + cacheWriteTokens?: number; + totalTokens?: number | null; + tokenUsageLastUsedAt?: string | null; + modelProvider?: string | null; + modelId?: string | null; +} + +function insertAgent(db: Database, id: string, name: string, role = "executor", state = "idle"): void { + db.prepare( + `INSERT INTO agents (id, name, role, state, createdAt, updatedAt) + VALUES (?, ?, ?, ?, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')`, + ).run(id, name, role, state); +} + +function modifiedFilesValue(value: unknown): string | null { + if (value === undefined) return "[]"; + if (value === null) return null; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +function insertTask(db: Database, task: TaskSeed): void { + const updatedAt = task.updatedAt ?? "2026-03-01T00:00:00.000Z"; + db.prepare( + `INSERT INTO tasks + (id, description, "column", createdAt, updatedAt, columnMovedAt, assignedAgentId, + modifiedFiles, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, + tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, modelProvider, modelId) + VALUES (?, 'desc', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + task.id, + task.column ?? "todo", + updatedAt, + updatedAt, + task.columnMovedAt ?? null, + task.agentId ?? null, + modifiedFilesValue(task.modifiedFiles), + task.inputTokens ?? null, + task.outputTokens ?? null, + task.cachedTokens ?? null, + task.cacheWriteTokens ?? null, + task.totalTokens === undefined ? null : task.totalTokens, + task.tokenUsageLastUsedAt ?? null, + task.modelProvider ?? null, + task.modelId ?? null, + ); +} + +describe("team-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-team-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("aggregates multiple agents with token cost, files, completed tasks, and live state", () => { + insertAgent(db, "agent-a", "Alpha", "executor", "running"); + insertAgent(db, "agent-b", "Beta", "reviewer", "idle"); + insertTask(db, { + id: "a-tokens", + agentId: "agent-a", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + totalTokens: 2_000_000, + tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", + modelProvider: "openai", + modelId: "gpt-4o", + }); + insertTask(db, { + id: "a-done", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-03T00:00:00.000Z", + modifiedFiles: ["src/a.ts", "src/b.ts"], + updatedAt: "2026-03-03T00:00:00.000Z", + }); + insertTask(db, { + id: "a-progress", + agentId: "agent-a", + column: "in-progress", + modifiedFiles: ["docs/readme.md"], + updatedAt: "2026-03-04T00:00:00.000Z", + }); + insertTask(db, { + id: "b-review", + agentId: "agent-b", + column: "in-review", + inputTokens: 50, + outputTokens: 25, + totalTokens: 75, + tokenUsageLastUsedAt: "2026-03-05T00:00:00.000Z", + modelProvider: "openai", + modelId: "gpt-4o-mini", + modifiedFiles: ["src/c.ts"], + updatedAt: "2026-03-05T00:00:00.000Z", + }); + + const result = aggregateTeamAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + now: Date.parse("2026-03-10T00:00:00.000Z"), + }); + + expect(result.from).toBe("2026-03-01T00:00:00.000Z"); + expect(result.to).toBe("2026-03-31T00:00:00.000Z"); + expect(result.agents.map((agent) => agent.agentId)).toEqual(["agent-a", "agent-b"]); + + const byAgent = new Map(result.agents.map((agent) => [agent.agentId, agent])); + expect(byAgent.get("agent-a")).toMatchObject({ + agentName: "Alpha", + role: "executor", + state: "running", + filesChanged: 3, + tasksCompleted: 1, + tasksInProgress: 1, + tasksInReview: 0, + }); + expect(byAgent.get("agent-a")?.tokens.totalTokens).toBe(2_000_000); + expect(byAgent.get("agent-a")?.cost).toEqual({ usd: 12.5, unavailable: false, stale: false }); + expect(byAgent.get("agent-b")).toMatchObject({ + agentName: "Beta", + role: "reviewer", + state: "idle", + filesChanged: 1, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 1, + }); + expect(result.totals.tokens.totalTokens).toBe(2_000_075); + expect(result.totals.filesChanged).toBe(4); + expect(result.totals.tasksCompleted).toBe(1); + expect(result.totals.tasksInProgress).toBe(1); + expect(result.totals.tasksInReview).toBe(1); + }); + + it("returns zeroed totals and an empty agent array for an empty database", () => { + const result = aggregateTeamAnalytics(db, {}); + + expect(result.totals).toEqual({ + tokens: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + nTasks: 0, + }, + cost: { usd: null, unavailable: false, stale: false }, + filesChanged: 0, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 0, + }); + expect(result.agents).toEqual([]); + }); + + it("filters completed tasks by range while preserving current in-progress counts", () => { + insertAgent(db, "agent-a", "Alpha"); + insertTask(db, { + id: "done-before", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-02-28T23:59:59.999Z", + }); + insertTask(db, { + id: "done-in-range", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-01T00:00:00.000Z", + }); + insertTask(db, { id: "active", agentId: "agent-a", column: "in-progress" }); + + const result = aggregateTeamAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + }); + + expect(result.agents[0].tasksCompleted).toBe(1); + expect(result.agents[0].tasksInProgress).toBe(1); + }); + + it("keeps a safe row for a task whose agent row was deleted", () => { + insertTask(db, { + id: "orphan", + agentId: "deleted-agent", + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", + modifiedFiles: ["src/orphan.ts"], + updatedAt: "2026-03-02T00:00:00.000Z", + }); + + const result = aggregateTeamAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + }); + + expect(result.agents).toHaveLength(1); + expect(result.agents[0]).toMatchObject({ + agentId: "deleted-agent", + agentName: null, + role: null, + state: null, + filesChanged: 1, + }); + expect(result.agents[0].tokens.totalTokens).toBe(15); + }); + + it("marks unpriced models unavailable instead of treating them as zero-cost", () => { + insertAgent(db, "agent-a", "Alpha"); + insertTask(db, { + id: "unknown-model", + agentId: "agent-a", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + tokenUsageLastUsedAt: "2026-03-02T00:00:00.000Z", + modelProvider: "unknown-provider", + modelId: "unknown-model", + }); + + const result = aggregateTeamAnalytics(db, {}); + + expect(result.agents[0].cost).toEqual({ usd: null, unavailable: true, stale: false }); + expect(result.totals.cost).toEqual({ usd: null, unavailable: true, stale: false }); + }); + + it("uses inclusive upper and lower bounds for tokens, completions, and files", () => { + insertAgent(db, "agent-a", "Alpha"); + insertTask(db, { + id: "from-boundary", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-01T00:00:00.000Z", + tokenUsageLastUsedAt: "2026-03-01T00:00:00.000Z", + inputTokens: 10, + totalTokens: 10, + modifiedFiles: ["from.ts"], + updatedAt: "2026-03-01T00:00:00.000Z", + }); + insertTask(db, { + id: "to-boundary", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-31T00:00:00.000Z", + tokenUsageLastUsedAt: "2026-03-31T00:00:00.000Z", + inputTokens: 20, + totalTokens: 20, + modifiedFiles: ["to.ts"], + updatedAt: "2026-03-31T00:00:00.000Z", + }); + insertTask(db, { + id: "after-boundary", + agentId: "agent-a", + column: "done", + columnMovedAt: "2026-03-31T00:00:00.001Z", + tokenUsageLastUsedAt: "2026-03-31T00:00:00.001Z", + inputTokens: 30, + totalTokens: 30, + modifiedFiles: ["after.ts"], + updatedAt: "2026-03-31T00:00:00.001Z", + }); + + const result = aggregateTeamAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + }); + + expect(result.agents[0].tokens.totalTokens).toBe(30); + expect(result.agents[0].tasksCompleted).toBe(2); + expect(result.agents[0].filesChanged).toBe(2); + }); + + it("tolerates invalid modifiedFiles JSON", () => { + insertAgent(db, "agent-a", "Alpha"); + insertTask(db, { + id: "bad-files", + agentId: "agent-a", + modifiedFiles: "not-json", + updatedAt: "2026-03-02T00:00:00.000Z", + }); + + const result = aggregateTeamAnalytics(db, {}); + + expect(result.agents[0].filesChanged).toBe(0); + }); +}); diff --git a/packages/core/src/__tests__/token-analytics.test.ts b/packages/core/src/__tests__/token-analytics.test.ts new file mode 100644 index 0000000000..b4afd06bf9 --- /dev/null +++ b/packages/core/src/__tests__/token-analytics.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { costFor } from "../model-pricing.js"; +import { aggregateTokenAnalytics } from "../token-analytics.js"; + +interface TaskSeed { + id: string; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + cacheWriteTokens?: number; + totalTokens?: number | null; + lastUsedAt: string | null; + modelProvider?: string | null; + modelId?: string | null; + tokenUsageModelProvider?: string | null; + tokenUsageModelId?: string | null; + nodeId?: string | null; + agentId?: string | null; +} + +function insertTask(db: Database, t: TaskSeed): void { + db.prepare( + `INSERT INTO tasks + (id, description, "column", createdAt, updatedAt, + tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens, + tokenUsageCacheWriteTokens, tokenUsageTotalTokens, tokenUsageLastUsedAt, + modelProvider, modelId, tokenUsageModelProvider, tokenUsageModelId, checkoutNodeId, assignedAgentId) + VALUES (?, 'desc', 'todo', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + t.id, + t.inputTokens ?? null, + t.outputTokens ?? null, + t.cachedTokens ?? null, + t.cacheWriteTokens ?? null, + t.totalTokens === undefined ? null : t.totalTokens, + t.lastUsedAt, + t.modelProvider ?? null, + t.modelId ?? null, + t.tokenUsageModelProvider ?? null, + t.tokenUsageModelId ?? null, + t.nodeId ?? null, + t.agentId ?? null, + ); +} + +describe("token-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-token-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("returns correct per-model token totals for 5 tasks across 2 models", () => { + // 3 tasks on model-A, 2 on model-B, all within range. + insertTask(db, { id: "t1", inputTokens: 100, outputTokens: 50, totalTokens: 150, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A", modelProvider: "anthropic" }); + insertTask(db, { id: "t2", inputTokens: 200, outputTokens: 80, totalTokens: 280, lastUsedAt: "2026-03-02T00:00:00.000Z", modelId: "model-A", modelProvider: "anthropic" }); + insertTask(db, { id: "t3", inputTokens: 300, outputTokens: 20, totalTokens: 320, lastUsedAt: "2026-03-03T00:00:00.000Z", modelId: "model-A", modelProvider: "anthropic" }); + insertTask(db, { id: "t4", inputTokens: 10, outputTokens: 5, totalTokens: 15, lastUsedAt: "2026-03-04T00:00:00.000Z", modelId: "model-B", modelProvider: "openai" }); + insertTask(db, { id: "t5", inputTokens: 40, outputTokens: 60, totalTokens: 100, lastUsedAt: "2026-03-05T00:00:00.000Z", modelId: "model-B", modelProvider: "openai" }); + + const result = aggregateTokenAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + groupBy: "model", + }); + + expect(result.totals.inputTokens).toBe(650); + expect(result.totals.outputTokens).toBe(215); + expect(result.totals.totalTokens).toBe(865); + expect(result.totals.nTasks).toBe(5); + + const groups = new Map(result.groups.map((g) => [g.key, g])); + expect(groups.get("model-A")!.inputTokens).toBe(600); + expect(groups.get("model-A")!.totalTokens).toBe(750); + expect(groups.get("model-A")!.nTasks).toBe(3); + expect(groups.get("model-B")!.inputTokens).toBe(50); + expect(groups.get("model-B")!.totalTokens).toBe(115); + expect(groups.get("model-B")!.nTasks).toBe(2); + // groups sorted descending by totalTokens + expect(result.groups[0].key).toBe("model-A"); + }); + + it("groups resolved-via-settings token usage by the actually-used model snapshot", () => { + insertTask(db, { id: "t1", inputTokens: 100, outputTokens: 50, totalTokens: 150, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: null, modelProvider: null, tokenUsageModelId: "claude-sonnet-4-5", tokenUsageModelProvider: "anthropic" }); + insertTask(db, { id: "t2", inputTokens: 25, outputTokens: 25, totalTokens: 50, lastUsedAt: "2026-03-02T00:00:00.000Z", modelId: null, modelProvider: null, tokenUsageModelId: "gpt-5", tokenUsageModelProvider: "openai" }); + insertTask(db, { id: "t3", inputTokens: 30, outputTokens: 20, totalTokens: 50, lastUsedAt: "2026-03-03T00:00:00.000Z", modelId: null, modelProvider: null, tokenUsageModelId: "gpt-5", tokenUsageModelProvider: "openai" }); + + const result = aggregateTokenAnalytics(db, { groupBy: "model" }); + + const groups = new Map(result.groups.map((g) => [g.key, g])); + expect([...groups.keys()].sort()).toEqual(["claude-sonnet-4-5", "gpt-5"]); + expect(groups.get("claude-sonnet-4-5")).toMatchObject({ totalTokens: 150, inputTokens: 100, outputTokens: 50, nTasks: 1 }); + expect(groups.get("gpt-5")).toMatchObject({ totalTokens: 100, inputTokens: 55, outputTokens: 45, nTasks: 2 }); + expect(groups.has(null)).toBe(false); + }); + + it("groups providers by the token-usage snapshot before task own-provider", () => { + insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: null, tokenUsageModelProvider: "anthropic", tokenUsageModelId: "claude-sonnet-4-5" }); + insertTask(db, { id: "t2", inputTokens: 200, totalTokens: 200, lastUsedAt: "2026-03-02T00:00:00.000Z", modelProvider: null, tokenUsageModelProvider: "openai", tokenUsageModelId: "gpt-5" }); + insertTask(db, { id: "t3", inputTokens: 25, totalTokens: 25, lastUsedAt: "2026-03-03T00:00:00.000Z", modelProvider: "legacy-provider", tokenUsageModelProvider: "openai", tokenUsageModelId: "gpt-5" }); + + const result = aggregateTokenAnalytics(db, { groupBy: "provider" }); + + expect(new Map(result.groups.map((g) => [g.key, g.totalTokens]))).toEqual( + new Map([["anthropic", 100], ["openai", 225]]), + ); + }); + + it("falls back to legacy task model columns when no token snapshot exists", () => { + insertTask(db, { id: "legacy", inputTokens: 40, totalTokens: 40, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: "anthropic", modelId: "legacy-model" }); + + const result = aggregateTokenAnalytics(db, { groupBy: "model" }); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0]).toMatchObject({ key: "legacy-model", totalTokens: 40, nTasks: 1 }); + }); + + it("keeps own-model and resolved-model token snapshots as distinct model groups", () => { + insertTask(db, { id: "own", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: "anthropic", modelId: "own-model", tokenUsageModelProvider: "anthropic", tokenUsageModelId: "own-model" }); + insertTask(db, { id: "resolved", inputTokens: 75, totalTokens: 75, lastUsedAt: "2026-03-02T00:00:00.000Z", modelProvider: null, modelId: null, tokenUsageModelProvider: "openai", tokenUsageModelId: "resolved-model" }); + + const result = aggregateTokenAnalytics(db, { groupBy: "model" }); + + expect(new Map(result.groups.map((g) => [g.key, g.totalTokens]))).toEqual( + new Map([["own-model", 100], ["resolved-model", 75]]), + ); + }); + + it("groups by provider, node, and agent", () => { + insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: "anthropic", nodeId: "node-1", agentId: "agent-x" }); + insertTask(db, { id: "t2", inputTokens: 200, totalTokens: 200, lastUsedAt: "2026-03-02T00:00:00.000Z", modelProvider: "openai", nodeId: "node-1", agentId: "agent-y" }); + + const byProvider = aggregateTokenAnalytics(db, { groupBy: "provider" }); + expect(new Map(byProvider.groups.map((g) => [g.key, g.totalTokens]))).toEqual( + new Map([["anthropic", 100], ["openai", 200]]), + ); + + const byNode = aggregateTokenAnalytics(db, { groupBy: "node" }); + expect(byNode.groups).toHaveLength(1); + expect(byNode.groups[0].key).toBe("node-1"); + expect(byNode.groups[0].totalTokens).toBe(300); + + const byAgent = aggregateTokenAnalytics(db, { groupBy: "agent" }); + expect(new Map(byAgent.groups.map((g) => [g.key, g.totalTokens]))).toEqual( + new Map([["agent-x", 100], ["agent-y", 200]]), + ); + }); + + it("empty range returns zeroed structures, not nulls", () => { + insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { + from: "2027-01-01T00:00:00.000Z", + to: "2027-12-31T00:00:00.000Z", + groupBy: "model", + }); + expect(result.totals).toEqual({ + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + nTasks: 0, + }); + expect(result.groups).toEqual([]); + }); + + it("includes a boundary task exactly at `from` (inclusive lower bound)", () => { + insertTask(db, { id: "boundary", inputTokens: 42, totalTokens: 42, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-31T00:00:00.000Z", + }); + expect(result.totals.nTasks).toBe(1); + expect(result.totals.inputTokens).toBe(42); + }); + + it("excludes tasks with no token usage (lastUsedAt null)", () => { + insertTask(db, { id: "no-usage", lastUsedAt: null, modelId: "model-A" }); + insertTask(db, { id: "has-usage", inputTokens: 5, totalTokens: 5, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, {}); + expect(result.totals.nTasks).toBe(1); + expect(result.totals.inputTokens).toBe(5); + }); + + it("derives totalTokens from parts when the persisted total is null", () => { + insertTask(db, { id: "t1", inputTokens: 10, outputTokens: 20, cachedTokens: 5, cacheWriteTokens: 1, totalTokens: null, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + const result = aggregateTokenAnalytics(db, {}); + expect(result.totals.totalTokens).toBe(36); + }); + + it("omits series unless granularity is requested while preserving totals", () => { + insertTask(db, { id: "t1", inputTokens: 10, totalTokens: 10, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, {}); + + expect(result.totals.totalTokens).toBe(10); + expect(result).not.toHaveProperty("series"); + }); + + it("buckets token usage by UTC day in ascending order with inclusive bounds", () => { + insertTask(db, { id: "before", inputTokens: 1, totalTokens: 1, lastUsedAt: "2026-02-29T23:59:59.999Z", modelId: "model-A" }); + insertTask(db, { id: "from", inputTokens: 100, outputTokens: 10, totalTokens: 110, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "same-day", inputTokens: 200, outputTokens: 20, totalTokens: 220, lastUsedAt: "2026-03-01T12:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "to", inputTokens: 300, outputTokens: 30, totalTokens: 330, lastUsedAt: "2026-03-02T00:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "after", inputTokens: 1, totalTokens: 1, lastUsedAt: "2026-03-02T00:00:00.001Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-02T00:00:00.000Z", + granularity: "day", + }); + + expect(result.series?.map((p) => p.bucket)).toEqual(["2026-03-01", "2026-03-02"]); + expect(result.series?.map((p) => p.totalTokens)).toEqual([330, 330]); + expect(result.totals.totalTokens).toBe(660); + }); + + it("buckets token usage by UTC hour", () => { + insertTask(db, { id: "h1a", inputTokens: 10, totalTokens: 10, lastUsedAt: "2026-03-01T01:05:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "h1b", inputTokens: 20, totalTokens: 20, lastUsedAt: "2026-03-01T01:59:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "h2", inputTokens: 30, totalTokens: 30, lastUsedAt: "2026-03-01T02:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { granularity: "hour" }); + + expect(result.series?.map((p) => [p.bucket, p.totalTokens])).toEqual([ + ["2026-03-01T01", 30], + ["2026-03-01T02", 30], + ]); + }); + + it("buckets token usage by ISO week across year boundaries", () => { + insertTask(db, { id: "w1", inputTokens: 10, totalTokens: 10, lastUsedAt: "2026-12-31T12:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "w1b", inputTokens: 20, totalTokens: 20, lastUsedAt: "2027-01-01T12:00:00.000Z", modelId: "model-A" }); + insertTask(db, { id: "w2", inputTokens: 30, totalTokens: 30, lastUsedAt: "2027-01-04T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { granularity: "week" }); + + expect(result.series?.map((p) => [p.bucket, p.totalTokens])).toEqual([ + ["2026-W53", 30], + ["2027-W01", 30], + ]); + }); + + it("computes per-bucket cost with priced and unavailable models", () => { + insertTask(db, { id: "priced", inputTokens: 1_000_000, outputTokens: 1_000_000, cachedTokens: 0, cacheWriteTokens: 0, totalTokens: 2_000_000, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: "openai", modelId: "gpt-4o" }); + insertTask(db, { id: "unknown", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T10:00:00.000Z", modelProvider: "unknown", modelId: "mystery" }); + + const result = aggregateTokenAnalytics(db, { granularity: "day" }); + + expect(result.series).toHaveLength(1); + expect(result.series?.[0].cost).toEqual({ usd: 12.5, unavailable: true, stale: false }); + }); + + it("prices resolved-model token usage costs from the usage snapshot across analytics surfaces", () => { + const usage = { inputTokens: 1_000_000, outputTokens: 1_000_000, cachedTokens: 0, cacheWriteTokens: 0 }; + const expected = costFor(usage, { provider: "openai", model: "gpt-4o" }); + expect(expected).toEqual({ usd: 12.5, unavailable: false, stale: false }); + + insertTask(db, { + id: "resolved", + ...usage, + totalTokens: 2_000_000, + lastUsedAt: "2026-03-01T00:00:00.000Z", + modelProvider: null, + modelId: null, + tokenUsageModelProvider: "openai", + tokenUsageModelId: "gpt-4o", + nodeId: "node-resolved", + agentId: "agent-resolved", + }); + + const byModel = aggregateTokenAnalytics(db, { groupBy: "model" }); + const modelGroup = byModel.groups.find((group) => group.key === "gpt-4o"); + expect(modelGroup?.cost).toEqual(expected); + expect(modelGroup?.cost.unavailable).toBe(false); + expect(byModel.cost).toEqual(expected); + + const byProvider = aggregateTokenAnalytics(db, { groupBy: "provider" }); + expect(byProvider.groups.find((group) => group.key === "openai")?.cost).toEqual(expected); + + const byNode = aggregateTokenAnalytics(db, { groupBy: "node" }); + expect(byNode.groups.find((group) => group.key === "node-resolved")?.cost).toEqual(expected); + + const byAgent = aggregateTokenAnalytics(db, { groupBy: "agent" }); + expect(byAgent.groups.find((group) => group.key === "agent-resolved")?.cost).toEqual(expected); + + const byDay = aggregateTokenAnalytics(db, { granularity: "day" }); + expect(byDay.series).toHaveLength(1); + expect(byDay.series?.[0].cost).toEqual(expected); + }); + + it("keeps token cost fallback and snapshot precedence guess-free", () => { + const usage = { inputTokens: 1_000_000, outputTokens: 1_000_000, cachedTokens: 0, cacheWriteTokens: 0 }; + const legacyExpected = costFor(usage, { provider: "openai", model: "gpt-4o-mini" }); + const snapshotExpected = costFor(usage, { provider: "openai", model: "gpt-4o" }); + expect(legacyExpected.usd).not.toBe(snapshotExpected.usd); + + insertTask(db, { + id: "legacy-priced", + ...usage, + totalTokens: 2_000_000, + lastUsedAt: "2026-03-01T00:00:00.000Z", + modelProvider: "openai", + modelId: "gpt-4o-mini", + }); + insertTask(db, { + id: "snapshot-wins", + ...usage, + totalTokens: 2_000_000, + lastUsedAt: "2026-03-02T00:00:00.000Z", + modelProvider: "openai", + modelId: "gpt-4o-mini", + tokenUsageModelProvider: "openai", + tokenUsageModelId: "gpt-4o", + }); + insertTask(db, { + id: "unpriced-snapshot", + inputTokens: 100, + totalTokens: 100, + lastUsedAt: "2026-03-03T00:00:00.000Z", + modelProvider: "openai", + modelId: "gpt-4o", + tokenUsageModelProvider: "unknown", + tokenUsageModelId: "mystery-model", + }); + + const result = aggregateTokenAnalytics(db, { groupBy: "model" }); + const groups = new Map(result.groups.map((group) => [group.key, group])); + + expect(groups.get("gpt-4o-mini")?.cost).toEqual(legacyExpected); + expect(groups.get("gpt-4o")?.cost).toEqual(snapshotExpected); + expect(groups.get("mystery-model")?.cost).toEqual({ usd: null, unavailable: true, stale: false }); + }); + + it("returns an empty series for an empty requested range", () => { + insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); + + const result = aggregateTokenAnalytics(db, { + from: "2027-01-01T00:00:00.000Z", + to: "2027-12-31T00:00:00.000Z", + granularity: "day", + }); + + expect(result.series).toEqual([]); + expect(result.totals.totalTokens).toBe(0); + }); +}); diff --git a/packages/core/src/__tests__/tool-analytics.test.ts b/packages/core/src/__tests__/tool-analytics.test.ts new file mode 100644 index 0000000000..ce6fb880a7 --- /dev/null +++ b/packages/core/src/__tests__/tool-analytics.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database } from "../db.js"; +import { emitUsageEvent } from "../usage-events.js"; +import { aggregateToolAnalytics, countInterventions } from "../tool-analytics.js"; +import type { SteeringComment } from "../types.js"; + +function insertTaskWithSteers(db: Database, id: string, steers: SteeringComment[]): void { + db.prepare( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt, steeringComments) + VALUES (?, 'desc', 'todo', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', ?)`, + ).run(id, JSON.stringify(steers)); +} + +function insertApprovalRequest(db: Database, id: string): void { + db.prepare( + `INSERT INTO approval_requests + (id, status, requesterActorId, requesterActorType, requesterActorName, + targetActionCategory, targetActionOperation, targetActionSummary, + targetResourceType, targetResourceId, requestedAt, createdAt, updatedAt) + VALUES (?, 'pending', 'a', 'agent', 'A', 'cat', 'op', 'sum', 'res', 'r1', + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z')`, + ).run(id); +} + +function insertApprovalEvent(db: Database, id: string, requestId: string, eventType: string, createdAt: string): void { + db.prepare( + `INSERT INTO approval_request_audit_events + (id, requestId, eventType, actorId, actorType, actorName, createdAt) + VALUES (?, ?, ?, 'u1', 'user', 'User', ?)`, + ).run(id, requestId, eventType, createdAt); +} + +describe("tool-analytics", () => { + let tmpDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "kb-tool-analytics-")); + db = new Database(join(tmpDir, ".fusion")); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("counts tool calls by category, sorted descending", () => { + emitUsageEvent(db, { kind: "tool_call", category: "read", ts: "2026-03-01T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", category: "read", ts: "2026-03-01T01:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", category: "edit", ts: "2026-03-01T02:00:00.000Z" }); + // a non-tool_call event is not counted + emitUsageEvent(db, { kind: "user_message", ts: "2026-03-01T03:00:00.000Z" }); + + const result = aggregateToolAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.toolCalls).toBe(3); + expect(result.byCategory).toEqual([ + { category: "read", count: 2 }, + { category: "edit", count: 1 }, + ]); + }); + + it("re-buckets historical other tool calls by tool name while preserving explicit categories", () => { + const ts = "2026-03-01T00:00:00.000Z"; + emitUsageEvent(db, { kind: "tool_call", toolName: "fn_task_create", category: "other", ts }); + emitUsageEvent(db, { kind: "tool_call", toolName: "fn_research_run", category: "other", ts }); + emitUsageEvent(db, { kind: "tool_call", toolName: "fn_memory_append", category: null, ts }); + emitUsageEvent(db, { kind: "tool_call", toolName: "fn_mission_show", category: "other", ts }); + emitUsageEvent(db, { kind: "tool_call", toolName: "fn_skills_search", category: "other", ts }); + emitUsageEvent(db, { kind: "tool_call", toolName: "Read", category: "other", ts }); + emitUsageEvent(db, { kind: "tool_call", toolName: "Bash", category: "other", ts }); + emitUsageEvent(db, { kind: "tool_call", toolName: "Unknown", category: "other", ts }); + emitUsageEvent(db, { kind: "tool_call", toolName: null, category: "other", ts }); + emitUsageEvent(db, { kind: "tool_call", toolName: "fn_task_update", category: "custom", ts }); + + const result = aggregateToolAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + const byCategory = new Map(result.byCategory.map((row) => [row.category, row.count])); + + expect(result.toolCalls).toBe(10); + expect(byCategory).toEqual( + new Map([ + ["other", 2], + ["custom", 1], + ["edit", 1], + ["execute", 1], + ["memory", 1], + ["planning", 1], + ["read", 1], + ["research", 1], + ["skills", 1], + ]), + ); + expect(result.byCategory[0]).toEqual({ category: "other", count: 2 }); + }); + + it("autonomy denominator counts a USER steer + an approval but NOT an agent steer", () => { + insertTaskWithSteers(db, "task-1", [ + { id: "s1", text: "do X", createdAt: "2026-03-02T00:00:00.000Z", author: "user" }, + { id: "s2", text: "agent note", createdAt: "2026-03-02T01:00:00.000Z", author: "agent" }, + ]); + insertApprovalRequest(db, "req-1"); + insertApprovalEvent(db, "ev-created", "req-1", "created", "2026-03-02T00:30:00.000Z"); + insertApprovalEvent(db, "ev-approved", "req-1", "approved", "2026-03-02T00:31:00.000Z"); + // a non-human eventType must NOT count + insertApprovalEvent(db, "ev-completed", "req-1", "completed", "2026-03-02T00:32:00.000Z"); + + const breakdown = countInterventions(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(breakdown.userSteers).toBe(1); // agent steer excluded + expect(breakdown.approvals).toBe(2); // created + approved, completed excluded + expect(breakdown.total).toBe(3); + }); + + it("autonomy ratio = toolCalls / interventions for an interactive session", () => { + // 12 tool calls, 3 interventions (1 user steer + 2 approvals) -> ratio 4 + for (let i = 0; i < 12; i++) { + emitUsageEvent(db, { kind: "tool_call", category: "read", ts: `2026-03-02T00:0${i % 6}:0${i % 6}.000Z` }); + } + emitUsageEvent(db, { kind: "session_start", ts: "2026-03-02T00:00:00.000Z" }); + insertTaskWithSteers(db, "task-1", [{ id: "s1", text: "x", createdAt: "2026-03-02T00:10:00.000Z", author: "user" }]); + insertApprovalRequest(db, "req-1"); + insertApprovalEvent(db, "ev-c", "req-1", "created", "2026-03-02T00:11:00.000Z"); + insertApprovalEvent(db, "ev-a", "req-1", "approved", "2026-03-02T00:12:00.000Z"); + + const result = aggregateToolAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.interventions.total).toBe(3); + expect(result.toolCalls).toBe(12); + expect(result.autonomyRatio).toBe(4); + expect(result.fullyAutonomous).toBe(false); + }); + + it("fully-autonomous session (zero interventions) reports tool-calls-per-session, not infinity", () => { + // 10 tool calls across 2 sessions, zero interventions -> 5 per session + for (let i = 0; i < 10; i++) { + emitUsageEvent(db, { kind: "tool_call", category: "execute", ts: "2026-03-02T00:00:00.000Z" }); + } + emitUsageEvent(db, { kind: "session_start", ts: "2026-03-02T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "session_start", ts: "2026-03-02T01:00:00.000Z" }); + + const result = aggregateToolAnalytics(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(result.interventions.total).toBe(0); + expect(result.fullyAutonomous).toBe(true); + expect(result.autonomyRatio).toBe(5); + expect(Number.isFinite(result.autonomyRatio)).toBe(true); + }); + + it("zero interventions and zero sessions does not divide by zero", () => { + for (let i = 0; i < 4; i++) { + emitUsageEvent(db, { kind: "tool_call", category: "read", ts: "2026-03-02T00:00:00.000Z" }); + } + const result = aggregateToolAnalytics(db, {}); + expect(result.sessions).toBe(0); + expect(result.fullyAutonomous).toBe(true); + // toolCalls / max(sessions, 1) = 4 / 1 + expect(result.autonomyRatio).toBe(4); + }); + + it("empty range returns zeroed structures, not nulls", () => { + const result = aggregateToolAnalytics(db, { from: "2027-01-01T00:00:00.000Z", to: "2027-12-31T00:00:00.000Z" }); + expect(result.toolCalls).toBe(0); + expect(result.byCategory).toEqual([]); + expect(result.sessions).toBe(0); + expect(result.interventions).toEqual({ approvals: 0, userSteers: 0, total: 0 }); + expect(result.autonomyRatio).toBe(0); + }); + + it("user steers outside the range are not counted", () => { + insertTaskWithSteers(db, "task-1", [ + { id: "s1", text: "old", createdAt: "2025-01-01T00:00:00.000Z", author: "user" }, + { id: "s2", text: "in range", createdAt: "2026-03-15T00:00:00.000Z", author: "user" }, + ]); + const breakdown = countInterventions(db, { from: "2026-03-01T00:00:00.000Z", to: "2026-03-31T00:00:00.000Z" }); + expect(breakdown.userSteers).toBe(1); + }); +}); diff --git a/packages/core/src/__tests__/usage-events.test.ts b/packages/core/src/__tests__/usage-events.test.ts new file mode 100644 index 0000000000..f17c2aabbe --- /dev/null +++ b/packages/core/src/__tests__/usage-events.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { Database, SCHEMA_VERSION } from "../db.js"; +import { + emitUsageEvent, + queryUsageEvents, + countUsageEventsBy, + categorizeToolName, + USAGE_EVENT_META_MAX_BYTES, +} from "../usage-events.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-usage-events-test-")); +} + +describe("usage_events", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("creates usage_events table with expected columns on fresh init", () => { + const columns = db.prepare("PRAGMA table_info(usage_events)").all() as Array<{ name: string }>; + expect(columns.map((c) => c.name)).toEqual([ + "id", + "ts", + "kind", + "taskId", + "agentId", + "nodeId", + "model", + "provider", + "toolName", + "category", + "meta", + ]); + }); + + it("creates the ts/taskId/agentId indexes on fresh init", () => { + const indexes = ( + db + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='usage_events'") + .all() as Array<{ name: string }> + ).map((r) => r.name); + expect(indexes).toContain("idxUsageEventsTs"); + expect(indexes).toContain("idxUsageEventsTaskId"); + expect(indexes).toContain("idxUsageEventsAgentId"); + }); + + it("inserts one row for a tool_call event with correct category", () => { + const ok = emitUsageEvent(db, { + kind: "tool_call", + taskId: "T-1", + agentId: "A-1", + nodeId: "node-1", + model: "claude-sonnet-4-5", + provider: "anthropic", + toolName: "Read", + }); + expect(ok).toBe(true); + + const rows = queryUsageEvents(db, { taskId: "T-1" }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + kind: "tool_call", + taskId: "T-1", + agentId: "A-1", + nodeId: "node-1", + model: "claude-sonnet-4-5", + provider: "anthropic", + toolName: "Read", + }); + }); + + it("categorizes tool names into coarse buckets", () => { + const cases: Array<[string | null | undefined, string]> = [ + ["Read", "read"], + ["Grep", "read"], + ["Glob", "read"], + ["ls", "read"], + ["semantic_search", "read"], + ["fn_task_list", "read"], + ["fn_task_show", "read"], + ["fn_task_get", "read"], + ["fn_task_search", "read"], + ["fn_list_agents", "read"], + ["fn_agent_org_chart", "read"], + ["fn_task_document_read", "read"], + ["fn_research_list", "research"], + ["Edit", "edit"], + ["Write", "edit"], + ["MultiEdit", "edit"], + ["NotebookEdit", "edit"], + ["fn_task_create", "edit"], + ["fn_task_update", "edit"], + ["fn_task_attach", "edit"], + ["fn_task_archive", "edit"], + ["fn_task_document_write", "edit"], + ["Bash", "execute"], + ["execute_command", "execute"], + ["terminal", "execute"], + ["WebFetch", "network"], + ["fn_web_fetch", "network"], + ["http_request", "network"], + ["fn_mission_show", "planning"], + ["fn_milestone_add", "planning"], + ["fn_slice_activate", "planning"], + ["fn_feature_link_task", "planning"], + ["fn_goal_create", "planning"], + ["fn_task_plan", "planning"], + ["fn_research_run", "research"], + ["fn_insight_show", "research"], + ["fn_experiment_finalize", "research"], + ["fn_memory_append", "memory"], + ["fn_agent_create", "agents"], + ["fn_delegate_task", "agents"], + ["fn_skills_search", "skills"], + ["fn_secret_get", "secrets"], + ["fn_task_import_github", "github"], + ["fn_task_import_github_issue", "github"], + ["fn_task_browse_github_issues", "github"], + ["fn_workflow_create", "workflow"], + ["fn_review_spec", "workflow"], + ["mcp__server__search", "read"], + ["mcp__server__tool", "other"], + ["Unknown", "other"], + ["", "other"], + [" ", "other"], + [undefined, "other"], + [null, "other"], + ]; + + for (const [toolName, expected] of cases) { + expect(categorizeToolName(toolName), String(toolName)).toBe(expected); + } + }); + + it("rejects a meta payload over the byte cap at write (event skipped, nothing inserted)", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const huge = "x".repeat(USAGE_EVENT_META_MAX_BYTES + 100); + const ok = emitUsageEvent(db, { + kind: "tool_error", + taskId: "T-cap", + meta: { blob: huge }, + }); + expect(ok).toBe(false); + expect(queryUsageEvents(db, { taskId: "T-cap" })).toHaveLength(0); + warn.mockRestore(); + }); + + it("never lets tool-argument content land in meta (caller controls meta; arg helpers are not stored)", () => { + // The write helper only persists what the caller puts in `meta`. A caller + // that follows the contract (descriptors only) leaves no tool args behind. + emitUsageEvent(db, { + kind: "tool_call", + taskId: "T-safe", + toolName: "Bash", + category: "execute", + meta: { durationMs: 12 }, + }); + const rows = queryUsageEvents(db, { taskId: "T-safe" }); + expect(rows).toHaveLength(1); + expect(rows[0].meta).toEqual({ durationMs: 12 }); + // No tool-argument/content fields are present. + const metaKeys = Object.keys(rows[0].meta ?? {}); + expect(metaKeys).not.toContain("command"); + expect(metaKeys).not.toContain("args"); + expect(metaKeys).not.toContain("content"); + }); + + it("skips a malformed event (unknown kind) without throwing", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const ok = emitUsageEvent(db, { + // @ts-expect-error intentionally invalid kind + kind: "not_a_real_kind", + taskId: "T-bad", + }); + expect(ok).toBe(false); + expect(queryUsageEvents(db, { taskId: "T-bad" })).toHaveLength(0); + warn.mockRestore(); + }); + + it("range-queries by inclusive ts bounds, ordered ascending", () => { + emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Read", ts: "2026-01-01T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Edit", ts: "2026-01-02T00:00:00.000Z" }); + emitUsageEvent(db, { kind: "tool_call", taskId: "T-r", toolName: "Bash", ts: "2026-01-03T00:00:00.000Z" }); + + const rows = queryUsageEvents(db, { + from: "2026-01-02T00:00:00.000Z", + to: "2026-01-03T00:00:00.000Z", + }); + expect(rows.map((r) => r.toolName)).toEqual(["Edit", "Bash"]); + }); + + it("counts events grouped by a column over a range", () => { + emitUsageEvent(db, { kind: "tool_call", toolName: "Read", category: "read" }); + emitUsageEvent(db, { kind: "tool_call", toolName: "Grep", category: "read" }); + emitUsageEvent(db, { kind: "tool_call", toolName: "Bash", category: "execute" }); + + const byCategory = countUsageEventsBy(db, "category"); + const map = new Map(byCategory.map((r) => [r.key, r.count])); + expect(map.get("read")).toBe(2); + expect(map.get("execute")).toBe(1); + }); + + it("records a chat-style event with null taskId and a set agentId", () => { + emitUsageEvent(db, { kind: "user_message", taskId: null, agentId: "A-chat" }); + const rows = queryUsageEvents(db, { kind: "user_message" }); + expect(rows).toHaveLength(1); + expect(rows[0].taskId).toBeNull(); + expect(rows[0].agentId).toBe("A-chat"); + }); + + // Migration: seed a DB at the version JUST BEFORE usage_events was introduced + // (117 — usage_events is the v118 migration), run migrate, assert the table + // exists and SCHEMA_VERSION reaches the highest migration target. Pinned to + // 117 (not SCHEMA_VERSION-1) so it keeps exercising usage_events' own + // migration as later migrations are added. Fresh-DB tests cannot catch the + // migrate-loop early-return bug this guards. + it("creates usage_events when migrating from the previous schema version", () => { + db.exec("DROP INDEX IF EXISTS idxUsageEventsTs"); + db.exec("DROP INDEX IF EXISTS idxUsageEventsTaskId"); + db.exec("DROP INDEX IF EXISTS idxUsageEventsAgentId"); + db.exec("DROP TABLE IF EXISTS usage_events"); + db.prepare("UPDATE __meta SET value = ? WHERE key = 'schemaVersion'").run("117"); + + (db as unknown as { migrate: () => void }).migrate(); + + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='usage_events'") + .get() as { name: string } | undefined; + expect(table?.name).toBe("usage_events"); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + + // The migrated table is writable and queryable. + emitUsageEvent(db, { kind: "session_start", taskId: "T-mig", agentId: "A-mig" }); + expect(queryUsageEvents(db, { taskId: "T-mig" })).toHaveLength(1); + }); + + it("SCHEMA_VERSION matches the highest applied migration on a fresh DB", () => { + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + }); +}); diff --git a/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts b/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts index 4eaa544fbf..1042892080 100644 --- a/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts +++ b/packages/core/src/__tests__/vitest-setup-tmp-redirect.test.ts @@ -1,9 +1,11 @@ +import { execSync } from "node:child_process"; import { existsSync, mkdtempSync, mkdirSync, rmSync, realpathSync, writeFileSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, sep } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { __fusionTmpdirRedirectTestHooks } from "../__test-utils__/vitest-setup"; +import { DatabaseSync } from "../sqlite-adapter.js"; const createdPaths: string[] = []; @@ -95,6 +97,37 @@ describe("vitest setup tmpdir mkdtemp redirect", () => { expect(existsSync(sink)).toBe(true); }); + it("revalidates cwd, HOME, tmpdir redirect, and SQLite opens after mid-run cleanup", () => { + const originalHome = process.env.HOME; + expect(originalHome).toBeTruthy(); + expect(existsSync(originalHome!)).toBe(true); + + const sink = __fusionTmpdirRedirectTestHooks.sinkForPid(process.pid); + const doomedCwd = remember(mkdtempSync(join(tmpdir(), "fn-redirect-cwd-"))); + process.chdir(doomedCwd); + rmSync(sink, { recursive: true, force: true }); + rmSync(originalHome!, { recursive: true, force: true }); + expect(existsSync(sink)).toBe(false); + expect(existsSync(originalHome!)).toBe(false); + + const sqliteProject = remember(mkdtempSync(join(tmpdir(), "fn-redirect-sqlite-"))); + const fusionDir = join(sqliteProject, ".fusion"); + mkdirSync(fusionDir, { recursive: true }); + const db = new DatabaseSync(join(fusionDir, "fusion.db")); + db.exec("CREATE TABLE smoke (id TEXT PRIMARY KEY)"); + db.prepare("INSERT INTO smoke (id) VALUES (?)").run("ok"); + expect(db.prepare("SELECT id FROM smoke").get()).toEqual({ id: "ok" }); + db.close(); + + const output = execSync("git config --global user.name fusion-test && git config --global --get user.name && pwd", { encoding: "utf8" }); + + expect(output).toContain("fusion-test"); + expect(output).toContain(process.env.FUSION_TEST_WORKER_ROOT!); + expect(process.env.HOME).toBe(originalHome); + expect(existsSync(process.env.HOME!)).toBe(true); + expect(existsSync(sink)).toBe(true); + }); + it("sweeps only dead redirect sinks and preserves current or alive pids", () => { const { registryPath, resetSweepForTest, sinkForPid, sweepDeadTmpdirRedirectSinks } = __fusionTmpdirRedirectTestHooks; const currentSink = rememberDir(sinkForPid(process.pid)); diff --git a/packages/core/src/__tests__/vitest-teardown-worker-root-cleanup.test.ts b/packages/core/src/__tests__/vitest-teardown-worker-root-cleanup.test.ts index d03fd35a12..4ca1ec9323 100644 --- a/packages/core/src/__tests__/vitest-teardown-worker-root-cleanup.test.ts +++ b/packages/core/src/__tests__/vitest-teardown-worker-root-cleanup.test.ts @@ -1,9 +1,12 @@ -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { __fusionWorkerRootCleanupTestHooks } from "../__test-utils__/vitest-setup"; import setup, { __setWorkerRootRmSyncForTests, __setWorkerRootSleepMsSyncForTests, + removeLegacyTopLevelHomeRoots, } from "../__test-utils__/vitest-teardown"; const createdPaths: string[] = []; @@ -75,6 +78,33 @@ describe("vitest global teardown worker-root cleanup", () => { expect(existsSync(workerRoot)).toBe(false); }); + it("retries transient ENOTEMPTY worker-root cleanup until the root can be removed", async () => { + const teardown = setup(); + const workerRoot = remember(process.env.FUSION_TEST_WORKER_ROOT!); + makeWorkerChild(workerRoot, "not-empty"); + let attempts = 0; + const sleeps: number[] = []; + + __setWorkerRootRmSyncForTests((path, options) => { + attempts++; + if (attempts <= 3) { + const error = new Error("directory not empty") as NodeJS.ErrnoException; + error.code = "ENOTEMPTY"; + throw error; + } + rmSync(path, options); + }); + __setWorkerRootSleepMsSyncForTests((ms) => { + sleeps.push(ms); + }); + + await teardown(); + + expect(attempts).toBe(4); + expect(sleeps).toEqual([75, 75, 75]); + expect(existsSync(workerRoot)).toBe(false); + }); + it("tolerates ENOENT when the worker root is already gone", async () => { const teardown = setup(); const workerRoot = remember(process.env.FUSION_TEST_WORKER_ROOT!); @@ -85,4 +115,33 @@ describe("vitest global teardown worker-root cleanup", () => { expect(existsSync(workerRoot)).toBe(false); }); + + it("sweeps legacy top-level temp HOME roots without walking unrelated temp entries", () => { + const tempRoot = remember(mkdtempSync(join(tmpdir(), "fusion-test-home-sweep-root-"))); + const legacyHome = join(tempRoot, "fn-test-home-stale"); + const unrelated = join(tempRoot, "fusion-test-workers-current"); + mkdirSync(legacyHome, { recursive: true }); + mkdirSync(unrelated, { recursive: true }); + writeFileSync(join(legacyHome, "payload.txt"), "legacy home state"); + + removeLegacyTopLevelHomeRoots(tempRoot); + + expect(existsSync(legacyHome)).toBe(false); + expect(existsSync(unrelated)).toBe(true); + }); + + it("removes a self-minted fallback worker root during exit cleanup", () => { + const workerRoot = remember(mkdtempSync(join(tmpdir(), "fusion-test-workers-self-minted-"))); + const workerDir = join(workerRoot, `w-${process.pid}-fallback`); + const redirDir = join(workerRoot, `redir-${process.pid}`); + mkdirSync(workerDir, { recursive: true }); + mkdirSync(redirDir, { recursive: true }); + writeFileSync(join(workerDir, "payload.txt"), "worker temp payload"); + writeFileSync(join(redirDir, "payload.txt"), "redirect temp payload"); + __fusionWorkerRootCleanupTestHooks.writeWorkerRootOwnerMarker(workerRoot); + + __fusionWorkerRootCleanupTestHooks.removeSelfMintedWorkerRootWithRetry(workerRoot, true, 0); + + expect(existsSync(workerRoot)).toBe(false); + }); }); diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts index 81e4c39dba..291e681e31 100644 --- a/packages/core/src/__tests__/workflow-ir.test.ts +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -62,6 +62,86 @@ describe("parseWorkflowIr — v2 columns & placement", () => { expect(() => parseWorkflowIr(ir)).toThrow(/undefined column 'ghost'/); }); + it("rejects a dangling top-level edge with an unknown target node", () => { + const ir = v2( + [{ id: "only", name: "Only", traits: [] }], + [ + { id: "start", kind: "start", column: "only" }, + { id: "a", kind: "prompt", column: "only" }, + { id: "end", kind: "end", column: "only" }, + ], + [ + { from: "start", to: "a" }, + { from: "a", to: "end" }, + { from: "a", to: "ghost" }, + ], + ); + + expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError); + expect(() => parseWorkflowIr(ir)).toThrow( + /Workflow edge 'a' -> 'ghost' references undefined node 'ghost'/, + ); + }); + + it("rejects a dangling top-level edge with an unknown source node", () => { + const ir = v2( + [{ id: "only", name: "Only", traits: [] }], + [ + { id: "start", kind: "start", column: "only" }, + { id: "a", kind: "prompt", column: "only" }, + { id: "end", kind: "end", column: "only" }, + ], + [ + { from: "start", to: "a" }, + { from: "a", to: "end" }, + { from: "ghost", to: "a" }, + ], + ); + + expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError); + expect(() => parseWorkflowIr(ir)).toThrow( + /Workflow edge 'ghost' -> 'a' references undefined node 'ghost'/, + ); + }); + + it("does not false-positive on valid top-level edges or legal rework-region edges", () => { + const validIr = v2( + [{ id: "only", name: "Only", traits: [] }], + [ + { id: "start", kind: "start", column: "only" }, + { id: "a", kind: "prompt", column: "only" }, + { id: "end", kind: "end", column: "only" }, + ], + [ + { from: "start", to: "a" }, + { from: "a", to: "end" }, + ], + ); + const reworkIr = v2( + [{ id: "only", name: "Only", traits: [] }], + [ + { id: "start", kind: "start", column: "only" }, + { + id: "head", + kind: "hold", + column: "only", + config: { release: "external-event", reworkRegion: true, maxReworkCycles: 3 }, + }, + { id: "body", kind: "prompt", column: "only" }, + { id: "end", kind: "end", column: "only" }, + ], + [ + { from: "start", to: "head" }, + { from: "head", to: "body", condition: "outcome:go" }, + { from: "head", to: "end", condition: "outcome:rework-exhausted" }, + { from: "body", to: "head", condition: "outcome:again", kind: "rework" }, + ], + ); + + expect(() => parseWorkflowIr(validIr)).not.toThrow(); + expect(() => parseWorkflowIr(reworkIr)).not.toThrow(); + }); + it("rejects duplicate column ids within a workflow", () => { const ir = v2( [ diff --git a/packages/core/src/__tests__/workflow-restart-durability.test.ts b/packages/core/src/__tests__/workflow-restart-durability.test.ts new file mode 100644 index 0000000000..79c2667acf --- /dev/null +++ b/packages/core/src/__tests__/workflow-restart-durability.test.ts @@ -0,0 +1,288 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import type { TaskStore } from "../store.js"; +import type { WorkflowRunStepInstance } from "../types.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +/* +FNXC:CustomWorkflows 2026-06-17-10:55: +FN-6580 found no restart evidence for explicit custom-workflow selections, interpreter-deferred built-ins, or their graph/foreach run progress. These tests use the disk-backed store reopen seam instead of booting the engine so restart durability stays fast while proving the store cannot silently switch an in-flight task to a different workflow after process restart. +*/ + +function linearIr(): WorkflowIr { + return { + version: "v1", + name: "restart-linear", + nodes: [ + { id: "start", kind: "start" }, + { id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } }, + { id: "spec", kind: "prompt", config: { name: "Spec", prompt: "verify restart" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "lint", condition: "success" }, + { from: "lint", to: "spec", condition: "success" }, + { from: "spec", to: "end", condition: "success" }, + ], + }; +} + +type RestartStore = TaskStore & { + getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined; + selectTaskWorkflow(taskId: string, workflowId: string): Promise; + saveWorkflowRunBranch(state: { + taskId: string; + runId: string; + branchId: string; + currentNodeId: string; + status: string; + }): void; + loadWorkflowRunBranches( + taskId: string, + runId: string, + ): Array<{ taskId: string; runId: string; branchId: string; currentNodeId: string; status: string }>; + getBranchProgressByTask(taskIds: readonly string[]): Map>; + saveWorkflowRunStepInstance(state: WorkflowRunStepInstance): void; + loadWorkflowRunStepInstances(taskId: string, runId: string): WorkflowRunStepInstance[]; +}; + +type PrivateRestartStore = RestartStore & { + db: { prepare: (sql: string) => { run: (...args: unknown[]) => unknown } }; + resolveTaskWorkflowIrSync(taskId: string): WorkflowIr; +}; + +function makeStepInstance(overrides: Partial = {}): WorkflowRunStepInstance { + return { + taskId: "FN-RESTART", + runId: "run-restart", + foreachNodeId: "foreach-steps", + stepIndex: 0, + pinnedStepCount: 2, + currentNodeId: "step-node-a", + status: "in-progress", + baselineSha: "abc123", + checkpointId: "checkpoint-a", + reworkCount: 1, + branchName: "fusion/fn-6585-step-0", + integratedAt: null, + updatedAt: "2026-06-17T10:55:00.000Z", + ...overrides, + }; +} + +describe("workflow restart durability for explicit selections", () => { + const harness = createTaskStoreTestHarness(); + + beforeEach(async () => { + await harness.beforeEach(); + await reopenAsDiskBackedStore(); + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + async function reopenAsDiskBackedStore(): Promise { + harness.store().close(); + await harness.reopenDiskBackedStore(); + } + + function store(): RestartStore { + return harness.store() as RestartStore; + } + + function privateStore(): PrivateRestartStore { + return harness.store() as PrivateRestartStore; + } + + async function taskJsonEnabledWorkflowSteps(taskId: string): Promise { + const raw = await readFile(join(harness.rootDir(), ".fusion", "tasks", taskId, "task.json"), "utf8"); + const parsed = JSON.parse(raw) as { enabledWorkflowSteps?: unknown }; + return Array.isArray(parsed.enabledWorkflowSteps) + ? parsed.enabledWorkflowSteps.filter((stepId): stepId is string => typeof stepId === "string") + : undefined; + } + + it("keeps the empty no-selection state on the default workflow after restart", async () => { + const task = await store().createTask({ description: "no explicit workflow", enabledWorkflowSteps: [] }); + + await reopenAsDiskBackedStore(); + + expect(store().getTaskWorkflowSelection(task.id)).toBeUndefined(); + expect((await store().getTask(task.id)).enabledWorkflowSteps ?? []).toEqual([]); + expect((await taskJsonEnabledWorkflowSteps(task.id)) ?? []).toEqual([]); + }); + + it("persists explicit custom linear selection, compiled steps, and node/step progress across restart", async () => { + const workflow = await store().createWorkflowDefinition({ name: "Restart QA", ir: linearIr() }); + const task = await store().createTask({ description: "custom selection", enabledWorkflowSteps: [] }); + + const selectedStepIds = await store().selectTaskWorkflow(task.id, workflow.id); + expect(selectedStepIds).toHaveLength(2); + store().saveWorkflowRunBranch({ + taskId: task.id, + runId: "run-restart", + branchId: "main", + currentNodeId: "lint", + status: "running", + }); + store().saveWorkflowRunBranch({ + taskId: task.id, + runId: "run-restart", + branchId: "review", + currentNodeId: "spec", + status: "completed", + }); + store().saveWorkflowRunStepInstance(makeStepInstance({ taskId: task.id, stepIndex: 0 })); + store().saveWorkflowRunStepInstance( + makeStepInstance({ + taskId: task.id, + stepIndex: 1, + currentNodeId: "step-node-b", + status: "completed", + reworkCount: 2, + branchName: "fusion/fn-6585-step-1", + integratedAt: "2026-06-17T11:00:00.000Z", + }), + ); + + await reopenAsDiskBackedStore(); + + const selection = store().getTaskWorkflowSelection(task.id); + expect(selection).toEqual({ workflowId: workflow.id, stepIds: selectedStepIds }); + expect((await store().getTask(task.id)).enabledWorkflowSteps).toEqual(selectedStepIds); + expect(await taskJsonEnabledWorkflowSteps(task.id)).toEqual(selectedStepIds); + for (const stepId of selectedStepIds) { + expect(await store().getWorkflowStep(stepId)).toBeDefined(); + } + + expect(store().loadWorkflowRunBranches(task.id, "run-restart")).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + taskId: task.id, + runId: "run-restart", + branchId: "main", + currentNodeId: "lint", + status: "running", + }), + expect.objectContaining({ + taskId: task.id, + runId: "run-restart", + branchId: "review", + currentNodeId: "spec", + status: "completed", + }), + ]), + ); + expect(store().getBranchProgressByTask([task.id]).get(task.id)).toEqual( + expect.arrayContaining([ + { branchId: "main", nodeId: "lint", status: "running" }, + { branchId: "review", nodeId: "spec", status: "completed" }, + ]), + ); + expect(store().loadWorkflowRunStepInstances(task.id, "run-restart")).toEqual([ + expect.objectContaining({ + taskId: task.id, + runId: "run-restart", + foreachNodeId: "foreach-steps", + stepIndex: 0, + pinnedStepCount: 2, + currentNodeId: "step-node-a", + status: "in-progress", + baselineSha: "abc123", + checkpointId: "checkpoint-a", + reworkCount: 1, + branchName: "fusion/fn-6585-step-0", + integratedAt: null, + }), + expect.objectContaining({ + taskId: task.id, + runId: "run-restart", + foreachNodeId: "foreach-steps", + stepIndex: 1, + pinnedStepCount: 2, + currentNodeId: "step-node-b", + status: "completed", + baselineSha: "abc123", + checkpointId: "checkpoint-a", + reworkCount: 2, + branchName: "fusion/fn-6585-step-1", + integratedAt: "2026-06-17T11:00:00.000Z", + }), + ]); + }); + + it("persists interpreter-deferred builtin selection with zero materialized steps across restart", async () => { + const task = await store().createTask({ description: "builtin selection", enabledWorkflowSteps: [] }); + + await expect(store().selectTaskWorkflow(task.id, "builtin:coding")).resolves.toEqual([]); + + await reopenAsDiskBackedStore(); + + expect(store().getTaskWorkflowSelection(task.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); + expect((await store().getTask(task.id)).enabledWorkflowSteps ?? []).toEqual([]); + expect((await taskJsonEnabledWorkflowSteps(task.id)) ?? []).toEqual([]); + expect(privateStore().resolveTaskWorkflowIrSync(task.id)).toEqual(BUILTIN_CODING_WORKFLOW_IR); + }); + + it("persists create-time workflowId selections for custom and builtin workflows across restart", async () => { + const workflow = await store().createWorkflowDefinition({ name: "Create-time QA", ir: linearIr() }); + const customTask = await store().createTask({ description: "custom at create", workflowId: workflow.id }); + const builtinTask = await store().createTask({ description: "builtin at create", workflowId: "builtin:coding" }); + + const customSelectionBefore = store().getTaskWorkflowSelection(customTask.id); + expect(customSelectionBefore?.workflowId).toBe(workflow.id); + expect(customSelectionBefore?.stepIds).toHaveLength(2); + expect(store().getTaskWorkflowSelection(builtinTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); + + await reopenAsDiskBackedStore(); + + const customSelection = store().getTaskWorkflowSelection(customTask.id); + expect(customSelection).toEqual(customSelectionBefore); + expect((await store().getTask(customTask.id)).enabledWorkflowSteps).toEqual(customSelectionBefore?.stepIds); + expect(await taskJsonEnabledWorkflowSteps(customTask.id)).toEqual(customSelectionBefore?.stepIds); + for (const stepId of customSelection?.stepIds ?? []) { + expect(await store().getWorkflowStep(stepId)).toBeDefined(); + } + expect(store().getTaskWorkflowSelection(builtinTask.id)).toEqual({ workflowId: "builtin:coding", stepIds: [] }); + expect((await store().getTask(builtinTask.id)).enabledWorkflowSteps ?? []).toEqual([]); + expect((await taskJsonEnabledWorkflowSteps(builtinTask.id)) ?? []).toEqual([]); + }); + + it("fails closed when a selected custom workflow definition is missing without corrupting the dangling selection", async () => { + const workflow = await store().createWorkflowDefinition({ name: "Dangling QA", ir: linearIr() }); + const selectedTask = await store().createTask({ description: "dangling custom", workflowId: workflow.id }); + const untouchedTask = await store().createTask({ description: "select missing later", enabledWorkflowSteps: [] }); + const selectionBefore = store().getTaskWorkflowSelection(selectedTask.id); + const enabledBefore = await taskJsonEnabledWorkflowSteps(selectedTask.id); + const taskCountBefore = (await store().listTasks({ includeArchived: true })).length; + + privateStore().db.prepare("DELETE FROM workflows WHERE id = ?").run(workflow.id); + + await reopenAsDiskBackedStore(); + + expect(store().getTaskWorkflowSelection(selectedTask.id)).toEqual(selectionBefore); + expect(await taskJsonEnabledWorkflowSteps(selectedTask.id)).toEqual(enabledBefore); + // Current hot-path resolution degrades a dangling custom definition to the built-in IR instead of throwing. + // The explicit materialization APIs below must still fail closed when asked to write that missing id again. + expect(privateStore().resolveTaskWorkflowIrSync(selectedTask.id)).toEqual(BUILTIN_CODING_WORKFLOW_IR); + + await expect(store().selectTaskWorkflow(untouchedTask.id, workflow.id)).rejects.toThrow( + `Workflow '${workflow.id}' not found`, + ); + await expect(store().createTask({ description: "create missing", workflowId: workflow.id })).rejects.toThrow( + `Workflow '${workflow.id}' not found`, + ); + + expect(store().getTaskWorkflowSelection(selectedTask.id)).toEqual(selectionBefore); + expect(await taskJsonEnabledWorkflowSteps(selectedTask.id)).toEqual(enabledBefore); + expect(store().getTaskWorkflowSelection(untouchedTask.id)).toBeUndefined(); + expect((await store().getTask(untouchedTask.id)).enabledWorkflowSteps ?? []).toEqual([]); + expect((await store().listTasks({ includeArchived: true })).length).toBe(taskCountBefore); + }); +}); diff --git a/packages/core/src/__tests__/workflow-selection-store.test.ts b/packages/core/src/__tests__/workflow-selection-store.test.ts index 840715be59..53d19e029f 100644 --- a/packages/core/src/__tests__/workflow-selection-store.test.ts +++ b/packages/core/src/__tests__/workflow-selection-store.test.ts @@ -4,6 +4,11 @@ import { WorkflowCompileError } from "../workflow-compiler.js"; import type { WorkflowIr } from "../workflow-ir-types.js"; import { createTaskStoreTestHarness } from "./store-test-helpers.js"; +/* +FNXC:CustomWorkflows 2026-06-18-12:00: +FN-6643 hardened the create-time workflow selection invariant: every task creation entry point that shares materializeExplicitWorkflowSteps must fail closed for unknown explicit workflow ids before creating a task row or task_workflow_selection state. +*/ + /** Linear workflow with two pre-merge steps. */ function linearIr(): WorkflowIr { return { @@ -245,15 +250,29 @@ describe("TaskStore workflow selection (U3)", () => { expect(after).toBe(before); }); - it("rejects an unknown workflow id before creating the task row", async () => { - const before = (await store.listTasks({ includeArchived: true })).length; + it("rejects an unknown workflow id before creating a task row across create entry points", async () => { + const beforeCreateTask = (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); + const afterCreateTask = (await store.listTasks({ includeArchived: true })).length; + expect(afterCreateTask).toBe(beforeCreateTask); + + const reservedTaskId = "FN-RESERVED-404"; + const beforeReservedCreate = (await store.listTasks({ includeArchived: true })).length; + + await expect( + store.createTaskWithReservedId( + { description: "bad reserved pick", workflowId: "WF-404" }, + { taskId: reservedTaskId, applyDefaultWorkflowSteps: true }, + ), + ).rejects.toThrow(/not found/i); + + const afterReservedCreate = (await store.listTasks({ includeArchived: true })).length; + expect(afterReservedCreate).toBe(beforeReservedCreate); + expect(store.getTaskWorkflowSelection(reservedTaskId)).toBeUndefined(); }); }); }); diff --git a/packages/core/src/__tests__/zai-provider.test.ts b/packages/core/src/__tests__/zai-provider.test.ts new file mode 100644 index 0000000000..a28d908048 --- /dev/null +++ b/packages/core/src/__tests__/zai-provider.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + mergeBuiltInZaiProviderModels, + registerBuiltInZaiProvider, + ZAI_PROVIDER_ID, + ZAI_PROVIDER_REGISTRATION, +} from "../zai-provider.js"; + +const EXISTING_ZAI_MODELS = [ + "glm-4.5-air", + "glm-4.7", + "glm-5-turbo", + "glm-5.1", + "glm-5v-turbo", +]; + +describe("ZAI_PROVIDER_REGISTRATION", () => { + it("uses the existing zai auth surface and API endpoint", () => { + expect(ZAI_PROVIDER_ID).toBe("zai"); + expect(ZAI_PROVIDER_REGISTRATION).toMatchObject({ + name: "ZAI", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + apiKey: "$ZAI_API_KEY", + api: "openai-completions", + }); + }); + + it("preserves existing built-in models and appends glm-5.2", () => { + const modelIds = ZAI_PROVIDER_REGISTRATION.models.map((model) => model.id); + + expect(modelIds).toEqual([...EXISTING_ZAI_MODELS, "glm-5.2"]); + for (const id of EXISTING_ZAI_MODELS) { + expect(modelIds).toContain(id); + } + }); + + it("registers GLM-5.2 with upstream model capabilities", () => { + expect(ZAI_PROVIDER_REGISTRATION.models.find((model) => model.id === "glm-5.2")).toMatchObject({ + id: "glm-5.2", + name: "GLM-5.2", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 131_072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }); + }); + + it("re-merges missing built-in models after a user zai extension replacement", () => { + const extensionModels = ZAI_PROVIDER_REGISTRATION.models + .filter((model) => model.id !== "glm-5.2") + .map((model) => ({ ...model })); + const registeredProviders = new Map>(); + const registry = { + registeredProviders, + registerProvider(providerName: string, config: typeof ZAI_PROVIDER_REGISTRATION) { + registeredProviders.set(providerName, { ...registeredProviders.get(providerName), ...config }); + }, + }; + + registerBuiltInZaiProvider(registry); + registry.registerProvider(ZAI_PROVIDER_ID, { + ...ZAI_PROVIDER_REGISTRATION, + name: "User ZAI extension", + models: extensionModels, + }); + + mergeBuiltInZaiProviderModels(registry); + + const mergedIds = registeredProviders.get(ZAI_PROVIDER_ID)?.models?.map((model) => model.id); + expect(mergedIds).toEqual([...EXISTING_ZAI_MODELS, "glm-5.2"]); + expect(registeredProviders.get(ZAI_PROVIDER_ID)?.name).toBe("User ZAI extension"); + }); +}); diff --git a/packages/core/src/activity-analytics.ts b/packages/core/src/activity-analytics.ts new file mode 100644 index 0000000000..d17b69b1ad --- /dev/null +++ b/packages/core/src/activity-analytics.ts @@ -0,0 +1,703 @@ +import type { Database } from "./db.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; +import type { WorkflowIrColumn } from "./workflow-ir-types.js"; + +/** + * Activity analytics: distinct active nodes/agents per day, sessions, messages, + * and stickiness (DAU/MAU) over an arbitrary date range. + * + * Sessions come from `cli_sessions` (by `createdAt`); messages and node/agent + * activity come from `usage_events`. Inclusivity: `from`/`to` are inclusive, + * matching `usage-events.ts`. + * + * **MTTR (U13).** Mean-time-to-resolve is computed over the `incidents` table + * introduced by U13: MTTR = mean(resolvedAt − openedAt) across incidents whose + * `resolvedAt` falls within the range. Unresolved incidents contribute to + * "open incidents", not to MTTR. Deployment frequency comes from the + * `deployments` table. See {@link MttrSummary} and {@link MonitorMetrics}. + */ + +export interface ActivityAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +/** Distinct active nodes/agents, messages, and agent-run count for a single UTC day. */ +export interface DailyActivity { + /** UTC date, `YYYY-MM-DD`. */ + day: string; + activeNodes: number; + activeAgents: number; + messages: number; + /** Agent heartbeat runs started on this UTC day. */ + agentRuns: number; +} + +/** Agent heartbeat-run counts over an activity range, grouped by canonical status. */ +export interface AgentRunSummary { + total: number; + active: number; + completed: number; + failed: number; +} + +/** + * MTTR summary. `value` is the mean minutes to resolve across incidents whose + * `resolvedAt` falls in the range. When no incident has been resolved in range + * MTTR cannot be computed: `value` is `null` and `unavailable` is `true`, never + * `0`. The `sampleCount` is the number of resolved incidents the mean is over. + */ +export interface MttrSummary { + /** Mean minutes to resolve; null when no resolved incident exists in range. */ + value: number | null; + /** True when MTTR cannot be computed (no resolved incidents in range). */ + unavailable: boolean; + /** Number of resolved incidents the mean is computed over. */ + sampleCount: number; +} + +/** + * Monitor-stage metrics (U13): MTTR plus deployment / incident counts that feed + * the Command Center's External Signals area and the Monitor surface. All counts + * are over the same date range as the parent activity query. + */ +export interface MonitorMetrics { + /** Mean-time-to-resolve over incidents resolved in range. */ + mttr: MttrSummary; + /** Incidents opened (by `openedAt`) within the range. */ + incidentsOpened: number; + /** Incidents resolved (by `resolvedAt`) within the range. */ + incidentsResolved: number; + /** Incidents currently in the `open` state (point-in-time, not range-bound). */ + openIncidents: number; + /** Deployments recorded (by `deployedAt`) within the range — deploy frequency. */ + deployments: number; +} + +export interface ActivityAnalytics { + from: string | null; + to: string | null; + /** Total `session_start` events from `cli_sessions` in range. */ + sessions: number; + /** Total `user_message` events in range. */ + messages: number; + /** Distinct nodes with any usage_event in range. */ + activeNodes: number; + /** Distinct agents with any usage_event in range. */ + activeAgents: number; + /** Agent heartbeat runs started in range, grouped by status. */ + agentRuns: AgentRunSummary; + /** Per-day breakdown, ascending by day. */ + daily: DailyActivity[]; + /** + * Stickiness = DAU/MAU. DAU = mean distinct-active-agents-per-day over the + * range; MAU = distinct active agents over the whole range. 0 when MAU is 0. + */ + stickiness: number; + /** MTTR over incidents resolved in range (U13). */ + mttr: MttrSummary; + /** Full monitor-stage metrics (MTTR + deploy/incident counts) (U13). */ + monitor: MonitorMetrics; + /** SDLC funnel + throughput over the same range (U7). */ + funnel: SdlcFunnel; +} + +interface CountRow { + count: number; +} + +interface DistinctRow { + count: number; +} + +interface DayAggRow { + day: string; + activeNodes: number; + activeAgents: number; + messages: number; +} + +interface AgentRunStatusRow { + status: string; + count: number; +} + +interface AgentRunDayRow { + day: string; + count: number; +} + +function rangeClauses( + column: string, + query: ActivityAnalyticsQuery, +): { where: string; params: string[] } { + const clauses: string[] = []; + const params: string[] = []; + if (query.from !== undefined) { + clauses.push(`${column} >= ?`); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push(`${column} <= ?`); + params.push(query.to); + } + return { + where: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "", + params, + }; +} + +/** + * Aggregate activity (sessions, messages, active nodes/agents, daily breakdown, + * stickiness) over a date range. Empty range yields zeroed structures and an + * empty `daily` array — never nulls. `mttr` is the U13 unavailable seam. + */ +export function aggregateActivityAnalytics( + db: Database, + query: ActivityAnalyticsQuery = {}, +): ActivityAnalytics { + // Sessions from cli_sessions (by createdAt). + const sessionRange = rangeClauses("createdAt", query); + const sessions = ( + db + .prepare(`SELECT COUNT(*) AS count FROM cli_sessions ${sessionRange.where}`) + .get(...sessionRange.params) as CountRow + ).count; + + // Messages from usage_events (kind = user_message). + const eventRange = rangeClauses("ts", query); + const eventWhereWith = (extra: string): string => + eventRange.where + ? `${eventRange.where} AND ${extra}` + : `WHERE ${extra}`; + + const messages = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM usage_events ${eventWhereWith("kind = 'user_message'")}`, + ) + .get(...eventRange.params) as CountRow + ).count; + + // Distinct active nodes/agents over the whole range. + const activeNodes = ( + db + .prepare( + `SELECT COUNT(DISTINCT nodeId) AS count FROM usage_events ${eventWhereWith("nodeId IS NOT NULL")}`, + ) + .get(...eventRange.params) as DistinctRow + ).count; + const activeAgents = ( + db + .prepare( + `SELECT COUNT(DISTINCT agentId) AS count FROM usage_events ${eventWhereWith("agentId IS NOT NULL")}`, + ) + .get(...eventRange.params) as DistinctRow + ).count; + + // Per-day distinct nodes/agents + message count. substr(ts,1,10) is the UTC + // day key (ISO-8601 timestamps). + const dailyRows = db + .prepare( + `SELECT + substr(ts, 1, 10) AS day, + COUNT(DISTINCT nodeId) AS activeNodes, + COUNT(DISTINCT agentId) AS activeAgents, + SUM(CASE WHEN kind = 'user_message' THEN 1 ELSE 0 END) AS messages + FROM usage_events ${eventRange.where} + GROUP BY day + ORDER BY day ASC`, + ) + .all(...eventRange.params) as DayAggRow[]; + /** + * FNXC:CommandCenter 2026-06-18-00:00: + * Command Center activity analytics must surface agent heartbeat-run volume as stat cards by status and as a per-day trend without requiring a schema migration or new endpoint. Count by agentRuns.startedAt in the selected range, and degrade to zeros when older databases do not have the table. + */ + const agentRunMetrics = aggregateAgentRunMetrics(db, query); + const dailyByDay = new Map(); + for (const r of dailyRows) { + dailyByDay.set(r.day, { + day: r.day, + activeNodes: r.activeNodes, + activeAgents: r.activeAgents, + messages: r.messages ?? 0, + agentRuns: 0, + }); + } + for (const r of agentRunMetrics.daily) { + const existing = dailyByDay.get(r.day); + if (existing) { + existing.agentRuns = r.count; + } else { + dailyByDay.set(r.day, { + day: r.day, + activeNodes: 0, + activeAgents: 0, + messages: 0, + agentRuns: r.count, + }); + } + } + const daily: DailyActivity[] = [...dailyByDay.values()].sort((a, b) => a.day.localeCompare(b.day)); + + // Stickiness = DAU/MAU. DAU = mean distinct-active-agents-per-day; MAU = + // distinct active agents over the range. + const dau = + daily.length > 0 + ? daily.reduce((sum, d) => sum + d.activeAgents, 0) / daily.length + : 0; + const mau = activeAgents; + const stickiness = mau > 0 ? dau / mau : 0; + + // U13: real monitor metrics over the incidents/deployments tables. + const monitor = aggregateMonitorMetrics(db, query); + + return { + from: query.from ?? null, + to: query.to ?? null, + sessions, + messages, + activeNodes, + activeAgents, + agentRuns: agentRunMetrics.summary, + daily, + stickiness, + mttr: monitor.mttr, + monitor, + // U7 seam: SDLC funnel/throughput over the same range, mapped by workflow + // trait. Uses the built-in workflow's column→trait mapping by default; + // callers with a custom workflow IR should call aggregateSdlcFunnel directly + // with that workflow's columns so custom column ids map correctly. + funnel: aggregateSdlcFunnel(db, query), + }; +} + +function zeroAgentRunSummary(): AgentRunSummary { + return { total: 0, active: 0, completed: 0, failed: 0 }; +} + +function aggregateAgentRunMetrics( + db: Database, + query: ActivityAnalyticsQuery, +): { summary: AgentRunSummary; daily: AgentRunDayRow[] } { + if (!tableExists(db, "agentRuns")) { + return { summary: zeroAgentRunSummary(), daily: [] }; + } + + const range = rangeClauses("startedAt", query); + const statusRows = db + .prepare( + `SELECT status, COUNT(*) AS count + FROM agentRuns ${range.where} + GROUP BY status`, + ) + .all(...range.params) as AgentRunStatusRow[]; + + const summary = zeroAgentRunSummary(); + for (const row of statusRows) { + summary.total += row.count; + if (row.status === "active") summary.active = row.count; + if (row.status === "completed") summary.completed = row.count; + if (row.status === "failed") summary.failed = row.count; + } + + const daily = db + .prepare( + `SELECT substr(startedAt, 1, 10) AS day, COUNT(*) AS count + FROM agentRuns ${range.where} + GROUP BY day + ORDER BY day ASC`, + ) + .all(...range.params) as AgentRunDayRow[]; + + return { summary, daily }; +} + +/* ------------------------------------------------------------------------- */ +/* U7 — SDLC funnel + throughput */ +/* ------------------------------------------------------------------------- */ + +/** + * The canonical SDLC funnel stages, in flow order. Workflow columns map onto + * these by **trait**, never by column id/name, so custom workflows whose columns + * carry the standard traits are placed correctly; anything unrecognized folds + * into {@link OTHER_STAGE}. + */ +export const SDLC_STAGES = [ + "triage", + "todo", + "in-progress", + "in-review", + "done", +] as const; +export type SdlcStage = (typeof SDLC_STAGES)[number]; + +/** Bucket for columns whose traits don't map to a known SDLC stage. */ +export const OTHER_STAGE = "other" as const; +export type SdlcStageKey = SdlcStage | typeof OTHER_STAGE; + +/** + * Trait → stage mapping. A column is placed at the first stage any of its traits + * matches, scanning in {@link SDLC_STAGES} order so e.g. an `in-review` column + * carrying both `human-review` and `merge` resolves deterministically. Keep this + * additive: new workflow traits that imply a stage are added here, not matched by + * column name. + */ +const TRAIT_TO_STAGE: Record = { + // triage + intake: "triage", + triage: "triage", + // todo + "reset-on-entry": "todo", + // in-progress + wip: "in-progress", + timing: "in-progress", + "abort-on-exit": "in-progress", + // in-review + "human-review": "in-review", + "merge-blocker": "in-review", + merge: "in-review", + "stall-detection": "in-review", + // done + complete: "done", +}; + +/** Resolve a column's traits to an SDLC stage, or OTHER if none map. */ +export function stageForTraits(traits: readonly string[]): SdlcStageKey { + // Prefer the earliest stage in flow order among matching traits so a column is + // anchored to its most representative stage deterministically. + let best: SdlcStage | undefined; + let bestIdx = Number.POSITIVE_INFINITY; + for (const t of traits) { + const stage = TRAIT_TO_STAGE[t]; + if (stage === undefined) continue; + const idx = SDLC_STAGES.indexOf(stage); + if (idx < bestIdx) { + bestIdx = idx; + best = stage; + } + } + return best ?? OTHER_STAGE; +} + +/** Minimal column shape needed to map columns to stages by trait. */ +export interface FunnelColumnTraitSource { + id: string; + traits: { trait: string }[]; +} + +/** + * Build a `columnId → stage` map from a workflow's columns, mapping each column + * by its traits (not its id/name). The `todo` builtin column carries `hold` + * (a generic gate trait shared by other columns) so we special-case the + * presence of `reset-on-entry` for todo above; columns with no recognized trait + * fold to OTHER. + */ +export function buildColumnStageMap( + columns: readonly FunnelColumnTraitSource[], +): Map { + const map = new Map(); + for (const col of columns) { + map.set( + col.id, + stageForTraits(col.traits.map((t) => t.trait)), + ); + } + return map; +} + +export interface SdlcFunnelQuery extends ActivityAnalyticsQuery { + /** + * Workflow columns to map by trait. Defaults to the built-in coding workflow's + * columns. Pass a custom workflow's columns so its column ids resolve; any + * column id seen in the activity log but absent here folds into OTHER. + */ + columns?: readonly FunnelColumnTraitSource[]; +} + +/** Per-stage funnel datum. */ +export interface SdlcFunnelStage { + stage: SdlcStageKey; + /** Distinct tasks that entered this stage within the range. */ + entered: number; + /** + * Conversion from the previous SDLC stage (entered / prevEntered) as a 0..1 + * ratio. `null` for the first stage and when the previous stage had zero + * entrants (no divide-by-zero). `other` is excluded from conversion chaining. + */ + conversionFromPrev: number | null; +} + +export interface SdlcFunnel { + from: string | null; + to: string | null; + stages: SdlcFunnelStage[]; + /** Distinct tasks that entered the first (triage) stage's pipeline in range. */ + enteredInRange: number; + /** Distinct tasks that reached `done` in range. */ + doneInRange: number; + /** + * Cohort completion rate for tasks that entered triage in range: count of + * those entrants that also reached `done`, divided by `enteredInRange`. + * Bounded to the 0..1 conversion ratio by set intersection; `null` when the + * denominator is zero (documented zero-denominator case), never NaN/∞. + */ + completionRate: number | null; + /** Number of whole UTC days in the range (>= 1), used for throughput. */ + rangeDays: number; + /** Tasks reaching `done` per day = doneInRange / rangeDays. */ + throughputPerDay: number; +} + +interface MoveRow { + taskId: string | null; + to: string | null; + ts: string; +} + +function defaultColumns(): FunnelColumnTraitSource[] { + const ir = BUILTIN_CODING_WORKFLOW_IR; + if (ir.version === "v2") { + return (ir.columns as WorkflowIrColumn[]).map((c) => ({ + id: c.id, + traits: c.traits.map((t) => ({ trait: t.trait })), + })); + } + return []; +} + +function countWholeDays(from?: string, to?: string): number { + if (from === undefined || to === undefined) return 1; + const f = Date.parse(from); + const t = Date.parse(to); + if (!Number.isFinite(f) || !Number.isFinite(t) || t < f) return 1; + const ms = t - f; + const days = Math.ceil(ms / 86_400_000); + return Math.max(1, days); +} + +/** + * Aggregate the SDLC funnel over a date range from `activityLog` transitions. + * + * **Entry into a stage** = a `task:moved` whose `metadata.to` column maps to that + * stage, OR a `task:created` whose initial column maps to it. Counts are distinct + * tasks per stage (a task that re-enters a stage is counted once). Columns map to + * stages **by trait** via {@link buildColumnStageMap}; unknown columns fold to + * OTHER. Completion rate divides done-in-range by entered-in-range with the + * zero-denominator case returning `null`. + */ +export function aggregateSdlcFunnel( + db: Database, + query: SdlcFunnelQuery = {}, +): SdlcFunnel { + const columns = query.columns ?? defaultColumns(); + const stageMap = buildColumnStageMap(columns); + const stageOf = (columnId: string | null): SdlcStageKey => { + if (columnId === null) return OTHER_STAGE; + return stageMap.get(columnId) ?? OTHER_STAGE; + }; + + const range = rangeClauses("timestamp", query); + const where = range.where + ? `${range.where} AND type = 'task:moved'` + : `WHERE type = 'task:moved'`; + + // task:moved carries metadata.to (the destination column id). The funnel is + // driven entirely by transitions — a task entering a stage is a move whose + // destination column maps to that stage. (task:created carries no column in + // metadata, so it is intentionally excluded; the first move records entry.) + const rows = db + .prepare( + `SELECT taskId, + json_extract(metadata, '$.to') AS "to", + timestamp AS ts + FROM activityLog ${where}`, + ) + .all(...range.params) as MoveRow[]; + + // Distinct tasks per stage. + const perStage = new Map>(); + const ensure = (s: SdlcStageKey): Set => { + let set = perStage.get(s); + if (!set) { + set = new Set(); + perStage.set(s, set); + } + return set; + }; + + for (const row of rows) { + if (row.taskId === null) continue; + const stage = stageOf(row.to); + ensure(stage).add(row.taskId); + } + + const stages: SdlcFunnelStage[] = []; + let prevEntered: number | null = null; + for (const stage of SDLC_STAGES) { + const entered = perStage.get(stage)?.size ?? 0; + const conversionFromPrev = + prevEntered === null || prevEntered === 0 ? null : entered / prevEntered; + stages.push({ stage, entered, conversionFromPrev }); + prevEntered = entered; + } + // Append OTHER as a trailing, non-chained bucket if anything landed there. + const otherCount = perStage.get(OTHER_STAGE)?.size ?? 0; + if (otherCount > 0) { + stages.push({ stage: OTHER_STAGE, entered: otherCount, conversionFromPrev: null }); + } + + // Entered-in-range = distinct tasks that entered the FIRST funnel stage + // (triage) in range. doneInRange remains every task that reached done in range. + const triageEntrants = perStage.get("triage") ?? new Set(); + const doneEntrants = perStage.get("done") ?? new Set(); + const enteredInRange = triageEntrants.size; + const doneInRange = doneEntrants.size; + /* + FNXC:CommandCenter 2026-06-18-00:00: + Completion rate must be a cohort conversion, not done-in-range divided by triage-in-range. Tasks can finish inside a date range after entering triage before the range (or never entering triage), so intersecting the in-range triage cohort with done tasks keeps the dashboard and OTEL metric trustable at 0..1 or null. + */ + const completedTriageEntrants = Array.from(triageEntrants).filter((taskId) => + doneEntrants.has(taskId), + ).length; + const completionRate = + enteredInRange === 0 ? null : completedTriageEntrants / enteredInRange; + + const rangeDays = countWholeDays(query.from, query.to); + const throughputPerDay = doneInRange / rangeDays; + + return { + from: query.from ?? null, + to: query.to ?? null, + stages, + enteredInRange, + doneInRange, + completionRate, + rangeDays, + throughputPerDay, + }; +} + +/* ------------------------------------------------------------------------- */ +/* U13 — Monitor stage: MTTR + deploy/incident metrics */ +/* ------------------------------------------------------------------------- */ + +interface ResolvedIncidentRow { + openedAt: string; + resolvedAt: string; +} + +/** + * Aggregate monitor-stage metrics over a date range from the `incidents` and + * `deployments` tables (U13). + * + * - **MTTR** = mean(resolvedAt − openedAt), in minutes, over incidents whose + * `resolvedAt` is within `[from, to]`. An incident with no `resolvedAt` + * (still open) is excluded — it contributes to {@link MonitorMetrics.openIncidents}, + * never to MTTR. When no incident is resolved in range, MTTR is the documented + * unavailable sentinel (`value: null`, `unavailable: true`), never `0`. + * - **incidentsOpened** counts incidents by `openedAt` in range. + * - **incidentsResolved** counts incidents by `resolvedAt` in range. + * - **openIncidents** is the current count of `status = 'open'` incidents + * (point-in-time, deliberately not range-bound — "how many are open now"). + * - **deployments** counts deploys by `deployedAt` in range (deploy frequency). + * + * Tables are queried defensively: if `incidents`/`deployments` are absent (a DB + * predating migration 120), every metric degrades to its empty value rather than + * throwing, so the aggregator is safe to call on any schema. + */ +export function aggregateMonitorMetrics( + db: Database, + query: ActivityAnalyticsQuery = {}, +): MonitorMetrics { + if (!tableExists(db, "incidents")) { + return { + mttr: { value: null, unavailable: true, sampleCount: 0 }, + incidentsOpened: 0, + incidentsResolved: 0, + openIncidents: 0, + deployments: tableExists(db, "deployments") + ? countDeployments(db, query) + : 0, + }; + } + + const openedRange = rangeClauses("openedAt", query); + const incidentsOpened = ( + db + .prepare(`SELECT COUNT(*) AS count FROM incidents ${openedRange.where}`) + .get(...openedRange.params) as CountRow + ).count; + + // Resolved-in-range: resolvedAt within [from,to]. Build clauses on resolvedAt + // plus a NOT NULL guard so unresolved incidents are excluded from MTTR. + const resolvedRange = rangeClauses("resolvedAt", query); + const resolvedWhere = resolvedRange.where + ? `${resolvedRange.where} AND resolvedAt IS NOT NULL` + : `WHERE resolvedAt IS NOT NULL`; + + const incidentsResolved = ( + db + .prepare(`SELECT COUNT(*) AS count FROM incidents ${resolvedWhere}`) + .get(...resolvedRange.params) as CountRow + ).count; + + const openIncidents = ( + db + .prepare(`SELECT COUNT(*) AS count FROM incidents WHERE status = 'open'`) + .get() as CountRow + ).count; + + const resolvedRows = db + .prepare( + `SELECT openedAt, resolvedAt FROM incidents ${resolvedWhere}`, + ) + .all(...resolvedRange.params) as ResolvedIncidentRow[]; + + let totalMs = 0; + let sampleCount = 0; + for (const row of resolvedRows) { + const opened = Date.parse(row.openedAt); + const resolved = Date.parse(row.resolvedAt); + if (!Number.isFinite(opened) || !Number.isFinite(resolved)) continue; + const delta = resolved - opened; + if (delta < 0) continue; // guard against clock skew / bad data + totalMs += delta; + sampleCount += 1; + } + + const mttr: MttrSummary = + sampleCount === 0 + ? { value: null, unavailable: true, sampleCount: 0 } + : { value: totalMs / sampleCount / 60_000, unavailable: false, sampleCount }; + + return { + mttr, + incidentsOpened, + incidentsResolved, + openIncidents, + deployments: tableExists(db, "deployments") + ? countDeployments(db, query) + : 0, + }; +} + +function countDeployments(db: Database, query: ActivityAnalyticsQuery): number { + const range = rangeClauses("deployedAt", query); + return ( + db + .prepare(`SELECT COUNT(*) AS count FROM deployments ${range.where}`) + .get(...range.params) as CountRow + ).count; +} + +function tableExists(db: Database, table: string): boolean { + const row = db + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, + ) + .get(table) as { name: string } | undefined; + return row !== undefined; +} diff --git a/packages/core/src/agent-prompts.ts b/packages/core/src/agent-prompts.ts index 65bb97aed4..e8c9b42b24 100644 --- a/packages/core/src/agent-prompts.ts +++ b/packages/core/src/agent-prompts.ts @@ -196,10 +196,10 @@ Lint, tests, and typecheck are also hard quality gates: ## Verification commands — use fn_run_verification For ALL test/lint/build/typecheck verification, use the \`fn_run_verification\` tool, NOT raw bash. -The tool prevents your session from being killed by the inactivity watchdog during long compiles. +The tool prevents your session from being killed by the inactivity watchdog during long compiles, and verification is time-bounded by default (project \`verificationCommandTimeoutMs\` when set, otherwise 300s package / 900s workspace, hard-capped at 1800s). -- Prefer **package-scoped** verification first: e.g. \`pnpm --filter @fusion/ test\` with \`scope: "package"\`. This is faster and isolated. -- For file-specific package tests, use direct Vitest execution with package-relative paths: \`pnpm --filter @fusion/ exec vitest run src/path/to/test.ts --silent=passed-only --reporter=dot\`. Do not use \`pnpm --filter @fusion/ test -- --run \`; package test scripts can expand into broad quality suites before the filter is applied. +- Prefer **targeted package-scoped** verification first: use direct Vitest execution with package-relative paths: \`pnpm --filter @fusion/ exec vitest run src/path/to/test.ts --silent=passed-only --reporter=dot\`. Do not use \`pnpm --filter @fusion/ test -- --run \`; package test scripts can expand into broad quality suites before the filter is applied. +- Marathon verification invocations (root \`pnpm test\`, \`pnpm test:full\`, \`pnpm verify:workspace\`, whole-package tests with no file filter, and repeat loops) are soft-capped by default. Use \`allowFullSuite: true\` only when the task explicitly requires a genuinely full run; the run still respects the hard timeout and emits progress heartbeats. - Run **workspace-scoped** verification (\`pnpm test\`, \`pnpm lint\`, \`pnpm build\` from root) only when it is explicitly required by the task/workflow or after impacted/package-scoped checks pass and you are doing final integration. - If you need to run \`pnpm install\` (e.g. you added a new package), use \`fn_run_verification\` with \`scope: "workspace"\` and \`timeoutSec: 600\`. - If a verification command times out, do NOT blindly retry — investigate. Check for hung subprocesses, infinite test loops, or tests waiting on missing dependencies. Use \`node_modules/.modules.yaml\` presence to confirm bootstrap.`; diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index bcca6fe7e2..e777a6c556 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -165,7 +165,20 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ prompt: "Produce a short implementation plan for this task before any code is written.", }, }, - { id: "execute", kind: "prompt", config: builtinPromptConfig("execute", "Execute") }, + { + id: "execute", + kind: "prompt", + config: { + name: "Execute", + executor: "skill", + skillName: "compound-engineering:ce-work", + // Coding mode so the step has write + spawn tools (readonly is the + // default and would strip them). ce-work does the implementation the + // CE way instead of the generic executor seam. + toolMode: "coding", + prompt: "Execute the plan for this task, following existing patterns and maintaining quality throughout.", + }, + }, { id: "review", kind: "prompt", config: builtinPromptConfig("review", "Review") }, { id: "code-review", @@ -178,6 +191,35 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ prompt: "Run a structured code review of the changes. Block merge on P0/P1 findings.", }, }, + { + id: "commit-pr", + kind: "prompt", + config: { + name: "Commit & open PR", + executor: "skill", + skillName: "compound-engineering:ce-commit-push-pr", + // Coding mode: this step runs git + gh. Per KTD-6 it OWNS commit / + // push / PR creation; it does NOT perform the board-state merge — that + // stays with Fusion's merge seam below (workflow-owned merge), so the + // two never race the same branch state. + toolMode: "coding", + prompt: "Commit the work in logical commits, push the branch, and open a pull request with a value-first description.", + }, + }, + { + id: "resolve-feedback", + kind: "prompt", + config: { + name: "Resolve PR feedback", + executor: "skill", + skillName: "compound-engineering:ce-resolve-pr-feedback", + toolMode: "coding", + // Resolves open PR review threads. On the first autonomous pass there + // may be no feedback yet (review is async); the skill no-ops when there + // are no threads, and a re-run picks up later feedback. + prompt: "Resolve open PR review feedback: evaluate each thread, fix valid issues, and reply.", + }, + }, { id: "merge", kind: "prompt", config: builtinPromptConfig("merge", "Merge boundary") }, { id: "document", diff --git a/packages/core/src/command-center-live.ts b/packages/core/src/command-center-live.ts new file mode 100644 index 0000000000..8aa0f71b5d --- /dev/null +++ b/packages/core/src/command-center-live.ts @@ -0,0 +1,185 @@ +import type { Database } from "./db.js"; + +/** + * Live Mission-Control snapshot composer (U6a). + * + * Builds an instantaneous, point-in-time view of orchestration activity from the + * existing tables — `agentRuns` / `agentHeartbeats` (active heartbeat runs), + * `cli_sessions` (live CLI/chat sessions), and `tasks` (current per-column + * counts). It is a **pure read** over a {@link Database} handle: no clock, no + * network, no engine dependency, so the engine, CLI, and the dashboard route + * (U9) can all reuse it. The dashboard's `/api/command-center/live` endpoint is a + * thin adapter over this function (KTD2). + * + * "Live" here means *current state*, not a date range: it counts what is active + * right now (active runs, live sessions) and the present board distribution. The + * snapshot carries a `capturedAt` ISO timestamp so callers can label staleness. + * + * Active definitions: + * - **Active session** — a `cli_sessions` row whose `agentState` is not a + * terminal state (`done`/`dead`) and whose `terminationReason` is still null. + * - **Active run** — an `agentRuns` row with `status = 'active'` (matching the + * {@link import("./types.js").AgentHeartbeatRun} status union). + * - **Active node** — a distinct, non-null node id observed across active + * sessions (no `nodeId` column exists on `agentRuns`, so nodes are sourced + * from `cli_sessions`). + */ + +/** A single active CLI/chat session in the live snapshot. */ +export interface LiveSession { + id: string; + /** Bound task id, or null for an unbound (e.g. chat) session. */ + taskId: string | null; + purpose: string; + adapterId: string; + agentState: string; + /** Worktree/node path the session runs in, or null. */ + worktreePath: string | null; + updatedAt: string; +} + +/** A single active heartbeat run in the live snapshot. */ +export interface LiveRun { + id: string; + agentId: string; + taskId: string | null; + startedAt: string; +} + +/** Current task count for one board column. */ +export interface ColumnCount { + column: string; + count: number; +} + +/** The composed live Mission-Control snapshot. */ +export interface LiveSnapshot { + /** ISO-8601 timestamp this snapshot was composed. */ + capturedAt: string; + /** Number of active (non-terminal, non-terminated) CLI/chat sessions. */ + activeSessions: number; + /** Number of active heartbeat runs (`agentRuns.status = 'active'`). */ + activeRuns: number; + /** Distinct non-null nodes with at least one active session. */ + activeNodes: number; + /** The active sessions, most-recently-updated first. */ + sessions: LiveSession[]; + /** The active heartbeat runs, most-recently-started first. */ + runs: LiveRun[]; + /** Current per-column task counts (the SDLC funnel's live snapshot). */ + columns: ColumnCount[]; +} + +/** Terminal CLI agent states — a session in one of these is not "active". */ +const TERMINAL_SESSION_STATES = ["done", "dead"] as const; + +interface SessionRow { + id: string; + taskId: string | null; + purpose: string; + adapterId: string; + agentState: string; + worktreePath: string | null; + updatedAt: string; +} + +interface ColumnRow { + column: string; + count: number; +} + +interface CountRow { + count: number; +} + +/** + * Compose a live Mission-Control snapshot from the current database state. + * + * Pure and synchronous: takes a {@link Database} handle and returns plain data. + * `capturedAt` defaults to `new Date().toISOString()`; pass `now` (epoch ms) to + * make the timestamp deterministic in tests — no other value reads the clock. + */ +export function composeLiveSnapshot(db: Database, now?: number): LiveSnapshot { + const capturedAt = new Date(now ?? Date.now()).toISOString(); + + const terminalPlaceholders = TERMINAL_SESSION_STATES.map(() => "?").join(", "); + + // Active sessions: not in a terminal state and not terminated. + const sessionRows = db + .prepare( + `SELECT id, taskId, purpose, adapterId, agentState, worktreePath, updatedAt + FROM cli_sessions + WHERE agentState NOT IN (${terminalPlaceholders}) + AND terminationReason IS NULL + ORDER BY updatedAt DESC`, + ) + .all(...TERMINAL_SESSION_STATES) as SessionRow[]; + const sessions: LiveSession[] = sessionRows.map((r) => ({ + id: r.id, + taskId: r.taskId ?? null, + purpose: r.purpose, + adapterId: r.adapterId, + agentState: r.agentState, + worktreePath: r.worktreePath ?? null, + updatedAt: r.updatedAt, + })); + + // Active nodes: distinct non-null worktree paths across active sessions. + // (cli_sessions has no nodeId column; worktreePath is the per-node locator.) + const activeNodes = new Set( + sessions + .map((s) => s.worktreePath) + .filter((p): p is string => typeof p === "string" && p.length > 0), + ).size; + + // Active heartbeat runs. + const runRows = db + .prepare( + `SELECT id, agentId, startedAt, data + FROM agentRuns + WHERE status = 'active' + ORDER BY startedAt DESC`, + ) + .all() as Array<{ id: string; agentId: string; startedAt: string; data: string }>; + const runs: LiveRun[] = runRows.map((r) => { + let taskId: string | null = null; + try { + const data = JSON.parse(r.data) as { taskId?: string }; + if (typeof data.taskId === "string") taskId = data.taskId; + } catch { + // Malformed run data → leave taskId null rather than throw. + } + return { id: r.id, agentId: r.agentId, taskId, startedAt: r.startedAt }; + }); + + const activeRuns = ( + db + .prepare(`SELECT COUNT(*) AS count FROM agentRuns WHERE status = 'active'`) + .get() as CountRow + ).count; + + // Current per-column task counts. `column` is a reserved word in the schema, + // so it is quoted. + const columnRows = db + .prepare( + `SELECT "column" AS column, COUNT(*) AS count + FROM tasks + GROUP BY "column" + ORDER BY count DESC`, + ) + .all() as ColumnRow[]; + const columns: ColumnCount[] = columnRows.map((r) => ({ + column: r.column, + count: r.count, + })); + + return { + capturedAt, + activeSessions: sessions.length, + activeRuns, + activeNodes, + sessions, + runs, + columns, + }; +} diff --git a/packages/core/src/db-migrate.ts b/packages/core/src/db-migrate.ts index 21ab682d3a..30d10cab8e 100644 --- a/packages/core/src/db-migrate.ts +++ b/packages/core/src/db-migrate.ts @@ -225,11 +225,11 @@ async function migrateTasks(fusionDir: string, db: Database): Promise { error, summary, thinkingLevel, createdAt, updatedAt, columnMovedAt, dependencies, steps, log, attachments, steeringComments, comments, workflowStepResults, prInfo, issueInfo, - sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, + sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt, mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, sliceId ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `); @@ -293,6 +293,7 @@ async function migrateTasks(fusionDir: string, db: Database): Promise { task.sourceIssue?.externalIssueId ?? null, task.sourceIssue?.issueNumber ?? null, task.sourceIssue?.url ?? null, + task.sourceIssue?.closedAt ?? null, toJsonNullable(task.mergeDetails), task.breakIntoSubtasks ? 1 : 0, task.noCommitsExpected ? 1 : 0, diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index ee18e21fbc..442c669363 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 118; +const SCHEMA_VERSION = 124; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -285,6 +285,8 @@ CREATE TABLE IF NOT EXISTS tasks ( tokenUsageTotalTokens INTEGER, tokenUsageFirstUsedAt TEXT, tokenUsageLastUsedAt TEXT, + tokenUsageModelProvider TEXT, + tokenUsageModelId TEXT, tokenBudgetSoftAlertedAt TEXT, tokenBudgetHardAlertedAt TEXT, tokenBudgetOverride TEXT, @@ -314,6 +316,7 @@ CREATE TABLE IF NOT EXISTS tasks ( sourceIssueExternalIssueId TEXT, sourceIssueNumber INTEGER, sourceIssueUrl TEXT, + sourceIssueClosedAt TEXT, mergeDetails TEXT, breakIntoSubtasks INTEGER DEFAULT 0, noCommitsExpected INTEGER DEFAULT 0, @@ -473,6 +476,8 @@ CREATE TABLE IF NOT EXISTS task_commit_associations ( matchedBy TEXT NOT NULL CHECK (matchedBy IN ('canonical-lineage-trailer', 'legacy-task-id-trailer', 'legacy-subject', 'manual-reconciliation')), confidence TEXT NOT NULL CHECK (confidence IN ('canonical', 'legacy', 'ambiguous')), note TEXT, + additions INTEGER, + deletions INTEGER, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, UNIQUE(taskLineageId, commitSha, matchedBy) @@ -1207,6 +1212,98 @@ CREATE TABLE IF NOT EXISTS todo_items ( CREATE INDEX IF NOT EXISTS idxTodoListsProjectId ON todo_lists(projectId); CREATE INDEX IF NOT EXISTS idxTodoItemsListId ON todo_items(listId); CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder); + +-- Normalized, queryable telemetry of agent activity (tool calls, messages, +-- session lifecycle). Fed by emitUsageEvent from the executor/session layer so +-- analytics never has to parse per-task JSONL agent logs at query time. +-- The meta column carries only non-sensitive descriptors (error code, +-- category, duration) -- never tool arguments/content/credentials -- and is +-- capped at write (see usage-events.ts). +CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + kind TEXT NOT NULL, + taskId TEXT, + agentId TEXT, + nodeId TEXT, + model TEXT, + provider TEXT, + toolName TEXT, + category TEXT, + meta TEXT +); +CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts); +CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId); +CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId); +-- FNXC:Database 2026-06-16-14:30: +-- Command Center tool analytics (aggregateToolAnalytics in tool-analytics.ts) filters usage_events by 'kind' (e.g. 'tool_call', 'session_start') with optional 'ts' bounds on every tool/session count. The (kind, ts) composite index keeps that path from scanning unrelated event kinds as telemetry grows. Added in the same unreleased PR (#1683) that introduces usage_events, so it ships inside migration 118 rather than a new version bump; mirrored there so fresh-init and migrated DBs converge. +CREATE INDEX IF NOT EXISTS idxUsageEventsKindTs ON usage_events(kind, ts); + +-- Persistent, incrementally-refreshed knowledge index (U14). One row per +-- knowledge page (currently one page per completed task; PR-history pages +-- share the same shape). Downstream agents query it through the dashboard's +-- scoped knowledge-index endpoint. searchText is a denormalized lowercased +-- concatenation of the page's title/summary/content + tags used for keyword +-- LIKE matching, so the index works without requiring SQLite FTS5 (which is +-- not available on every build -- see probeFts5 above). Refresh is per-source +-- (upsert by sourceKey), never a full re-index, so unaffected pages keep their +-- timestamps. +CREATE TABLE IF NOT EXISTS knowledge_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sourceKind TEXT NOT NULL, + sourceId TEXT NOT NULL, + sourceKey TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + summary TEXT, + content TEXT NOT NULL, + tags TEXT, + searchText TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idxKnowledgePagesSourceKind ON knowledge_pages(sourceKind); +CREATE INDEX IF NOT EXISTS idxKnowledgePagesUpdatedAt ON knowledge_pages(updatedAt); + +-- Monitor stage: deployments + incidents (U13). Deployments are recorded from +-- CI/Ship events; incidents are opened from U11 signals and resolved when the +-- underlying signal clears. MTTR = mean(resolvedAt - openedAt) over resolved +-- incidents in range (aggregated in activity-analytics.ts). Both ingest through +-- the authenticated monitor-routes endpoint and feed the Command Center. +CREATE TABLE IF NOT EXISTS deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deploymentId TEXT NOT NULL UNIQUE, + service TEXT, + environment TEXT, + version TEXT, + status TEXT, + deployedAt TEXT NOT NULL, + link TEXT, + meta TEXT, + createdAt TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idxDeploymentsDeployedAt ON deployments(deployedAt); +CREATE INDEX IF NOT EXISTS idxDeploymentsService ON deployments(service); + +CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incidentId TEXT NOT NULL UNIQUE, + groupingKey TEXT NOT NULL, + title TEXT NOT NULL, + severity TEXT, + status TEXT NOT NULL, + source TEXT, + fixTaskId TEXT, + openedAt TEXT NOT NULL, + resolvedAt TEXT, + link TEXT, + meta TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idxIncidentsGroupingKey ON incidents(groupingKey); +CREATE INDEX IF NOT EXISTS idxIncidentsStatus ON incidents(status); +CREATE INDEX IF NOT EXISTS idxIncidentsOpenedAt ON incidents(openedAt); +CREATE INDEX IF NOT EXISTS idxIncidentsResolvedAt ON incidents(resolvedAt); `; const TABLE_LEVEL_CONSTRAINT_PREFIXES = new Set([ @@ -1988,8 +2085,8 @@ export class Database { return { status: "failed", errors: check.errors }; } + const corruptBackupPath = `${dbPath}.corrupt-${ts}`; try { - const corruptBackupPath = `${dbPath}.corrupt-${ts}`; renameSync(dbPath, corruptBackupPath); // Stale WAL/SHM belong to the corrupt file; SQLite must not replay them // onto the rebuilt database. @@ -1999,7 +2096,20 @@ export class Database { return { status: "recovered", corruptBackupPath, errors: check.errors }; } catch (error) { const message = error instanceof Error ? error.message : String(error); - return { status: "failed", errors: [...(check.errors ?? []), message] }; + const restoreErrors: string[] = []; + /* + FNXC:DatabaseRecovery 2026-06-13-17:43: + A failed startup recovery must preserve the original corrupt database at fusion.db, even when the swap fails after the corrupt file was renamed to a backup path. Restore the backup before returning "failed" so manual repair still sees the documented database location. + */ + if (!existsSync(dbPath) && existsSync(corruptBackupPath)) { + try { + renameSync(corruptBackupPath, dbPath); + } catch (restoreError) { + restoreErrors.push(restoreError instanceof Error ? restoreError.message : String(restoreError)); + } + } + try { rmSync(recoveredPath, { force: true }); } catch { /* ignore */ } + return { status: "failed", errors: [...(check.errors ?? []), message, ...restoreErrors] }; } } @@ -4706,12 +4816,174 @@ export class Database { }); } + // Migration 118: Queryable usage_events telemetry table (tool calls, + // messages, session lifecycle). Mirrors the SCHEMA_SQL definition above so + // a fresh-from-SCHEMA_SQL DB and a migrated DB converge on the same table. + // FNXC:Database 2026-06-16-14:30: + // The (kind, ts) composite index (idxUsageEventsKindTs) backs the Command + // Center analytics path: aggregateToolAnalytics filters usage_events by kind + // with optional ts bounds for every tool/session count, and would otherwise + // scan unrelated event kinds as telemetry grows. Folded into this migration + // (rather than a new SCHEMA_VERSION bump) because usage_events itself is + // unreleased — every DB that runs migration 118 runs it from this PR's code, + // so no migrated DB can be stuck at v118+ without the index. The IF NOT + // EXISTS body stays re-runnable. if (version < 118) { this.applyMigration(118, () => { - // FN: behavioral verification — classify contract assertions so the - // validator can scope the default-to-fail / verification posture to - // behavioral/bug assertions. Existing rows default to 'static' to - // preserve legacy read-only judging (no sudden mass-fail). + this.db.exec(` + CREATE TABLE IF NOT EXISTS usage_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + kind TEXT NOT NULL, + taskId TEXT, + agentId TEXT, + nodeId TEXT, + model TEXT, + provider TEXT, + toolName TEXT, + category TEXT, + meta TEXT + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsTs ON usage_events(ts) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsTaskId ON usage_events(taskId) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsAgentId ON usage_events(agentId) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxUsageEventsKindTs ON usage_events(kind, ts) + `); + }); + } + + // Migration 119: Persistent knowledge index (U14). One queryable page per + // completed task / PR-history entry, refreshed incrementally (upsert by + // sourceKey) on task completion. Mirrors the SCHEMA_SQL definition above so + // a fresh-from-SCHEMA_SQL DB and a migrated DB converge on the same table. + if (version < 119) { + this.applyMigration(119, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS knowledge_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sourceKind TEXT NOT NULL, + sourceId TEXT NOT NULL, + sourceKey TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + summary TEXT, + content TEXT NOT NULL, + tags TEXT, + searchText TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxKnowledgePagesSourceKind ON knowledge_pages(sourceKind) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxKnowledgePagesUpdatedAt ON knowledge_pages(updatedAt) + `); + }); + } + + // Migration 120: Monitor stage — deployments + incidents tables (U13). + // Deployments are recorded from CI/Ship events; incidents are opened from + // U11 signals and resolved when the signal clears. MTTR is computed over + // resolved incidents in activity-analytics.ts. Mirrors the SCHEMA_SQL + // definition above so a fresh-from-SCHEMA_SQL DB and a migrated DB converge + // on the same tables. + if (version < 120) { + this.applyMigration(120, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deploymentId TEXT NOT NULL UNIQUE, + service TEXT, + environment TEXT, + version TEXT, + status TEXT, + deployedAt TEXT NOT NULL, + link TEXT, + meta TEXT, + createdAt TEXT NOT NULL + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxDeploymentsDeployedAt ON deployments(deployedAt) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxDeploymentsService ON deployments(service) + `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + incidentId TEXT NOT NULL UNIQUE, + groupingKey TEXT NOT NULL, + title TEXT NOT NULL, + severity TEXT, + status TEXT NOT NULL, + source TEXT, + fixTaskId TEXT, + openedAt TEXT NOT NULL, + resolvedAt TEXT, + link TEXT, + meta TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxIncidentsGroupingKey ON incidents(groupingKey) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxIncidentsStatus ON incidents(status) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxIncidentsOpenedAt ON incidents(openedAt) + `); + this.db.exec(` + CREATE INDEX IF NOT EXISTS idxIncidentsResolvedAt ON incidents(resolvedAt) + `); + }); + } + + // Migration 121: Token-usage model snapshot for Command Center analytics. + if (version < 121) { + this.applyMigration(121, () => { + this.addColumnIfMissing("tasks", "tokenUsageModelProvider", "TEXT"); + this.addColumnIfMissing("tasks", "tokenUsageModelId", "TEXT"); + }); + } + + // Migration 122: source-issue closure timestamp for exact Fixed by Fusion analytics. + // Additive and nullable with no historical backfill; legacy rows deserialize with + // TaskSourceIssue.closedAt undefined until the GitHub reconciler observes a real close time. + if (version < 122) { + this.applyMigration(122, () => { + this.addColumnIfMissing("tasks", "sourceIssueClosedAt", "TEXT"); + }); + } + + // Migration 123: nullable merge-time diff stats for Command Center LOC analytics. + // FNXC:CommandCenterProductivity 2026-06-19-00:00: + // Productivity LOC must distinguish unknown historical commit stats from real zero-line commits. Store merge-time additions/deletions as nullable columns with no default; null means stats were unavailable, not zero. + if (version < 123) { + this.applyMigration(123, () => { + this.addColumnIfMissing("task_commit_associations", "additions", "INTEGER"); + this.addColumnIfMissing("task_commit_associations", "deletions", "INTEGER"); + }); + } + + // Migration 124: behavioral verification — classify contract assertions so the + // validator can scope the default-to-fail / verification posture to + // behavioral/bug assertions. Existing rows default to 'static' to + // preserve legacy read-only judging (no sudden mass-fail). + if (version < 124) { + this.applyMigration(124, () => { if (this.hasTable("mission_contract_assertions")) { this.addColumnIfMissing("mission_contract_assertions", "type", "TEXT NOT NULL DEFAULT 'static'"); } diff --git a/packages/core/src/github-issue-analytics.ts b/packages/core/src/github-issue-analytics.ts new file mode 100644 index 0000000000..f5bc55796f --- /dev/null +++ b/packages/core/src/github-issue-analytics.ts @@ -0,0 +1,188 @@ +import type { Database } from "./db.js"; + +/** + * FNXC:CommandCenterGithub 2026-06-18-00:00: + * Command Center GitHub issue analytics must derive filed/fixed counts only from the project-scoped local task store. "Filed" means a task has `githubTracking.issue`; "fixed" means an imported GitHub source issue task is currently in the `done` column. Fixed trends use the exact persisted `sourceIssueClosedAt` when available, fall back to the `updatedAt` completion approximation only when it is absent, and never fabricate a close date. + */ + +export interface GithubIssueAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +export interface GithubIssueDailyPoint { + /** UTC date, `YYYY-MM-DD`. */ + date: string; + /** Fusion-created GitHub issues filed on this date. */ + filed: number; + /** Imported GitHub issue tasks completed on this date. */ + fixed: number; +} + +export interface GithubIssueRepoBreakdown { + /** Repository key, usually `owner/repo`; `(unknown)` when historical data lacks it. */ + repo: string; + filed: number; + fixed: number; +} + +export interface GithubIssueAnalytics { + from: string | null; + to: string | null; + /** Fusion-created GitHub issues in range. Undated tracked issues are included because no date can be honestly inferred. */ + filed: number; + /** Imported GitHub issue tasks currently in `done`, filtered by exact `sourceIssueClosedAt` when present with `updatedAt` fallback. */ + fixed: number; + /** Filed minus fixed. */ + net: number; + /** Filed/fixed counts grouped by UTC day, ascending. */ + daily: GithubIssueDailyPoint[]; + /** Filed/fixed counts grouped by repository, descending by total activity. */ + byRepo: GithubIssueRepoBreakdown[]; +} + +interface GithubTrackingRow { + githubTracking: string | null; +} + +interface FixedIssueRow { + sourceIssueRepository: string | null; + sourceIssueClosedAt: string | null; + updatedAt: string | null; +} + +interface TrackedIssueLike { + number?: unknown; + owner?: unknown; + repo?: unknown; + createdAt?: unknown; +} + +interface GithubTrackingLike { + issue?: TrackedIssueLike; +} + +function isInRange(iso: string, query: GithubIssueAnalyticsQuery): boolean { + const t = Date.parse(iso); + if (!Number.isFinite(t)) return false; + if (query.from !== undefined && t < Date.parse(query.from)) return false; + if (query.to !== undefined && t > Date.parse(query.to)) return false; + return true; +} + +function dayKey(iso: string): string | null { + const t = Date.parse(iso); + if (!Number.isFinite(t)) return null; + return new Date(t).toISOString().slice(0, 10); +} + +function repoFromIssue(issue: TrackedIssueLike): string { + const owner = typeof issue.owner === "string" ? issue.owner.trim() : ""; + const repo = typeof issue.repo === "string" ? issue.repo.trim() : ""; + if (owner && repo) return `${owner}/${repo}`; + if (repo) return repo; + return "(unknown)"; +} + +function addDaily( + daily: Map, + date: string, + kind: "filed" | "fixed", +): void { + const current = daily.get(date) ?? { filed: 0, fixed: 0 }; + current[kind] += 1; + daily.set(date, current); +} + +function addRepo( + byRepo: Map, + repo: string, + kind: "filed" | "fixed", +): void { + const current = byRepo.get(repo) ?? { filed: 0, fixed: 0 }; + current[kind] += 1; + byRepo.set(repo, current); +} + +/** + * Aggregate locally persisted GitHub issue analytics for the Command Center. + * Empty ranges return zeroed structures, never null collections. Bounds are + * inclusive. Malformed historical `githubTracking` JSON is ignored rather than + * failing the entire analytics request. + */ +export function aggregateGithubIssueAnalytics( + db: Database, + query: GithubIssueAnalyticsQuery = {}, +): GithubIssueAnalytics { + const daily = new Map(); + const byRepo = new Map(); + + const filedRows = db + .prepare( + "SELECT githubTracking FROM tasks WHERE githubTracking IS NOT NULL AND githubTracking NOT IN ('', '{}')", + ) + .all() as GithubTrackingRow[]; + + let filed = 0; + for (const row of filedRows) { + if (!row.githubTracking) continue; + let parsed: unknown; + try { + parsed = JSON.parse(row.githubTracking); + } catch { + continue; + } + const tracking = parsed as GithubTrackingLike; + const issue = tracking.issue; + if (!issue || typeof issue.number !== "number" || !Number.isFinite(issue.number)) continue; + + const createdAt = typeof issue.createdAt === "string" ? issue.createdAt : undefined; + const hasUsableDate = createdAt !== undefined && dayKey(createdAt) !== null; + if (hasUsableDate && !isInRange(createdAt, query)) continue; + + filed += 1; + const repo = repoFromIssue(issue); + addRepo(byRepo, repo, "filed"); + if (hasUsableDate && createdAt !== undefined) { + const day = dayKey(createdAt); + if (day !== null) addDaily(daily, day, "filed"); + } + } + + const fixedRows = db + .prepare( + `SELECT sourceIssueRepository, sourceIssueClosedAt, updatedAt FROM tasks WHERE sourceIssueProvider = 'github' AND "column" = 'done'`, + ) + .all() as FixedIssueRow[]; + + let fixed = 0; + for (const row of fixedRows) { + const fixedDate = row.sourceIssueClosedAt ?? row.updatedAt; + if (fixedDate === null || !isInRange(fixedDate, query)) continue; + + fixed += 1; + const repo = row.sourceIssueRepository?.trim() || "(unknown)"; + addRepo(byRepo, repo, "fixed"); + const day = dayKey(fixedDate); + if (day !== null) addDaily(daily, day, "fixed"); + } + + return { + from: query.from ?? null, + to: query.to ?? null, + filed, + fixed, + net: filed - fixed, + daily: [...daily.entries()] + .map(([date, counts]) => ({ date, filed: counts.filed, fixed: counts.fixed })) + .sort((a, b) => a.date.localeCompare(b.date)), + byRepo: [...byRepo.entries()] + .map(([repo, counts]) => ({ repo, filed: counts.filed, fixed: counts.fixed })) + .sort((a, b) => { + const total = b.filed + b.fixed - (a.filed + a.fixed); + return total !== 0 ? total : a.repo.localeCompare(b.repo); + }), + }; +} diff --git a/packages/core/src/in-review-stall.ts b/packages/core/src/in-review-stall.ts index 23bfe0300c..a4512f16d7 100644 --- a/packages/core/src/in-review-stall.ts +++ b/packages/core/src/in-review-stall.ts @@ -39,8 +39,20 @@ export interface InReviewStallContext { /** Keep aligned with engine DEFAULT_STALE_MERGING_STATUS_MIN_AGE_MS. */ export const DEFAULT_STALE_MERGING_MIN_AGE_MS = 5 * 60_000; -/** Keep aligned with engine MAX_AUTO_MERGE_RETRIES (core must not import engine). */ +/** Historical default for the configurable auto-merge conflict retry cap. */ export const DEFAULT_MAX_AUTO_MERGE_RETRIES = 3; + +/** + * FNXC:AutoMergeRetries 2026-06-17-04:20: + * Every engine, self-healing, dashboard, and core display surface must resolve the same project setting with defensive fallback semantics. Invalid persisted values intentionally fall back to 3 so old configs and hand-edits preserve the prior hardcoded behavior. + */ +export function resolveMaxAutoMergeRetries(settings?: { maxAutoMergeRetries?: unknown } | null): number { + const configured = Number(settings?.maxAutoMergeRetries); + if (Number.isFinite(configured) && configured > 0) { + return Math.floor(configured); + } + return DEFAULT_MAX_AUTO_MERGE_RETRIES; +} export const IN_REVIEW_STALL_LOG_PREFIX = "In-review stall surfaced ["; export const IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX = "In-review stall auto-disposed ["; export const IN_REVIEW_STALL_TERMINAL_LOG_PREFIX = "In-review stall terminal disposed ["; @@ -126,7 +138,7 @@ export function getInReviewStallReason( const now = context.now ?? Date.now(); const observedAt = new Date(now).toISOString(); const staleMergingMinAgeMs = context.staleMergingMinAgeMs ?? DEFAULT_STALE_MERGING_MIN_AGE_MS; - const maxAutoMergeRetries = context.maxAutoMergeRetries ?? DEFAULT_MAX_AUTO_MERGE_RETRIES; + const maxAutoMergeRetries = resolveMaxAutoMergeRetries(context); if (task.mergeDetails?.mergeConfirmed === true) { return undefined; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c21110d810..b7911623cb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,9 +17,19 @@ export type { } from "./branch-assignment.js"; export { customProviderRegistryKey } from "./custom-provider-key.js"; export { redactSecrets } from "./redact-secrets.js"; +export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; +export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js"; export * from "./frontend-ux-policy.js"; +export { MAX_TASK_LIST_TEXT_CHARS, clampTaskListText, formatTaskListText } from "./task-list-format.js"; export { MOCK_PROVIDER_ID } from "./mock-provider-constants.js"; export type { MockProviderId, MockSessionPurpose } from "./mock-provider-constants.js"; +export { + ZAI_PROVIDER_ID, + ZAI_PROVIDER_REGISTRATION, + mergeBuiltInZaiProviderModels, + registerBuiltInZaiProvider, +} from "./zai-provider.js"; +export type { ZaiProviderRegistration } from "./zai-provider.js"; export { resolveWorktrunkSettings, requiresWorktrunkInstallVerification, @@ -485,6 +495,8 @@ export { type NoOpCompletionMarker, type NoOpCompletionMarkerKind, } from "./no-op-completion-marker.js"; +export { evaluateNoCommitsNoOpFinalize } from "./no-commits-finalize-guard.js"; +export type { NoCommitsNoOpFinalizeEvaluation } from "./no-commits-finalize-guard.js"; export { __getDeterministicGuardMutexSize, deterministicGuardLocks, @@ -506,6 +518,101 @@ export { computeRetrySummary, RETRY_STORM_WARNING_RATIO } from "./retry-summary. export { RetryStormError, serializeRetryStormError } from "./retry-storm-error.js"; export { aggregateAgentTokenUsage } from "./agent-token-usage.js"; export type { AgentTokenUsageSummary, AgentTokenUsageWindowSummary } from "./agent-token-usage.js"; +export { + emitUsageEvent, + queryUsageEvents, + countUsageEventsBy, + categorizeToolName, + USAGE_EVENT_META_MAX_BYTES, +} from "./usage-events.js"; +export type { + UsageEvent, + UsageEventInput, + UsageEventKind, + UsageEventRangeQuery, +} from "./usage-events.js"; +export { + costFor, + lookupPricing, + MODEL_PRICING, + pricingAsOf, + PRICING_STALE_AFTER_MS, +} from "./model-pricing.js"; +export type { + ModelPricing, + ModelRef, + UsageForCost, + CostResult, +} from "./model-pricing.js"; +export { aggregateTokenAnalytics } from "./token-analytics.js"; +export type { + TokenAnalytics, + TokenAnalyticsQuery, + TokenGroupBy, + TokenGroupSummary, + TokenTimeGranularity, + TokenTimePoint, + TokenTotals, +} from "./token-analytics.js"; +export { aggregateToolAnalytics, countInterventions } from "./tool-analytics.js"; +export type { + ToolAnalytics, + ToolAnalyticsQuery, + ToolCategoryCount, + InterventionBreakdown, +} from "./tool-analytics.js"; +export { aggregateActivityAnalytics, aggregateMonitorMetrics } from "./activity-analytics.js"; +export type { + ActivityAnalytics, + ActivityAnalyticsQuery, + DailyActivity, + MttrSummary, + MonitorMetrics, +} from "./activity-analytics.js"; +export { aggregateProductivityAnalytics } from "./productivity-analytics.js"; +export type { + ProductivityAnalytics, + ProductivityAnalyticsQuery, + LanguageCount, + LocSummary, +} from "./productivity-analytics.js"; +export { aggregateTeamAnalytics } from "./team-analytics.js"; +export type { + TeamAnalytics, + TeamAnalyticsQuery, + TeamAgentSummary, + TeamMetricTotals, +} from "./team-analytics.js"; +export { aggregateGithubIssueAnalytics } from "./github-issue-analytics.js"; +export type { + GithubIssueAnalytics, + GithubIssueAnalyticsQuery, + GithubIssueDailyPoint, + GithubIssueRepoBreakdown, +} from "./github-issue-analytics.js"; +export { aggregateSignalsAnalytics } from "./signals-analytics.js"; +export type { + SignalsAnalytics, + SignalsAnalyticsQuery, + SignalsBreakdown, + SignalsSeverityBreakdown, + SignalsStatusBreakdown, +} from "./signals-analytics.js"; +export { composeLiveSnapshot } from "./command-center-live.js"; +export type { + LiveSnapshot, + LiveSession, + LiveRun, + ColumnCount, +} from "./command-center-live.js"; +export { mapAnalyticsToOtlp, OTEL_METRIC_PREFIX } from "./otel-metrics.js"; +export type { + OtelMappingInput, + OtlpExportPayload, + OtlpMetric, + OtlpNumberDataPoint, + OtlpAttribute, +} from "./otel-metrics.js"; export { STALLED_REVIEW_REENQUEUE_THRESHOLD, STALLED_REVIEW_INVALID_TRANSITION_THRESHOLD, @@ -650,6 +757,8 @@ export { isPrEntityActionable, isPrEntityAutoMergeReady, autoMergeGateReason, + summarizePrThreadActivity, + type PrThreadActivity, } from "./pr-entity.js"; export { findVitestProcessIds, @@ -664,6 +773,7 @@ export { IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, DEFAULT_STALE_MERGING_MIN_AGE_MS, DEFAULT_MAX_AUTO_MERGE_RETRIES, + resolveMaxAutoMergeRetries, } from "./in-review-stall.js"; export type { InReviewStallSignal, InReviewStallCode, ProviderErrorClassification } from "./in-review-stall.js"; export { @@ -1045,8 +1155,26 @@ export { resolveTitleSummarizerSettingsModel, resolveValidatorSettingsModel, TEST_MODE_RESOLVED, + routeTaskExecutionModel, + routeTaskPlanningModel, + routeTaskValidatorModel, } from "./model-resolution.js"; -export type { ResolvedModelSelection } from "./model-resolution.js"; +export type { ResolvedModelSelection, RouterLaneOptions } from "./model-resolution.js"; +export { + routeModel, + routeModelAndEmit, + isMechanicalRoutableContext, +} from "./model-router.js"; +export type { + RouterLane, + RouterReason, + RouterPair, + RouterTaskContext, + RouteModelInput, + RouterDecision, + RouterEscalation, + ModelGovernancePredicate, +} from "./model-router.js"; // ── Memory Compaction ───────────────────────────────────────────────── diff --git a/packages/core/src/model-pricing.ts b/packages/core/src/model-pricing.ts new file mode 100644 index 0000000000..6f81eeafa7 --- /dev/null +++ b/packages/core/src/model-pricing.ts @@ -0,0 +1,333 @@ +/** + * Model pricing → USD cost derivation (KTD6, U3). + * + * Cost is **derived at read time** from token counts × a hand-maintained + * pricing map; it is never persisted (so historical rows stay correct when + * prices change, and no backfill migration is needed). Unknown models surface + * tokens with cost marked `unavailable` rather than guessing a price. + * + * ⚠️ HAND-MAINTAINED MAP. The `MODEL_PRICING` table below is curated by humans + * from each provider's public pricing pages — it is NOT fetched at runtime. + * When you update a rate, bump {@link pricingAsOf} in the same change. The UI + * surfaces `pricingAsOf` ("prices as of ") and marks entries older than + * {@link PRICING_STALE_AFTER_MS} as low-confidence, so stale-but-present rates + * (which the unknown-model guard does not catch) are visible rather than + * silently wrong. + * + * Rates are USD **per 1,000,000 tokens**. + * + * Pure data module: no DB, no I/O, and no `Date.now()` at import time. Callers + * that care about staleness pass an explicit `now`; otherwise staleness is + * judged against {@link pricingAsOf} alone (i.e. never stale). + */ + +/** + * The date the rates in {@link MODEL_PRICING} were last verified, ISO-8601. + * Bump this whenever you edit a rate. Surfaced in the UI as "prices as of". + */ +export const pricingAsOf = "2026-06-15"; + +/** + * Pricing entries older than this (relative to a caller-supplied `now`) are + * flagged `stale: true`. 180 days ≈ two quarters — long enough that routine + * price churn doesn't fire constantly, short enough that a long-unmaintained + * map is surfaced. Compared against {@link pricingAsOf}, not per-entry dates. + */ +export const PRICING_STALE_AFTER_MS = 180 * 24 * 60 * 60 * 1000; + +/** A single model's per-1M-token rates plus a citation. */ +export interface ModelPricing { + /** USD per 1M uncached input tokens. */ + inputPer1M: number; + /** USD per 1M output tokens. */ + outputPer1M: number; + /** USD per 1M cache-read (cached) input tokens. */ + cacheReadPer1M: number; + /** USD per 1M cache-write tokens. */ + cacheWritePer1M: number; + /** Where the rate came from (provider pricing page / docs). */ + source: string; +} + +/** Token counts to price. Mirrors {@link TokenTotals} from token-analytics. */ +export interface UsageForCost { + inputTokens: number; + outputTokens: number; + /** Cache-read tokens (priced at the cache-read rate, NOT the input rate). */ + cachedTokens: number; + /** Cache-write tokens (priced at the cache-write rate). */ + cacheWriteTokens: number; +} + +/** Result of {@link costFor}. */ +export interface CostResult { + /** Derived USD cost, or `null` when no price is known for the model. */ + usd: number | null; + /** True when the model has no pricing entry (cost is a guess-free `null`). */ + unavailable: boolean; + /** True when the pricing map is older than the staleness threshold. */ + stale: boolean; +} + +/** + * Hand-maintained pricing table, keyed by `provider:model`. + * + * Keys are lowercased `${provider}:${model}`. Lookup also falls back to the + * bare model id (`:model`) so callers that only know the model still resolve. + * Model ids match the strings Fusion stores in `tasks.modelId` / + * `tasks.modelProvider` (see `runtime-provider-probes.ts` and grep for + * `modelId`/`modelProvider`): Anthropic Claude, OpenAI, Google Gemini. + * + * Sources (verified 2026-06-15, see `pricingAsOf`): + * - Anthropic: platform.claude.com/docs/en/pricing (per-MTok; cache read ≈ + * 0.1× input, 5-min cache write ≈ 1.25× input). + * - OpenAI: openai.com/api/pricing (cached input ≈ 0.5×/0.25× input; OpenAI + * has no separate cache-write charge, so cacheWrite = input rate). + * - Google Gemini: ai.google.dev/gemini-api/docs/pricing (context-cache read + * rate; no distinct cache-write token charge, so cacheWrite = input rate). + */ +export const MODEL_PRICING: Readonly> = { + // ── Anthropic Claude ──────────────────────────────────────────────── + // input / output / cacheRead(0.1×) / cacheWrite(1.25×, 5-min TTL) + "anthropic:claude-opus-4-8": { + inputPer1M: 5, + outputPer1M: 25, + cacheReadPer1M: 0.5, + cacheWritePer1M: 6.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-7": { + inputPer1M: 5, + outputPer1M: 25, + cacheReadPer1M: 0.5, + cacheWritePer1M: 6.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-6": { + inputPer1M: 5, + outputPer1M: 25, + cacheReadPer1M: 0.5, + cacheWritePer1M: 6.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-5": { + inputPer1M: 5, + outputPer1M: 25, + cacheReadPer1M: 0.5, + cacheWritePer1M: 6.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-1": { + inputPer1M: 15, + outputPer1M: 75, + cacheReadPer1M: 1.5, + cacheWritePer1M: 18.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-opus-4-20250514": { + inputPer1M: 15, + outputPer1M: 75, + cacheReadPer1M: 1.5, + cacheWritePer1M: 18.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-sonnet-4-6": { + inputPer1M: 3, + outputPer1M: 15, + cacheReadPer1M: 0.3, + cacheWritePer1M: 3.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-sonnet-4-5": { + inputPer1M: 3, + outputPer1M: 15, + cacheReadPer1M: 0.3, + cacheWritePer1M: 3.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-sonnet-4-20250514": { + inputPer1M: 3, + outputPer1M: 15, + cacheReadPer1M: 0.3, + cacheWritePer1M: 3.75, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-haiku-4-5": { + inputPer1M: 1, + outputPer1M: 5, + cacheReadPer1M: 0.1, + cacheWritePer1M: 1.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-haiku-4-5-20251001": { + inputPer1M: 1, + outputPer1M: 5, + cacheReadPer1M: 0.1, + cacheWritePer1M: 1.25, + source: "platform.claude.com/docs/en/pricing", + }, + "anthropic:claude-fable-5": { + inputPer1M: 10, + outputPer1M: 50, + cacheReadPer1M: 1, + cacheWritePer1M: 12.5, + source: "platform.claude.com/docs/en/pricing", + }, + + // ── OpenAI ────────────────────────────────────────────────────────── + // OpenAI has no separate cache-write charge → cacheWrite = input rate. + "openai:gpt-5": { + inputPer1M: 1.25, + outputPer1M: 10, + cacheReadPer1M: 0.125, + cacheWritePer1M: 1.25, + source: "openai.com/api/pricing", + }, + "openai:gpt-5-mini": { + inputPer1M: 0.25, + outputPer1M: 2, + cacheReadPer1M: 0.025, + cacheWritePer1M: 0.25, + source: "openai.com/api/pricing", + }, + "openai:gpt-4o": { + inputPer1M: 2.5, + outputPer1M: 10, + cacheReadPer1M: 1.25, + cacheWritePer1M: 2.5, + source: "openai.com/api/pricing", + }, + "openai:gpt-4o-mini": { + inputPer1M: 0.15, + outputPer1M: 0.6, + cacheReadPer1M: 0.075, + cacheWritePer1M: 0.15, + source: "openai.com/api/pricing", + }, + "openai:gpt-4.1": { + inputPer1M: 2, + outputPer1M: 8, + cacheReadPer1M: 0.5, + cacheWritePer1M: 2, + source: "openai.com/api/pricing", + }, + "openai:gpt-4-turbo": { + inputPer1M: 10, + outputPer1M: 30, + cacheReadPer1M: 10, + cacheWritePer1M: 10, + source: "openai.com/api/pricing", + }, + "openai:o1": { + inputPer1M: 15, + outputPer1M: 60, + cacheReadPer1M: 7.5, + cacheWritePer1M: 15, + source: "openai.com/api/pricing", + }, + "openai:o3-mini": { + inputPer1M: 1.1, + outputPer1M: 4.4, + cacheReadPer1M: 0.55, + cacheWritePer1M: 1.1, + source: "openai.com/api/pricing", + }, + + // ── Google Gemini ─────────────────────────────────────────────────── + // No distinct cache-write token charge → cacheWrite = input rate. + "google:gemini-2.5-pro": { + inputPer1M: 1.25, + outputPer1M: 10, + cacheReadPer1M: 0.31, + cacheWritePer1M: 1.25, + source: "ai.google.dev/gemini-api/docs/pricing", + }, + "google:gemini-2.5-flash": { + inputPer1M: 0.3, + outputPer1M: 2.5, + cacheReadPer1M: 0.075, + cacheWritePer1M: 0.3, + source: "ai.google.dev/gemini-api/docs/pricing", + }, + "google:gemini-2.0-flash": { + inputPer1M: 0.1, + outputPer1M: 0.4, + cacheReadPer1M: 0.025, + cacheWritePer1M: 0.1, + source: "ai.google.dev/gemini-api/docs/pricing", + }, + "google:gemini-2.0-pro": { + inputPer1M: 1.25, + outputPer1M: 10, + cacheReadPer1M: 0.31, + cacheWritePer1M: 1.25, + source: "ai.google.dev/gemini-api/docs/pricing", + }, +}; + +/** Reference to a model, by provider + id (either may be unset). */ +export interface ModelRef { + provider?: string | null; + model?: string | null; +} + +function normalize(s: string | null | undefined): string { + return (s ?? "").trim().toLowerCase(); +} + +/** + * Resolve a pricing entry for a model. Tries `provider:model` first, then the + * bare `:model` (provider-agnostic) fallback. Returns `undefined` for unknown + * models — callers must treat that as `unavailable`, never as a guessed price. + */ +export function lookupPricing(ref: ModelRef): ModelPricing | undefined { + const provider = normalize(ref.provider); + const model = normalize(ref.model); + if (!model) return undefined; + if (provider) { + const exact = MODEL_PRICING[`${provider}:${model}`]; + if (exact) return exact; + } + // Provider-agnostic fallback: scan for any entry whose model id matches. + for (const [key, entry] of Object.entries(MODEL_PRICING)) { + if (key.endsWith(`:${model}`)) return entry; + } + return undefined; +} + +/** True when the pricing map is older than the threshold relative to `now`. */ +function isStale(now: number | undefined): boolean { + if (now === undefined) return false; + const asOf = Date.parse(pricingAsOf); + if (Number.isNaN(asOf)) return false; + return now - asOf > PRICING_STALE_AFTER_MS; +} + +/** + * Derive USD cost for `usage` under `model`'s rates. + * + * - Unknown model → `{ usd: null, unavailable: true, stale }` (never guessed). + * - Cache-read tokens are priced at the cache-read rate, cache-write tokens at + * the cache-write rate — NOT the input rate. + * - `stale` is true when the (caller-supplied) `now` is more than + * {@link PRICING_STALE_AFTER_MS} past {@link pricingAsOf}. With no `now`, + * `stale` is always false. + */ +export function costFor( + usage: UsageForCost, + model: ModelRef, + now?: number, +): CostResult { + const stale = isStale(now); + const pricing = lookupPricing(model); + if (!pricing) { + return { usd: null, unavailable: true, stale }; + } + const usd = + (usage.inputTokens * pricing.inputPer1M + + usage.outputTokens * pricing.outputPer1M + + usage.cachedTokens * pricing.cacheReadPer1M + + usage.cacheWriteTokens * pricing.cacheWritePer1M) / + 1_000_000; + return { usd, unavailable: false, stale }; +} diff --git a/packages/core/src/model-resolution.ts b/packages/core/src/model-resolution.ts index 85b527c567..6159ea1752 100644 --- a/packages/core/src/model-resolution.ts +++ b/packages/core/src/model-resolution.ts @@ -1,4 +1,11 @@ import type { Settings } from "./types.js"; +import type { + ModelGovernancePredicate, + RouterDecision, + RouterLane, + RouterTaskContext, +} from "./model-router.js"; +import { routeModel } from "./model-router.js"; export interface ResolvedModelSelection { provider?: string; @@ -183,3 +190,73 @@ export function resolveTaskPlanningModel( settings, ); } + +// ── Fusion Model Router lane wrappers (U17 / KTD9) ───────────────────────── +// +// These are the **governed** session-start lanes: execution, planning, and +// validation. Each first resolves the lane's default pair exactly as today (the +// router's counterfactual), then hands it to the selection layer. The router is +// OFF by default — when disabled it returns the default pair byte-identically, +// so these wrappers are safe drop-ins. The non-routed resolvers above remain +// untouched; the settings-only resolvers, `resolveProjectDefaultModel`, and +// `resolveTitleSummarizerSettingsModel` are **ungoverned** (no task signal / +// non-session purpose) and the router never touches them. + +/** Options shared by the router-aware lane resolvers. */ +export interface RouterLaneOptions { + /** Per-task per-lane override pair (e.g. a column-agent binding). When complete, + * the router defers to it. */ + overridePair?: ResolvedModelSelection | null; + /** Classification signal for the conservative v0 allowlist. */ + context?: RouterTaskContext; + /** Governance gate — the router never returns a pair this rejects. */ + isPermitted?: ModelGovernancePredicate; +} + +function routeLane( + lane: RouterLane, + defaultPair: ResolvedModelSelection, + settings: Partial | undefined, + options: RouterLaneOptions | undefined, +): RouterDecision { + return routeModel({ + lane, + defaultPair, + overridePair: options?.overridePair ?? null, + context: options?.context, + settings, + isPermitted: options?.isPermitted, + }); +} + +/** + * Router-aware execution-lane resolution. Returns the full {@link RouterDecision} + * (selection + counterfactual + reason) so the caller can emit telemetry and wire + * the escalation seam. With the router disabled, `decision.selection` equals + * {@link resolveTaskExecutionModel}. + */ +export function routeTaskExecutionModel( + task: TaskModelLike, + settings?: Partial, + options?: RouterLaneOptions, +): RouterDecision { + return routeLane("execution", resolveTaskExecutionModel(task, settings), settings, options); +} + +/** Router-aware planning-lane resolution. See {@link routeTaskExecutionModel}. */ +export function routeTaskPlanningModel( + task: TaskModelLike, + settings?: Partial, + options?: RouterLaneOptions, +): RouterDecision { + return routeLane("planning", resolveTaskPlanningModel(task, settings), settings, options); +} + +/** Router-aware validation-lane resolution. See {@link routeTaskExecutionModel}. */ +export function routeTaskValidatorModel( + task: TaskModelLike, + settings?: Partial, + options?: RouterLaneOptions, +): RouterDecision { + return routeLane("validation", resolveTaskValidatorModel(task, settings), settings, options); +} diff --git a/packages/core/src/model-router.ts b/packages/core/src/model-router.ts new file mode 100644 index 0000000000..c61ec1adab --- /dev/null +++ b/packages/core/src/model-router.ts @@ -0,0 +1,332 @@ +/** + * Fusion Model Router (U17 / KTD9). + * + * A **selection layer** that picks a `(provider, model)` pair *before* a session + * starts. It is NOT a new executor: it never adds an executor kind, it only + * chooses which already-configured CLI/provider runs. Routing is **session-level + * only** for this unit — per-request mid-session re-routing is deferred (it needs + * its own design pass on streaming continuity / context-window compatibility / + * prompt-cache invalidation). + * + * ## Conservative v0 signal + * + * There is no validated `complexity`/`difficulty` field on tasks or steps today, + * and prompt size is a weak proxy. So v0 does NOT invent a classifier. It routes + * only an **allowlist of mechanical traits** (dependabot bumps, lint-only fixes) + * to a cheap tier; **everything else resolves to the configured default pair**. + * The signal is isolated behind {@link isMechanicalRoutableContext} so a + * validated classifier can replace it later without touching the governance, + * override, or fallback machinery. + * + * ## Governance, override, fallback (load-bearing — tested per lane) + * + * 1. **Override wins.** If a column-agent (or any caller-supplied) override pins a + * pair, the router defers and returns that pair unchanged. + * 2. **Governance is absolute.** The router NEVER returns a pair an org/project/ + * user model control forbids — including on the fallback path. A forbidden + * cheap pick is dropped and the router falls back; if the default pair is + * itself forbidden the router returns it untouched (governance of the default + * pair is the resolver/caller's job, not the router's to silently rewrite). + * 3. **Disabled / unavailable → default pair.** When the router is off, the cheap + * tier is unconfigured, or no pick is available, the result is byte-identical + * to the supplied default pair. + * + * ## Quality guardrail seam + * + * A cheap-tier pick carries an `escalation` describing the strong tier to retry + * with on cheap-tier failure (see {@link RouterDecision.escalation}). v0 wires + * the seam (the default pair is the escalation target) but does not itself run + * the retry loop — that lives in the executor/session layer that owns failure + * detection. + * + * ## Telemetry + * + * Every decision (including the **counterfactual** model that would have run + * absent the router) is emitted via the U1 {@link emitUsageEvent} seam so the + * Command Center can show adoption and realized cost delta versus always-premium. + * Emission is fail-soft and never alters the returned decision. + */ + +import type { Database } from "./db.js"; +import type { Settings } from "./types.js"; +import { emitUsageEvent } from "./usage-events.js"; +import type { ResolvedModelSelection } from "./model-resolution.js"; + +/** The resolution lanes the router governs. Ungoverned lanes are never touched. */ +export type RouterLane = "execution" | "planning" | "validation"; + +/** + * Why the router produced the pair it did. Surfaced in telemetry `meta` and + * usable by callers for diagnostics. + */ +export type RouterReason = + | "disabled" // router off → default pair + | "override" // a column-agent/caller override pinned the pair → defer + | "cheap-tier" // an allowlisted mechanical step routed to the cheap tier + | "cheap-unconfigured" // router on but no cheap pair configured → default + | "cheap-forbidden" // cheap pick forbidden by governance → default + | "not-routable" // step not on the mechanical allowlist → default + | "no-default"; // no usable default pair to fall back to + +/** + * A `(provider, model)` pair the router can choose. Mirrors + * {@link ResolvedModelSelection} but with both fields concrete when present. + */ +export interface RouterPair { + provider?: string; + modelId?: string; +} + +/** + * Predicate that returns `true` iff a pair is **permitted** by the active model + * controls (org/project/user governance). The router NEVER returns a pair for + * which this returns `false` on a routed pick. Supplied by the caller because + * governance schema lives outside core's resolution layer; when omitted, all + * pairs are permitted (no governance configured). + */ +export type ModelGovernancePredicate = (pair: RouterPair) => boolean; + +/** + * The signal the router classifies. Neutral, schema-light fields so the router + * does not depend on task schema that does not exist yet — callers populate from + * whatever trait/label/source data they have in scope. + */ +export interface RouterTaskContext { + /** Workflow trait flags on the task/column (e.g. `["dependabot", "lint-only"]`). */ + traits?: readonly string[]; + /** Labels on the task / source issue (e.g. `["dependencies", "lint"]`). */ + labels?: readonly string[]; + /** How the task was created (e.g. a `dependabot` / `renovate` source). */ + source?: string | null; + /** Task title — used only for conservative keyword matching on the allowlist. */ + title?: string | null; +} + +export interface RouteModelInput { + lane: RouterLane; + /** + * The pair resolution would return absent the router — the **counterfactual**. + * The router falls back to this and emits it as the counterfactual in telemetry. + */ + defaultPair: ResolvedModelSelection; + /** + * A column-agent (or other) override pair. When it carries both provider and + * model, the router defers to it unconditionally (override wins). + */ + overridePair?: ResolvedModelSelection | null; + /** The classification signal. */ + context?: RouterTaskContext; + settings?: Partial; + /** Governance gate. When omitted, all pairs are permitted. */ + isPermitted?: ModelGovernancePredicate; +} + +/** The strong-tier retry target for the quality guardrail. */ +export interface RouterEscalation { + provider?: string; + modelId?: string; +} + +export interface RouterDecision { + /** The pair to actually use. */ + selection: ResolvedModelSelection; + /** True iff the router down-routed to the cheap tier. */ + routed: boolean; + reason: RouterReason; + lane: RouterLane; + /** What would have run absent the router (always the supplied default pair). */ + counterfactual: ResolvedModelSelection; + /** + * Quality-guardrail seam: the strong tier to retry with if the cheap-tier pick + * fails. Present only when `routed` is true. v0 sets this to the counterfactual. + */ + escalation?: RouterEscalation; +} + +const DEPENDABOT_SOURCES: ReadonlySet = new Set([ + "dependabot", + "renovate", + "renovatebot", +]); + +const MECHANICAL_TRAITS: ReadonlySet = new Set([ + "dependabot", + "dependency-bump", + "deps", + "lint-only", + "lint-fix", + "lint", + "formatting", + "format-only", +]); + +const MECHANICAL_LABELS: ReadonlySet = new Set([ + "dependencies", + "dependabot", + "deps", + "lint", + "lint-only", + "formatting", + "style", +]); + +function normalize(s: string | null | undefined): string { + return (s ?? "").trim().toLowerCase(); +} + +function hasComplete(pair: ResolvedModelSelection | null | undefined): pair is { provider: string; modelId: string } { + return Boolean(pair?.provider && pair?.modelId); +} + +/** + * Conservative v0 classifier: is this step a mechanical, allowlisted candidate + * for the cheap tier? Pure and isolated so a validated classifier can replace it + * later. Returns `true` ONLY for clearly-mechanical signals; the default is + * `false` (→ default pair). + */ +export function isMechanicalRoutableContext(context: RouterTaskContext | undefined): boolean { + if (!context) return false; + + if (DEPENDABOT_SOURCES.has(normalize(context.source))) return true; + + for (const trait of context.traits ?? []) { + if (MECHANICAL_TRAITS.has(normalize(trait))) return true; + } + for (const label of context.labels ?? []) { + if (MECHANICAL_LABELS.has(normalize(label))) return true; + } + + // Conservative title keyword match: a dependabot/bump or lint-only chore. + const title = normalize(context.title); + if (title) { + if (/\bbump\b/.test(title) && /\bfrom\b/.test(title) && /\bto\b/.test(title)) return true; + if (title.startsWith("chore(deps)") || title.startsWith("build(deps)")) return true; + if (/\blint\b/.test(title) && /\b(only|fix|fixes)\b/.test(title)) return true; + } + + return false; +} + +/** Resolve the configured cheap-tier pair, or `undefined` when unconfigured. */ +function resolveCheapPair(settings: Partial | undefined): RouterPair | undefined { + const provider = settings?.modelRouterCheapProvider; + const modelId = settings?.modelRouterCheapModelId; + if (provider && modelId) return { provider, modelId }; + return undefined; +} + +function isRouterEnabled(settings: Partial | undefined): boolean { + return settings?.modelRouterEnabled === true; +} + +/** + * The core selection function. **Pure** (no DB, no telemetry) so it is trivially + * testable; {@link routeModelAndEmit} wraps it to also emit telemetry. + * + * Decision order (each rule is tested): + * 1. override pinned → defer (return override, `routed: false`) + * 2. router disabled → default pair + * 3. not mechanical → default pair + * 4. cheap tier unconfigured→ default pair + * 5. cheap pick forbidden → default pair (governance, incl. fallback path) + * 6. otherwise → cheap pick (with escalation seam) + * + * Governance also guards the override (an override forbidden by policy is NOT + * honored — governance is absolute) and is noted on the default-pair paths via + * `reason`, but the router never rewrites a forbidden default pair: governing the + * default is the resolver/caller's responsibility, the router only guarantees it + * does not *introduce* a forbidden pair. + */ +export function routeModel(input: RouteModelInput): RouterDecision { + const { lane, defaultPair, overridePair, context, settings } = input; + const isPermitted = input.isPermitted ?? (() => true); + const counterfactual: ResolvedModelSelection = { ...defaultPair }; + + const fallback = (reason: RouterReason): RouterDecision => ({ + selection: { ...defaultPair }, + routed: false, + reason: hasComplete(defaultPair) ? reason : "no-default", + lane, + counterfactual, + }); + + // 1. Override wins — but governance is absolute, so a forbidden override is not + // honored; it falls through to default resolution. + if (hasComplete(overridePair) && isPermitted({ provider: overridePair.provider, modelId: overridePair.modelId })) { + return { + selection: { provider: overridePair.provider, modelId: overridePair.modelId }, + routed: false, + reason: "override", + lane, + counterfactual, + }; + } + + // 2. Disabled → byte-identical default-pair behavior. + if (!isRouterEnabled(settings)) { + return fallback("disabled"); + } + + // 3. Conservative allowlist: only mechanical steps are routable. + if (!isMechanicalRoutableContext(context)) { + return fallback("not-routable"); + } + + // 4. Cheap tier must be configured. + const cheap = resolveCheapPair(settings); + if (!cheap || !hasComplete(cheap)) { + return fallback("cheap-unconfigured"); + } + + // 5. Governance is absolute — never return a forbidden cheap pick. + if (!isPermitted({ provider: cheap.provider, modelId: cheap.modelId })) { + return fallback("cheap-forbidden"); + } + + // 6. Route to the cheap tier, wiring the quality-guardrail escalation seam. + return { + selection: { provider: cheap.provider, modelId: cheap.modelId }, + routed: true, + reason: "cheap-tier", + lane, + counterfactual, + escalation: hasComplete(defaultPair) + ? { provider: defaultPair.provider, modelId: defaultPair.modelId } + : undefined, + }; +} + +/** + * {@link routeModel} plus fail-soft telemetry: emits one `session_start` usage + * event carrying the routing decision and the **counterfactual** model. Emission + * never alters or blocks the returned decision (the U1 seam is itself fail-soft). + */ +export function routeModelAndEmit( + db: Database | undefined, + input: RouteModelInput & { taskId?: string | null; agentId?: string | null; nodeId?: string | null }, +): RouterDecision { + const decision = routeModel(input); + if (db) { + emitUsageEvent(db, { + kind: "session_start", + taskId: input.taskId ?? null, + agentId: input.agentId ?? null, + nodeId: input.nodeId ?? null, + model: decision.selection.modelId ?? null, + provider: decision.selection.provider ?? null, + category: "model-router", + meta: { + router: true, + lane: decision.lane, + routed: decision.routed, + reason: decision.reason, + // The counterfactual model that WOULD have run absent the router. + counterfactualProvider: decision.counterfactual.provider ?? null, + counterfactualModelId: decision.counterfactual.modelId ?? null, + escalationProvider: decision.escalation?.provider ?? null, + escalationModelId: decision.escalation?.modelId ?? null, + }, + }); + } + return decision; +} diff --git a/packages/core/src/near-duplicate-canonical.ts b/packages/core/src/near-duplicate-canonical.ts new file mode 100644 index 0000000000..48a56b6ea0 --- /dev/null +++ b/packages/core/src/near-duplicate-canonical.ts @@ -0,0 +1,25 @@ +import type { ColumnId } from "./types.js"; + +export interface NearDuplicateCanonicalState { + column?: ColumnId | null; + deletedAt?: string | null; +} + +export function isActiveNearDuplicateColumn(column: ColumnId | null | undefined): boolean { + return column !== "archived" && column !== "done"; +} + +/** + * FNXC:NearDuplicateDetection 2026-06-14-12:00: + * A near-duplicate flag is only actionable while its canonical task exists and remains active. + * Treat missing, archived, done, and soft-deleted canonicals as inactive so stale persisted flags cannot strand executable work behind a false user-decision block. + */ +export function isNearDuplicateCanonicalInactive(canonical: NearDuplicateCanonicalState | undefined): boolean { + if (!canonical) { + return true; + } + if (canonical.deletedAt) { + return true; + } + return !isActiveNearDuplicateColumn(canonical.column); +} diff --git a/packages/core/src/near-duplicate.ts b/packages/core/src/near-duplicate.ts index c9480d4826..5a239645b9 100644 --- a/packages/core/src/near-duplicate.ts +++ b/packages/core/src/near-duplicate.ts @@ -39,6 +39,9 @@ export interface NearDuplicateCandidate { createdAt?: number; } +export { isActiveNearDuplicateColumn, isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; +export type { NearDuplicateCanonicalState } from "./near-duplicate-canonical.js"; + export interface NearDuplicateMatch { id: string; score: number; diff --git a/packages/core/src/no-commits-finalize-guard.ts b/packages/core/src/no-commits-finalize-guard.ts new file mode 100644 index 0000000000..895737d861 --- /dev/null +++ b/packages/core/src/no-commits-finalize-guard.ts @@ -0,0 +1,42 @@ +import type { Task } from "./types.js"; + +export interface NoCommitsNoOpFinalizeEvaluation { + blocked: boolean; + reason?: string; + doneCount: number; + incompleteCount: number; +} + +/** + * FNXC:Lifecycle 2026-06-14-19:54: + * FN-6461/FN-6455 showed that release and ops tasks marked `noCommitsExpected` can be silently finalized as no-op after skipping substantive steps. + * Zero-diff finalize lanes must only trust step evidence when completed work outweighs incomplete work; ties block because a todo requeue is recoverable while dropping operational work is not. + */ +export function evaluateNoCommitsNoOpFinalize( + task: Pick, +): NoCommitsNoOpFinalizeEvaluation { + const steps = task.steps ?? []; + const doneCount = steps.filter((step) => step.status === "done").length; + const incompleteCount = steps.length - doneCount; + + if ( + task.noCommitsExpected === true && + steps.length > 0 && + incompleteCount > 0 && + // Equal counts still block: requeueing is recoverable, but silently dropping ops work is not. + incompleteCount >= doneCount + ) { + return { + blocked: true, + reason: `no-commits task skipped/incomplete work outweighs completed work (done=${doneCount}, incomplete=${incompleteCount}) with no net branch changes`, + doneCount, + incompleteCount, + }; + } + + return { + blocked: false, + doneCount, + incompleteCount, + }; +} diff --git a/packages/core/src/oauth-credential-interop.ts b/packages/core/src/oauth-credential-interop.ts index 6394f321a9..4573411cad 100644 --- a/packages/core/src/oauth-credential-interop.ts +++ b/packages/core/src/oauth-credential-interop.ts @@ -8,6 +8,7 @@ export type StoredAuthCredential = { access?: string; refresh?: string; expires?: number; + scopes?: string[]; accountId?: string; [key: string]: unknown; }; @@ -235,6 +236,9 @@ export function extractClaudeCliStoredCredential(raw: unknown): StoredAuthCreden const refresh = typeof oauthRecord.refreshToken === "string" ? oauthRecord.refreshToken : undefined; const expiresRaw = oauthRecord.expiresAt; const expires = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) ? expiresRaw : undefined; + const scopes = Array.isArray(oauthRecord.scopes) + ? oauthRecord.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0) + : undefined; if (!access || !refresh || expires === undefined) { return undefined; @@ -245,6 +249,7 @@ export function extractClaudeCliStoredCredential(raw: unknown): StoredAuthCreden access, refresh, expires, + ...(scopes && scopes.length > 0 ? { scopes } : {}), }; } diff --git a/packages/core/src/otel-metrics.ts b/packages/core/src/otel-metrics.ts new file mode 100644 index 0000000000..43990bbbd8 --- /dev/null +++ b/packages/core/src/otel-metrics.ts @@ -0,0 +1,284 @@ +/** + * OpenTelemetry (OTLP) metric mapping (U10). + * + * Pure mapping of the Command Center aggregator outputs (tokens / cost / activity) + * to OTLP metric instruments. This module produces the **OTLP/HTTP JSON wire + * shape** (`{ resourceMetrics: [...] }`) directly — the exact body an OTLP/HTTP + * collector accepts at `/v1/metrics` — so it is testable without a live collector + * and without pulling the full `@opentelemetry/*` SDK into `@fusion/core`. + * + * Design (KTD2): the MAPPING lives in core (reusable, side-effect-free); the + * network export (endpoint validation, auth headers, periodic scheduling, back + * off) lives in the dashboard exporter. This module never reads the clock, the + * network, or env — callers pass an explicit `timeUnixNano`. + * + * Instrument choices: + * - Token counts and USD cost are **monotonic counters** (`Sum`, cumulative, + * monotonic) — they only grow over a fixed range and aggregate cleanly. + * - Activity "current state" figures (active nodes/agents, sessions, stickiness) + * are **gauges** — point-in-time values that should not be summed across + * series. + * + * Attributes (model / provider / node / agent) are attached per data point from + * the aggregator's group keys, so a collector can break metrics down by any of + * them. We emit one data point per group plus an unattributed grand-total point. + */ + +import type { TokenAnalytics } from "./token-analytics.js"; +import type { ActivityAnalytics } from "./activity-analytics.js"; + +/** Instrument namespace prefix for every metric this module emits. */ +export const OTEL_METRIC_PREFIX = "fusion.command_center"; + +/** A single OTLP attribute key/value (string-valued; numbers are stringified). */ +export interface OtlpAttribute { + key: string; + value: { stringValue: string }; +} + +/** An OTLP number data point (used for both Sum and Gauge). */ +export interface OtlpNumberDataPoint { + /** Group attributes (model/provider/node/agent), empty for grand totals. */ + attributes: OtlpAttribute[]; + /** Nanoseconds since epoch; the start of the measurement window. */ + startTimeUnixNano: string; + /** Nanoseconds since epoch; when the value was observed. */ + timeUnixNano: string; + /** Integer counts use asInt; fractional values (cost, ratios) use asDouble. */ + asInt?: string; + asDouble?: number; +} + +/** An OTLP metric (one instrument), either a Sum (counter) or a Gauge. */ +export interface OtlpMetric { + name: string; + description: string; + unit: string; + sum?: { + dataPoints: OtlpNumberDataPoint[]; + /** 2 = CUMULATIVE in the OTLP AggregationTemporality enum. */ + aggregationTemporality: 2; + isMonotonic: boolean; + }; + gauge?: { + dataPoints: OtlpNumberDataPoint[]; + }; +} + +/** The OTLP/HTTP JSON export envelope sent to a collector's `/v1/metrics`. */ +export interface OtlpExportPayload { + resourceMetrics: Array<{ + resource: { attributes: OtlpAttribute[] }; + scopeMetrics: Array<{ + scope: { name: string; version: string }; + metrics: OtlpMetric[]; + }>; + }>; +} + +/** Inputs to {@link mapAnalyticsToOtlp}. */ +export interface OtelMappingInput { + tokens: TokenAnalytics; + activity: ActivityAnalytics; + /** Observation time in nanoseconds since the Unix epoch (caller-supplied). */ + timeUnixNano: string; + /** + * Start of the measurement window in nanoseconds since the Unix epoch. Used + * for the Sum start time so a collector treats the counters as a fresh + * cumulative series. Defaults to {@link OtelMappingInput.timeUnixNano}. + */ + startTimeUnixNano?: string; + /** + * Resource attributes describing the emitting service (e.g. + * `{ "service.name": "fusion-dashboard" }`). Defaults to a minimal + * `service.name`. + */ + resourceAttributes?: Record; + /** OTLP scope (instrumentation library) version. Defaults to `"1"`. */ + scopeVersion?: string; +} + +function attr(key: string, value: string): OtlpAttribute { + return { key, value: { stringValue: value } }; +} + +function toAttributes(record: Record): OtlpAttribute[] { + return Object.entries(record).map(([k, v]) => attr(k, v)); +} + +function intPoint( + value: number, + attributes: OtlpAttribute[], + startTimeUnixNano: string, + timeUnixNano: string, +): OtlpNumberDataPoint { + return { + attributes, + startTimeUnixNano, + timeUnixNano, + // OTLP ints are wire-encoded as strings. Coerce non-finite/negative to 0. + asInt: String(Math.max(0, Math.trunc(Number.isFinite(value) ? value : 0))), + }; +} + +function doublePoint( + value: number, + attributes: OtlpAttribute[], + startTimeUnixNano: string, + timeUnixNano: string, +): OtlpNumberDataPoint { + return { + attributes, + startTimeUnixNano, + timeUnixNano, + asDouble: Number.isFinite(value) ? value : 0, + }; +} + +function counter( + name: string, + description: string, + unit: string, + dataPoints: OtlpNumberDataPoint[], +): OtlpMetric { + return { + name, + description, + unit, + sum: { dataPoints, aggregationTemporality: 2, isMonotonic: true }, + }; +} + +function gauge( + name: string, + description: string, + unit: string, + dataPoints: OtlpNumberDataPoint[], +): OtlpMetric { + return { name, description, unit, gauge: { dataPoints } }; +} + +/** + * Attributes for a token group. The grouped dimension is reflected by the key + * the aggregator chose (`groupBy`); we tag it with the matching attribute name + * so a collector sees `model` / `provider` / `node.id` / `agent.id`. + */ +function groupAttributes( + groupBy: TokenAnalytics["groupBy"], + key: string | null, +): OtlpAttribute[] { + if (!groupBy || key === null) return []; + switch (groupBy) { + case "model": + return [attr("model", key)]; + case "provider": + return [attr("provider", key)]; + case "node": + return [attr("node.id", key)]; + case "agent": + return [attr("agent.id", key)]; + } +} + +/** + * Map token + activity analytics to an OTLP/HTTP JSON export payload. + * + * Token/cost metrics emit one data point per group (carrying the group's + * model/provider/node/agent attribute) plus an unattributed grand-total point. + * Cost is omitted from a data point when `cost.usd` is null (unpriced models) so + * an unavailable cost never reports as `$0`. Activity metrics are gauges with no + * group attributes (the activity aggregator is range-scoped, not grouped). + * + * Pure: no I/O, no clock. Returns a fresh payload every call. + */ +export function mapAnalyticsToOtlp(input: OtelMappingInput): OtlpExportPayload { + const { tokens, activity, timeUnixNano } = input; + const start = input.startTimeUnixNano ?? timeUnixNano; + const resourceAttributes = input.resourceAttributes ?? { + "service.name": "fusion-dashboard", + }; + const scopeVersion = input.scopeVersion ?? "1"; + + const p = OTEL_METRIC_PREFIX; + + // ── Token counters (one data point per group + a grand total) ────────── + const inputTokenPoints: OtlpNumberDataPoint[] = []; + const outputTokenPoints: OtlpNumberDataPoint[] = []; + const cachedTokenPoints: OtlpNumberDataPoint[] = []; + const totalTokenPoints: OtlpNumberDataPoint[] = []; + const costPoints: OtlpNumberDataPoint[] = []; + + // Grand totals (unattributed). + inputTokenPoints.push(intPoint(tokens.totals.inputTokens, [], start, timeUnixNano)); + outputTokenPoints.push(intPoint(tokens.totals.outputTokens, [], start, timeUnixNano)); + cachedTokenPoints.push(intPoint(tokens.totals.cachedTokens, [], start, timeUnixNano)); + totalTokenPoints.push(intPoint(tokens.totals.totalTokens, [], start, timeUnixNano)); + if (tokens.cost.usd !== null) { + costPoints.push(doublePoint(tokens.cost.usd, [], start, timeUnixNano)); + } + + // Per-group points. + for (const group of tokens.groups) { + const attrs = groupAttributes(tokens.groupBy, group.key); + inputTokenPoints.push(intPoint(group.inputTokens, attrs, start, timeUnixNano)); + outputTokenPoints.push(intPoint(group.outputTokens, attrs, start, timeUnixNano)); + cachedTokenPoints.push(intPoint(group.cachedTokens, attrs, start, timeUnixNano)); + totalTokenPoints.push(intPoint(group.totalTokens, attrs, start, timeUnixNano)); + if (group.cost.usd !== null) { + costPoints.push(doublePoint(group.cost.usd, attrs, start, timeUnixNano)); + } + } + + const metrics: OtlpMetric[] = [ + counter(`${p}.tokens.input`, "Input (uncached) tokens consumed", "{token}", inputTokenPoints), + counter(`${p}.tokens.output`, "Output tokens generated", "{token}", outputTokenPoints), + counter(`${p}.tokens.cached`, "Cache-read (cached input) tokens", "{token}", cachedTokenPoints), + counter(`${p}.tokens.total`, "Total tokens consumed", "{token}", totalTokenPoints), + counter(`${p}.cost.usd`, "Derived USD cost from token usage", "USD", costPoints), + // ── Activity gauges (point-in-time) ────────────────────────────────── + gauge( + `${p}.activity.active_nodes`, + "Distinct active nodes over the range", + "{node}", + [intPoint(activity.activeNodes, [], start, timeUnixNano)], + ), + gauge( + `${p}.activity.active_agents`, + "Distinct active agents over the range", + "{agent}", + [intPoint(activity.activeAgents, [], start, timeUnixNano)], + ), + gauge( + `${p}.activity.sessions`, + "CLI/chat sessions over the range", + "{session}", + [intPoint(activity.sessions, [], start, timeUnixNano)], + ), + gauge( + `${p}.activity.messages`, + "User messages over the range", + "{message}", + [intPoint(activity.messages, [], start, timeUnixNano)], + ), + gauge( + `${p}.activity.stickiness`, + "Stickiness ratio (DAU/MAU)", + "1", + [doublePoint(activity.stickiness, [], start, timeUnixNano)], + ), + ]; + + return { + resourceMetrics: [ + { + resource: { attributes: toAttributes(resourceAttributes) }, + scopeMetrics: [ + { + scope: { name: p, version: scopeVersion }, + metrics, + }, + ], + }, + ], + }; +} diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index d6f1f5adb1..8e8e6a1f7e 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -10,7 +10,7 @@ */ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; -import { existsSync } from "node:fs"; +import { existsSync, readdirSync, statSync } from "node:fs"; import { stat } from "node:fs/promises"; import { copyFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; @@ -53,10 +53,17 @@ let moduleImportVersion = 0; /** * Resolve the actual loadable entry FILE path for a plugin directory. Node ESM * does not allow directory imports, so the registered plugin path must be the - * explicit file the loader will dynamic-import. Preference order: - * 1. ./bundled.js (esbuild-bundled, shipped in npm tarball) - * 2. ./dist/index.js (legacy prebuilt fallback) - * 3. ./src/index.ts (workspace/dev fallback when no bundle exists) + * explicit file the loader will dynamic-import. Resolution keeps ./bundled.js + * unconditional because production npm tarballs ship that esbuild-bundled entry. + * In dev/worktree contexts where no bundle exists, ./dist/index.js remains the + * prebuilt fallback unless any file under ./src/ is newer than dist/index.js; + * then ./src/index.ts wins so stale gitignored dist output cannot mask a source + * fix (FN-6615/FN-6596). + * + * FNXC:PluginLoader 2026-06-17-19:20: + * Prefer fresher src over stale dist only when bundled.js is absent. This keeps + * production tarballs on their bundled entry while preventing dev/worktree runs + * from silently loading old gitignored build output after a source fix. * * Returns null when the directory exists but none of the loadable entry files * are present. Callers must treat that as a missing/unloadable plugin rather @@ -65,16 +72,74 @@ let moduleImportVersion = 0; * Keep in sync with resolvePluginEntryPath in the CLI's * bundled-plugin-install.ts, which keeps a local copy so its fs mocks work. */ -export function resolvePluginEntryPath(pluginDir: string): string | null { - const candidates = [ - join(pluginDir, "bundled.js"), - join(pluginDir, "dist", "index.js"), - join(pluginDir, "src", "index.ts"), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; +function newestSourceMtimeMs(srcDir: string): number | null { + let newest = Number.NEGATIVE_INFINITY; + + function visit(dir: string): boolean { + const entries = (() => { + try { + return readdirSync(dir, { withFileTypes: true, encoding: "utf8" }); + } catch { + return null; + } + })(); + if (!entries) return false; + + for (const entry of entries) { + const entryPath = join(dir, entry.name); + let entryStat: ReturnType; + try { + entryStat = statSync(entryPath); + } catch { + return false; + } + + if (entryStat.isDirectory()) { + if (!visit(entryPath)) return false; + continue; + } + + if (entryStat.mtimeMs > newest) { + newest = entryStat.mtimeMs; + } } + + return true; + } + + return visit(srcDir) && newest !== Number.NEGATIVE_INFINITY ? newest : null; +} + +function isSourceNewerThanDist(srcDir: string, distIndexPath: string): boolean { + try { + const distMtimeMs = statSync(distIndexPath).mtimeMs; + const srcMtimeMs = newestSourceMtimeMs(srcDir); + return srcMtimeMs !== null && srcMtimeMs > distMtimeMs; + } catch { + return false; + } +} + +export function resolvePluginEntryPath(pluginDir: string): string | null { + const bundledPath = join(pluginDir, "bundled.js"); + if (existsSync(bundledPath)) { + return bundledPath; + } + + const distIndexPath = join(pluginDir, "dist", "index.js"); + const srcDir = join(pluginDir, "src"); + const srcIndexPath = join(srcDir, "index.ts"); + const hasDist = existsSync(distIndexPath); + const hasSrc = existsSync(srcIndexPath); + + if (hasDist && hasSrc) { + return isSourceNewerThanDist(srcDir, distIndexPath) ? srcIndexPath : distIndexPath; + } + if (hasDist) { + return distIndexPath; + } + if (hasSrc) { + return srcIndexPath; } return null; } diff --git a/packages/core/src/pr-entity.ts b/packages/core/src/pr-entity.ts index 39f071a1da..09ac6b7164 100644 --- a/packages/core/src/pr-entity.ts +++ b/packages/core/src/pr-entity.ts @@ -4,7 +4,7 @@ // and the reconcile all consult one definition and cannot drift — the same // discipline that put isBranchGroupMemberLanded in branch-group-completion.ts. -import type { PrEntity } from "./types.js"; +import type { PrEntity, PrThreadState } from "./types.js"; /** Non-terminal lifecycle states — the entity is "live". */ export function isPrEntityActive(entity: Pick): boolean { @@ -64,6 +64,50 @@ export function isPrEntityAutoMergeReady( return true; } +/** + * Aggregate Review-response-loop activity for a single PR entity (U18, R15). + * + * A lightweight, dependency-free read seam so the Command Center / Mission + * Control can surface what the Review-response loop actually did — threads acted + * on, and the fixed-vs-disagreed split — without each surface re-deriving the + * counts from raw `PrThreadState[]` (and silently disagreeing with one another). + * + * `acted` = fixed + disagreed (threads the loop reached a terminal verdict on). + * `pending` rows are in-flight (recorded before GitHub confirmed) and are NOT + * counted as acted-on. The same discipline that put `isPrEntityAutoMergeReady` + * in @fusion/core keeps this single-sourced. + */ +export interface PrThreadActivity { + /** Total threads with a recorded outcome (fixed + disagreed + pending). */ + total: number; + /** Threads the loop reached a terminal verdict on (fixed + disagreed). */ + acted: number; + /** Threads fixed (a change was pushed and the thread replied/resolved). */ + fixed: number; + /** Threads the loop disagreed on (reasoning posted, thread left open). */ + disagreed: number; + /** Threads recorded but not yet GitHub-confirmed (in-flight). */ + pending: number; +} + +export function summarizePrThreadActivity(threads: PrThreadState[]): PrThreadActivity { + let fixed = 0; + let disagreed = 0; + let pending = 0; + for (const t of threads) { + if (t.outcome === "fixed") fixed += 1; + else if (t.outcome === "disagreed") disagreed += 1; + else if (t.outcome === "pending") pending += 1; + } + return { + total: threads.length, + acted: fixed + disagreed, + fixed, + disagreed, + pending, + }; +} + /** * The live auto-merge gate reason shown next to the toggle (R11). Mirrors the * auto-merge-ready predicate ordering so every surface (the dashboard route and diff --git a/packages/core/src/productivity-analytics.ts b/packages/core/src/productivity-analytics.ts new file mode 100644 index 0000000000..aff8a976a6 --- /dev/null +++ b/packages/core/src/productivity-analytics.ts @@ -0,0 +1,189 @@ +import type { Database } from "./db.js"; + +/** + * Productivity analytics: files modified (count + language distribution) from + * `tasks.modifiedFiles`, commit associations from `task_commit_associations`, + * pull requests from `pull_requests`, and LOC from merge-time commit diff stats. + * + * **LOC availability.** Fusion persists nullable `additions`/`deletions` on + * `task_commit_associations` when merge paths can capture git shortstat output. + * LOC is reported as a real value only when at least one in-range association + * has non-null stats. If the range has no recorded stats, the documented + * unavailable sentinel — `{ value: null, unavailable: true }` — is preserved, + * **never `0`**, so missing historical data is not mistaken for "zero lines + * changed". + * + * Inclusivity: `from`/`to` bounds are inclusive. Tasks are filtered by + * `updatedAt` (the last time the task — and therefore its modifiedFiles — was + * touched); commit associations by `authoredAt`; PRs by `createdAt`. + */ + +export interface ProductivityAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +/** A single language's modified-file count. */ +export interface LanguageCount { + /** Lowercased file extension (no dot), or `other` when none. */ + language: string; + count: number; +} + +/** + * LOC summary. `value` is null and `unavailable` true when no in-range commit + * association has diff stats — never `0` for unknown data. + */ +export interface LocSummary { + value: number | null; + unavailable: boolean; +} + +export interface ProductivityAnalytics { + from: string | null; + to: string | null; + /** Total modified-file paths across matched tasks. */ + modifiedFiles: number; + /** Modified files grouped by language (extension), descending by count. */ + byLanguage: LanguageCount[]; + /** Rows in `task_commit_associations` in range. */ + commits: number; + /** Rows in `pull_requests` in range. */ + pullRequests: number; + /** LOC from commit association diff stats when at least one in-range row has stats. */ + loc: LocSummary; +} + +interface CountRow { + count: number; +} + +interface CommitStatsRow { + count: number; + additions: number | null; + deletions: number | null; + statsRows: number; +} + +interface ModifiedFilesRow { + modifiedFiles: string | null; +} + +/** Extract a coarse language key from a file path (its lowercased extension). */ +function languageOf(path: string): string { + const base = path.split("/").pop() ?? path; + const dot = base.lastIndexOf("."); + if (dot <= 0 || dot === base.length - 1) return "other"; + return base.slice(dot + 1).toLowerCase(); +} + +/** + * Aggregate productivity metrics over a date range. Empty range yields zeroed + * structures (not nulls); LOC remains the unavailable sentinel unless at least + * one in-range commit association carries diff stats. + */ +export function aggregateProductivityAnalytics( + db: Database, + query: ProductivityAnalyticsQuery = {}, +): ProductivityAnalytics { + // Modified files: read the JSON array off tasks updated in range. + const taskClauses: string[] = [ + "modifiedFiles IS NOT NULL", + "modifiedFiles NOT IN ('', '[]')", + ]; + const taskParams: string[] = []; + if (query.from !== undefined) { + taskClauses.push("updatedAt >= ?"); + taskParams.push(query.from); + } + if (query.to !== undefined) { + taskClauses.push("updatedAt <= ?"); + taskParams.push(query.to); + } + const taskRows = db + .prepare( + `SELECT modifiedFiles FROM tasks WHERE ${taskClauses.join(" AND ")}`, + ) + .all(...taskParams) as ModifiedFilesRow[]; + + let modifiedFiles = 0; + const langMap = new Map(); + for (const row of taskRows) { + if (!row.modifiedFiles) continue; + let files: unknown; + try { + files = JSON.parse(row.modifiedFiles); + } catch { + continue; + } + if (!Array.isArray(files)) continue; + for (const f of files) { + if (typeof f !== "string" || f.length === 0) continue; + modifiedFiles += 1; + const lang = languageOf(f); + langMap.set(lang, (langMap.get(lang) ?? 0) + 1); + } + } + const byLanguage: LanguageCount[] = [...langMap.entries()] + .map(([language, count]) => ({ language, count })) + .sort((a, b) => b.count - a.count); + + // Commits from task_commit_associations (by authoredAt). + const commitClauses: string[] = []; + const commitParams: string[] = []; + if (query.from !== undefined) { + commitClauses.push("authoredAt >= ?"); + commitParams.push(query.from); + } + if (query.to !== undefined) { + commitClauses.push("authoredAt <= ?"); + commitParams.push(query.to); + } + const commitWhere = + commitClauses.length > 0 ? `WHERE ${commitClauses.join(" AND ")}` : ""; + const commitStats = db + .prepare( + `SELECT + COUNT(*) AS count, + SUM(additions) AS additions, + SUM(deletions) AS deletions, + COUNT(CASE WHEN additions IS NOT NULL OR deletions IS NOT NULL THEN 1 END) AS statsRows + FROM task_commit_associations ${commitWhere}`, + ) + .get(...commitParams) as CommitStatsRow; + const commits = commitStats.count; + const loc: LocSummary = commitStats.statsRows > 0 + ? { value: (commitStats.additions ?? 0) + (commitStats.deletions ?? 0), unavailable: false } + : { value: null, unavailable: true }; + + // Pull requests. `pull_requests.createdAt` is an INTEGER epoch-ms column, so + // convert the ISO bounds to epoch ms for comparison. + const prClauses: string[] = []; + const prParams: number[] = []; + if (query.from !== undefined) { + prClauses.push("createdAt >= ?"); + prParams.push(Date.parse(query.from)); + } + if (query.to !== undefined) { + prClauses.push("createdAt <= ?"); + prParams.push(Date.parse(query.to)); + } + const prWhere = prClauses.length > 0 ? `WHERE ${prClauses.join(" AND ")}` : ""; + const pullRequests = ( + db + .prepare(`SELECT COUNT(*) AS count FROM pull_requests ${prWhere}`) + .get(...prParams) as CountRow + ).count; + + return { + from: query.from ?? null, + to: query.to ?? null, + modifiedFiles, + byLanguage, + commits, + pullRequests, + loc, + }; +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 3aefafdfd7..764ed87f07 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -1,3 +1,4 @@ +import { DEFAULT_MAX_AUTO_MERGE_RETRIES } from "./in-review-stall.js"; import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js"; export interface MergeRequestContractShadowSettingsSource { @@ -69,6 +70,9 @@ export const DEFAULT_GLOBAL_SETTINGS = { defaultProvider: undefined, defaultModelId: undefined, testMode: undefined, + modelRouterEnabled: undefined, + modelRouterCheapProvider: undefined, + modelRouterCheapModelId: undefined, mergeRequestContractShadowEnabled: false, fallbackProvider: undefined, fallbackModelId: undefined, @@ -252,6 +256,9 @@ export const DEFAULT_PROJECT_SETTINGS = { groupOverlappingFiles: true, overlapIgnorePaths: [], autoMerge: true, + // U18 (R15): the Review-response loop is default-on. Independent of `autoMerge` — + // with this on but auto-merge off, review threads are resolved but the PR is not merged. + autoResolveReviewComments: true, testMode: undefined, mergeRequestContractShadowEnabled: false, mergeStrategy: "direct", @@ -314,6 +321,11 @@ export const DEFAULT_PROJECT_SETTINGS = { ], prerebaseDivergenceThreshold: 50, mergeConflictStrategy: "smart-prefer-main", + /** + * FNXC:AutoMergeRetries 2026-06-17-04:20: + * Project settings own the auto-merge conflict retry cap because existing engine/dashboard consumers already resolve project settings; the default imports core's stall-detection fallback to keep every surface on the historical value of 3. + */ + maxAutoMergeRetries: DEFAULT_MAX_AUTO_MERGE_RETRIES, merger: { mode: "ai", maxReviewPasses: 3, allowDirtyLocalCheckoutSync: false }, mergeDiffVolumeMinLines: undefined, mergeDiffVolumeThreshold: undefined, @@ -330,9 +342,12 @@ export const DEFAULT_PROJECT_SETTINGS = { // planOnlyScopeLeakEnforcement, workflowRevisionForkOnScopeMismatch, // strictScopeEnforcement, buildRetryCount, verificationFixRetries, // requirePlanApproval) MOVED to workflow settings (U4) — see - // MOVED_SETTINGS_KEYS. `buildTimeoutMs` is NOT moved (no engine reader) and - // stays a plain project setting: + // MOVED_SETTINGS_KEYS. `buildTimeoutMs` and `verificationCommandTimeoutMs` + // are NOT moved and stay plain project settings. Keep verificationCommandTimeoutMs + // undefined so fn_run_verification preserves legacy per-scope defaults until a + // project opts into a single default budget. buildTimeoutMs: 300_000, + verificationCommandTimeoutMs: undefined, ephemeralAgentsEnabled: true, agentProvisioning: {}, sandboxProvisioning: {}, diff --git a/packages/core/src/signals-analytics.ts b/packages/core/src/signals-analytics.ts new file mode 100644 index 0000000000..b874aff86c --- /dev/null +++ b/packages/core/src/signals-analytics.ts @@ -0,0 +1,195 @@ +import type { Database } from "./db.js"; +import type { MttrSummary } from "./activity-analytics.js"; + +/** + * Command Center external-signal analytics over the existing `incidents` table. + * + * FNXC:CommandCenter 2026-06-19-00:00: + * The Signals tab must be backed by real project data, not a swallowed 404. Use the scoped incidents table that monitor ingestion already owns; when no incident source is connected, return honest zeros plus the MTTR unavailable sentinel instead of fabricating signal volume. + */ +export interface SignalsAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +export interface SignalsBreakdown { + source: string; + count: number; +} + +export interface SignalsSeverityBreakdown { + severity: string; + count: number; +} + +export interface SignalsStatusBreakdown { + status: string; + count: number; +} + +export interface SignalsAnalytics { + from: string | null; + to: string | null; + /** Incidents opened in range. */ + totalSignals: number; + /** Open incidents opened in range. */ + open: number; + /** Incidents resolved in range. */ + resolved: number; + /** Mean time to resolve for incidents resolved in range. */ + mttr: MttrSummary; + /** Incidents opened in range, grouped by source. */ + bySource: SignalsBreakdown[]; + /** Incidents opened in range, grouped by severity. */ + bySeverity: SignalsSeverityBreakdown[]; + /** Incidents opened in range, grouped by current status. */ + byStatus: SignalsStatusBreakdown[]; +} + +interface CountRow { + count: number; +} + +interface GroupRow { + key: string | null; + count: number; +} + +interface ResolvedIncidentRow { + openedAt: string; + resolvedAt: string; +} + +function tableExists(db: Database, name: string): boolean { + const row = db + .prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(name) as CountRow; + return row.count > 0; +} + +function rangeWhere(column: string, query: SignalsAnalyticsQuery): { where: string; params: string[] } { + const clauses: string[] = []; + const params: string[] = []; + if (query.from !== undefined) { + clauses.push(`${column} >= ?`); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push(`${column} <= ?`); + params.push(query.to); + } + return { + where: clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "", + params, + }; +} + +function emptySignals(query: SignalsAnalyticsQuery): SignalsAnalytics { + return { + from: query.from ?? null, + to: query.to ?? null, + totalSignals: 0, + open: 0, + resolved: 0, + mttr: { value: null, unavailable: true, sampleCount: 0 }, + bySource: [], + bySeverity: [], + byStatus: [], + }; +} + +function count(db: Database, sql: string, params: string[]): number { + return (db.prepare(sql).get(...params) as CountRow).count; +} + +function groupByColumn( + db: Database, + column: "source" | "severity" | "status", + openedWhere: string, + params: string[], + fallback: string, +): Array<{ key: string; count: number }> { + const rows = db + .prepare( + `SELECT COALESCE(NULLIF(TRIM(${column}), ''), ?) AS key, COUNT(*) AS count + FROM incidents ${openedWhere} + GROUP BY key + ORDER BY count DESC, key ASC`, + ) + .all(fallback, ...params) as GroupRow[]; + return rows.map((row) => ({ key: row.key ?? fallback, count: row.count })); +} + +function computeMttr(db: Database, query: SignalsAnalyticsQuery): MttrSummary { + const resolvedRange = rangeWhere("resolvedAt", query); + const resolvedWhere = resolvedRange.where + ? `${resolvedRange.where} AND resolvedAt IS NOT NULL` + : "WHERE resolvedAt IS NOT NULL"; + const rows = db + .prepare(`SELECT openedAt, resolvedAt FROM incidents ${resolvedWhere}`) + .all(...resolvedRange.params) as ResolvedIncidentRow[]; + + let totalMinutes = 0; + let sampleCount = 0; + for (const row of rows) { + const opened = Date.parse(row.openedAt); + const resolved = Date.parse(row.resolvedAt); + if (!Number.isFinite(opened) || !Number.isFinite(resolved) || resolved < opened) continue; + totalMinutes += (resolved - opened) / 60_000; + sampleCount += 1; + } + + return sampleCount === 0 + ? { value: null, unavailable: true, sampleCount: 0 } + : { value: totalMinutes / sampleCount, unavailable: false, sampleCount }; +} + +/** + * Aggregate the Command Center Signals surface from locally recorded incidents. + * Missing/older schemas return an honest empty payload so the dashboard can show + * "no source connected" without pretending that a zero came from ingestion. + */ +export function aggregateSignalsAnalytics( + db: Database, + query: SignalsAnalyticsQuery = {}, +): SignalsAnalytics { + if (!tableExists(db, "incidents")) return emptySignals(query); + + const openedRange = rangeWhere("openedAt", query); + const resolvedRange = rangeWhere("resolvedAt", query); + const resolvedWhere = resolvedRange.where + ? `${resolvedRange.where} AND resolvedAt IS NOT NULL` + : "WHERE resolvedAt IS NOT NULL"; + const openWhere = openedRange.where + ? `${openedRange.where} AND status = 'open'` + : "WHERE status = 'open'"; + + const totalSignals = count( + db, + `SELECT COUNT(*) AS count FROM incidents ${openedRange.where}`, + openedRange.params, + ); + const open = count(db, `SELECT COUNT(*) AS count FROM incidents ${openWhere}`, openedRange.params); + const resolved = count(db, `SELECT COUNT(*) AS count FROM incidents ${resolvedWhere}`, resolvedRange.params); + + const bySource = groupByColumn(db, "source", openedRange.where, openedRange.params, "(unknown)") + .map((row) => ({ source: row.key, count: row.count })); + const bySeverity = groupByColumn(db, "severity", openedRange.where, openedRange.params, "unknown") + .map((row) => ({ severity: row.key, count: row.count })); + const byStatus = groupByColumn(db, "status", openedRange.where, openedRange.params, "unknown") + .map((row) => ({ status: row.key, count: row.count })); + + return { + from: query.from ?? null, + to: query.to ?? null, + totalSignals, + open, + resolved, + mttr: computeMttr(db, query), + bySource, + bySeverity, + byStatus, + }; +} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index af54729181..04a05d1416 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -142,6 +142,7 @@ import { readAgentLogEntriesByTimeRange, } from "./agent-log-file-store.js"; import { truncateAgentLogDetail } from "./agent-log-constants.js"; +import { emitUsageEvent as emitUsageEventToDb, type UsageEventInput } from "./usage-events.js"; import { validateNodeOverrideChange } from "./node-override-guard.js"; import { sanitizeTitle, summarizeTitle } from "./ai-summarize.js"; import { extractTaskIdTokens, normalizeTitleForTaskId } from "./task-title-id-drift.js"; @@ -158,6 +159,7 @@ import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNod import { detectStalledReview } from "./stalled-review-detector.js"; import { computeRetrySummary } from "./retry-summary.js"; import { archiveAsSameAgentDuplicate, findSameAgentDuplicates } from "./duplicate-intake.js"; +import { isNearDuplicateCanonicalInactive } from "./near-duplicate-canonical.js"; import { detectTaskIdIntegrityAnomalies, type TaskIdIntegrityReport, @@ -231,6 +233,8 @@ interface TaskRow { tokenUsageTotalTokens: number | null; tokenUsageFirstUsedAt: string | null; tokenUsageLastUsedAt: string | null; + tokenUsageModelProvider: string | null; + tokenUsageModelId: string | null; tokenBudgetSoftAlertedAt: string | null; tokenBudgetHardAlertedAt: string | null; tokenBudgetOverride: string | null; @@ -260,6 +264,7 @@ interface TaskRow { sourceIssueExternalIssueId: string | null; sourceIssueNumber: number | null; sourceIssueUrl: string | null; + sourceIssueClosedAt: string | null; mergeDetails: string | null; breakIntoSubtasks: number | null; noCommitsExpected: number | null; @@ -379,6 +384,8 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("tokenUsageTotalTokens", (task) => task.tokenUsage?.totalTokens ?? null), defineTaskColumn("tokenUsageFirstUsedAt", (task) => task.tokenUsage?.firstUsedAt ?? null), defineTaskColumn("tokenUsageLastUsedAt", (task) => task.tokenUsage?.lastUsedAt ?? null), + defineTaskColumn("tokenUsageModelProvider", (task) => task.tokenUsage?.modelProvider ?? null), + defineTaskColumn("tokenUsageModelId", (task) => task.tokenUsage?.modelId ?? null), defineTaskColumn("tokenBudgetSoftAlertedAt", (task) => task.tokenBudgetSoftAlertedAt ?? null), defineTaskColumn("tokenBudgetHardAlertedAt", (task) => task.tokenBudgetHardAlertedAt ?? null), defineTaskColumn("tokenBudgetOverride", (task) => toJsonNullable(task.tokenBudgetOverride)), @@ -408,6 +415,7 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("sourceIssueExternalIssueId", (task) => task.sourceIssue?.externalIssueId ?? null), defineTaskColumn("sourceIssueNumber", (task) => task.sourceIssue?.issueNumber ?? null), defineTaskColumn("sourceIssueUrl", (task) => task.sourceIssue?.url ?? null), + defineTaskColumn("sourceIssueClosedAt", (task) => task.sourceIssue?.closedAt ?? null), defineTaskColumn("mergeDetails", (task) => toJsonNullable(task.mergeDetails)), defineTaskColumn("breakIntoSubtasks", (task) => task.breakIntoSubtasks ? 1 : 0), defineTaskColumn("noCommitsExpected", (task) => task.noCommitsExpected ? 1 : 0), @@ -552,6 +560,8 @@ interface TaskCommitAssociationRow { matchedBy: TaskCommitAssociationMatchSource; confidence: TaskCommitAssociationConfidence; note: string | null; + additions: number | null; + deletions: number | null; createdAt: string; updatedAt: string; } @@ -2012,6 +2022,8 @@ export class TaskStore extends EventEmitter { totalTokens: row.tokenUsageTotalTokens, firstUsedAt: row.tokenUsageFirstUsedAt, lastUsedAt: row.tokenUsageLastUsedAt, + modelProvider: row.tokenUsageModelProvider ?? undefined, + modelId: row.tokenUsageModelId ?? undefined, }; })(), attachments: (() => { const a = fromJson(row.attachments); return a && a.length > 0 ? a : undefined; })(), @@ -2060,6 +2072,7 @@ export class TaskStore extends EventEmitter { externalIssueId: row.sourceIssueExternalIssueId, issueNumber: row.sourceIssueNumber, url: row.sourceIssueUrl ?? undefined, + closedAt: row.sourceIssueClosedAt ?? undefined, }; })(), mergeDetails: fromJson(row.mergeDetails), @@ -2477,10 +2490,10 @@ export class TaskStore extends EventEmitter { "planningModelProvider", "planningModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "executionMode", - "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", + "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", - "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", + "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", @@ -2526,10 +2539,10 @@ export class TaskStore extends EventEmitter { "planningModelProvider", "planningModelId", "mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "graphResumeRetryCount", "resumeLimboTipSha", "resumeLimboStepSignature", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt", "error", "summary", "thinkingLevel", "executionMode", - "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", + "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "attachments", "steeringComments", - "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails", + "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", @@ -6178,6 +6191,78 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return rows.map((row) => this.rowToTask(row)); } + /** + * FNXC:NearDuplicateDetection 2026-06-14-12:00: + * FN-6439 requires the store to reconcile persisted duplicate flags after a canonical becomes inactive. + * sourceMetadataPatch only merges, so this reverse lookup performs a bounded read-modify-write that strips stale near-duplicate keys without pausing or failing the referencing tasks. + */ + private async clearNearDuplicateReferencesTo( + canonicalId: string, + inactiveState: { column?: ColumnId | null; deletedAt?: string | null; reason: string }, + ): Promise { + if (!isNearDuplicateCanonicalInactive(inactiveState)) { + return []; + } + + const selectClause = this.getTaskSelectClause(false, "t"); + const rows = this.db.prepare(` + SELECT ${selectClause} + FROM tasks t + WHERE t."deletedAt" IS NULL + AND t."column" != 'archived' + AND t."column" != 'done' + AND json_extract(t.sourceMetadata, '$.nearDuplicateOf') = ? + ORDER BY t.createdAt ASC + `).all(canonicalId) as TaskRow[]; + + const updatedTasks: Task[] = []; + for (const row of rows) { + const task = this.rowToTask(row); + const nextSourceMetadata = { ...(task.sourceMetadata ?? {}) }; + delete nextSourceMetadata.nearDuplicateOf; + delete nextSourceMetadata.nearDuplicateScore; + delete nextSourceMetadata.nearDuplicateSharedTokens; + delete nextSourceMetadata.nearDuplicateDismissed; + + task.sourceMetadata = Object.keys(nextSourceMetadata).length > 0 ? nextSourceMetadata : undefined; + const updatedAt = new Date().toISOString(); + task.updatedAt = updatedAt; + task.log = [ + ...(task.log ?? []), + { + timestamp: updatedAt, + action: `Near-duplicate canonical ${canonicalId} is now inactive (${inactiveState.reason}); cleared duplicate flag (informational, no decision required)`, + }, + ]; + + this.db.transactionImmediate(() => { + this.upsertTaskWithFtsRecovery(task); + this.db.bumpLastModified(); + }); + await this.writeTaskJsonFile(this.taskDir(task.id), task); + if (this.isWatching) this.taskCache.set(task.id, { ...task }); + this.emit("task:updated", task); + updatedTasks.push(task); + } + + return updatedTasks; + } + + private async clearNearDuplicateReferencesToFailSoft( + canonicalId: string, + inactiveState: { column?: ColumnId | null; deletedAt?: string | null; reason: string }, + ): Promise { + try { + await this.clearNearDuplicateReferencesTo(canonicalId, inactiveState); + } catch (error) { + storeLog.warn("Failed to clear stale near-duplicate references (degraded)", { + taskId: canonicalId, + reason: inactiveState.reason, + error: error instanceof Error ? error.message : String(error), + }); + } + } + async getTasksByAssignedAgent( agentId: string, options?: { pausedOnly?: boolean; excludeArchived?: boolean }, @@ -6664,6 +6749,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }, }); this.enqueueMergeQueue(id, { priority: task.priority, now: internal.now }); + this.createCompletionHandoffWorkflowWork(task, { + runId: internal.runContext?.runId, + now: internal.now, + source: internal.evidence?.reason, + }); this.insertRunAuditEventRow({ taskId: id, agentId: internal.runContext?.agentId, @@ -6691,6 +6781,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} if (this.isWatching) this.taskCache.set(id, { ...task }); this.emit("task:updated", task); } + if (toColumn === "done") { + await this.clearNearDuplicateReferencesToFailSoft(id, { + column: "done", + reason: "done", + }); + } return task; } @@ -7126,6 +7222,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} if (internal.fromHandoff) { alreadyEnqueued = Boolean(this.db.prepare("SELECT 1 FROM mergeQueue WHERE taskId = ?").get(id)); this.enqueueMergeQueue(id, { priority: task.priority, now: internal.now }); + this.createCompletionHandoffWorkflowWork(task, { + runId: internal.runContext?.runId, + now: internal.now, + source: internal.evidence?.reason, + }); this.insertRunAuditEventRow({ taskId: id, agentId: internal.runContext?.agentId, @@ -7224,6 +7325,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} if (fromColumn !== toColumn) { this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource }); } + if (toColumn === "done") { + await this.clearNearDuplicateReferencesToFailSoft(id, { + column: "done", + reason: "done", + }); + } return task; } @@ -8896,6 +9003,10 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return state === "succeeded" || state === "failed" || state === "cancelled" || state === "exhausted"; } + private isActiveWorkflowWorkItemState(state: WorkflowWorkItemState): boolean { + return state === "runnable" || state === "running" || state === "held" || state === "retrying" || state === "manual-required"; + } + private workflowStateForMergeRequestState(state: MergeRequestState): WorkflowWorkItemState { const states: Record = { queued: "runnable", @@ -9054,6 +9165,79 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} }); } + createCompletionHandoffWorkflowWork( + task: Pick, + opts: { runId?: string; now?: string; source?: string } = {}, + ): WorkflowWorkItem { + const autoMerge = task.autoMerge !== false; + const runId = opts.runId ?? `completion-handoff:${task.id}:${randomUUID()}`; + const nodeId = autoMerge ? "merge-gate" : "merge-manual-hold"; + const kind: WorkflowWorkItemKind = autoMerge ? "merge" : "manual-hold"; + const existing = this.getWorkflowWorkItemByIdentity(runId, task.id, nodeId, kind); + if (existing && this.isActiveWorkflowWorkItemState(existing.state)) { + this.cancelActiveWorkflowWorkItemsForTask(task.id, { + kinds: ["merge", "manual-hold"], + excludeIds: [existing.id], + now: opts.now, + lastError: "superseded-by-completion-handoff", + }); + this.insertCompletionHandoffWorkflowWorkAudit(task, existing, autoMerge, opts.source); + return existing; + } + + this.cancelActiveWorkflowWorkItemsForTask(task.id, { + kinds: ["merge", "manual-hold"], + now: opts.now, + lastError: "superseded-by-completion-handoff", + }); + const item = this.upsertWorkflowWorkItem({ + runId, + taskId: task.id, + nodeId, + kind, + state: autoMerge ? "runnable" : "manual-required", + blockedReason: autoMerge ? null : "autoMerge:false", + now: opts.now, + }); + this.insertCompletionHandoffWorkflowWorkAudit(task, item, autoMerge, opts.source); + return item; + } + + private getWorkflowWorkItemByIdentity( + runId: string, + taskId: string, + nodeId: string, + kind: WorkflowWorkItemKind, + ): WorkflowWorkItem | null { + const row = this.db + .prepare("SELECT * FROM workflow_work_items WHERE runId = ? AND taskId = ? AND nodeId = ? AND kind = ?") + .get(runId, taskId, nodeId, kind) as WorkflowWorkItemRow | undefined; + return row ? this.rowToWorkflowWorkItem(row) : null; + } + + private insertCompletionHandoffWorkflowWorkAudit( + task: Pick, + item: WorkflowWorkItem, + autoMerge: boolean, + source?: string, + ): void { + this.insertRunAuditEventRow({ + taskId: task.id, + runId: item.runId, + domain: "database", + mutationType: "workflowWorkItem:completion-handoff", + target: item.id, + metadata: { + taskId: task.id, + autoMerge, + source: source ?? "completion-handoff", + workItemId: item.id, + nodeId: item.nodeId, + state: item.state, + }, + }); + } + upsertWorkflowWorkItem(input: WorkflowWorkItemUpsertInput): WorkflowWorkItem { return this.db.transactionImmediate(() => { const existing = this.db @@ -9195,11 +9379,13 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} cancelActiveWorkflowWorkItemsForTask( taskId: string, - opts: { kinds?: WorkflowWorkItemKind[]; now?: string; lastError?: string | null } = {}, + opts: { kinds?: WorkflowWorkItemKind[]; now?: string; lastError?: string | null; excludeIds?: string[] } = {}, ): WorkflowWorkItem[] { return this.db.transactionImmediate(() => { - const activeStates: WorkflowWorkItemState[] = ["runnable", "running", "held", "retrying", "manual-required"]; - const items = this.listWorkflowWorkItemsForTask(taskId, opts).filter((item) => activeStates.includes(item.state)); + const excludeIds = new Set(opts.excludeIds ?? []); + const items = this.listWorkflowWorkItemsForTask(taskId, opts).filter((item) => + this.isActiveWorkflowWorkItemState(item.state) && !excludeIds.has(item.id) + ); return items.map((item) => this.transitionWorkflowWorkItem(item.id, "cancelled", { now: opts.now, @@ -10141,7 +10327,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} auditContext?: { agentId: string; runId: string; sessionId?: string }; }, ): Promise { - return this.withTaskLock(id, async () => { + const deletedTask = await this.withTaskLock(id, async () => { // Flush buffered agent logs inside the lock so no new appends for this // task can sneak in between flush and soft-delete mutation. this.flushAgentLogBuffer(); @@ -10244,6 +10430,13 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} this.emit("task:deleted", task, { githubIssueAction: options?.githubIssueAction ?? "auto" }); return task; }); + + await this.clearNearDuplicateReferencesToFailSoft(id, { + column: "archived", + deletedAt: deletedTask.deletedAt ?? new Date().toISOString(), + reason: "deleted", + }); + return deletedTask; } private deleteTaskById(taskId: string): void { @@ -10812,7 +11005,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} id: string, optionsOrCleanup: boolean | { cleanup?: boolean; removeLineageReferences?: boolean } = true, ): Promise { - return this.withTaskLock(id, async () => { + const archivedTask = await this.withTaskLock(id, async () => { const dir = this.taskDir(id); const task = await this.readTaskJson(dir); @@ -10898,6 +11091,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} this.emit("task:moved", { task, from: fromColumn, to: "archived" as Column, source: "engine" }); return this.archiveEntryToTask(entry, false); }); + + await this.clearNearDuplicateReferencesToFailSoft(id, { + column: "archived", + reason: "archived", + }); + return archivedTask; } /** @@ -11492,6 +11691,22 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } } + /** + * Append a normalized telemetry row to `usage_events` (tool calls, messages, + * session lifecycle) for the Command Center analytics layer. Callers in the + * executor/session layer pass `model`/`provider`/`nodeId`/`category` from the + * session context (see usage-events.ts / KTD3). + * + * **Fail-soft**: the underlying helper swallows malformed events and write + * errors, so this never throws and never aborts the agent-log write or the + * agent hot path. + * + * @returns `true` if a row was inserted, `false` if the event was skipped. + */ + emitUsageEvent(event: UsageEventInput): boolean { + return emitUsageEventToDb(this.db, event); + } + /** * Flush all buffered agent log entries to per-task JSONL files. * Called when the buffer is full or on a timer. @@ -15989,14 +16204,16 @@ ${notificationsSection}`; }); this.db.prepare( `INSERT INTO task_commit_associations - (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, taskLineageId, taskIdSnapshot, commitSha, commitSubject, authoredAt, matchedBy, confidence, note, additions, deletions, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(taskLineageId, commitSha, matchedBy) DO UPDATE SET taskIdSnapshot = excluded.taskIdSnapshot, commitSubject = excluded.commitSubject, authoredAt = excluded.authoredAt, confidence = excluded.confidence, note = excluded.note, + additions = excluded.additions, + deletions = excluded.deletions, updatedAt = excluded.updatedAt`, ).run( association.id, @@ -16008,6 +16225,8 @@ ${notificationsSection}`; association.matchedBy, association.confidence, association.note ?? null, + association.additions ?? null, + association.deletions ?? null, association.createdAt, association.updatedAt, ); @@ -16018,7 +16237,12 @@ ${notificationsSection}`; const rows = this.db.prepare( `SELECT * FROM task_commit_associations WHERE taskLineageId = ? ORDER BY authoredAt DESC, createdAt DESC`, ).all(lineageId) as TaskCommitAssociationRow[]; - return rows.map((row) => normalizeTaskCommitAssociation({ ...row, note: row.note ?? undefined })); + return rows.map((row) => normalizeTaskCommitAssociation({ + ...row, + note: row.note ?? undefined, + additions: row.additions ?? undefined, + deletions: row.deletions ?? undefined, + })); } async replaceLegacyTaskCommitAssociations( diff --git a/packages/core/src/task-lineage.ts b/packages/core/src/task-lineage.ts index 3ce3013176..14ced13080 100644 --- a/packages/core/src/task-lineage.ts +++ b/packages/core/src/task-lineage.ts @@ -42,6 +42,8 @@ export function normalizeTaskCommitAssociation( return { ...row, note: row.note?.trim() || undefined, + additions: row.additions ?? undefined, + deletions: row.deletions ?? undefined, confidence: row.confidence ?? classifyTaskCommitAssociationConfidence(row.matchedBy), }; } diff --git a/packages/core/src/task-list-format.ts b/packages/core/src/task-list-format.ts new file mode 100644 index 0000000000..a1feb4f7d1 --- /dev/null +++ b/packages/core/src/task-list-format.ts @@ -0,0 +1,83 @@ +export const MAX_TASK_LIST_TEXT_CHARS = 3_000; + +const TRUNCATION_HINT = "truncated to fit; narrow with column/limit"; + +function markerLine(droppedCount: number): string { + return `... and ${droppedCount} more tasks (${TRUNCATION_HINT})`; +} + +function joinWithMarker(lines: string[], marker: string): string { + return [...lines, marker].join("\n"); +} + +/** + * FNXC:TaskListOutput 2026-06-16-17:45: + * FN-6492 requires every fn_task_list surface to emit bounded plain text so column-filtered or otherwise large board listings remain readable to text-only heartbeat agents and stay below host runtimes' imageification thresholds. + * The default budget is intentionally below common MCP attachment-conversion limits while preserving dozens of compact task rows. + * + * FNXC:TaskListOutput 2026-06-18-03:12: + * FN-6629 lowers the budget from 12,000 because realistic column-filtered heartbeat listings stayed under that old clamp while still exceeding the host imageification threshold. Keep the bound in the low-thousands so todo/triage/done limit-50 outputs remain text-only for heartbeat and other text agents. + */ +export function clampTaskListText( + lines: string[], + opts: { maxChars?: number } = {}, +): string { + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS)); + const text = lines.join("\n"); + if (text.length <= maxChars) { + return text; + } + + const droppedTotal = lines.length; + let kept = lines.slice(); + while (kept.length > 0) { + const droppedCount = droppedTotal - kept.length; + const candidate = joinWithMarker(kept, markerLine(droppedCount)); + if (candidate.length <= maxChars) { + return candidate; + } + kept = kept.slice(0, -1); + } + + const marker = markerLine(droppedTotal); + if (marker.length <= maxChars) { + return marker; + } + + return marker.slice(0, Math.max(0, maxChars - 1)) + "…"; +} + +function fallbackClampTaskListText(lines: string[], maxChars: number): string { + const text = lines.join("\n"); + if (text.length <= maxChars) { + return text; + } + + return text.slice(0, Math.max(0, maxChars - 1)) + "…"; +} + +/** + * FNXC:TaskListOutput 2026-06-17-05:44: + * FN-6570 requires fn_task_list tool surfaces to resolve the formatter defensively because stale or mismatched @fusion/core builds can omit the clampTaskListText export and crash ambient heartbeat agents as `(0 , _core.clampTaskListText) is not a function`. + * Keep the canonical clamp as the normal path, but degrade to a bounded inline fallback so board listing tools return text instead of throwing. + */ +export function formatTaskListText( + lines: string[], + opts: { + maxChars?: number; + clamp?: (lines: string[], opts?: { maxChars?: number }) => string; + } = {}, +): string { + const maxChars = Math.max(1, Math.floor(opts.maxChars ?? MAX_TASK_LIST_TEXT_CHARS)); + const clamp = opts.clamp ?? clampTaskListText; + if (typeof clamp !== "function") { + return fallbackClampTaskListText(lines, maxChars); + } + + try { + const text = clamp(lines, { maxChars }); + return typeof text === "string" ? text : fallbackClampTaskListText(lines, maxChars); + } catch { + return fallbackClampTaskListText(lines, maxChars); + } +} diff --git a/packages/core/src/task-priority.ts b/packages/core/src/task-priority.ts index 35c0ad3c76..4919a46eb0 100644 --- a/packages/core/src/task-priority.ts +++ b/packages/core/src/task-priority.ts @@ -1,6 +1,6 @@ import { computeBlockerFanoutMap } from "./blocker-fanout.js"; import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES } from "./types.js"; -import type { Task, TaskPriority } from "./types.js"; +import type { ProjectSettings, Task, TaskPriority } from "./types.js"; export interface TaskPrioritySortable { id: string; @@ -90,7 +90,7 @@ const UNBLOCK_ACTIVE_COLUMNS = new Set(["triage", "todo", "in-pr const DONE_COLUMNS = new Set(["done", "archived"]); export interface BuildUnblockWeightMapOptions { - maxAutoMergeRetries?: number; + maxAutoMergeRetries?: ProjectSettings["maxAutoMergeRetries"]; } function countUnmetDependencies(task: Task, taskById: Map): number { diff --git a/packages/core/src/team-analytics.ts b/packages/core/src/team-analytics.ts new file mode 100644 index 0000000000..ca49585568 --- /dev/null +++ b/packages/core/src/team-analytics.ts @@ -0,0 +1,315 @@ +import type { Database } from "./db.js"; +import { costFor, type CostResult } from "./model-pricing.js"; +import type { TokenTotals } from "./token-analytics.js"; + +export interface TeamAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; + /** Epoch ms "now" used only for pricing-staleness. */ + now?: number; +} + +export interface TeamMetricTotals { + tokens: TokenTotals; + cost: CostResult; + filesChanged: number; + tasksCompleted: number; + tasksInProgress: number; + tasksInReview: number; +} + +export interface TeamAgentSummary extends TeamMetricTotals { + agentId: string; + agentName: string | null; + role: string | null; + state: string | null; +} + +export interface TeamAnalytics { + from: string | null; + to: string | null; + totals: TeamMetricTotals; + agents: TeamAgentSummary[]; +} + +interface AgentRow { + id: string; + name: string | null; + role: string | null; + state: string | null; +} + +interface TaskTokenRow { + agentId: string; + inputTokens: number | null; + outputTokens: number | null; + cachedTokens: number | null; + cacheWriteTokens: number | null; + totalTokens: number | null; + modelProvider: string | null; + modelId: string | null; +} + +interface CountByAgentRow { + agentId: string; + count: number; +} + +interface ModifiedFilesRow { + agentId: string; + modifiedFiles: string | null; +} + +function emptyTokenTotals(): TokenTotals { + return { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + nTasks: 0, + }; +} + +interface CostAccumulator { + usd: number; + anyPriced: boolean; + anyUnavailable: boolean; + anyStale: boolean; +} + +function emptyCostAccumulator(): CostAccumulator { + return { usd: 0, anyPriced: false, anyUnavailable: false, anyStale: false }; +} + +function finalizeCost(acc: CostAccumulator): CostResult { + return { + usd: acc.anyPriced ? acc.usd : null, + unavailable: acc.anyUnavailable, + stale: acc.anyStale, + }; +} + +function addTokenRow(totals: TokenTotals, row: TaskTokenRow): void { + totals.inputTokens += row.inputTokens ?? 0; + totals.outputTokens += row.outputTokens ?? 0; + totals.cachedTokens += row.cachedTokens ?? 0; + totals.cacheWriteTokens += row.cacheWriteTokens ?? 0; + totals.totalTokens += + row.totalTokens ?? + (row.inputTokens ?? 0) + + (row.outputTokens ?? 0) + + (row.cachedTokens ?? 0) + + (row.cacheWriteTokens ?? 0); + totals.nTasks += 1; +} + +function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void { + const result = costFor( + { + inputTokens: row.inputTokens ?? 0, + outputTokens: row.outputTokens ?? 0, + cachedTokens: row.cachedTokens ?? 0, + cacheWriteTokens: row.cacheWriteTokens ?? 0, + }, + { provider: row.modelProvider, model: row.modelId }, + now, + ); + if (result.stale) acc.anyStale = true; + if (result.unavailable || result.usd === null) { + acc.anyUnavailable = true; + } else { + acc.usd += result.usd; + acc.anyPriced = true; + } +} + +function emptyMetricTotals(): TeamMetricTotals { + return { + tokens: emptyTokenTotals(), + cost: { usd: null, unavailable: false, stale: false }, + filesChanged: 0, + tasksCompleted: 0, + tasksInProgress: 0, + tasksInReview: 0, + }; +} + +function countModifiedFiles(value: string | null): number { + if (!value) return 0; + let files: unknown; + try { + files = JSON.parse(value); + } catch { + return 0; + } + if (!Array.isArray(files)) return 0; + let count = 0; + for (const file of files) { + if (typeof file === "string" && file.length > 0) count += 1; + } + return count; +} + +function addRangeClauses(column: string, clauses: string[], params: string[], query: TeamAnalyticsQuery): void { + if (query.from !== undefined) { + clauses.push(`${column} >= ?`); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push(`${column} <= ?`); + params.push(query.to); + } +} + +function makeSummary(agentId: string, agent?: AgentRow): TeamAgentSummary { + return { + agentId, + agentName: agent?.name ?? null, + role: agent?.role ?? null, + state: agent?.state ?? null, + ...emptyMetricTotals(), + }; +} + +/** + * Aggregate store-derived per-agent Command Center metrics over a date range. + * + * FNXC:CommandCenter 2026-06-18-16:57: + * Team analytics derives per-agent tokens/cost, files changed, and tasks completed from the tasks+agents tables only; no new schema, no GitHub-issue data (that is FN-6653). Keep the aggregator pure/read-only and project-scoped by accepting the already-scoped Database handle from the HTTP layer. + */ +export function aggregateTeamAnalytics( + db: Database, + query: TeamAnalyticsQuery = {}, +): TeamAnalytics { + const summaries = new Map(); + const costAccumulators = new Map(); + const totalTokens = emptyTokenTotals(); + const totalCost = emptyCostAccumulator(); + + const agents = db + .prepare(`SELECT id, name, role, state FROM agents ORDER BY id`) + .all() as AgentRow[]; + for (const agent of agents) { + summaries.set(agent.id, makeSummary(agent.id, agent)); + costAccumulators.set(agent.id, emptyCostAccumulator()); + } + + const ensureSummary = (agentId: string): TeamAgentSummary => { + const existing = summaries.get(agentId); + if (existing) return existing; + const created = makeSummary(agentId); + summaries.set(agentId, created); + costAccumulators.set(agentId, emptyCostAccumulator()); + return created; + }; + + const tokenClauses = ["assignedAgentId IS NOT NULL", "tokenUsageLastUsedAt IS NOT NULL"]; + const tokenParams: string[] = []; + addRangeClauses("tokenUsageLastUsedAt", tokenClauses, tokenParams, query); + const tokenRows = db + .prepare( + `SELECT + assignedAgentId AS agentId, + tokenUsageInputTokens AS inputTokens, + tokenUsageOutputTokens AS outputTokens, + tokenUsageCachedTokens AS cachedTokens, + tokenUsageCacheWriteTokens AS cacheWriteTokens, + tokenUsageTotalTokens AS totalTokens, + modelProvider, + modelId + FROM tasks + WHERE ${tokenClauses.join(" AND ")}`, + ) + .all(...tokenParams) as TaskTokenRow[]; + + for (const row of tokenRows) { + const summary = ensureSummary(row.agentId); + const agentCost = costAccumulators.get(row.agentId) ?? emptyCostAccumulator(); + costAccumulators.set(row.agentId, agentCost); + addTokenRow(summary.tokens, row); + addTokenRow(totalTokens, row); + addRowCost(agentCost, row, query.now); + addRowCost(totalCost, row, query.now); + } + + const completedClauses = ["assignedAgentId IS NOT NULL", `"column" = 'done'`, "columnMovedAt IS NOT NULL"]; + const completedParams: string[] = []; + addRangeClauses("columnMovedAt", completedClauses, completedParams, query); + const completedRows = db + .prepare( + `SELECT assignedAgentId AS agentId, COUNT(*) AS count + FROM tasks + WHERE ${completedClauses.join(" AND ")} + GROUP BY assignedAgentId`, + ) + .all(...completedParams) as CountByAgentRow[]; + for (const row of completedRows) { + ensureSummary(row.agentId).tasksCompleted = row.count; + } + + const currentRows = db + .prepare( + `SELECT assignedAgentId AS agentId, "column" AS columnName, COUNT(*) AS count + FROM tasks + WHERE assignedAgentId IS NOT NULL AND "column" IN ('in-progress', 'in-review') + GROUP BY assignedAgentId, "column"`, + ) + .all() as Array; + for (const row of currentRows) { + const summary = ensureSummary(row.agentId); + if (row.columnName === "in-progress") summary.tasksInProgress = row.count; + if (row.columnName === "in-review") summary.tasksInReview = row.count; + } + + const filesClauses = ["assignedAgentId IS NOT NULL", "modifiedFiles IS NOT NULL", "modifiedFiles NOT IN ('', '[]')"]; + const filesParams: string[] = []; + addRangeClauses("updatedAt", filesClauses, filesParams, query); + const fileRows = db + .prepare( + `SELECT assignedAgentId AS agentId, modifiedFiles + FROM tasks + WHERE ${filesClauses.join(" AND ")}`, + ) + .all(...filesParams) as ModifiedFilesRow[]; + for (const row of fileRows) { + ensureSummary(row.agentId).filesChanged += countModifiedFiles(row.modifiedFiles); + } + + for (const [agentId, summary] of summaries) { + summary.cost = finalizeCost(costAccumulators.get(agentId) ?? emptyCostAccumulator()); + } + + let filesChanged = 0; + let tasksCompleted = 0; + let tasksInProgress = 0; + let tasksInReview = 0; + for (const summary of summaries.values()) { + filesChanged += summary.filesChanged; + tasksCompleted += summary.tasksCompleted; + tasksInProgress += summary.tasksInProgress; + tasksInReview += summary.tasksInReview; + } + + const sortedAgents = [...summaries.values()].sort((a, b) => { + const tokenCmp = b.tokens.totalTokens - a.tokens.totalTokens; + if (tokenCmp !== 0) return tokenCmp; + return a.agentId.localeCompare(b.agentId); + }); + + return { + from: query.from ?? null, + to: query.to ?? null, + totals: { + tokens: totalTokens, + cost: finalizeCost(totalCost), + filesChanged, + tasksCompleted, + tasksInProgress, + tasksInReview, + }, + agents: sortedAgents, + }; +} diff --git a/packages/core/src/token-analytics.ts b/packages/core/src/token-analytics.ts new file mode 100644 index 0000000000..f7ee561263 --- /dev/null +++ b/packages/core/src/token-analytics.ts @@ -0,0 +1,330 @@ +import type { Database } from "./db.js"; +import { costFor, type CostResult } from "./model-pricing.js"; + +/** + * Token-consumption analytics over the `tasks` table, generalizing the fixed + * 24h/7d/all-time windows of `agent-token-usage.ts` to an arbitrary `(from, to)` + * range. Sums the `tokenUsage*` columns filtered by `tokenUsageLastUsedAt` and + * groups by model / provider / node / agent. + * + * Inclusivity: `from`/`to` bounds are **inclusive** (`>= from AND <= to`), + * matching `usage-events.ts` and the range-scan house style. A task whose + * `tokenUsageLastUsedAt` is exactly equal to `from` is therefore included. + * + * Pure read-only aggregation: takes a `Database` handle and returns plain data. + */ + +/** Dimension to group token totals by. */ +export type TokenGroupBy = "model" | "provider" | "node" | "agent"; + +/** Bucket size for optional token-usage time-series analytics. */ +export type TokenTimeGranularity = "hour" | "day" | "week"; + +/** Summed token counts for a group (or the grand total). */ +export interface TokenTotals { + inputTokens: number; + outputTokens: number; + cachedTokens: number; + cacheWriteTokens: number; + totalTokens: number; + /** Number of tasks that contributed to these totals. */ + nTasks: number; +} + +/** One group's token totals, keyed by the grouped dimension value. */ +export interface TokenGroupSummary extends TokenTotals { + /** The group key (model id, provider, nodeId, or agentId); null when unset. */ + key: string | null; + /** + * Derived USD cost for this group (U3). Each contributing task is priced at + * its own model's rates and summed, so the cost is meaningful for any + * `groupBy`. `usd` is null when none of the group's tasks had a known price; + * `unavailable` is true when at least one task's model was unpriced. + */ + cost: CostResult; +} + +/** One time bucket in the optional token-usage series. */ +export interface TokenTimePoint extends TokenTotals { + /** UTC bucket key (`YYYY-MM-DDTHH`, `YYYY-MM-DD`, or ISO week `YYYY-Www`). */ + bucket: string; + /** Derived USD cost for this bucket, summed per contributing task. */ + cost: CostResult; +} + +/** Result of {@link aggregateTokenAnalytics}. */ +export interface TokenAnalytics { + from: string | null; + to: string | null; + groupBy: TokenGroupBy | null; + /** Grand total across all matched tasks. */ + totals: TokenTotals; + /** + * Derived USD cost across all matched tasks (U3), each priced at its own + * model's rates. `usd` is null when no task had a known price; `unavailable` + * is true when at least one task's model had no pricing entry. + */ + cost: CostResult; + /** Per-group totals; empty array when no `groupBy` requested. */ + groups: TokenGroupSummary[]; + /** Optional token-usage totals over time, present only when requested. */ + series?: TokenTimePoint[]; +} + +export interface TokenAnalyticsQuery { + /** ISO-8601 lower bound (inclusive) on `tokenUsageLastUsedAt`. */ + from?: string; + /** ISO-8601 upper bound (inclusive) on `tokenUsageLastUsedAt`. */ + to?: string; + groupBy?: TokenGroupBy; + /** Optional UTC bucket size for a token-usage time series. */ + granularity?: TokenTimeGranularity; + /** + * Epoch ms "now" used only for pricing-staleness (U3). When omitted, derived + * cost is never marked stale. Pure: the module never reads the clock itself. + */ + now?: number; +} + +function emptyTotals(): TokenTotals { + return { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + nTasks: 0, + }; +} + +interface TaskTokenRow { + inputTokens: number | null; + outputTokens: number | null; + cachedTokens: number | null; + cacheWriteTokens: number | null; + totalTokens: number | null; + modelProvider: string | null; + modelId: string | null; + tokenUsageModelProvider: string | null; + tokenUsageModelId: string | null; + checkoutNodeId: string | null; + assignedAgentId: string | null; + tokenUsageLastUsedAt: string; +} + +function groupKeyFor(row: TaskTokenRow, groupBy: TokenGroupBy): string | null { + switch (groupBy) { + case "model": + /* + * FNXC:TokenAnalytics 2026-06-18-16:23: + * By-model analytics must prefer the analytics-only actually-used model snapshot because task.modelId is only an own-model override. Fall back to legacy task.modelId so pre-snapshot rows keep their historical grouping and never throw. + */ + return row.tokenUsageModelId ?? row.modelId; + case "provider": + return row.tokenUsageModelProvider ?? row.modelProvider; + case "node": + return row.checkoutNodeId; + case "agent": + return row.assignedAgentId; + } +} + +/** + * Running cost tally. Each task is priced at its own model, then summed: `usd` + * accumulates priced tasks, `anyUnavailable` records whether any task's model + * was unpriced, `anyStale` whether the pricing map was stale, and `anyPriced` + * whether at least one task had a known price. {@link finalizeCost} converts + * this to a {@link CostResult}. + */ +interface CostAccumulator { + usd: number; + anyPriced: boolean; + anyUnavailable: boolean; + anyStale: boolean; +} + +function emptyCostAccumulator(): CostAccumulator { + return { usd: 0, anyPriced: false, anyUnavailable: false, anyStale: false }; +} + +function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void { + /* + * FNXC:CommandCenter 2026-06-18-12:00: + * Token cost attribution must use the actually-used model snapshot first, then legacy own-model columns, matching groupKeyFor so resolved-via-settings tasks show priced Command Center costs instead of unavailable groups. + */ + const result = costFor( + { + inputTokens: row.inputTokens ?? 0, + outputTokens: row.outputTokens ?? 0, + cachedTokens: row.cachedTokens ?? 0, + cacheWriteTokens: row.cacheWriteTokens ?? 0, + }, + { + provider: row.tokenUsageModelProvider ?? row.modelProvider, + model: row.tokenUsageModelId ?? row.modelId, + }, + now, + ); + if (result.stale) acc.anyStale = true; + if (result.unavailable || result.usd === null) { + acc.anyUnavailable = true; + } else { + acc.usd += result.usd; + acc.anyPriced = true; + } +} + +function finalizeCost(acc: CostAccumulator): CostResult { + return { + usd: acc.anyPriced ? acc.usd : null, + unavailable: acc.anyUnavailable, + stale: acc.anyStale, + }; +} + +function addRow(totals: TokenTotals, row: TaskTokenRow): void { + totals.inputTokens += row.inputTokens ?? 0; + totals.outputTokens += row.outputTokens ?? 0; + totals.cachedTokens += row.cachedTokens ?? 0; + totals.cacheWriteTokens += row.cacheWriteTokens ?? 0; + // Prefer the persisted total when present; otherwise derive it from the parts + // so callers always get a coherent `totalTokens` even on older rows. + const persistedTotal = row.totalTokens; + totals.totalTokens += + persistedTotal ?? + (row.inputTokens ?? 0) + + (row.outputTokens ?? 0) + + (row.cachedTokens ?? 0) + + (row.cacheWriteTokens ?? 0); + totals.nTasks += 1; +} + +function isoWeekBucket(isoTimestamp: string): string { + const date = new Date(isoTimestamp); + if (!Number.isFinite(date.getTime())) return isoTimestamp.slice(0, 10); + const day = date.getUTCDay() || 7; + const thursday = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 4 - day)); + const yearStart = new Date(Date.UTC(thursday.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((thursday.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +function bucketFor(row: TaskTokenRow, granularity: TokenTimeGranularity): string { + switch (granularity) { + case "hour": + return row.tokenUsageLastUsedAt.slice(0, 13); + case "day": + return row.tokenUsageLastUsedAt.slice(0, 10); + case "week": + return isoWeekBucket(row.tokenUsageLastUsedAt); + } +} + +/** + * Aggregate per-task token usage over a date range, optionally grouped. + * + * Tasks are matched by `tokenUsageLastUsedAt` within `[from, to]` (inclusive). + * Tasks with no token usage (`tokenUsageLastUsedAt IS NULL`) are excluded. An + * empty range yields zeroed `totals` and an empty `groups` array — never nulls. + * + * FNXC:CommandCenter 2026-06-18-00:00: + * The Command Center token view needs a live, scalable, animated token-over-time chart without changing existing CSV/OTel consumers. Keep `series` opt-in via `granularity`, bucket ISO timestamps in UTC (substring for hour/day, ISO-week in JS), and reuse per-task cost accumulation so each bucket prices mixed known/unknown models correctly. + */ +export function aggregateTokenAnalytics( + db: Database, + query: TokenAnalyticsQuery = {}, +): TokenAnalytics { + const clauses: string[] = ["tokenUsageLastUsedAt IS NOT NULL"]; + const params: string[] = []; + if (query.from !== undefined) { + clauses.push("tokenUsageLastUsedAt >= ?"); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push("tokenUsageLastUsedAt <= ?"); + params.push(query.to); + } + const where = `WHERE ${clauses.join(" AND ")}`; + + const rows = db + .prepare( + `SELECT + tokenUsageInputTokens AS inputTokens, + tokenUsageOutputTokens AS outputTokens, + tokenUsageCachedTokens AS cachedTokens, + tokenUsageCacheWriteTokens AS cacheWriteTokens, + tokenUsageTotalTokens AS totalTokens, + modelProvider, + modelId, + tokenUsageModelProvider, + tokenUsageModelId, + checkoutNodeId, + assignedAgentId, + tokenUsageLastUsedAt + FROM tasks ${where}`, + ) + .all(...params) as TaskTokenRow[]; + + const totals = emptyTotals(); + const totalCost = emptyCostAccumulator(); + const groupMap = new Map(); + const groupCostMap = new Map(); + const seriesMap = new Map(); + const seriesCostMap = new Map(); + const groupBy = query.groupBy; + const granularity = query.granularity; + const now = query.now; + + for (const row of rows) { + addRow(totals, row); + addRowCost(totalCost, row, now); + if (groupBy) { + const key = groupKeyFor(row, groupBy); + let group = groupMap.get(key); + if (!group) { + group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } }; + groupMap.set(key, group); + groupCostMap.set(key, emptyCostAccumulator()); + } + addRow(group, row); + addRowCost(groupCostMap.get(key)!, row, now); + } + if (granularity) { + const bucket = bucketFor(row, granularity); + let point = seriesMap.get(bucket); + if (!point) { + point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } }; + seriesMap.set(bucket, point); + seriesCostMap.set(bucket, emptyCostAccumulator()); + } + addRow(point, row); + addRowCost(seriesCostMap.get(bucket)!, row, now); + } + } + + // Finalize per-group cost from each group's accumulator. + for (const [key, group] of groupMap) { + group.cost = finalizeCost(groupCostMap.get(key)!); + } + + const groups = [...groupMap.values()].sort( + (a, b) => b.totalTokens - a.totalTokens, + ); + + for (const [bucket, point] of seriesMap) { + point.cost = finalizeCost(seriesCostMap.get(bucket)!); + } + const series = granularity + ? [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket)) + : undefined; + + return { + from: query.from ?? null, + to: query.to ?? null, + groupBy: groupBy ?? null, + totals, + cost: finalizeCost(totalCost), + groups, + ...(granularity ? { series } : {}), + }; +} diff --git a/packages/core/src/tool-analytics.ts b/packages/core/src/tool-analytics.ts new file mode 100644 index 0000000000..6d1ac96ff9 --- /dev/null +++ b/packages/core/src/tool-analytics.ts @@ -0,0 +1,233 @@ +import type { Database } from "./db.js"; +import { categorizeToolName } from "./usage-events.js"; +import type { SteeringComment } from "./types.js"; + +/** + * Tool-usage analytics over `usage_events`, plus the **autonomy ratio**. + * + * Autonomy ratio = tool_call count / human-intervention count. The denominator + * is NOT raw user messages (which trend to zero for autonomous execution); it is + * the count of human interventions, which has **three distinct sources** — they + * are not one queryable table: + * + * 1. **Approvals** — rows in `approval_request_audit_events` whose `eventType` + * is `created` or `approved` (a human was asked to / did approve an action), + * timestamped by `createdAt`. + * 2. **User-authored steers** — entries in the `steeringComments` JSON column + * on the `tasks` row, filtered to `author === "user"` (agent-authored steers + * are excluded), timestamped by each comment's `createdAt`. + * 3. **Waiting-on-input** — a task *status*, not a counted event; intentionally + * DROPPED here (no concrete answer event is defined). + * + * A fully-autonomous session (zero interventions) must not divide by zero or + * report ∞: when `interventions === 0` the ratio falls back to + * tool-calls-per-session (`toolCalls / max(sessions, 1)`), and the result flags + * `interventions: 0` so callers can render it as "fully autonomous". + * + * Inclusivity: `from`/`to` bounds are inclusive, matching `usage-events.ts`. + */ + +export interface ToolAnalyticsQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; +} + +/** Tool-call count for a single coarse category. */ +export interface ToolCategoryCount { + category: string; + count: number; +} + +/** Breakdown of the autonomy-ratio denominator by source. */ +export interface InterventionBreakdown { + /** `created`/`approved` rows in `approval_request_audit_events`. */ + approvals: number; + /** `steeringComments` entries with `author === "user"`. */ + userSteers: number; + /** Total human interventions (sum of the components above). */ + total: number; +} + +export interface ToolAnalytics { + from: string | null; + to: string | null; + /** Total `tool_call` events in range. */ + toolCalls: number; + /** Tool calls grouped by `category`, descending by count. */ + byCategory: ToolCategoryCount[]; + /** Distinct sessions (`session_start` events) in range. */ + sessions: number; + interventions: InterventionBreakdown; + /** + * Autonomy ratio. When `interventions.total > 0` this is + * `toolCalls / interventions.total`. When there are zero interventions it is + * tool-calls-per-session (`toolCalls / max(sessions, 1)`) and + * `fullyAutonomous` is true — never ∞ or NaN. + */ + autonomyRatio: number; + /** True when zero human interventions were recorded in range. */ + fullyAutonomous: boolean; +} + +interface CountRow { + count: number; +} + +interface CategoryRow { + toolName: string | null; + category: string | null; + count: number; +} + +interface SteeringRow { + steeringComments: string | null; +} + +function inRange(ts: string, from?: string, to?: string): boolean { + if (from !== undefined && ts < from) return false; + if (to !== undefined && ts > to) return false; + return true; +} + +/** + * Count human interventions from the three named sources (waiting-on-input is a + * status, not counted). Returns the per-source breakdown plus the total. + */ +export function countInterventions( + db: Database, + query: ToolAnalyticsQuery = {}, +): InterventionBreakdown { + // Source 1: approvals. `approval_request_audit_events.createdAt` is the ts; + // count only the human-touch event types. + const approvalClauses: string[] = ["eventType IN ('created', 'approved')"]; + const approvalParams: string[] = []; + if (query.from !== undefined) { + approvalClauses.push("createdAt >= ?"); + approvalParams.push(query.from); + } + if (query.to !== undefined) { + approvalClauses.push("createdAt <= ?"); + approvalParams.push(query.to); + } + const approvals = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM approval_request_audit_events WHERE ${approvalClauses.join(" AND ")}`, + ) + .get(...approvalParams) as CountRow + ).count; + + // Source 2: user-authored steers from the `steeringComments` JSON on tasks. + // This re-introduces a per-task JSON read (documented in U2). Only rows with a + // non-empty JSON array are scanned. + const steeringRows = db + .prepare( + `SELECT steeringComments FROM tasks + WHERE steeringComments IS NOT NULL AND steeringComments NOT IN ('', '[]')`, + ) + .all() as SteeringRow[]; + let userSteers = 0; + for (const row of steeringRows) { + if (!row.steeringComments) continue; + let parsed: SteeringComment[]; + try { + parsed = JSON.parse(row.steeringComments) as SteeringComment[]; + } catch { + continue; + } + if (!Array.isArray(parsed)) continue; + for (const comment of parsed) { + if (comment?.author !== "user") continue; + if (!inRange(comment.createdAt ?? "", query.from, query.to)) continue; + userSteers += 1; + } + } + + return { approvals, userSteers, total: approvals + userSteers }; +} + +/** + * Aggregate tool usage and the autonomy ratio over a date range. + * + * Empty range yields zeroed structures (not nulls) and `autonomyRatio: 0`. + */ +export function aggregateToolAnalytics( + db: Database, + query: ToolAnalyticsQuery = {}, +): ToolAnalytics { + const eventClauses: string[] = []; + const eventParams: string[] = []; + if (query.from !== undefined) { + eventClauses.push("ts >= ?"); + eventParams.push(query.from); + } + if (query.to !== undefined) { + eventClauses.push("ts <= ?"); + eventParams.push(query.to); + } + const rangeWhere = eventClauses.length > 0 ? `AND ${eventClauses.join(" AND ")}` : ""; + + const toolCalls = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM usage_events WHERE kind = 'tool_call' ${rangeWhere}`, + ) + .get(...eventParams) as CountRow + ).count; + + /** + * FNXC:CommandCenter 2026-06-17-21:43: + * Historical usage rows were logged with `category = "other"` before Fusion tool families were mapped, so aggregation must re-derive those buckets from `toolName`. + * Preserve explicit non-`other` categories because external callers may already provide a deliberate custom bucket. + */ + const categoryRows = db + .prepare( + `SELECT toolName AS toolName, category AS category, COUNT(*) AS count + FROM usage_events + WHERE kind = 'tool_call' ${rangeWhere} + GROUP BY toolName, category`, + ) + .all(...eventParams) as CategoryRow[]; + const categoryCounts = new Map(); + for (const row of categoryRows) { + const category = row.category && row.category !== "other" ? row.category : categorizeToolName(row.toolName); + categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + row.count); + } + const byCategory: ToolCategoryCount[] = [...categoryCounts.entries()] + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count || a.category.localeCompare(b.category)); + + const sessions = ( + db + .prepare( + `SELECT COUNT(*) AS count FROM usage_events WHERE kind = 'session_start' ${rangeWhere}`, + ) + .get(...eventParams) as CountRow + ).count; + + const interventions = countInterventions(db, query); + + let autonomyRatio: number; + let fullyAutonomous: boolean; + if (interventions.total > 0) { + autonomyRatio = toolCalls / interventions.total; + fullyAutonomous = false; + } else { + // Zero interventions: report tool-calls-per-session, never ∞ / divide-by-zero. + autonomyRatio = toolCalls / Math.max(sessions, 1); + fullyAutonomous = true; + } + + return { + from: query.from ?? null, + to: query.to ?? null, + toolCalls, + byCategory, + sessions, + interventions, + autonomyRatio, + fullyAutonomous, + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c8a0b1f83e..39c31184cc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1179,6 +1179,12 @@ export interface TaskSourceIssue { issueNumber: number; /** Optional canonical URL to the source issue. */ url?: string; + /** + * FNXC:GithubSourceIssueAnalytics 2026-06-18-17:56: + * Command Center "Fixed by Fusion" analytics need the real source-issue closure time when Fusion closed or observed the issue, replacing the prior `updatedAt` completion approximation when exact data is available. + * ISO-8601 timestamp for when the source issue was closed; absent when the issue has never been observed closed. + */ + closedAt?: string; } export interface BatchStatusRequest { @@ -1240,6 +1246,8 @@ export type ActivityEventType = | "task:auto-archived-deterministic-duplicate" | "task:auto-archived-near-duplicate" | "task:near-duplicate-flagged" + /** FNXC:ReleaseAuthorizationGate 2026-06-15-02:44: Release-class tasks parked by triage need a distinct activity so operators can see that explicit user approval is required before dispatch. */ + | "task:release-authorization-required" | "task:auto-archived-ghost-bug" | "task:auto-archived-duplicate" | "task:merge-worktree-reacquired" @@ -1812,6 +1820,16 @@ export interface TaskTokenUsage { firstUsedAt: string; /** ISO-8601 timestamp of the most recent recorded usage event for this task. */ lastUsedAt: string; + /** + * FNXC:TokenAnalytics 2026-06-18-16:23: + * Snapshot the provider of the actually-used model for analytics only. This is intentionally distinct from task.modelProvider, which is an own-model override used by model resolution and must not be written by token bookkeeping. + */ + modelProvider?: string; + /** + * FNXC:TokenAnalytics 2026-06-18-16:23: + * Snapshot the id of the actually-used model for analytics only. This is intentionally distinct from task.modelId, which is an own-model override used by model resolution and must not be written by token bookkeeping. + */ + modelId?: string; } export interface TaskTokenBudget { @@ -2814,6 +2832,22 @@ export interface GlobalSettings { * of per-task or per-lane overrides. No network calls, zero token cost. * Project `testMode` takes precedence over the global value. */ testMode?: boolean; + /** Fusion Model Router opt-in (U17/KTD9). When true, a conservative selection + * layer may down-route an allowlist of mechanical steps (dependabot bumps, + * lint-only fixes) to a cheap model tier before a session starts; everything + * else resolves to the configured default pair. OFF by default — when unset or + * false, model resolution is byte-identical to its non-router behavior. + * Selection is governed: it never returns a pair the model controls forbid and + * always defers to a column-agent override. */ + modelRouterEnabled?: boolean; + /** Provider for the Model Router's cheap tier (U17). Used only when + * `modelRouterEnabled` is true and a step is allowlisted for down-routing. + * Must be set together with `modelRouterCheapModelId`; if either is unset the + * router falls back to the configured default pair. */ + modelRouterCheapProvider?: string; + /** Model ID for the Model Router's cheap tier (U17). See + * `modelRouterCheapProvider`. */ + modelRouterCheapModelId?: string; /** Phase-1 FN-5741 write-only shadow seam toggle. * When true, executor/self-healing/merger persist additive merge-request contract * records and completion-handoff markers without changing merge authority. @@ -3170,9 +3204,14 @@ export interface GlobalSettings { * "another-experiment": false * } * - * Default: workflow columns, graph executor, dual-observe, and authoritative - * interpreter flags enabled; operators may explicitly set individual flags - * false while rollout controls remain available. */ + * Default: workflow columns, graph executor, dual-observe, authoritative + * interpreter, and `claudeCliAcp` flags enabled; operators may explicitly set + * individual flags false while rollout controls remain available. + * + * `claudeCliAcp` (default ON): routes the Claude CLI provider through the + * `claude-code-cli-acp` ACP bridge instead of `claude -p`. Effective only when + * the acp-runtime plugin is installed (it publishes the bundled bridge path); + * otherwise the provider fails closed to `-p`. Set false to force `-p`. */ experimentalFeatures?: Record; /** Per-adapter CLI-agent launch configuration (CLI Agent Executor, U15). * Keyed by adapter id (e.g. `"claude-code"`, `"codex"`, `"generic"`). Each @@ -3377,6 +3416,13 @@ export interface ProjectSettings { * be enforced server-side. Only applies when `mergeStrategy === "pull-request"`. * Default: false. */ requirePrApproval?: boolean; + /** When true (default), the Review-response loop automatically acts on PR review + * threads (human + bot): it dispatches an agent that fixes + pushes + replies, or + * disagrees with reasoning. When false, the loop is inert — review threads are left + * untouched for a human to handle. Independent of `autoMerge`: with auto-resolution + * on but auto-merge off, threads are still resolved but the PR is NOT merged (the + * human checkpoint remains merge). U18, R15. Default: true. */ + autoResolveReviewComments?: boolean; /** Direct-merge commit routing mode. * - "auto": squash single-substantive branches, preserve history for multi-substantive branches * - "always-squash": always use the legacy squash path for direct merges @@ -3638,6 +3684,14 @@ export interface ProjectSettings { /** Strategy used when a merge conflict can't be resolved by AI. See * {@link MergeConflictStrategy}. Default: "smart". */ mergeConflictStrategy?: MergeConflictStrategy; + /** + * FNXC:AutoMergeRetries 2026-06-17-04:20: + * The auto-merge conflict-resolution retry cap is project-configurable so operators can tune when tasks park for human visibility. Default 3 preserves the historical fixed cap; non-positive or non-finite values fall back to the default. + * + * Maximum number of auto-merge conflict-resolution retries before a task is + * parked as failed for manual recovery. Must be a positive integer. Default: 3. + */ + maxAutoMergeRetries?: number; /** AI merge path configuration (FN-5633). See {@link MergerSettings}. * When mode is "ai" (default), the standalone AI merge path is used and the * legacy merge settings above/below it do not apply. */ @@ -3706,6 +3760,12 @@ export interface ProjectSettings { verificationFixRetries?: number; /** Timeout in milliseconds for build commands during merge. Default: 300000 (5 min). */ buildTimeoutMs?: number; + /** + * FNXC:Verification 2026-06-17-14:20: + * Engine verification commands need a durable project-level budget so marathon test runs abort cleanly instead of tripping the stuck detector and requeueing forever. + * When set, this millisecond value overrides both fn_run_verification scope defaults (package 300s, workspace 900s); when unset, the legacy per-scope defaults still apply. + */ + verificationCommandTimeoutMs?: number; /** When enabled, AI-generated task specifications require manual approval * before the task can move from triage to todo. Tasks with approved specs * remain in triage with status "awaiting-approval" until a user approves @@ -4327,6 +4387,8 @@ export interface TaskCommitAssociation { matchedBy: TaskCommitAssociationMatchSource; confidence: TaskCommitAssociationConfidence; note?: string; + additions?: number; + deletions?: number; createdAt: string; updatedAt: string; } diff --git a/packages/core/src/usage-events.ts b/packages/core/src/usage-events.ts new file mode 100644 index 0000000000..fbce4c90e1 --- /dev/null +++ b/packages/core/src/usage-events.ts @@ -0,0 +1,340 @@ +import type { Database } from "./db.js"; + +/** + * Queryable telemetry of agent activity (tool calls, messages, session + * lifecycle), persisted to the `usage_events` table (db.ts schema). This is the + * normalized source the Command Center analytics layer reads from, so it does + * not have to parse per-task JSONL agent logs at query time. + * + * Events are appended via {@link emitUsageEvent} from the executor/session layer + * where `model`/`provider`/`nodeId`/`category` are already in scope (see + * KTD3/U1). The append helper is intentionally fail-soft: a malformed event or a + * write error is swallowed so it never aborts the underlying agent-log write or + * the agent hot path. + */ + +/** + * The kind of activity an event records. + * + * - `tool_call` — an agent invoked a tool (agent-log `type: "tool"` maps here; + * `AgentLogType` has no `tool_call` member). + * - `tool_result` / `tool_error` — the tool completed / failed. + * - `user_message` — a human-authored message (chat/CLI sessions). + * - `session_start` / `session_stop` — session lifecycle. + */ +export type UsageEventKind = + | "tool_call" + | "tool_result" + | "tool_error" + | "user_message" + | "session_start" + | "session_stop"; + +const USAGE_EVENT_KINDS: ReadonlySet = new Set([ + "tool_call", + "tool_result", + "tool_error", + "user_message", + "session_start", + "session_stop", +]); + +/** + * Maximum serialized byte size of a `meta` payload. Events whose `meta` + * exceeds this cap are rejected at write (the whole event is skipped) rather + * than truncated, so an oversized payload can never silently land partial data. + */ +export const USAGE_EVENT_META_MAX_BYTES = 4096; + +/** An event to append to `usage_events`. */ +export interface UsageEventInput { + kind: UsageEventKind; + /** ISO-8601 timestamp. Defaults to now when omitted. */ + ts?: string; + taskId?: string | null; + agentId?: string | null; + /** Workflow/session node this event belongs to; null when no node context. */ + nodeId?: string | null; + model?: string | null; + provider?: string | null; + toolName?: string | null; + category?: string | null; + /** + * Non-sensitive descriptors only (error code, category, duration). NEVER tool + * arguments/content or credential-class fields. Capped at + * {@link USAGE_EVENT_META_MAX_BYTES}; over the cap, the event is rejected. + */ + meta?: Record | null; +} + +/** A row read back from `usage_events`. */ +export interface UsageEvent { + id: number; + ts: string; + kind: UsageEventKind; + taskId: string | null; + agentId: string | null; + nodeId: string | null; + model: string | null; + provider: string | null; + toolName: string | null; + category: string | null; + meta: Record | null; +} + +interface UsageEventRow { + id: number; + ts: string; + kind: string; + taskId: string | null; + agentId: string | null; + nodeId: string | null; + model: string | null; + provider: string | null; + toolName: string | null; + category: string | null; + meta: string | null; +} + +/** + * Coarse tool category derived from a tool name, for the Tools analytics area. + * Pure and side-effect free; callers may also pass an explicit `category`. + * + * FNXC:CommandCenter 2026-06-17-21:35: + * Fusion agents mostly call namespaced `fn_*` tools, so Command Center analytics must bucket those families meaningfully instead of letting the Tools chart collapse into `other`. + * Keep this mapping pure and lowercase-normalized because it is used both at log-write time and when re-bucketing historical rows. + */ +export function categorizeToolName(toolName: string | null | undefined): string { + if (!toolName) return "other"; + const name = toolName.trim().toLowerCase(); + if (!name) return "other"; + + if (name.startsWith("fn_task_import_github") || name.startsWith("fn_task_browse_github")) { + return "github"; + } + if (name === "fn_web_fetch") return "network"; + if (name.startsWith("fn_secret_")) return "secrets"; + if (name.startsWith("fn_skills_")) return "skills"; + if (name.startsWith("fn_memory_")) return "memory"; + if (name === "fn_list_agents" || name === "fn_agent_org_chart") return "read"; + if (name.startsWith("fn_agent_") || name === "fn_delegate_task") return "agents"; + if ( + name.startsWith("fn_mission_") || + name.startsWith("fn_milestone_") || + name.startsWith("fn_slice_") || + name.startsWith("fn_feature_") || + name.startsWith("fn_goal_") || + name === "fn_task_plan" + ) { + return "planning"; + } + if (name.startsWith("fn_research_") || name.startsWith("fn_insight_") || name.startsWith("fn_experiment_")) { + return "research"; + } + if (name.startsWith("fn_workflow_") || name === "fn_review_spec") return "workflow"; + + if ( + name === "read" || + name === "grep" || + name === "glob" || + name === "ls" || + name.includes("search") || + name === "fn_list_agents" || + name === "fn_agent_org_chart" || + name === "fn_task_document_read" || + name.endsWith("_list") || + name.endsWith("_show") || + name.endsWith("_get") || + name.endsWith("_search") + ) { + return "read"; + } + if ( + name === "edit" || + name === "write" || + name === "multiedit" || + name.includes("notebook") || + name === "fn_task_create" || + name === "fn_task_update" || + name === "fn_task_attach" || + name === "fn_task_pause" || + name === "fn_task_unpause" || + name === "fn_task_retry" || + name === "fn_task_duplicate" || + name === "fn_task_refine" || + name === "fn_task_archive" || + name === "fn_task_unarchive" || + name === "fn_task_delete" || + name === "fn_task_document_write" + ) { + return "edit"; + } + if (name === "bash" || name.includes("exec") || name.includes("command") || name.includes("terminal")) { + return "execute"; + } + if (name.includes("web") || name.includes("fetch") || name.includes("http")) { + return "network"; + } + return "other"; +} + +/** + * Validate and serialize a `meta` payload. Returns the serialized JSON string, + * or throws if it exceeds the byte cap. `null`/`undefined` serialize to `null`. + */ +function serializeMeta(meta: Record | null | undefined): string | null { + if (meta === undefined || meta === null) return null; + const serialized = JSON.stringify(meta); + if (serialized === undefined) return null; + if (Buffer.byteLength(serialized, "utf8") > USAGE_EVENT_META_MAX_BYTES) { + throw new Error( + `usage_events meta payload exceeds ${USAGE_EVENT_META_MAX_BYTES} bytes (got ${Buffer.byteLength(serialized, "utf8")})`, + ); + } + return serialized; +} + +/** + * Append a single usage event. **Fail-soft**: a malformed event (unknown kind), + * an oversized `meta`, or any DB error is logged and swallowed — it must never + * throw, so it cannot abort the underlying agent-log write or the hot path. + * + * @returns `true` if the row was inserted, `false` if the event was skipped. + */ +export function emitUsageEvent(db: Database, event: UsageEventInput): boolean { + try { + if (!event || !USAGE_EVENT_KINDS.has(event.kind)) { + return false; + } + const ts = event.ts ?? new Date().toISOString(); + const meta = serializeMeta(event.meta); + db.prepare( + `INSERT INTO usage_events + (ts, kind, taskId, agentId, nodeId, model, provider, toolName, category, meta) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + ts, + event.kind, + event.taskId ?? null, + event.agentId ?? null, + event.nodeId ?? null, + event.model ?? null, + event.provider ?? null, + event.toolName ?? null, + event.category ?? null, + meta, + ); + return true; + } catch (err) { + console.warn("[fusion] emitUsageEvent skipped a malformed/failed event:", err); + return false; + } +} + +/** Filters for {@link queryUsageEvents}. All bounds are inclusive. */ +export interface UsageEventRangeQuery { + /** ISO-8601 lower bound (inclusive). */ + from?: string; + /** ISO-8601 upper bound (inclusive). */ + to?: string; + kind?: UsageEventKind; + taskId?: string; + agentId?: string; +} + +function rowToUsageEvent(row: UsageEventRow): UsageEvent { + let meta: Record | null = null; + if (row.meta) { + try { + meta = JSON.parse(row.meta) as Record; + } catch { + meta = null; + } + } + return { + id: row.id, + ts: row.ts, + kind: row.kind as UsageEventKind, + taskId: row.taskId, + agentId: row.agentId, + nodeId: row.nodeId, + model: row.model, + provider: row.provider, + toolName: row.toolName, + category: row.category, + meta, + }; +} + +/** + * Range-scan `usage_events` ordered by timestamp ascending. Mirrors the + * windowed-scan shape of `agent-token-usage.ts`, generalized to an arbitrary + * `(from, to)` range with optional kind/task/agent filters. + */ +export function queryUsageEvents(db: Database, query: UsageEventRangeQuery = {}): UsageEvent[] { + const clauses: string[] = []; + const params: Array = []; + if (query.from !== undefined) { + clauses.push("ts >= ?"); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push("ts <= ?"); + params.push(query.to); + } + if (query.kind !== undefined) { + clauses.push("kind = ?"); + params.push(query.kind); + } + if (query.taskId !== undefined) { + clauses.push("taskId = ?"); + params.push(query.taskId); + } + if (query.agentId !== undefined) { + clauses.push("agentId = ?"); + params.push(query.agentId); + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = db + .prepare(`SELECT * FROM usage_events ${where} ORDER BY ts ASC, id ASC`) + .all(...params) as UsageEventRow[]; + return rows.map(rowToUsageEvent); +} + +/** + * Count `usage_events` grouped by a single column over a range. Convenience for + * the analytics aggregators (e.g. tool calls by `category`). + */ +export function countUsageEventsBy( + db: Database, + column: "kind" | "category" | "toolName" | "model" | "provider" | "nodeId" | "agentId", + query: UsageEventRangeQuery = {}, +): Array<{ key: string | null; count: number }> { + const clauses: string[] = []; + const params: Array = []; + if (query.from !== undefined) { + clauses.push("ts >= ?"); + params.push(query.from); + } + if (query.to !== undefined) { + clauses.push("ts <= ?"); + params.push(query.to); + } + if (query.kind !== undefined) { + clauses.push("kind = ?"); + params.push(query.kind); + } + if (query.taskId !== undefined) { + clauses.push("taskId = ?"); + params.push(query.taskId); + } + if (query.agentId !== undefined) { + clauses.push("agentId = ?"); + params.push(query.agentId); + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; + const rows = db + .prepare(`SELECT ${column} AS key, COUNT(*) AS count FROM usage_events ${where} GROUP BY ${column}`) + .all(...params) as Array<{ key: string | null; count: number }>; + return rows; +} diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index ff13a996b3..0a052a87a9 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -1238,6 +1238,23 @@ function validateV2(ir: WorkflowIrV2): void { } } + /* + FNXC:WorkflowValidation 2026-06-17-13:17: + Top-level workflow edges must reference declared top-level nodes. Fail closed on dangling endpoints so imported, AI-designed, and editor-authored IR cannot persist an edge to a non-existent node (FN-6583 / FN-6580 readiness gap). + */ + for (const edge of ir.edges) { + if (!nodesById.has(edge.from)) { + throw new WorkflowIrError( + `Workflow edge '${edge.from}' -> '${edge.to}' references undefined node '${edge.from}'`, + ); + } + if (!nodesById.has(edge.to)) { + throw new WorkflowIrError( + `Workflow edge '${edge.from}' -> '${edge.to}' references undefined node '${edge.to}'`, + ); + } + } + const outgoing = buildOutgoing(ir.edges); validateParallelism(ir.nodes, outgoing, nodesById); diff --git a/packages/core/src/zai-provider.ts b/packages/core/src/zai-provider.ts new file mode 100644 index 0000000000..c8c2020805 --- /dev/null +++ b/packages/core/src/zai-provider.ts @@ -0,0 +1,218 @@ +export const ZAI_PROVIDER_ID = "zai"; + +type ZaiModelInput = "text" | "image"; + +interface ZaiModelRegistration { + id: string; + name: string; + reasoning: boolean; + input: ZaiModelInput[]; + cost: { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + }; + contextWindow: number; + maxTokens: number; + compat: { + supportsDeveloperRole: boolean; + thinkingFormat: "zai"; + zaiToolStream?: boolean; + }; +} + +export interface ZaiProviderRegistration { + name: string; + baseUrl: string; + apiKey: string; + api: "openai-completions"; + models: ZaiModelRegistration[]; +} + +// pi registerProvider() replaces the provider's model list, so keep every +// currently built-in Z.ai model here and append new models such as GLM-5.2. +export const ZAI_PROVIDER_REGISTRATION: ZaiProviderRegistration = { + name: "ZAI", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + apiKey: "$ZAI_API_KEY", + api: "openai-completions", + models: [ + { + id: "glm-4.5-air", + name: "GLM-4.5-Air", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 98304, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + }, + }, + { + id: "glm-4.7", + name: "GLM-4.7", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 204800, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + { + id: "glm-5-turbo", + name: "GLM-5-Turbo", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + { + id: "glm-5.1", + name: "GLM-5.1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + { + id: "glm-5v-turbo", + name: "GLM-5V-Turbo", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + { + id: "glm-5.2", + name: "GLM-5.2", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 131072, + compat: { + supportsDeveloperRole: false, + thinkingFormat: "zai", + zaiToolStream: true, + }, + }, + ], +}; + +type ZaiModelLike = Partial> & { + id: string; + name?: unknown; + provider?: string; + baseUrl?: unknown; + api?: unknown; + compat?: unknown; +}; + +interface ZaiModelRegistryLike { + registerProvider(providerName: string, config: ZaiProviderRegistration): void; + getAll?: () => ZaiModelLike[]; +} + +type RegistryWithProviderState = ZaiModelRegistryLike & { + registeredProviders?: Map>; +}; + +function toZaiModelRegistration(model: ZaiModelLike): ZaiModelRegistration & { baseUrl?: string; api?: string } { + return { + id: model.id, + name: String(model.name ?? model.id), + api: typeof model.api === "string" ? model.api : undefined, + baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined, + reasoning: model.reasoning === true, + input: Array.isArray(model.input) ? model.input as ZaiModelInput[] : ["text"], + cost: model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: Number(model.contextWindow ?? 0), + maxTokens: Number(model.maxTokens ?? 0), + compat: typeof model.compat === "object" && model.compat !== null + ? { ...(model.compat as ZaiModelRegistration["compat"]) } + : ZAI_PROVIDER_REGISTRATION.models.find((builtInModel) => builtInModel.id === model.id)?.compat ?? { + supportsDeveloperRole: false, + thinkingFormat: "zai", + }, + }; +} + +function cloneZaiProviderRegistration(config: ZaiProviderRegistration): ZaiProviderRegistration { + return { + ...config, + models: config.models.map((model) => toZaiModelRegistration(model)), + }; +} + +/** + * FNXC:ModelRegistry 2026-06-13-22:04: + * pi's registerProvider() treats a provider config with models as a full provider replacement, and user extensions load after Fusion's built-in provider registration. + * Re-merge missing built-in Z.ai models after extension registration so zai/glm-5.2 remains visible wherever the user's existing Z.ai extension models are visible, without deleting extension-supplied models. + * Always pass cloned configs because pi stores and mutates registered provider objects during later upserts. + */ +export function registerBuiltInZaiProvider( + modelRegistry: ZaiModelRegistryLike, + logWarning: (message: string) => void = () => {}, +): void { + try { + modelRegistry.registerProvider(ZAI_PROVIDER_ID, cloneZaiProviderRegistration(ZAI_PROVIDER_REGISTRATION)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logWarning(`Failed to register built-in ${ZAI_PROVIDER_ID} provider: ${message}`); + } +} + +export function mergeBuiltInZaiProviderModels( + modelRegistry: ZaiModelRegistryLike, + logWarning: (message: string) => void = () => {}, +): void { + try { + const registryWithState = modelRegistry as RegistryWithProviderState; + const registeredProvider = registryWithState.registeredProviders?.get(ZAI_PROVIDER_ID); + if (!registeredProvider && !modelRegistry.getAll) return; + const registeredModels = registeredProvider?.models?.map((model) => toZaiModelRegistration(model)) ?? []; + const currentModels = registeredModels.length > 0 + ? registeredModels + : modelRegistry.getAll?.() + .filter((model) => model.provider === ZAI_PROVIDER_ID) + .map((model) => toZaiModelRegistration(model)) ?? []; + const currentModelIds = new Set(currentModels.map((model) => model.id)); + const missingBuiltInModels = ZAI_PROVIDER_REGISTRATION.models.filter((model) => !currentModelIds.has(model.id)); + + if (missingBuiltInModels.length === 0) return; + + modelRegistry.registerProvider(ZAI_PROVIDER_ID, { + ...cloneZaiProviderRegistration(ZAI_PROVIDER_REGISTRATION), + ...registeredProvider, + models: [...currentModels, ...missingBuiltInModels.map((model) => toZaiModelRegistration(model))], + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logWarning(`Failed to merge built-in ${ZAI_PROVIDER_ID} models: ${message}`); + } +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index ec8e682047..4b16048f65 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -4,6 +4,32 @@ import { computeMaxWorkers } from "./src/__test-utils__/vitest-workers"; const maxWorkers = computeMaxWorkers(); +const quarantinedCoreTests = [ + /* + FNXC:CoreTests 2026-06-13-17:43: + The full workspace suite must not fail on suite-load-sensitive tests that pass standalone or only fail after excessive wall time. Quarantine observed core offenders after package-lane hook timeouts instead of appeasing them with wider hook timeouts. + + FNXC:CoreTests 2026-06-14-02:14: + FN-6433 re-ran the core quarantine batch after FN-6430's shared fixture cleanup and rescued all five files without timeout or assertion changes. Keep this array empty unless a future quarantine is mirrored in scripts/lib/test-quarantine.json in the same commit. + + FNXC:CoreTests 2026-06-15-03:13: + FN-6481 observed the disk-backed concurrent write test fail in the changed-package workspace lane with a transient SQLite BEGIN IMMEDIATE lock after the gate had already passed. Quarantine the flaky file instead of widening lock-recovery timeouts or weakening assertions. + + FNXC:CoreTests 2026-06-15-07:39: + FN-6486 rescued store-concurrent-writes by making the transient lock helper release independent of event-loop timer scheduling, then removed the quarantine in lockstep with scripts/lib/test-quarantine.json. Keep this array empty unless a future observed flake is mirrored in the ledger in the same commit. + + FNXC:CoreTests 2026-06-17-17:21: + FN-6596 verification observed task-list-format and test-project timing out only in the broad changed-package core lane after the merge gate had passed; both files passed immediate isolated reruns. Quarantine the suite-load flakes without widening timeouts or weakening assertions. + + FNXC:CoreTests 2026-06-17-17:55: + FN-6592 rescued mission-integration by closing every reopened TaskStore handle and strengthening restart-fidelity assertions across mission hierarchy read paths. Keep the quarantine absent in both this exclude list and scripts/lib/test-quarantine.json unless a future observed flake is mirrored in both files. + + FNXC:CoreTests 2026-06-17-19:03: + FN-6600 re-ran the core quarantine candidates under the broad-run worker budget and rescued the current core ledger entries without timeout, retry, assertion, or worker-budget appeasement. + Keep core quarantines mirrored here only when a loaded run still fails after shared teardown cleanup has been ruled out. + */ +]; + export default defineConfig({ resolve: { alias: { @@ -14,7 +40,7 @@ export default defineConfig({ }, test: { include: ["src/**/*.test.ts"], - exclude: [], + exclude: quarantinedCoreTests, setupFiles: [ "./src/__test-utils__/vitest-setup.ts", ], diff --git a/packages/dashboard/CHANGELOG.md b/packages/dashboard/CHANGELOG.md index 5481aa048f..fe843cb769 100644 --- a/packages/dashboard/CHANGELOG.md +++ b/packages/dashboard/CHANGELOG.md @@ -1,5 +1,56 @@ # @fusion/dashboard +## 0.44.0 + +### Patch Changes + +- @fusion/core@0.44.0 +- @fusion/engine@0.44.0 +- @fusion/i18n@0.39.7 +- @fusion-plugin-examples/cli-printing-press@0.1.24 +- @fusion-plugin-examples/compound-engineering@0.1.7 +- @fusion-plugin-examples/dependency-graph@0.1.38 +- @fusion-plugin-examples/roadmap@0.1.26 +- @fusion-plugin-examples/cursor-runtime@0.1.26 +- @fusion-plugin-examples/droid-runtime@0.1.33 +- @fusion-plugin-examples/hermes-runtime@0.2.57 +- @fusion-plugin-examples/openclaw-runtime@0.2.57 +- @fusion-plugin-examples/paperclip-runtime@0.2.57 + +## 0.43.1 + +### Patch Changes + +- @fusion/core@0.43.1 +- @fusion/engine@0.43.1 +- @fusion/i18n@0.39.6 +- @fusion-plugin-examples/cli-printing-press@0.1.23 +- @fusion-plugin-examples/compound-engineering@0.1.6 +- @fusion-plugin-examples/dependency-graph@0.1.37 +- @fusion-plugin-examples/roadmap@0.1.25 +- @fusion-plugin-examples/cursor-runtime@0.1.25 +- @fusion-plugin-examples/droid-runtime@0.1.32 +- @fusion-plugin-examples/hermes-runtime@0.2.56 +- @fusion-plugin-examples/openclaw-runtime@0.2.56 +- @fusion-plugin-examples/paperclip-runtime@0.2.56 + +## 0.43.0 + +### Patch Changes + +- @fusion/core@0.43.0 +- @fusion/engine@0.43.0 +- @fusion/i18n@0.39.5 +- @fusion-plugin-examples/cli-printing-press@0.1.22 +- @fusion-plugin-examples/compound-engineering@0.1.5 +- @fusion-plugin-examples/dependency-graph@0.1.36 +- @fusion-plugin-examples/roadmap@0.1.24 +- @fusion-plugin-examples/cursor-runtime@0.1.24 +- @fusion-plugin-examples/droid-runtime@0.1.31 +- @fusion-plugin-examples/hermes-runtime@0.2.55 +- @fusion-plugin-examples/openclaw-runtime@0.2.55 +- @fusion-plugin-examples/paperclip-runtime@0.2.55 + ## 0.42.0 ### Patch Changes diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index bb6b7e5431..76497e3ca0 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -345,9 +345,9 @@ Access a fully functional PTY (pseudo-terminal) shell directly from the dashboar - `Escape` - Close terminal modal ### Saved Scripts -Saved scripts (managed via the Scripts modal or QuickScripts dropdown in the header) launch inside the existing interactive Terminal modal instead of a separate read-only output dialog. This gives users a consistent terminal experience and lets them interact with the shell after the script starts — for example, to inspect output files, run follow-up commands, or debug failures. +Saved scripts (managed via the Scripts modal or QuickScripts dropdown in the header) launch inside the existing interactive Terminal modal instead of a separate read-only output dialog. Each run opens a dedicated new terminal tab backed by a fresh PTY session, so script output never overwrites an existing shell. This gives users a consistent terminal experience and lets them interact with the shell after the script starts — for example, to inspect output files, run follow-up commands, or debug failures. -**Modal Handoff**: When a script is launched from the Scripts modal, the modal closes immediately so the Terminal modal becomes the topmost surface — the user never sees both overlays stacked. The script command is sent to the terminal as an `initialCommand` once the PTY session connects. Running a different script while the terminal is already open sends the new command without needing to close and reopen the modal. +**Modal Handoff**: When a script is launched from the Scripts modal, the modal closes immediately so the Terminal modal becomes the topmost surface — the user never sees both overlays stacked. The script command is sent to the new terminal tab as an `initialCommand` once the fresh PTY session connects. Running any script, including the same script again while the terminal is already open, creates another dedicated tab without needing to close and reopen the modal. **Features**: - **Real PTY Terminal**: Spawns a real shell (bash/zsh/powershell) using node-pty for authentic terminal behavior @@ -770,6 +770,32 @@ For real-time PR/issue badge updates, configure a GitHub App instead of relying **Fallback Behavior:** When webhook delivery is unavailable, the 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) continue to work as the fallback path. Staleness is computed from persisted `lastCheckedAt` timestamps only (no in-memory poller state). +### External Signal Ingestion (Sentry / Datadog / PagerDuty / generic webhook) + +Inbound signals from error trackers and alerting tools are ingested into triage +tasks via `POST /api/signals/:provider`. Every endpoint requires a valid HMAC +signature against a per-provider secret — there is no unauthenticated +task-creation endpoint. Secrets come from the environment and are never +source-controlled: + +- `FUSION_SIGNAL_WEBHOOK_SECRET` — generic webhook (`POST /api/signals/webhook`). + Sign the raw body with HMAC-SHA256 in `X-Fusion-Signature` (hex, optional + `sha256=` prefix) and send `X-Fusion-Timestamp` (epoch ms) for the replay + window. Payload: `{ id, title, body?, severity?, link?, groupingKey?, timestamp?, meta? }`. + If `groupingKey` is omitted it falls back to `source + normalized-title`. +- `FUSION_SIGNAL_SENTRY_SECRET` — Sentry (`POST /api/signals/sentry`), verifies + `Sentry-Hook-Signature`; `groupingKey` = Sentry `issue.id`. +- `FUSION_SIGNAL_DATADOG_SECRET` — Datadog (`POST /api/signals/datadog`), + verifies `X-Datadog-Signature`; `groupingKey` = monitor `aggreg_key`/`alert_id`. +- `FUSION_SIGNAL_PAGERDUTY_SECRET` — PagerDuty (`POST /api/signals/pagerduty`), + verifies `X-PagerDuty-Signature` (`v1=`); `groupingKey` = `incident.id`. + +**Security:** mandatory HMAC (401 on missing/invalid secret or signature), +replay window (±5 min) + delivery-id nonce dedup, persistent external-id dedup, +~1 MB body cap (413), per-source rate limit (429), field-length caps on +normalized fields, and SSRF-untrusted handling of payload URLs (stored as data, +never fetched). The `meta` JSON is stored as data and never rendered as raw HTML. + ### Multi-Instance Deployments When running the dashboard on multiple instances behind a load balancer, badge updates can be shared across instances using Redis pub/sub. This ensures that a PR/issue badge change detected on instance A is delivered to subscribed WebSocket clients on instance B. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 358e603710..01ba53439d 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -8,6 +8,7 @@ import { type TaskDetail, type WorkflowStep, } from "@fusion/core"; +import { isNearDuplicateCanonicalInactive } from "../../core/src/near-duplicate-canonical"; import { Header, useViewportMode } from "./components/Header"; import { Board } from "./components/Board"; import { TaskCard } from "./components/TaskCard"; @@ -21,7 +22,7 @@ import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorP import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader"; import { TopProgressBar } from "./components/TopProgressBar"; import { ExecutorStatusBar } from "./components/ExecutorStatusBar"; -import { SessionNotificationBanner } from "./components/SessionNotificationBanner"; +import { SessionNotificationBanner, type CliActionId } from "./components/SessionNotificationBanner"; import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner"; import { SetupWarningBanner } from "./components/SetupWarningBanner"; import { CapacityRiskBanner } from "./components/CapacityRiskBanner"; @@ -89,7 +90,7 @@ import { NativeShellConnectionManager } from "./components/NativeShellConnection import { ShellConnectionStatus } from "./components/ShellConnectionStatus"; import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native"; import type { AiSessionSummary, DashboardHealthResponse } from "./api"; -import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth } from "./api"; +import { api, fetchDashboardHealth, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps, refreshDashboardHealth, relaunchCliSession } from "./api"; import { getScopedItem, removeScopedItem, setScopedItem } from "./utils/projectStorage"; import { subscribeSse } from "./sse-bus"; import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth"; @@ -114,7 +115,7 @@ const ChatView = lazy(() => import("./components/ChatView").then((m) => ({ defau const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ default: m.SkillsView }))); const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView }))); const SecretsView = lazy(() => import("./components/SecretsView").then((m) => ({ default: m.SecretsView }))); -const ReliabilityView = lazy(() => import("./components/ReliabilityView").then((m) => ({ default: m.ReliabilityView }))); +const CommandCenter = lazy(() => import("./components/command-center/CommandCenter").then((m) => ({ default: m.CommandCenter }))); const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView }))); const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView }))); const GoalsView = lazy(() => import("./components/GoalsView").then((m) => ({ default: m.GoalsView }))); @@ -144,7 +145,7 @@ function prefetchLazyViews() { void import("./components/SkillsView"); void import("./components/MemoryView"); void import("./components/SecretsView"); - void import("./components/ReliabilityView"); + void import("./components/command-center/CommandCenter"); void import("./components/DevServerView"); void import("./components/TodoView"); void import("./components/GoalsView"); @@ -245,6 +246,85 @@ export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectC return projectsLoading && projectCount === 0; } +export function isSessionNeedingInputForBanner(session: AiSessionSummary): boolean { + return ( + session.status === "awaiting_input" || + session.status === "error" || + session.status === "waiting_on_input" || + session.status === "needs_attention" + ); +} + +export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null { + if ((action === "advance" || action === "relaunch") && !session.cliSessionId) { + return "CLI session id is missing."; + } + return null; +} + +interface CliActionDeps { + currentProjectId?: string; + retryTask: (id: string) => Promise; + moveTask: (id: string, column: "todo") => Promise; + openAuthenticationSettings: () => void; + addToast: (message: string, type: "success" | "error") => void; + apiClient?: typeof api; + relaunchCliSessionClient?: typeof relaunchCliSession; +} + +export async function executeCliSessionBannerAction( + session: AiSessionSummary, + action: CliActionId, + deps: CliActionDeps, +): Promise { + try { + /* + * FNXC:SessionBanner 2026-06-14-19:32: + * CLI banner verbs must either call an existing dashboard route/flow or be disabled by the banner. `advance` confirms the CLI session, `retry` and `cancel` reuse task operations keyed by the session id until summaries expose a distinct task id, and `reauthenticate` opens the existing authentication settings flow. + * + * FNXC:SessionBanner 2026-06-14-20:16: + * `relaunch` is now a supported route-backed action for resume-exhausted CLI sessions; if `cliSessionId` is absent the handler exits without firing a malformed API call, preserving the no-silent-no-op invariant through the banner disabled reason. + */ + if (action === "advance") { + if (!session.cliSessionId) { + throw new Error("CLI session id is required to advance this session."); + } + await (deps.apiClient ?? api)(`/cli-sessions/${encodeURIComponent(session.cliSessionId)}/confirm-advance`, { + method: "POST", + body: JSON.stringify({ decision: "advance", ...(deps.currentProjectId ? { projectId: deps.currentProjectId } : {}) }), + }); + return; + } + + if (action === "relaunch") { + if (!session.cliSessionId) return; + await (deps.relaunchCliSessionClient ?? relaunchCliSession)(session.cliSessionId, deps.currentProjectId); + deps.addToast("CLI session relaunch requested", "success"); + return; + } + + if (action === "retry") { + await deps.retryTask(session.id); + return; + } + + if (action === "cancel") { + await deps.moveTask(session.id, "todo"); + return; + } + + if (action === "reauthenticate") { + deps.openAuthenticationSettings(); + return; + } + + throw new Error("This CLI action is not supported yet."); + } catch (err) { + const message = err instanceof Error ? err.message : "CLI action failed"; + deps.addToast(message, "error"); + } +} + function AppInner() { const { t } = useTranslation("app"); const { toasts, addToast, removeToast } = useToast(); @@ -371,9 +451,11 @@ function AppInner() { // Background AI sessions - required before useModalManager const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id); - const sessionsNeedingInput = bgSessions.filter( - (session) => session.status === "awaiting_input" || session.status === "error" - ); + /* + * FNXC:SessionBanner 2026-06-14-19:32: + * CLI agent sessions use `waiting_on_input` and `needs_attention` to represent user-actionable states. The banner feed must include those statuses in addition to the legacy planning-session statuses so visible CLI actions cannot be silently hidden from users. + */ + const sessionsNeedingInput = bgSessions.filter(isSessionNeedingInputForBanner); const sessionBannersHidden = useSessionBannersHidden(); // Modal state/handlers - required before useViewState @@ -1103,7 +1185,7 @@ function AppInner() { addToast, }); - const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries") => { + const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => { if (initialTab === "changes") { modalManager.openDetailWithChangesTab(task); } else { @@ -1251,11 +1333,6 @@ function AppInner() { pushNav({ type: "modal", close: modalManager.closeGitManager }); }, [modalManager, pushNav]); - const openSystemStatsWithNav = useCallback(() => { - modalManager.openSystemStats(); - pushNav({ type: "modal", close: modalManager.closeSystemStats }); - }, [modalManager, pushNav]); - const openSchedulesWithNav = useCallback(() => { modalManager.openSchedules(); pushNav({ type: "modal", close: modalManager.closeSchedules }); @@ -1346,6 +1423,18 @@ function AppInner() { // intentional no-op }, []); + const handleCliAction = useCallback( + (session: AiSessionSummary, action: CliActionId) => + executeCliSessionBannerAction(session, action, { + currentProjectId: currentProject?.id, + retryTask, + moveTask, + openAuthenticationSettings: () => modalManager.openSettings("authentication" as SectionId), + addToast, + }), + [addToast, currentProject?.id, modalManager, moveTask, retryTask], + ); + const [shellOnboardingComplete, setShellOnboardingComplete] = useState(false); const [shellConnectionManagerOpen, setShellConnectionManagerOpen] = useState(false); const [shellConnectionStatus, setShellConnectionStatus] = useState(null); @@ -1469,13 +1558,14 @@ function AppInner() { // Project view if (resolvedPluginTaskView) { + const pluginTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; return ( 0 ? remoteData.tasks : tasks, + tasks: pluginTasks, workflowSteps, subscribePluginEvents, openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab), @@ -1490,6 +1580,9 @@ function AppInner() { disableDrag={true} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMerge} + nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string" + ? isNearDuplicateCanonicalInactive(pluginTasks.find((candidate) => candidate.id === task.sourceMetadata?.nearDuplicateOf)) + : undefined} /> ), addToast, @@ -1598,6 +1691,7 @@ function AppInner() { projectId={currentProject?.id} addToast={addToast} onOpenDetail={openDetailTask} + onSendSelectionToTask={modalManager.openNewTaskWithDescription} /> @@ -1688,7 +1782,11 @@ function AppInner() { return ( - + ); @@ -1711,17 +1809,17 @@ function AppInner() { return ( - + ); } - if (taskView === "reliability") { + if (taskView === "command-center") { return ( - + ); @@ -1858,7 +1956,6 @@ function AppInner() { activePlanningSessionCount={bgPlanningSessions.length} onOpenUsage={openUsageWithNav} onOpenActivityLog={openActivityLogWithNav} - onOpenSystemStats={openSystemStatsWithNav} onOpenMailbox={() => handleTaskViewChange("mailbox")} mailboxUnreadCount={mailboxUnreadCount} mailboxPendingApprovalCount={mailboxPendingApprovalCount} @@ -1943,6 +2040,8 @@ function AppInner() { onResumeSession={handleOpenBackgroundSession} onDismissSession={handleDismissNeedingInputSession} onDismissAll={handleDismissAllNeedingInputSessions} + onCliAction={handleCliAction} + getCliActionDisabledReason={getCliActionDisabledReasonForBanner} /> )} {viewMode === "project" && currentProject && ( @@ -2064,7 +2163,6 @@ function AppInner() { keyboardOpen={mobileNavKeyboardOpen} onOpenSettings={openSettingsWithNav} onOpenActivityLog={openActivityLogWithNav} - onOpenSystemStats={openSystemStatsWithNav} onOpenMailbox={() => handleTaskViewChange("mailbox")} onOpenNodes={handleOpenNodesWithNav} mailboxUnreadCount={mailboxUnreadCount} diff --git a/packages/dashboard/app/__tests__/activity-log-mobile-layout.test.ts b/packages/dashboard/app/__tests__/activity-log-layout.test.ts similarity index 82% rename from packages/dashboard/app/__tests__/activity-log-mobile-layout.test.ts rename to packages/dashboard/app/__tests__/activity-log-layout.test.ts index 7390f1e466..537596c24a 100644 --- a/packages/dashboard/app/__tests__/activity-log-mobile-layout.test.ts +++ b/packages/dashboard/app/__tests__/activity-log-layout.test.ts @@ -1,19 +1,15 @@ import { describe, it, expect } from "vitest"; import { loadAllAppCss } from "../test/cssFixture"; -import { readFileSync } from "fs"; -import { resolve } from "path"; /** - * Stylesheet regression test for Activity Log mobile layout. + * Stylesheet regression test for Activity Log modal layout. * - * Parses `packages/dashboard/app/styles.css` and asserts that an - * `@media (max-width: 768px)` block contains Activity Log mobile rules - * for stacked/wrapped controls and entry layout. These selectors must - * remain inside a mobile media query so the Activity Log renders - * correctly on narrow screens. + * Parses the app CSS bundle and asserts that desktop viewport constraints + * keep the modal on screen while mobile rules keep controls usable on + * narrow screens. */ -describe("activity-log-mobile-layout.css", () => { +describe("activity-log-layout.css", () => { const cssContent = loadAllAppCss(); /** Extract all content inside @media (max-width: 768px) blocks. */ @@ -42,12 +38,27 @@ describe("activity-log-mobile-layout.css", () => { // ── Modal sizing ──────────────────────────────────────────────────── - it("uses modal-lg base class for consistent wide sizing", () => { - // The activity-log-modal should NOT set its own max-width; modal-lg handles width + it("keeps desktop modal width within the viewport", () => { const modalBlock = cssContent.match(/\.activity-log-modal\s*\{[^}]*\}/)?.[0]; expect(modalBlock).toBeTruthy(); - // Should NOT contain max-width (handled by modal-lg base class) - expect(modalBlock).not.toMatch(/max-width:\s*\d+px/); + expect(modalBlock).toContain("width: min(95vw, 640px);"); + expect(modalBlock).toContain("max-width: 95vw;"); + }); + + it("keeps desktop modal height inside the visible viewport", () => { + const modalBlock = cssContent.match(/\.activity-log-modal\s*\{[^}]*\}/)?.[0]; + expect(modalBlock).toBeTruthy(); + expect(modalBlock).toMatch(/max-height:\s*calc\(100dvh - var\(--overlay-padding-top,\s*10vh\) - 16px\);/); + expect(modalBlock).toContain("overflow: hidden;"); + }); + + it("allows content pane to shrink and scroll inside the capped modal", () => { + const contentBlock = cssContent.match( + /\.activity-log-content\s*\{(?=[^}]*overflow-y:\s*auto;)(?=[^}]*min-height:\s*0;)[^}]*\}/, + )?.[0]; + expect(contentBlock).toBeTruthy(); + expect(contentBlock).toContain("overflow-y: auto;"); + expect(contentBlock).toContain("min-height: 0;"); }); // ── Close button ──────────────────────────────────────────────────── diff --git a/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx b/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx new file mode 100644 index 0000000000..e925c68205 --- /dev/null +++ b/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AiSessionSummary } from "../api"; +import { + executeCliSessionBannerAction, + getCliActionDisabledReasonForBanner, + isSessionNeedingInputForBanner, +} from "../App"; +import type { CliActionId } from "../components/SessionNotificationBanner"; + +function cliSession(overrides: Partial = {}): AiSessionSummary { + return { + id: overrides.id ?? "FN-6458", + type: "cli-agent", + status: overrides.status ?? "needs_attention", + title: overrides.title ?? "CLI session needs attention", + projectId: overrides.projectId ?? "proj-1", + lockedByTab: overrides.lockedByTab ?? null, + updatedAt: overrides.updatedAt ?? "2026-06-14T19:32:00.000Z", + cliVariant: overrides.cliVariant ?? "userExited", + cliSessionId: Object.prototype.hasOwnProperty.call(overrides, "cliSessionId") + ? overrides.cliSessionId + : "cli-session-1", + }; +} + +describe("App CLI session banner wiring", () => { + it("surfaces cli-agent needs_attention and waiting_on_input sessions through the App banner filter", () => { + expect(isSessionNeedingInputForBanner(cliSession({ status: "needs_attention" }))).toBe(true); + expect(isSessionNeedingInputForBanner(cliSession({ status: "waiting_on_input" }))).toBe(true); + expect(isSessionNeedingInputForBanner(cliSession({ status: "awaiting_input" }))).toBe(true); + expect(isSessionNeedingInputForBanner(cliSession({ status: "error" }))).toBe(true); + expect(isSessionNeedingInputForBanner(cliSession({ status: "generating" }))).toBe(false); + expect(isSessionNeedingInputForBanner(cliSession({ status: "complete" }))).toBe(false); + }); + + it.each([ + ["advance", "api"], + ["retry", "retryTask"], + ["cancel", "moveTask"], + ["reauthenticate", "openSettings"], + ["relaunch", "relaunchCliSession"], + ] as const)("maps %s to an observable existing route or flow", async (action, expected) => { + const apiClient = vi.fn().mockResolvedValue({ ok: true }); + const retryTask = vi.fn().mockResolvedValue({ id: "FN-6458" }); + const moveTask = vi.fn().mockResolvedValue({ id: "FN-6458" }); + const openAuthenticationSettings = vi.fn(); + const addToast = vi.fn(); + const relaunchCliSessionClient = vi.fn().mockResolvedValue({ ok: true, taskId: "FN-6458" }); + + await executeCliSessionBannerAction(cliSession(), action, { + currentProjectId: "proj-1", + retryTask, + moveTask, + openAuthenticationSettings, + addToast, + apiClient, + relaunchCliSessionClient, + }); + + if (expected === "api") { + expect(apiClient).toHaveBeenCalledWith( + "/cli-sessions/cli-session-1/confirm-advance", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ decision: "advance", projectId: "proj-1" }), + }), + ); + } else if (expected === "retryTask") { + expect(retryTask).toHaveBeenCalledWith("FN-6458"); + } else if (expected === "moveTask") { + expect(moveTask).toHaveBeenCalledWith("FN-6458", "todo"); + } else if (expected === "relaunchCliSession") { + expect(relaunchCliSessionClient).toHaveBeenCalledWith("cli-session-1", "proj-1"); + expect(addToast).toHaveBeenCalledWith("CLI session relaunch requested", "success"); + } else { + expect(openAuthenticationSettings).toHaveBeenCalledTimes(1); + } + if (expected !== "relaunchCliSession") { + expect(addToast).not.toHaveBeenCalled(); + } + }); + + it("marks missing-id actions disabled so visible buttons are not silent no-ops", () => { + const actions: CliActionId[] = ["advance", "retry", "cancel", "reauthenticate", "relaunch"]; + const missingId = cliSession({ cliSessionId: undefined }); + const withId = cliSession(); + + const disabled = new Map(actions.map((action) => [action, getCliActionDisabledReasonForBanner(withId, action)])); + expect(disabled.get("relaunch")).toBeNull(); + expect(disabled.get("advance")).toBeNull(); + expect(getCliActionDisabledReasonForBanner(missingId, "advance")).toMatch(/missing/i); + expect(getCliActionDisabledReasonForBanner(missingId, "relaunch")).toMatch(/missing/i); + }); + + it("does not fire a relaunch API call when the CLI session id is missing", async () => { + const relaunchCliSessionClient = vi.fn(); + const addToast = vi.fn(); + + await executeCliSessionBannerAction(cliSession({ cliSessionId: undefined }), "relaunch", { + retryTask: vi.fn(), + moveTask: vi.fn(), + openAuthenticationSettings: vi.fn(), + addToast, + relaunchCliSessionClient, + }); + + expect(relaunchCliSessionClient).not.toHaveBeenCalled(); + expect(addToast).not.toHaveBeenCalled(); + }); + + it("toasts instead of silently failing if an enabled CLI action route rejects", async () => { + const addToast = vi.fn(); + await executeCliSessionBannerAction(cliSession(), "retry", { + retryTask: vi.fn().mockRejectedValue(new Error("retry failed")), + moveTask: vi.fn(), + openAuthenticationSettings: vi.fn(), + addToast, + apiClient: vi.fn(), + }); + + expect(addToast).toHaveBeenCalledWith("retry failed", "error"); + }); +}); diff --git a/packages/dashboard/app/__tests__/board-mobile-column-swipe.test.ts b/packages/dashboard/app/__tests__/board-mobile-column-swipe.test.ts new file mode 100644 index 0000000000..4f8230d683 --- /dev/null +++ b/packages/dashboard/app/__tests__/board-mobile-column-swipe.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss, loadAllAppCssBaseOnly } from "../test/cssFixture"; + +function extractMediaBlocks(content: string, pattern: RegExp): string { + const blocks: string[] = []; + + for (const match of content.matchAll(pattern)) { + const start = match.index! + match[0].length; + let index = start; + let depth = 1; + while (index < content.length && depth > 0) { + if (content[index] === "{") depth++; + if (content[index] === "}") depth--; + index++; + } + expect(depth).toBe(0); + blocks.push(content.slice(start, index - 1)); + } + + expect(blocks.length).toBeGreaterThan(0); + return blocks.join("\n"); +} + +function stripCssComments(css: string): string { + return css.replace(/\/\*[\s\S]*?\*\//g, ""); +} + +function ruleBlocks(css: string, selector: string): string[] { + const blocks: string[] = []; + const rulePattern = /([^{}]+)\{([^{}]*)\}/g; + + for (const match of stripCssComments(css).matchAll(rulePattern)) { + const selectorList = match[1] + .split(",") + .map((part) => part.trim()) + .filter(Boolean); + if (selectorList.includes(selector)) { + blocks.push(`${match[1].trim()} {${match[2]}}`); + } + } + + return blocks; +} + +function ruleBlock(css: string, selector: string): string { + const blocks = ruleBlocks(css, selector); + expect(blocks.length, `missing CSS rule for ${selector}`).toBeGreaterThan(0); + return blocks[0]; +} + +function declarationValue(rule: string, property: string): string | null { + const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = rule.match(new RegExp(`${escaped}\\s*:\\s*([^;]+);`)); + return match?.[1]?.trim() ?? null; +} + +function expectTouchPanXY(css: string, selector: string): void { + const block = ruleBlock(css, selector); + + expect(declarationValue(block, "touch-action")).toBe("pan-x pan-y"); + expect(block).not.toMatch(/touch-action:\s*pan-y\s*;/); +} + +function expectContainmentScroller(block: string): void { + expect(block).toContain("overflow-x: auto"); + expect(block).toContain("overscroll-behavior-x: contain"); + expect(block).toContain("scroll-snap-type: x proximity"); + expect(block).not.toContain("scroll-snap-type: x mandatory"); +} + +describe("mobile board column swipe target containment (FN-6389)", () => { + const css = loadAllAppCss(); + const baseCss = loadAllAppCssBaseOnly(); + const mobileCss = extractMediaBlocks(css, /@media\s*\([^)]*max-width:\s*768px[^)]*\)[^{]*\{/g); + + it("opts classic mobile board column interiors into horizontal panning", () => { + for (const selector of [".board > .column", ".column", ".column-header", ".column-body"]) { + expectTouchPanXY(mobileCss, selector); + } + + const columnBodyBlock = ruleBlock(mobileCss, ".column-body"); + expect(columnBodyBlock).not.toContain("overflow-y: hidden"); + }); + + it("opts workflow and multi-lane board interiors into horizontal panning", () => { + for (const selector of [ + ".board.board-workflow-columns", + ".board.board-workflow-columns > .column", + ".lane-columns", + ".lane-columns > .column", + ]) { + expectTouchPanXY(baseCss, selector); + } + }); + + it("preserves the FN-6365 mobile document pan lock", () => { + const rootBlock = ruleBlock(mobileCss, "html"); + const appRootBlock = ruleBlock(mobileCss, "#root"); + const starBlocks = ruleBlocks(mobileCss, "*"); + const defaultTouchBlock = starBlocks.find((block) => block.includes("touch-action: pan-y;")) ?? ""; + const widthContainmentBlock = starBlocks.find((block) => block.includes("max-inline-size: 100%;")) ?? ""; + + for (const block of [rootBlock, appRootBlock]) { + expect(block).toContain("overflow-x: hidden;"); + expect(block).toContain("overscroll-behavior-x: none;"); + expect(block).toContain("touch-action: pan-y;"); + } + + expect(rootBlock).toContain("width: 100%;"); + expect(rootBlock).toContain("max-width: 100%;"); + expect(appRootBlock).toContain("min-width: 0;"); + expect(declarationValue(defaultTouchBlock, "touch-action")).toBe("pan-y"); + expect(widthContainmentBlock).toContain("max-width: 100%;"); + expect(widthContainmentBlock).toContain("max-inline-size: 100%;"); + }); + + it("preserves FN-6378 horizontal overscroll containment and proximity snap", () => { + expectContainmentScroller(ruleBlock(baseCss, ".board")); + expectContainmentScroller(ruleBlock(mobileCss, ".board")); + expectContainmentScroller(ruleBlock(baseCss, ".board.board-workflow-columns")); + expectContainmentScroller(ruleBlock(baseCss, ".lane-columns")); + }); +}); diff --git a/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts b/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts index 247a1e1fff..09995fa4e0 100644 --- a/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts +++ b/packages/dashboard/app/__tests__/component-css-no-raw-rgba.test.ts @@ -38,6 +38,14 @@ function findRawRgbViolations(source: string, fileName: string): string[] { ); } +function findRawRgbViolationsIncludingFallbacks(source: string, fileName: string): string[] { + const lines = source.split(/\r?\n/); + + return lines.flatMap((line, index) => + /rgba?\(/.test(line) ? [`${fileName}:${index + 1}:${line.trim()}`] : [] + ); +} + function buildRawRgbFailureMessage(violations: string[]): string { return [ "Raw rgb/rgba() found in component CSS.", @@ -76,4 +84,23 @@ describe("component CSS color token hygiene", () => { expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]); }); + + it("contains no raw rgb/rgba calls anywhere in command-center component CSS", () => { + /* + FNXC:CommandCenterStyling 2026-06-18-00:00: + Command Center has a stricter invariant than the global guard: raw rgb/rgba is forbidden even inside var() fallbacks because undefined surface/border tokens must keep concrete-hex color-mix fallbacks. Use the recursive component CSS scan because loadAllAppCss() only includes top-level components/*.css and does not load command-center subdirectories. + */ + const cssFiles = findComponentCssFiles().filter((filePath) => + formatComponentCssPath(filePath).startsWith("command-center/") + ); + + const violations = cssFiles.flatMap((filePath) => + findRawRgbViolationsIncludingFallbacks( + readFileSync(filePath, "utf8"), + formatComponentCssPath(filePath) + ) + ); + + expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]); + }); }); diff --git a/packages/dashboard/app/__tests__/dashboard-component-color-tokenization.test.ts b/packages/dashboard/app/__tests__/dashboard-component-color-tokenization.test.ts index 93c921ff2c..faf53cc80a 100644 --- a/packages/dashboard/app/__tests__/dashboard-component-color-tokenization.test.ts +++ b/packages/dashboard/app/__tests__/dashboard-component-color-tokenization.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { loadStylesCss } from "../test/cssFixture"; const root = resolve(__dirname, "../components"); @@ -40,6 +41,86 @@ function stripVarCalls(line: string): string { return line.replace(/var\([^)]*\)/g, ""); } +function extractRootBlock(css: string): string { + const rootRegex = /:root\s*\{/g; + let match; + let secondRootIdx = -1; + let count = 0; + + while ((match = rootRegex.exec(css)) !== null) { + count++; + if (count === 2) { + secondRootIdx = match.index; + break; + } + } + + if (secondRootIdx === -1) { + throw new Error("Could not find second :root block"); + } + + return extractBlockAt(css, secondRootIdx); +} + +function extractLightThemeBlock(css: string): string { + const startMatch = css.match(/:root\[data-theme="light"\]\s*\{/); + if (!startMatch) { + throw new Error("Could not find :root[data-theme=\"light\"] block"); + } + + return extractBlockAt(css, startMatch.index!); +} + +function extractBlockAt(css: string, startIdx: number): string { + const openBraceIdx = startIdx + css.slice(startIdx).indexOf("{"); + let depth = 1; + let end = openBraceIdx; + + for (let i = openBraceIdx + 1; i < css.length; i++) { + if (css[i] === "{") depth++; + if (css[i] === "}") depth--; + if (depth === 0) { + end = i; + break; + } + } + + return css.slice(startIdx, end + 1); +} + +function extractTokenDefinition(block: string, token: string): string { + const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = block.match(new RegExp(`${escapedToken}:\\s*([^;]+);`)); + if (!match) { + throw new Error(`Could not find ${token} definition`); + } + + return match[1].trim(); +} + +describe("dashboard surface token definitions", () => { + it("defines neutral surface and subtle border tokens in base and light themes", () => { + const css = loadStylesCss(); + const rootBlock = extractRootBlock(css); + const lightThemeBlock = extractLightThemeBlock(css); + const tokens = ["--surface-1", "--surface-2", "--border-subtle"]; + + /* + FNXC:DashboardSurfaceTokens 2026-06-18-21:29: + FN-6678 keeps these tokens defined in both canonical theme blocks because charts, Command Center surfaces, areas, and NewTaskModal consume them through bare var() calls with no fallback. Require color-mix token derivations so removing a definition or replacing it with a raw color fails the guard. + */ + for (const token of tokens) { + const rootDefinition = extractTokenDefinition(rootBlock, token); + const lightDefinition = extractTokenDefinition(lightThemeBlock, token); + + expect(rootDefinition, `${token} must be defined in :root`).toMatch(/^color-mix\(in\s+srgb,/); + expect(lightDefinition, `${token} must be defined in :root[data-theme="light"]`).toMatch(/^color-mix\(in\s+srgb,/); + expect(rootDefinition, `${token} root definition must not use raw colors`).not.toMatch(/rgba\(|#[0-9a-fA-F]{3,8}/); + expect(lightDefinition, `${token} light definition must not use raw colors`).not.toMatch(/rgba\(|#[0-9a-fA-F]{3,8}/); + } + }); +}); + describe("dashboard component color tokenization", () => { it("keeps audited compliant files free of raw rgba()", () => { for (const file of auditedCompliant) { diff --git a/packages/dashboard/app/__tests__/dashboard-css-token-validity.css.test.ts b/packages/dashboard/app/__tests__/dashboard-css-token-validity.css.test.ts new file mode 100644 index 0000000000..ddfe96641e --- /dev/null +++ b/packages/dashboard/app/__tests__/dashboard-css-token-validity.css.test.ts @@ -0,0 +1,165 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const APP_ROOT = path.resolve(__dirname, ".."); +const COMPONENTS_ROOT = path.join(APP_ROOT, "components"); + +const JS_SET_PROPERTY_ALLOWLIST = new Set([ + "--cc-radial-value", + "--mobile-wf-depth", + "--icb-bottom-offset", + "--icb-right-offset", + "--quick-chat-fab-lift", + "--quick-chat-fab-shadow", + "--quick-chat-fab-shadow-hover", + "--selection-comment-panel-width", + "--task-chat-composer-max-height", + "--layout-content-max-width", + "--opacity-disabled", + "--provider-icon-color", +]); + +/** + * FNXC:DashboardStyling 2026-06-19-00:00: + * FN-6690/FN-6693 proved jsdom style assertions miss undefined CSS custom-property references because jsdom does not resolve `var()` at computed-value time. + * Scan raw dashboard CSS instead: a bare `var(--missing-token)` can silently invalidate a declaration or depend on a stale fallback, so every referenced custom property must be defined by CSS, assigned by React inline style, or documented as a runtime-local allowlist entry. + */ +function stripCssComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, ""); +} + +function collectFiles(dir: string, predicate: (fileName: string) => boolean): string[] { + const out: string[] = []; + + for (const entry of readdirSync(dir)) { + if (entry === "node_modules" || entry === "dist" || entry === "public" || entry.startsWith(".")) continue; + + const fullPath = path.join(dir, entry); + const info = statSync(fullPath); + + if (info.isDirectory()) { + out.push(...collectFiles(fullPath, predicate)); + continue; + } + + if (info.isFile() && predicate(entry)) out.push(fullPath); + } + + return out.sort((left, right) => formatAppPath(left).localeCompare(formatAppPath(right))); +} + +function collectCssFilesToScan(): string[] { + const componentCss = collectFiles(COMPONENTS_ROOT, (fileName) => fileName.endsWith(".css")); + const appLevelCss = readdirSync(APP_ROOT) + .filter((entry) => entry.endsWith(".css") && entry !== "styles.css") + .map((entry) => path.join(APP_ROOT, entry)); + + return [...componentCss, ...appLevelCss].sort((left, right) => formatAppPath(left).localeCompare(formatAppPath(right))); +} + +function collectAllCssFiles(): string[] { + return collectFiles(APP_ROOT, (fileName) => fileName.endsWith(".css")); +} + +function collectSourceFiles(): string[] { + return collectFiles(APP_ROOT, (fileName) => /\.(tsx?|jsx?)$/.test(fileName)); +} + +function collectDefinedProperties(cssFiles: string[]): Set { + const properties = new Set(); + + for (const filePath of cssFiles) { + const source = stripCssComments(readFileSync(filePath, "utf8")); + for (const match of source.matchAll(/(^|[\s{;])(--[A-Za-z0-9_-]+)\s*:/g)) { + properties.add(match[2]); + } + } + + return properties; +} + +function collectInlineSetProperties(sourceFiles: string[]): Set { + const properties = new Set(); + + for (const filePath of sourceFiles) { + const source = readFileSync(filePath, "utf8"); + for (const match of source.matchAll(/\[\s*["'`](--[A-Za-z0-9_-]+)["'`]\s*(?:as\s+string)?\s*\]\s*:/g)) { + properties.add(match[1]); + } + for (const match of source.matchAll(/["'`](--[A-Za-z0-9_-]+)["'`]\s*:/g)) { + properties.add(match[1]); + } + } + + return properties; +} + +function collectReferencedProperties(source: string): Set { + const references = new Set(); + const uncommented = stripCssComments(source); + + for (const match of uncommented.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) { + references.add(match[1]); + } + + return references; +} + +function formatAppPath(filePath: string): string { + return path.relative(APP_ROOT, filePath).split(path.sep).join("/"); +} + +function findUndefinedReferences(args: { + cssFilesToScan: string[]; + definedProperties: Set; + inlineSetProperties: Set; + allowlist?: Set; + sourceByFile?: Map; +}): string[] { + const { + cssFilesToScan, + definedProperties, + inlineSetProperties, + allowlist = JS_SET_PROPERTY_ALLOWLIST, + sourceByFile = new Map(), + } = args; + const violations: string[] = []; + + for (const filePath of cssFilesToScan) { + const source = sourceByFile.get(filePath) ?? readFileSync(filePath, "utf8"); + for (const property of collectReferencedProperties(source)) { + if (definedProperties.has(property) || inlineSetProperties.has(property) || allowlist.has(property)) continue; + violations.push(`${formatAppPath(filePath)} references ${property}`); + } + } + + return violations.sort(); +} + +describe("dashboard CSS token validity", () => { + it("flags a synthetic undefined custom-property reference", () => { + const fixturePath = path.join(APP_ROOT, "fixture.css"); + const fixtureSource = "/* var(--commented-out) */ .x { color: var(--does-not-exist); }"; + const violations = findUndefinedReferences({ + cssFilesToScan: [fixturePath], + definedProperties: new Set(["--defined-token"]), + inlineSetProperties: new Set(), + allowlist: new Set(), + sourceByFile: new Map([[fixturePath, fixtureSource]]), + }); + + expect(collectReferencedProperties(fixtureSource)).toEqual(new Set(["--does-not-exist"])); + expect(violations).toEqual(["fixture.css references --does-not-exist"]); + }); + + it("keeps component and app-level CSS references backed by defined or runtime-set properties", () => { + const violations = findUndefinedReferences({ + cssFilesToScan: collectCssFilesToScan(), + definedProperties: collectDefinedProperties(collectAllCssFiles()), + inlineSetProperties: collectInlineSetProperties(collectSourceFiles()), + }); + + expect(violations, [`Undefined CSS custom-property references found:`, ...violations].join("\n")).toEqual([]); + }); +}); diff --git a/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx b/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx new file mode 100644 index 0000000000..c3d822ccd5 --- /dev/null +++ b/packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx @@ -0,0 +1,435 @@ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { loadAllAppCss, loadAllAppCssBaseOnly } from "../test/cssFixture"; +import { getViewportMode, isMobileViewport, MOBILE_MEDIA_QUERY } from "../hooks/useViewportMode"; + +type BreakpointCase = { + name: "mobile" | "tablet"; + width: number; + height: number; +}; + +const BREAKPOINTS: BreakpointCase[] = [ + { name: "mobile", width: 375, height: 812 }, + { name: "tablet", width: 834, height: 1112 }, +]; + +const MOBILE_WIDTH_MEDIA_QUERY = "(max-width: 768px)"; +const MOBILE_HEIGHT_MEDIA_QUERY = "(max-height: 480px)"; +const TABLET_MEDIA_QUERY = "(min-width: 769px) and (max-width: 1024px)"; +const originalScreen = window.screen; + +function extractMediaBlocks(content: string, pattern: RegExp): string { + const blocks: string[] = []; + + for (const match of content.matchAll(pattern)) { + const start = match.index! + match[0].length; + let index = start; + let depth = 1; + while (index < content.length && depth > 0) { + if (content[index] === "{") depth++; + if (content[index] === "}") depth--; + index++; + } + expect(depth).toBe(0); + blocks.push(content.slice(start, index - 1)); + } + + expect(blocks.length).toBeGreaterThan(0); + return blocks.join("\n"); +} + +function ruleBlocks(css: string, selector: string): string[] { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [...css.matchAll(new RegExp(`${escaped}\\s*\\{[^}]*\\}`, "gs"))].map((match) => match[0]); +} + +function ruleBlock(css: string, selector: string): string { + const blocks = ruleBlocks(css, selector); + expect(blocks.length, `missing CSS rule for ${selector}`).toBeGreaterThan(0); + return blocks[0]; +} + +function declarationValue(rule: string, property: string): string | null { + const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = rule.match(new RegExp(`${escaped}\\s*:\\s*([^;]+);`)); + return match?.[1]?.trim() ?? null; +} + +function defineMetric(element: Element, property: "clientWidth" | "scrollWidth", value: number) { + Object.defineProperty(element, property, { configurable: true, value }); +} + +function defineRect(element: Element, rect: Partial) { + const fullRect = { + x: rect.left ?? 0, + y: rect.top ?? 0, + width: (rect.right ?? 0) - (rect.left ?? 0), + height: (rect.bottom ?? 0) - (rect.top ?? 0), + top: rect.top ?? 0, + right: rect.right ?? 0, + bottom: rect.bottom ?? 0, + left: rect.left ?? 0, + toJSON: () => ({}), + } satisfies DOMRectReadOnly; + vi.spyOn(element, "getBoundingClientRect").mockReturnValue(fullRect); +} + +function installViewport(width: number, height: number) { + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: height }); + Object.defineProperty(window, "screen", { + configurable: true, + value: { + ...originalScreen, + width, + height, + availWidth: width, + availHeight: height, + } as Screen, + }); + + vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({ + matches: + query === MOBILE_WIDTH_MEDIA_QUERY ? width <= 768 : + query === MOBILE_HEIGHT_MEDIA_QUERY ? height <= 480 : + query === MOBILE_MEDIA_QUERY ? width <= 768 || height <= 480 : + query === TABLET_MEDIA_QUERY ? width >= 769 && width <= 1024 : + false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => true), + })); + + defineMetric(document.documentElement, "clientWidth", width); + defineMetric(document.documentElement, "scrollWidth", width); + defineMetric(document.body, "clientWidth", width); + defineMetric(document.body, "scrollWidth", width); +} + +function assertNoDocumentHorizontalOverflow(label: string) { + expect( + document.documentElement.scrollWidth, + `${label}: documentElement should not horizontally overflow`, + ).toBeLessThanOrEqual(document.documentElement.clientWidth + 1); + expect(document.body.scrollWidth, `${label}: body should not horizontally overflow`).toBeLessThanOrEqual( + document.body.clientWidth + 1, + ); +} + +function assertContained(element: Element, label: string) { + expect(element.scrollWidth, label).toBeLessThanOrEqual(element.clientWidth + 1); +} + +function assertInViewport(element: Element, viewport: BreakpointCase, label: string) { + const rect = element.getBoundingClientRect(); + expect(rect.left, `${label}: left edge`).toBeGreaterThanOrEqual(0); + expect(rect.right, `${label}: right edge`).toBeLessThanOrEqual(viewport.width + 1); + expect(rect.top, `${label}: top edge`).toBeGreaterThanOrEqual(0); + expect(rect.bottom, `${label}: bottom edge`).toBeLessThanOrEqual(viewport.height + 1); +} + +function BoardFixture({ populated }: { populated: boolean }) { + const columns = populated ? ["Triage", "Todo", "In Progress", "In Review", "Done", "Archived"] : ["Empty"]; + return ( +
+
+ {columns.map((column) => ( +
+
+

{column}

+
+
+ {populated ? ( +
Wide task title withaverylongunbrokenidentifierthatmuststayinsidecard
+ ) : ( +

No tasks

+ )} +
+
+ ))} +
+
+ ); +} + +function TaskDetailFixture({ populated }: { populated: boolean }) { + return ( +
+
+
+

{populated ? "Long task detail" : "Empty task detail"}

+ +
+
+ {populated ? ( +
+

Long content pressure withaverylongunbrokenwordthatmustnotescape-the-detail-body.

+
very-wide-command --with --many --arguments --that --scrolls --internally
+
+ ) : ( +

No task selected.

+ )} +
+
+
+ ); +} + +function WorkflowFixture({ simple }: { simple: boolean }) { + return ( +
+
+
+

{simple ? "Simple workflow editor" : "Workflow editor"}

+ +
+
+ +
+
+ + +
+
Canvas
+
+
+ +
+
+ + +
+
+
+
+
+
+ ); +} + +function ActivityLogFixture() { + return ( +
+
+
+

Activity Log

+
+ + + +
+ +
+

No activity yet.

+
+
+ ); +} + +function setSurfaceMetrics(surface: Element, viewport: BreakpointCase, options: { internalScroller?: boolean } = {}) { + defineMetric(surface, "clientWidth", viewport.width); + defineMetric(surface, "scrollWidth", viewport.width); + if (options.internalScroller) { + defineMetric(surface, "scrollWidth", viewport.width * 2); + } + defineRect(surface, { left: 0, top: 0, right: viewport.width, bottom: Math.min(viewport.height, 720) }); +} + +function setActionMetrics(container: Element, viewport: BreakpointCase) { + const actions = within(container as HTMLElement).queryAllByRole("button"); + actions.forEach((action, index) => { + defineRect(action, { + left: Math.max(0, viewport.width - 56 - index * 72), + right: Math.max(44, viewport.width - 16 - index * 72), + top: 16 + index * 4, + bottom: 60 + index * 4, + }); + }); +} + +/** + * Surface Enumeration coverage for FN-6385: + * - CSS stylesheet rules via loadAllAppCss + rendered DOM fixtures with mocked viewport metrics. + * - Mobile max-width: 768px, tablet 769px–1024px, and landscape-phone max-height branch. + * - Empty + populated board/detail states; wide content pressure is represented by fixture content and metrics. + * - Shared seams: useViewportMode helpers, modal/detail shell classes, loadAllAppCss aggregation. + * - Board/kanban, task-detail modal, workflow editor, simple workflow editor, and Activity Log modal. + * - Primary controls are asserted inside viewport; intended internal scrollers remain overflow-x:auto usable. + */ +describe("dashboard overflow containment shared mobile/tablet net (FN-6385)", () => { + const css = loadAllAppCss(); + const baseCss = loadAllAppCssBaseOnly(); + const mobileCss = extractMediaBlocks(css, /@media\s*\([^)]*max-width:\s*768px[^)]*\)[^{]*\{/g); + const tabletCss = extractMediaBlocks(css, /@media\s*\(\s*min-width:\s*769px\s*\)\s*and\s*\(\s*max-width:\s*1024px\s*\)\s*\{/g); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(window, "screen", { configurable: true, value: originalScreen }); + }); + + it("keeps the shared CSS contract on the root/body, modal shell, and intended horizontal scrollers", () => { + const rootBlock = ruleBlock(baseCss, "html,\nbody"); + const appRootBlock = ruleBlock(baseCss, "#root"); + const mobileRootBlock = ruleBlock(mobileCss, "html,\n body"); + const mobileOverlayBlock = ruleBlock( + mobileCss, + ".modal-overlay:not(.confirm-dialog-overlay),\n .agent-detail-overlay,\n .agent-dialog-overlay,\n .workflow-output-modal-overlay", + ); + const detailBodyBlock = ruleBlock(baseCss, ".detail-body"); + const boardBaseBlock = ruleBlock(baseCss, ".board"); + const boardMobileBlock = ruleBlock(mobileCss, ".board"); + const boardTabletBlock = ruleBlock(tabletCss, ".board"); + const activityTabletBlock = ruleBlock(tabletCss, ".activity-log-modal"); + + expect(rootBlock).toContain("overflow: hidden;"); + expect(appRootBlock).toContain("overflow: hidden;"); + expect(mobileRootBlock).toContain("overflow-x: hidden;"); + expect(mobileRootBlock).toContain("overscroll-behavior-x: none;"); + expect(mobileOverlayBlock).toContain("overflow-x: hidden;"); + + expect(detailBodyBlock).toContain("overflow-x: hidden;"); + expect(detailBodyBlock).toContain("overflow-y: auto;"); + + expect(declarationValue(boardBaseBlock, "overflow-x")).toBe("auto"); + expect(declarationValue(boardMobileBlock, "overflow-x")).toBe("auto"); + expect(declarationValue(boardTabletBlock, "overflow-x")).toBe("auto"); + expect(boardMobileBlock).toContain("touch-action: pan-x pan-y;"); + expect(activityTabletBlock).toContain("max-width: calc(100vw - var(--space-2xl));"); + }); + + it("keeps workflow editor and simple editor CSS from owning page-level horizontal scroll", () => { + const mobileBodyBlock = ruleBlock(mobileCss, ".wf-editor-body"); + const mobileListSidebarBlock = ruleBlock(mobileCss, ".wf-editor-body--list-stage .wf-editor-sidebar"); + const mobileCanvasBlocks = ruleBlocks(mobileCss, ".wf-editor-canvas"); + const mobileCanvasBlock = mobileCanvasBlocks.find((block) => block.includes("max-width: 100%;")) ?? ""; + expect(mobileCanvasBlock, "missing mobile canvas containment rule").not.toBe(""); + const mobileShellBlock = ruleBlock(mobileCss, ".wf-mobile-shell"); + const simpleShellBlock = ruleBlock(baseCss, ".wf-editor-body--simple-layout .wf-mobile-shell"); + const simpleTabsBlock = ruleBlock(baseCss, ".wf-mobile-tabs"); + + expect(mobileBodyBlock).toContain("min-width: 0;"); + expect(mobileBodyBlock).toContain("overflow-x: hidden;"); + expect(mobileListSidebarBlock).toContain("min-width: 0;"); + expect(mobileListSidebarBlock).toContain("overflow-x: hidden;"); + expect(mobileCanvasBlock).toContain("max-width: 100%;"); + expect(mobileCanvasBlock).toContain("overflow: hidden;"); + expect(mobileShellBlock).toContain("overflow: hidden;"); + expect(simpleShellBlock).toContain("overflow: hidden;"); + expect(simpleTabsBlock).toContain("overflow-x: auto;"); + }); + + it("resolves viewport helper modes for mobile, tablet, and landscape-phone breakpoints", () => { + installViewport(375, 812); + expect(isMobileViewport()).toBe(true); + expect(getViewportMode()).toBe("mobile"); + + installViewport(834, 1112); + expect(isMobileViewport()).toBe(false); + expect(getViewportMode()).toBe("tablet"); + + installViewport(844, 390); + expect(isMobileViewport()).toBe(true); + expect(getViewportMode()).toBe("mobile"); + }); + + it.each(BREAKPOINTS)("keeps board/kanban overflow contained at $name width", (viewport) => { + installViewport(viewport.width, viewport.height); + render( + <> + + + , + ); + + for (const board of [screen.getByTestId("board-empty"), screen.getByTestId("board-populated")]) { + setSurfaceMetrics(board, viewport, { internalScroller: true }); + expect(board.scrollWidth).toBeGreaterThan(board.clientWidth); + expect(ruleBlock(viewport.name === "mobile" ? mobileCss : tabletCss, ".board")).toContain("overflow-x: auto;"); + } + + assertNoDocumentHorizontalOverflow(`${viewport.name} board root`); + }); + + it.each(BREAKPOINTS)("keeps task-detail modal shell contained with empty and long content at $name width", (viewport) => { + installViewport(viewport.width, viewport.height); + render( + <> + + + , + ); + + for (const modal of [screen.getByTestId("detail-empty"), screen.getByTestId("detail-populated")]) { + setSurfaceMetrics(modal, viewport); + assertContained(modal, `${viewport.name} task detail modal`); + setActionMetrics(modal, viewport); + assertInViewport(within(modal).getByRole("button", { name: /close task detail/i }), viewport, "task detail close"); + } + + for (const body of [screen.getByTestId("detail-empty-body"), screen.getByTestId("detail-populated-body")]) { + defineMetric(body, "clientWidth", viewport.width); + defineMetric(body, "scrollWidth", viewport.width); + assertContained(body, `${viewport.name} detail body`); + } + + assertNoDocumentHorizontalOverflow(`${viewport.name} task detail root`); + }); + + it.each(BREAKPOINTS)("keeps workflow and simple-editor controls reachable at $name width", (viewport) => { + installViewport(viewport.width, viewport.height); + render( + <> + + + , + ); + + for (const surface of [screen.getByTestId("workflow-editor"), screen.getByTestId("simple-workflow")]) { + setSurfaceMetrics(surface, viewport); + assertContained(surface, `${viewport.name} workflow surface`); + setActionMetrics(surface, viewport); + for (const saveButton of within(surface).getAllByRole("button", { name: /save/i })) { + assertInViewport(saveButton, viewport, "workflow save action"); + } + assertInViewport(within(surface).getByRole("button", { name: /close workflow editor/i }), viewport, "workflow close action"); + } + + const tabStrip = screen.getAllByRole("navigation", { name: /workflow editor sections/i })[1]; + defineMetric(tabStrip, "clientWidth", viewport.width); + defineMetric(tabStrip, "scrollWidth", viewport.width * 2); + expect(ruleBlock(baseCss, ".wf-mobile-tabs")).toContain("overflow-x: auto;"); + expect(tabStrip.scrollWidth).toBeGreaterThan(tabStrip.clientWidth); + + assertNoDocumentHorizontalOverflow(`${viewport.name} workflow root`); + }); + + it.each(BREAKPOINTS)("keeps Activity Log modal actions reachable at $name width", (viewport) => { + installViewport(viewport.width, viewport.height); + render(); + + const modal = screen.getByTestId("activity-log-modal"); + setSurfaceMetrics(modal, viewport); + setActionMetrics(modal, viewport); + + assertContained(modal, `${viewport.name} activity log modal`); + assertInViewport(screen.getByRole("button", { name: /refresh activity log/i }), viewport, "activity log refresh"); + assertInViewport(screen.getByRole("button", { name: /clear activity log/i }), viewport, "activity log clear"); + assertInViewport(screen.getByRole("button", { name: /close activity log/i }), viewport, "activity log close"); + assertNoDocumentHorizontalOverflow(`${viewport.name} activity log root`); + }); +}); diff --git a/packages/dashboard/app/__tests__/global-theme-css-no-raw-rgba.test.ts b/packages/dashboard/app/__tests__/global-theme-css-no-raw-rgba.test.ts new file mode 100644 index 0000000000..e6f7c69220 --- /dev/null +++ b/packages/dashboard/app/__tests__/global-theme-css-no-raw-rgba.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss, loadAllAppCssBaseOnly, loadThemeDataCss } from "../test/cssFixture"; + +const ALLOWED_EXCEPTIONS: string[] = []; + +function stripVarFallbackRgba(content: string): string { + return content.replace(/var\([^()]*,\s*rgba?\([^)]*\)\s*\)/g, ""); +} + +function findRawRgbViolations(source: string, fileName: string): string[] { + const withoutFallbacks = stripVarFallbackRgba(source); + const lines = withoutFallbacks.split(/\r?\n/); + + return lines + .flatMap((line, index) => + /rgba?\(/.test(line) ? [`${fileName}:${index + 1}:${line.trim()}`] : [] + ) + .filter((violation) => !ALLOWED_EXCEPTIONS.includes(violation)); +} + +function buildRawRgbFailureMessage(violations: string[]): string { + return [ + "Raw rgb/rgba() found in global dashboard CSS.", + "Use design tokens or color-mix(in srgb, var(--color-X) N%, transparent) instead.", + "Allowed exceptions must be documented in ALLOWED_EXCEPTIONS:", + ...violations, + ].join("\n"); +} + +describe("global and theme CSS color token hygiene", () => { + it("detects raw rgb/rgba calls but permits var() fallback rgb/rgba", () => { + const source = [ + ".clean { color: var(--color-text); }", + ".fallback { color: var(--custom-color, rgba(1, 2, 3, 0.5)); }", + ".violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }", + ].join("\n"); + + const violations = findRawRgbViolations(source, "fixture.css"); + + expect(violations).toEqual([ + "fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }", + ]); + expect(buildRawRgbFailureMessage(violations)).toContain( + "fixture.css:3:.violation { box-shadow: 0 0 0 1px rgba(1, 2, 3, 0.5); }" + ); + expect(buildRawRgbFailureMessage(violations)).toContain( + "color-mix(in srgb, var(--color-X) N%, transparent)" + ); + }); + + it("keeps base global CSS free of raw rgb/rgba calls outside var() fallbacks", () => { + const violations = findRawRgbViolations(loadAllAppCssBaseOnly(), "loadAllAppCssBaseOnly()"); + + expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]); + }); + + it("keeps all app CSS free of raw rgb/rgba calls outside var() fallbacks", () => { + const violations = findRawRgbViolations(loadAllAppCss(), "loadAllAppCss()"); + + expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]); + }); + + it("keeps theme-data CSS free of raw rgb/rgba calls outside var() fallbacks", () => { + const violations = findRawRgbViolations(loadThemeDataCss(), "public/theme-data.css"); + + expect(violations, buildRawRgbFailureMessage(violations)).toEqual([]); + }); +}); diff --git a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts index bff329f1df..ed22338b2f 100644 --- a/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts +++ b/packages/dashboard/app/__tests__/lazy-loaded-views-docs.test.ts @@ -1,3 +1,14 @@ +/* +FNXC:CommandCenter 2026-06-16-09:40: +The Command Center view (PR #1683) is an App-level lazy-loaded view added to the curated inventory. This +test enforces that the inventory in AGENTS.md stays in sync with App.tsx (and AppModals.tsx). + +FNXC:CommandCenter 2026-06-17-09:00: +Merging main reconciled the curated count to 23 (main's 22 lazy views/modals + Command Center). + +FNXC:CommandCenter 2026-06-19-00:00: +FN-6702 removes ReliabilityView from the App-level lazy inventory because Reliability now mounts inside the lazy CommandCenter chunk. +*/ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -13,13 +24,15 @@ const EXPECTED_DOCUMENTED_VIEWS = new Set([ "DocumentsView", "SkillsView", "ResearchView", - "ReliabilityView", + "CommandCenter", "EvalsView", "TodoView", "GoalsView", "StashRecoveryView", "PullRequestView", "SetupWizardModal", + "SettingsModal", + "WorkflowNodeEditor", "PluginManager", "PiExtensionsManager", "AgentDetailView", @@ -36,7 +49,7 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([ "SkillsView", "MemoryView", "SecretsView", - "ReliabilityView", + "CommandCenter", "DevServerView", "TodoView", "GoalsView", @@ -44,6 +57,16 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([ "PullRequestView", ]); +/* + * FNXC:DashboardLazyViews 2026-06-16-17:40: + * AppModals lazy-loads top-level heavy modals outside App.tsx, so the docs guard must scan that source site too; otherwise SettingsModal and WorkflowNodeEditor can drift out of the canonical inventory while tests stay green. + */ +const EXPECTED_APP_MODALS_LAZY_VIEWS = new Set([ + "SetupWizardModal", + "SettingsModal", + "WorkflowNodeEditor", +]); + function extractLazyLoadedSection(agentsDoc: string): string { const match = agentsDoc.match(/### Lazy-Loaded Heavy Views[\s\S]*?(?=\n### |\n---|$)/); if (!match) { @@ -59,9 +82,12 @@ function extractBacktickedNamesFromBullets(section: string): string[] { .flatMap((line) => [...line.matchAll(/`([^`]+)`/g)].map((m) => m[1])); } +function extractConstLazyViews(source: string): string[] { + return [...source.matchAll(/const\s+(\w+)\s*=\s*lazy\(/g)].map((m) => m[1]); +} + function extractAppLazyViews(appSource: string): Set { - const matches = [...appSource.matchAll(/const\s+(\w+)\s*=\s*lazy\(/g)].map((m) => m[1]); - const normalized = matches + const normalized = extractConstLazyViews(appSource) .map((name) => { if (name === "_TodoView") { return "TodoView"; @@ -75,22 +101,29 @@ function extractAppLazyViews(appSource: string): Set { return new Set(normalized); } +function extractAppModalsLazyViews(appModalsSource: string): Set { + return new Set(extractConstLazyViews(appModalsSource)); +} + describe("AGENTS lazy-loaded views inventory", () => { - it("documents the App-level lazy views accurately and keeps the curated 20-view list in sync", () => { + it("documents the App-level and AppModals lazy views accurately and keeps the curated 22-view list in sync", () => { const agentsDoc = readFileSync(resolve(__dirname, "../../../../AGENTS.md"), "utf-8"); const appSource = readFileSync(resolve(__dirname, "../App.tsx"), "utf-8"); + const appModalsSource = readFileSync(resolve(__dirname, "../components/AppModals.tsx"), "utf-8"); const section = extractLazyLoadedSection(agentsDoc); const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/); expect(countMatch).toBeTruthy(); - expect(Number(countMatch?.[1])).toBe(20); + expect(Number(countMatch?.[1])).toBe(22); const documentedViews = extractBacktickedNamesFromBullets(section); expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS); - expect(documentedViews).toHaveLength(20); + expect(documentedViews).toHaveLength(22); expect(section).toContain("`ResearchView`"); expect(section).toContain("`TodoView`"); + expect(section).toContain("`SettingsModal`"); + expect(section).toContain("`WorkflowNodeEditor`"); expect((section.match(/`AgentDetailView`/g) ?? []).length).toBe(1); const appLevelViews = extractAppLazyViews(appSource); @@ -99,5 +132,13 @@ describe("AGENTS lazy-loaded views inventory", () => { for (const view of appLevelViews) { expect(EXPECTED_DOCUMENTED_VIEWS.has(view)).toBe(true); } + + const appModalsLazyViews = extractAppModalsLazyViews(appModalsSource); + expect(appModalsLazyViews).toEqual(EXPECTED_APP_MODALS_LAZY_VIEWS); + + for (const view of appModalsLazyViews) { + expect(EXPECTED_DOCUMENTED_VIEWS.has(view)).toBe(true); + expect(section).toContain(`\`${view}\``); + } }); }); diff --git a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx index 6a56028d42..c3f98cf858 100644 --- a/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx +++ b/packages/dashboard/app/__tests__/mobile-feature-access-regression.test.tsx @@ -159,28 +159,21 @@ describe("Mobile Feature Access Regression Guard", () => { expect(screen.getByTestId("mobile-more-item-schedules")).toBeDefined(); expect(screen.getByTestId("mobile-more-item-github")).toBeDefined(); expect(screen.getByTestId("mobile-more-item-usage")).toBeDefined(); - expect(screen.getByTestId("mobile-more-item-reliability")).toBeDefined(); + expect(screen.queryByTestId("mobile-more-item-reliability")).toBeNull(); expect(screen.queryByTestId("mobile-more-item-chat")).toBeNull(); expect(screen.queryByTestId("mobile-more-item-nodes")).toBeNull(); expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined(); }); - it("reliability view is reachable from mobile More sheet", () => { + it("reliability is no longer a mobile More item and is reached via Command Center", () => { const props = createDefaultMobileNavProps(); render(); fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); + expect(screen.queryByTestId("mobile-more-item-reliability")).toBeNull(); - const reliabilityItem = screen.getByTestId("mobile-more-item-reliability"); - expect(reliabilityItem).toBeDefined(); - fireEvent.click(reliabilityItem); - expect(props.onChangeView).toHaveBeenCalledWith("reliability"); - }); - - it("more tab is active when reliability view is open", () => { - render(); - - expect(screen.getByTestId("mobile-nav-tab-more").className).toContain("mobile-nav-tab--active"); + fireEvent.click(screen.getByTestId("mobile-nav-tab-command-center")); + expect(props.onChangeView).toHaveBeenCalledWith("command-center"); }); it("nodes view is reachable from mobile More sheet when enabled", () => { diff --git a/packages/dashboard/app/__tests__/pwa.test.ts b/packages/dashboard/app/__tests__/pwa.test.ts index 522dc5fa5d..45fe05fac2 100644 --- a/packages/dashboard/app/__tests__/pwa.test.ts +++ b/packages/dashboard/app/__tests__/pwa.test.ts @@ -1,8 +1,16 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; +import { inflateSync } from "node:zlib"; import { describe, expect, it } from "vitest"; import { loadAllAppCss } from "../test/cssFixture"; +type DecodedPng = { + width: number; + height: number; + colorType: number; + pixels: Buffer; +}; + function getStandaloneDisplayModeBlock(css: string): string { const match = /@media\s*\(\s*display-mode:\s*standalone\s*\)\s*\{/.exec(css); expect(match).toBeTruthy(); @@ -21,6 +29,89 @@ function getStandaloneDisplayModeBlock(css: string): string { return css.slice(start, i); } +function decodeRgbaPng(filePath: string): DecodedPng { + const buffer = readFileSync(filePath); + const signature = buffer.subarray(0, 8).toString("hex"); + expect(signature).toBe("89504e470d0a1a0a"); + + let offset = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idatChunks: Buffer[] = []; + + while (offset < buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.subarray(offset + 4, offset + 8).toString("ascii"); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + const data = buffer.subarray(dataStart, dataEnd); + + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data.readUInt8(8); + colorType = data.readUInt8(9); + } else if (type === "IDAT") { + idatChunks.push(data); + } else if (type === "IEND") { + break; + } + + offset = dataEnd + 4; + } + + expect(bitDepth).toBe(8); + expect(colorType).toBe(6); + + const bytesPerPixel = 4; + const stride = width * bytesPerPixel; + const inflated = inflateSync(Buffer.concat(idatChunks)); + const pixels = Buffer.alloc(width * height * bytesPerPixel); + let inputOffset = 0; + let outputOffset = 0; + + for (let y = 0; y < height; y += 1) { + const filter = inflated[inputOffset]; + inputOffset += 1; + + for (let x = 0; x < stride; x += 1) { + const raw = inflated[inputOffset + x]; + const left = x >= bytesPerPixel ? pixels[outputOffset + x - bytesPerPixel] : 0; + const up = y > 0 ? pixels[outputOffset + x - stride] : 0; + const upLeft = y > 0 && x >= bytesPerPixel ? pixels[outputOffset + x - stride - bytesPerPixel] : 0; + let value: number; + + if (filter === 0) { + value = raw; + } else if (filter === 1) { + value = raw + left; + } else if (filter === 2) { + value = raw + up; + } else if (filter === 3) { + value = raw + Math.floor((left + up) / 2); + } else if (filter === 4) { + const predictor = left + up - upLeft; + const pa = Math.abs(predictor - left); + const pb = Math.abs(predictor - up); + const pc = Math.abs(predictor - upLeft); + const paeth = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + value = raw + paeth; + } else { + throw new Error(`Unsupported PNG filter ${filter} in ${filePath}`); + } + + pixels[outputOffset + x] = value & 0xff; + } + + inputOffset += stride; + outputOffset += stride; + } + + return { width, height, colorType, pixels }; +} + describe("PWA configuration", () => { it("manifest defines required PWA fields and icon sizes", () => { const manifestPath = resolve(__dirname, "../public/manifest.json"); @@ -29,7 +120,7 @@ describe("PWA configuration", () => { short_name?: string; start_url?: string; display?: string; - icons?: Array<{ sizes?: string }>; + icons?: Array<{ src?: string; sizes?: string; type?: string; purpose?: string }>; }; expect(manifest.name).toBe("Fusion"); @@ -37,8 +128,18 @@ describe("PWA configuration", () => { expect(manifest.start_url).toBe("/"); expect(manifest.display).toBe("standalone"); expect(Array.isArray(manifest.icons)).toBe(true); - expect(manifest.icons?.some((icon) => icon.sizes?.includes("192"))).toBe(true); - expect(manifest.icons?.some((icon) => icon.sizes?.includes("512"))).toBe(true); + expect(manifest.icons).toContainEqual({ + src: "/icons/icon-192.png", + sizes: "192x192", + type: "image/png", + purpose: "any", + }); + expect(manifest.icons).toContainEqual({ + src: "/icons/icon-512.png", + sizes: "512x512", + type: "image/png", + purpose: "any", + }); }); it("index.html includes required PWA meta tags", () => { @@ -93,7 +194,7 @@ describe("PWA configuration", () => { expect(swSource).toContain('addEventListener("install"'); expect(swSource).toContain('addEventListener("fetch"'); expect(swSource).toContain('addEventListener("activate"'); - expect(swSource).toMatch(/fusion-cache-v\d+/); + expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v4";'); }); it("service worker bypasses SSE requests instead of trying to cache them", () => { @@ -162,21 +263,60 @@ describe("PWA configuration", () => { expect(logoSvg).not.toContain("r=\"20\""); }); - it("PWA icon files exist with correct sizes", async () => { - const fs = await import("node:fs"); + it("PWA icon files exist, decode to expected sizes, and are opaque non-blank PNGs", () => { + const icons = [ + { path: resolve(__dirname, "../public/icons/icon-192.png"), size: 192 }, + { path: resolve(__dirname, "../public/icons/icon-512.png"), size: 512 }, + ]; - const icon192Path = resolve(__dirname, "../public/icons/icon-192.png"); - const icon512Path = resolve(__dirname, "../public/icons/icon-512.png"); + for (const icon of icons) { + expect(existsSync(icon.path)).toBe(true); + expect(statSync(icon.path).size).toBeGreaterThan(icon.size * 12); - expect(fs.existsSync(icon192Path)).toBe(true); - expect(fs.existsSync(icon512Path)).toBe(true); + const png = decodeRgbaPng(icon.path); + expect(png.width).toBe(icon.size); + expect(png.height).toBe(icon.size); + expect(png.colorType).toBe(6); - // Verify PNG files have reasonable size (not empty) - const stats192 = fs.statSync(icon192Path); - const stats512 = fs.statSync(icon512Path); + let opaquePixels = 0; + let transparentPixels = 0; + let brandMarkPixels = 0; + const brandBackground = [0x1a, 0x1a, 0x2e]; - expect(stats192.size).toBeGreaterThan(100); - expect(stats512.size).toBeGreaterThan(100); + for (let index = 0; index < png.pixels.length; index += 4) { + const alpha = png.pixels[index + 3]; + if (alpha === 255) opaquePixels += 1; + else transparentPixels += 1; + + const colorDistance = + Math.abs(png.pixels[index] - brandBackground[0]) + + Math.abs(png.pixels[index + 1] - brandBackground[1]) + + Math.abs(png.pixels[index + 2] - brandBackground[2]); + if (colorDistance > 8) brandMarkPixels += 1; + } + + expect(transparentPixels).toBe(0); + expect(opaquePixels).toBe(icon.size * icon.size); + expect(brandMarkPixels).toBeGreaterThan(icon.size * icon.size * 0.1); + } + }); + + it("wires the same PWA icons through manifest, apple touch, and service-worker precache", () => { + const manifest = JSON.parse(readFileSync(resolve(__dirname, "../public/manifest.json"), "utf8")) as { + icons?: Array<{ src?: string; sizes?: string; purpose?: string }>; + }; + const indexHtml = readFileSync(resolve(__dirname, "../index.html"), "utf8"); + const swSource = readFileSync(resolve(__dirname, "../public/sw.js"), "utf8"); + const iconSources = ["/icons/icon-192.png", "/icons/icon-512.png"]; + + for (const iconSource of iconSources) { + expect(manifest.icons?.some((icon) => icon.src === iconSource && icon.purpose === "any")).toBe(true); + expect(swSource).toContain(`"${iconSource}"`); + } + + expect(indexHtml).toContain(''); + expect(indexHtml).toContain(''); + expect(swSource).toContain('const CACHE_NAME = "fusion-cache-v4";'); }); }); }); diff --git a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx index eca5a3e11f..b8a4f8947d 100644 --- a/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx +++ b/packages/dashboard/app/__tests__/tablet-header-controls.test.tsx @@ -94,12 +94,36 @@ describe("tablet header controls", () => { expect(screen.getByTitle("Board view")).toBeDefined(); expect(screen.getByTitle("List view")).toBeDefined(); expect(screen.getByTitle("Agents view")).toBeDefined(); + expect(screen.getByTestId("view-toggle-command-center")).toBeDefined(); + expect(screen.queryByTitle("Documents view")).toBeNull(); // Skills and Insights are NOT inline (they're in overflow) expect(screen.queryByTitle("Skills view")).toBeNull(); expect(screen.queryByTitle("Roadmaps view")).toBeNull(); expect(screen.queryByTitle("Insights view")).toBeNull(); }); + it("places tablet Command Center inline immediately after Agents and Documents only in overflow", () => { + renderTabletHeader({ onChangeView: noop, showAgentsTab: true }); + + expect(screen.getByTestId("view-toggle-command-center").previousElementSibling).toBe(screen.getByTitle("Agents view")); + expect(screen.queryByTitle("Documents view")).toBeNull(); + + fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); + expect(screen.getByTestId("view-overflow-documents")).toBeDefined(); + expect(screen.queryByTestId("view-overflow-command-center")).toBeNull(); + }); + + it("keeps desktop Documents inline and Command Center in overflow", () => { + renderDesktopHeader({ onChangeView: noop, showAgentsTab: true }); + + expect(screen.getByTitle("Documents view")).toBeDefined(); + expect(screen.queryByTestId("view-toggle-command-center")).toBeNull(); + + fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger")); + expect(screen.getByTestId("view-overflow-command-center")).toBeDefined(); + expect(screen.queryByTestId("view-overflow-documents")).toBeNull(); + }); + it("renders view toggle overflow trigger on tablet when overflow items are available", () => { renderTabletHeader({ onChangeView: noop, experimentalFeatures: { insights: true } }); expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeDefined(); diff --git a/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts b/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts index 9d3486e9ab..25a3027dc7 100644 --- a/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts +++ b/packages/dashboard/app/__tests__/task-detail-modal-tablet-width.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; -describe("task detail modal tablet width (FN-5599)", () => { +describe("task detail modal tablet width (FN-5599, FN-6500)", () => { const detailModalCss = readFileSync( resolve(__dirname, "../components/TaskDetailModal.css"), "utf-8", @@ -14,17 +14,23 @@ describe("task detail modal tablet width (FN-5599)", () => { expect(baseRuleMatch![0]).toContain("width: min(95vw, 800px);"); }); - it("defines a tablet breakpoint override for task detail modal width", () => { + it("defines a tablet breakpoint override for task detail modal width and height coupling", () => { const tabletBlockMatch = detailModalCss.match( /@media\s*\(min-width:\s*769px\)\s*and\s*\(max-width:\s*1024px\)\s*\{([\s\S]*?)\n\}/, ); expect(tabletBlockMatch).toBeTruthy(); const tabletBlock = tabletBlockMatch![1]; + const overlayRuleMatch = tabletBlock.match(/\.modal-overlay:has\(\.task-detail-modal\)\s*\{[^}]*\}/s); const modalRuleMatch = tabletBlock.match(/\.modal\.task-detail-modal\s*\{[^}]*\}/s); + const overlayOffset = overlayRuleMatch?.[0].match(/--overlay-padding-top:\s*([^;]+);/)?.[1]?.trim(); + const maxHeightOffset = modalRuleMatch?.[0].match(/max-height:\s*calc\(100dvh - var\(--overlay-padding-top,\s*([^)]+)\) - var\(--space-md\)\);/)?.[1]?.trim(); + + expect(overlayRuleMatch).toBeTruthy(); expect(modalRuleMatch).toBeTruthy(); - expect(modalRuleMatch![0]).toContain("width: min(96vw, 1024px);"); - expect(modalRuleMatch![0]).toContain("max-width: 96vw;"); + expect(maxHeightOffset).toBe(overlayOffset); + expect(modalRuleMatch![0]).toContain("width: 98vw;"); + expect(modalRuleMatch![0]).toContain("max-width: 98vw;"); }); it("keeps mobile full-screen sheet width behavior", () => { diff --git a/packages/dashboard/app/__tests__/task-log-entry-display.test.ts b/packages/dashboard/app/__tests__/task-log-entry-display.test.ts new file mode 100644 index 0000000000..24af305c8a --- /dev/null +++ b/packages/dashboard/app/__tests__/task-log-entry-display.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import type { InReviewStallCode, Task } from "@fusion/core"; +import { findInReviewStallLogEntry } from "../utils/findInReviewStallLogEntry"; +import { getInReviewStallDeadlockCopy } from "../utils/inReviewStallCopy"; +import { getTaskLogEntryAction, getTaskLogEntryOutcome } from "../utils/taskLogEntryDisplay"; + +describe("task log entry display helpers", () => { + it("falls back to text/detail for legacy or operator-shaped log entries", () => { + const entry = { + timestamp: "2026-06-14T18:50:17Z", + text: "Operator parked incomplete stuck-loop-exhausted task", + detail: "Backups preserved before recovery", + type: "operator", + }; + + expect(getTaskLogEntryAction(entry)).toBe("Operator parked incomplete stuck-loop-exhausted task"); + expect(getTaskLogEntryOutcome(entry)).toBe("Backups preserved before recovery"); + }); + + it("returns safe empty display values for malformed entries", () => { + expect(getTaskLogEntryAction({ timestamp: "now" })).toBe(""); + expect(getTaskLogEntryOutcome({ timestamp: "now" })).toBeUndefined(); + expect(getTaskLogEntryAction(undefined)).toBe(""); + }); + + it("falls back from blank action/outcome strings to legacy fields", () => { + const entry = { + timestamp: "2026-06-14T18:50:17Z", + action: " ", + outcome: "", + text: "Legacy action text", + detail: "Legacy detail text", + }; + + expect(getTaskLogEntryAction(entry)).toBe("Legacy action text"); + expect(getTaskLogEntryOutcome(entry)).toBe("Legacy detail text"); + }); + + it("does not throw while scanning logs that contain entries without action", () => { + const task = { + log: [ + { timestamp: "2026-06-14T18:50:17Z", text: "operator note", type: "operator" }, + { timestamp: "2026-06-14T18:51:17Z", action: "In-review stall surfaced [merge-retries-exhausted]" }, + ], + } as unknown as Pick; + + const code: InReviewStallCode = "merge-retries-exhausted"; + expect(findInReviewStallLogEntry(task, code)?.reversedIndex).toBe(0); + }); + + it("does not throw while checking deadlock copy logs that contain entries without action", () => { + const task = { + pausedReason: undefined, + log: [ + { timestamp: "2026-06-14T18:50:17Z", text: "operator note", type: "operator" }, + { timestamp: "2026-06-14T18:51:17Z", action: "In-review stall auto-disposed [merge-blocker]" }, + ], + } as unknown as Pick; + + expect(getInReviewStallDeadlockCopy(task)?.headline).toBe("In-review deadlock auto-disposed"); + }); +}); diff --git a/packages/dashboard/app/__tests__/terminal-input.test.ts b/packages/dashboard/app/__tests__/terminal-input.test.ts index ffaeb5b917..98b77b3bc6 100644 --- a/packages/dashboard/app/__tests__/terminal-input.test.ts +++ b/packages/dashboard/app/__tests__/terminal-input.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from "vitest"; import { loadAllAppCss } from "../test/cssFixture"; -import { readFileSync } from "fs"; -import { resolve } from "path"; +import { + TERMINAL_FONT_FAMILY_PRESETS, + XTERM_FONT_FAMILY, +} from "../utils/terminalPreferences"; const css = loadAllAppCss(); @@ -12,6 +14,72 @@ function findHelperTextareaRule(): string { return match?.[1] ?? ""; } +function findTerminalTextSizingRule(): string { + const match = css.match(/\.terminal-xterm\s*,\s*\.terminal-xterm \*\s*\{([^}]*)\}/); + return match?.[1] ?? ""; +} + +function findSessionTerminalTextSizingRule(): string { + const match = css.match( + /\.cli-session-terminal__viewport\s*,\s*\.cli-session-terminal__viewport \*\s*\{([^}]*)\}/, + ); + return match?.[1] ?? ""; +} + +function findTerminalGlyphFallbackRule(): string { + const match = css.match(/\.terminal-xterm\s+\.xterm-rows\s+span\s*\{([^}]*)\}/); + return match?.[1] ?? ""; +} + +function findSessionTerminalGlyphFallbackRule(): string { + const match = css.match( + /\.cli-session-terminal__viewport\s+\.xterm-rows\s+span\s*\{([^}]*)\}/, + ); + return match?.[1] ?? ""; +} + +function expectTextSizeAdjustPinned(ruleBody: string): void { + expect(ruleBody).not.toBe(""); + expect(ruleBody).toMatch(/-webkit-text-size-adjust\s*:\s*100%\s*;/); + expect(ruleBody).toMatch(/text-size-adjust\s*:\s*100%\s*;/); +} + +function findTerminalSymbolsFontFaceRule(): string { + const fontFaceRules = css.match(/@font-face\s*\{[^}]*\}/g) ?? []; + return ( + fontFaceRules.find((rule) => + /font-family\s*:\s*["']Fusion Terminal Nerd Font Symbols["']/.test(rule), + ) ?? "" + ); +} + +function parseUnicodeRangeValues(ruleBody: string): string[] { + const match = ruleBody.match(/unicode-range\s*:\s*([^;}]*)/i); + return match?.[1] + .split(",") + .map((range) => range.trim().toUpperCase()) + .filter(Boolean) ?? []; +} + +function unicodeRangeIncludesAsciiPrintable(range: string): boolean { + const normalized = range.toUpperCase(); + const rangeMatch = normalized.match(/^U\+([0-9A-F?]+)(?:-([0-9A-F]+))?$/); + if (!rangeMatch) { + return false; + } + + const [, startRaw, endRaw] = rangeMatch; + if (startRaw.includes("?")) { + const start = Number.parseInt(startRaw.replace(/\?/g, "0"), 16); + const end = Number.parseInt(startRaw.replace(/\?/g, "F"), 16); + return start <= 0x007e && end >= 0x0020; + } + + const start = Number.parseInt(startRaw, 16); + const end = endRaw ? Number.parseInt(endRaw, 16) : start; + return start <= 0x007e && end >= 0x0020; +} + describe("terminal helper textarea CSS contract", () => { it("defines the xterm helper textarea rule", () => { const ruleBody = findHelperTextareaRule(); @@ -39,4 +107,57 @@ describe("terminal helper textarea CSS contract", () => { const ruleBody = findHelperTextareaRule(); expect(ruleBody).toMatch(/opacity:\s*0\.01\b/); }); + + it("pins iOS text-size adjustment across the xterm measurement subtree", () => { + expectTextSizeAdjustPinned(findTerminalTextSizingRule()); + }); + + it("pins iOS text-size adjustment on the SessionTerminal xterm viewport", () => { + expectTextSizeAdjustPinned(findSessionTerminalTextSizingRule()); + }); + + it("keeps a DOM glyph fallback mechanism outside xterm measurement options", () => { + expect(findTerminalGlyphFallbackRule()).toMatch(/--terminal-glyph-font-family/); + expect(findSessionTerminalGlyphFallbackRule()).toMatch(/--terminal-glyph-font-family/); + }); +}); + +describe("FN-6424 terminal symbols font CSS contract", () => { + it("scopes the symbols-only Nerd Font away from ASCII cell measurement", () => { + const ruleBody = findTerminalSymbolsFontFaceRule(); + expect(ruleBody).not.toBe(""); + + const unicodeRanges = parseUnicodeRangeValues(ruleBody); + expect(unicodeRanges).toEqual( + expect.arrayContaining(["U+E0A0-E0D7", "U+E700-E8EF", "U+F0001-F1AF0"]), + ); + expect(unicodeRanges.some(unicodeRangeIncludesAsciiPrintable)).toBe(false); + }); +}); + +describe("FN-6659 terminal font stack measurement contract", () => { + const symbolsFamily = '"Fusion Terminal Nerd Font Symbols"'; + + function splitFontFamilies(stack: string): string[] { + return stack + .split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/) + .map((family) => family.trim()) + .filter(Boolean); + } + + it("keeps the default xterm measurement family free of the symbols face", () => { + const families = splitFontFamilies(XTERM_FONT_FAMILY); + + expect(families).not.toContain(symbolsFamily); + expect(families.length).toBeGreaterThan(0); + }); + + it("keeps every terminal preset free of the symbols face xterm measures", () => { + for (const preset of TERMINAL_FONT_FAMILY_PRESETS) { + const families = splitFontFamilies(preset.css); + + expect(families, `${preset.id} xterm measurement stack`).not.toContain(symbolsFamily); + expect(families.length, `${preset.id} has a text font`).toBeGreaterThan(0); + } + }); }); diff --git a/packages/dashboard/app/__tests__/text-token-canonicalization.test.ts b/packages/dashboard/app/__tests__/text-token-canonicalization.test.ts index 614568ea7b..e136e559d8 100644 --- a/packages/dashboard/app/__tests__/text-token-canonicalization.test.ts +++ b/packages/dashboard/app/__tests__/text-token-canonicalization.test.ts @@ -1,4 +1,4 @@ -// Regression guard for FN-4286 follow-up to FN-4195: prevent reintroducing undefined --text-secondary. +// Regression guard for FN-4286/FN-6688: prevent reintroducing undefined primary/secondary text aliases. import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -38,13 +38,27 @@ describe("text token canonicalization", () => { expect(offenders, `Unexpected --text-secondary references in: ${offenders.join(", ")}`).toEqual([]); }); - it("defines canonical text tokens and does not define --text-secondary at :root", () => { + it("keeps --text-primary out of dashboard source files outside command-center", () => { + const offenders: string[] = []; + for (const relPath of collectSourceFiles(APP_ROOT)) { + if (relPath.startsWith("components/command-center/")) continue; + if (ALLOWLIST.has(relPath)) continue; + const content = readFileSync(path.join(APP_ROOT, relPath), "utf8"); + if (content.includes("--text-primary")) offenders.push(relPath); + } + + expect(offenders, `Unexpected --text-primary references in: ${offenders.join(", ")}`).toEqual([]); + }); + + it("defines canonical text tokens and does not define legacy text aliases at :root", () => { const stylesCss = loadStylesCss(); const rootBlocks = [...stylesCss.matchAll(/:root\s*\{([\s\S]*?)\}/g)].map((match) => match[1]); expect(rootBlocks.length).toBeGreaterThan(0); const allRootContent = rootBlocks.join("\n"); + expect(allRootContent).not.toMatch(/^\s*--text-primary\s*:/m); expect(allRootContent).not.toMatch(/^\s*--text-secondary\s*:/m); + expect(allRootContent).toMatch(/^\s*--text\s*:/m); expect(allRootContent).toMatch(/^\s*--text-muted\s*:/m); expect(allRootContent).toMatch(/^\s*--text-dim\s*:/m); }); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index f8d98c8740..489c95c7e5 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -724,6 +724,13 @@ export function retryTask(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/retry`, projectId), { method: "POST" }); } +export function relaunchCliSession(sessionId: string, projectId?: string): Promise<{ ok: boolean; taskId?: string }> { + return api<{ ok: boolean; taskId?: string }>( + withProjectId(`/cli-sessions/${encodeURIComponent(sessionId)}/relaunch`, projectId), + { method: "POST" }, + ); +} + export function recoverBranchBinding(id: string, projectId?: string): Promise { return api(withProjectId(`/tasks/${id}/recover-branch-binding`, projectId), { method: "POST" }); } @@ -812,6 +819,19 @@ export function refreshUpdateCheck(projectId?: string): Promise { + return api(withProjectId("/update-check/install", projectId), { + method: "POST", + }); +} + export interface RemoteSettings { remoteActiveProvider: "tailscale" | "cloudflare" | null; remoteTailscaleEnabled: boolean; @@ -1654,6 +1674,18 @@ export interface ClaudeCliStatus { reason?: string; } | null; ready: boolean; + /** Route A ACP transport state (Claude CLI via the claude-code-cli-acp bridge). */ + acp?: { + /** experimentalFeatures.claudeCliAcp (default ON). */ + enabled: boolean; + /** The acp-runtime plugin published a bundled bridge path. */ + bridgeAvailable: boolean; + /** Claude CLI is actually routing through the bridge (enabled + flag + bridge). */ + active: boolean; + /** The bridged `claude` returned "Not logged in" — needs fallback or re-auth (R17). */ + authFailed: boolean; + authReason?: string; + }; } export interface DroidCliStatus { @@ -7164,6 +7196,14 @@ export interface KillVitestResponse { pids: number[]; } +export interface GithubSourceIssueClosedAtBackfillResult { + scanned: number; + filled: number; + skipped: number; + errors: number; + hasMore: boolean; +} + export function fetchSystemStats(projectId?: string): Promise { return api(withProjectId("/system-stats", projectId)); } @@ -7174,6 +7214,23 @@ export function killVitestProcesses(projectId?: string): Promise { + return api( + withProjectId("/git/github/backfill-source-issue-closed-at", projectId), + { + method: "POST", + body: JSON.stringify({ offset: options.offset, limit: options.limit }), + }, + ); +} + /** Fetch unified activity feed */ export function fetchActivityFeed(options?: FeedOptions): Promise { const params = new URLSearchParams(); @@ -9453,7 +9510,7 @@ export function fetchChatSession(id: string, projectId?: string): Promise { return api(withProjectId(`/chat/sessions/${encodeURIComponent(id)}`, projectId), { diff --git a/packages/dashboard/app/components/ActiveAgentsPanel.css b/packages/dashboard/app/components/ActiveAgentsPanel.css index e8014c9e08..8035c789f3 100644 --- a/packages/dashboard/app/components/ActiveAgentsPanel.css +++ b/packages/dashboard/app/components/ActiveAgentsPanel.css @@ -33,12 +33,12 @@ } .live-agent-card:hover { - background: var(--bg-hover); - border-color: var(--border-strong, var(--border)); + background: var(--surface-hover); + border-color: color-mix(in srgb, var(--border) 65%, var(--text) 35%); } .live-agent-card:focus-visible { - outline: 2px solid var(--accent, var(--color-primary)); + outline: 2px solid var(--accent); outline-offset: 2px; } @@ -79,7 +79,7 @@ .live-agent-card-status { font-style: normal; font-weight: 500; - color: var(--text-primary); + color: var(--text); opacity: 1; white-space: nowrap; overflow: hidden; @@ -152,9 +152,9 @@ } .live-agent-card-logs-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); - border-color: var(--border-strong, var(--border)); + background: var(--surface-hover); + color: var(--text); + border-color: color-mix(in srgb, var(--border) 65%, var(--text) 35%); } @media (max-width: 768px) { diff --git a/packages/dashboard/app/components/ActivityFeed.tsx b/packages/dashboard/app/components/ActivityFeed.tsx index eedbf309ac..21f71e920d 100644 --- a/packages/dashboard/app/components/ActivityFeed.tsx +++ b/packages/dashboard/app/components/ActivityFeed.tsx @@ -12,6 +12,7 @@ import { Trash2, } from "lucide-react"; import type { ActivityFeedEntry } from "../api"; +import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; export interface ActivityFeedProps { entries: ActivityFeedEntry[]; @@ -33,6 +34,11 @@ const TYPE_CONFIG: Record): Record = { "task:deleted": , "task:merged": , "task:failed": , + /* + FNXC:ReleaseAuthorizationGate 2026-06-15-04:00: + The release gate parks unauthorized publish-class tasks; activity logs must expose that blocked state with warning styling so a human can authorize or revise the task. + */ + "task:release-authorization-required": , "task:duplicate-warning-overridden": , "task:auto-archived-ghost-bug": , "task:auto-archived-duplicate": , @@ -64,19 +71,30 @@ const EVENT_TYPE_ICONS: Record = { }; function formatTimestamp(timestamp: string, t: TFunction<"app">): string { - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); + /* + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 centralizes ActivityLogModal bucket math without changing its activityLog.time.* keys, uppercase Just now default, future-as-just-now behavior, or Invalid Date fallback. + */ + const bucket = getRelativeTimeBucket(timestamp); + if (!bucket) { + const timestampMs = Date.parse(timestamp); + if (Number.isFinite(timestampMs) && Date.now() - timestampMs < 0) return t("activityLog.time.justNow", "Just now"); + return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } - if (diffMins < 1) return t("activityLog.time.justNow", "Just now"); - if (diffMins < 60) return t("activityLog.time.minutesAgo", "{{count}}m ago", { count: diffMins }); - if (diffHours < 24) return t("activityLog.time.hoursAgo", "{{count}}h ago", { count: diffHours }); - if (diffDays < 7) return t("activityLog.time.daysAgo", "{{count}}d ago", { count: diffDays }); - - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + switch (bucket.bucket) { + case "just-now": + return t("activityLog.time.justNow", "Just now"); + case "minutes": + return t("activityLog.time.minutesAgo", "{{count}}m ago", { count: bucket.count }); + case "hours": + return t("activityLog.time.hoursAgo", "{{count}}h ago", { count: bucket.count }); + case "days": + return t("activityLog.time.daysAgo", "{{count}}d ago", { count: bucket.count }); + case "weeks": + case "older": + return bucket.date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + } } /** diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index 6eeab880fb..0fb2591b66 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -234,6 +234,11 @@ gap: var(--space-sm); } +/* +FNXC:AgentDetailView 2026-06-14-11:22: +The mobile global `* { touch-action: pan-y; }` lock from FN-6365 prevents horizontal swipe gestures unless each known horizontal scroller opts back into pan-x. +The overflowing agent-detail tab strip must keep horizontal touch panning enabled so all tabs remain reachable on narrow touch viewports (FN-6450). +*/ .agent-detail-tabs { display: flex; gap: var(--space-xs); @@ -242,6 +247,7 @@ background: var(--bg-secondary); flex-shrink: 0; overflow-x: auto; + touch-action: pan-x pan-y; -webkit-overflow-scrolling: touch; scrollbar-width: none; } diff --git a/packages/dashboard/app/components/AgentLogViewer.css b/packages/dashboard/app/components/AgentLogViewer.css index 90ec4b3047..df6c7df981 100644 --- a/packages/dashboard/app/components/AgentLogViewer.css +++ b/packages/dashboard/app/components/AgentLogViewer.css @@ -298,7 +298,7 @@ .agent-log-summary { padding: var(--space-xs) var(--space-md); - font-size: var(--text-xs, 12px); + font-size: 0.75rem; color: var(--text-muted); border-bottom: 1px solid var(--border); text-align: center; diff --git a/packages/dashboard/app/components/AgentLogViewer.tsx b/packages/dashboard/app/components/AgentLogViewer.tsx index c71229ca4c..0b052b67c9 100644 --- a/packages/dashboard/app/components/AgentLogViewer.tsx +++ b/packages/dashboard/app/components/AgentLogViewer.tsx @@ -9,6 +9,7 @@ import type { Components } from "react-markdown"; import { Maximize2, Minimize2, Loader2, ChevronDown, ChevronRight } from "lucide-react"; import "./AgentLogViewer.css"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; +import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; const MARKDOWN_TOGGLE_STORAGE_KEY = "fn-agent-log-markdown"; const TOOL_OUTPUT_TOGGLE_STORAGE_KEY = "fn-agent-log-tool-output"; @@ -33,19 +34,30 @@ function writeBooleanPref(key: string, value: boolean): void { } } +/* +FNXC:AgentLogTimestamps 2026-06-17-17:34: +FN-6601 centralizes timestamp bucket math but AgentLog keeps its existing translation keys and future timestamps continue to render as "just now". +*/ function formatTimestamp(iso: string, t: TFunction<"app">): string { - const date = new Date(iso); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMin = Math.floor(diffMs / 60000); - const diffHr = Math.floor(diffMin / 60); - const diffDay = Math.floor(diffHr / 24); + const bucket = getRelativeTimeBucket(iso); + if (!bucket) { + const date = new Date(iso); + return Number.isFinite(date.getTime()) ? t("agentLog.timeJustNow", "just now") : date.toLocaleDateString(); + } - if (diffMin < 1) return t("agentLog.timeJustNow", "just now"); - if (diffMin < 60) return t("agentLog.timeMinutesAgo", "{{count}}m ago", { count: diffMin }); - if (diffHr < 24) return t("agentLog.timeHoursAgo", "{{count}}h ago", { count: diffHr }); - if (diffDay < 7) return t("agentLog.timeDaysAgo", "{{count}}d ago", { count: diffDay }); - return date.toLocaleDateString(); + switch (bucket.bucket) { + case "just-now": + return t("agentLog.timeJustNow", "just now"); + case "minutes": + return t("agentLog.timeMinutesAgo", "{{count}}m ago", { count: bucket.count }); + case "hours": + return t("agentLog.timeHoursAgo", "{{count}}h ago", { count: bucket.count }); + case "days": + return t("agentLog.timeDaysAgo", "{{count}}d ago", { count: bucket.count }); + case "weeks": + case "older": + return bucket.date.toLocaleDateString(); + } } export const markdownComponents: Components = { diff --git a/packages/dashboard/app/components/AgentReflectionsTab.css b/packages/dashboard/app/components/AgentReflectionsTab.css index 7f8f2da012..707ca58947 100644 --- a/packages/dashboard/app/components/AgentReflectionsTab.css +++ b/packages/dashboard/app/components/AgentReflectionsTab.css @@ -109,7 +109,7 @@ } .reflection-card:hover { - border-color: var(--border-active); + border-color: var(--border); background: var(--card-hover); } @@ -119,7 +119,7 @@ } .reflection-card--expanded { - border-color: var(--color-primary); + border-color: var(--accent); } .reflection-card-header { @@ -149,8 +149,8 @@ } .reflection-trigger-manual { - background: color-mix(in srgb, var(--color-primary) 15%, transparent); - color: var(--color-primary); + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); } .reflection-trigger-user-requested { @@ -221,7 +221,7 @@ content: "→"; position: absolute; left: 0; - color: var(--color-primary); + color: var(--accent); } .reflection-metrics { diff --git a/packages/dashboard/app/components/AgentReflectionsTab.tsx b/packages/dashboard/app/components/AgentReflectionsTab.tsx index 9ad8dda1a9..3048f21074 100644 --- a/packages/dashboard/app/components/AgentReflectionsTab.tsx +++ b/packages/dashboard/app/components/AgentReflectionsTab.tsx @@ -48,7 +48,12 @@ function formatPercent(rate: number): string { return `${Math.round(rate * 100)}%`; } -/** Format an ISO timestamp to a relative time string */ +/** + * Format an ISO timestamp to a relative time string. + * + * FNXC:RelativeTime 2026-06-17-20:48: + * FN-6618 intentionally leaves AgentReflectionsTab local because its `agents.time.in*` future-time i18n outputs would be lost if getRelativeTimeBucket's negative-diff null were treated as an invalid timestamp. + */ function relativeTime(iso: string, t: (key: string, defaultValue: string, opts?: Record) => string): string { const now = Date.now(); const then = new Date(iso).getTime(); diff --git a/packages/dashboard/app/components/AgentsView.css b/packages/dashboard/app/components/AgentsView.css index b7ec8b9e3c..57b72b6348 100644 --- a/packages/dashboard/app/components/AgentsView.css +++ b/packages/dashboard/app/components/AgentsView.css @@ -1135,7 +1135,7 @@ height: var(--org-chart-children-offset, var(--space-md)); left: var(--org-chart-first-child-center-offset); right: var(--org-chart-last-child-center-offset); - border-top: 1px solid var(--border-color, currentColor); + border-top: 1px solid var(--border); pointer-events: none; } @@ -1146,7 +1146,7 @@ left: 50%; width: 1px; height: var(--org-chart-children-offset); - border-left: 1px solid var(--border-color, currentColor); + border-left: 1px solid var(--border); pointer-events: none; } @@ -1158,7 +1158,7 @@ width: 1px; height: auto; border-top: none; - border-left: 1px solid var(--border-color, currentColor); + border-left: 1px solid var(--border); } .agent-org-chart--vertical { diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index c626cd2700..c8b0de8c6c 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -17,7 +17,6 @@ import { TodoModal } from "./TodoModal"; import { UsageIndicator } from "./UsageIndicator"; import { ScheduledTasksModal } from "./ScheduledTasksModal"; import { NewTaskModal } from "./NewTaskModal"; -import { SystemStatsModal } from "./SystemStatsModal"; import { ActivityLogModal } from "./ActivityLogModal"; import { GitManagerModal } from "./GitManagerModal"; import { AgentListModal } from "./AgentListModal"; @@ -187,11 +186,6 @@ export function AppModals({ modalManager.closeUsage(); }, [modalManager.closeUsage, removeNav]); - const closeSystemStatsWithNav = useCallback(() => { - removeNav(modalManager.closeSystemStats); - modalManager.closeSystemStats(); - }, [modalManager.closeSystemStats, removeNav]); - const closeSchedulesWithNav = useCallback(() => { removeNav(modalManager.closeSchedules); modalManager.closeSchedules(); @@ -385,6 +379,7 @@ export function AppModals({ isOpen={modalManager.terminalOpen} onClose={closeTerminalWithNav} initialCommand={modalManager.terminalInitialCommand} + initialCommandGeneration={modalManager.terminalInitialCommandGeneration} projectId={projectId} /> @@ -404,6 +399,7 @@ export function AppModals({ onClose={closeFilesWithNav} onWorkspaceChange={modalManager.setFileWorkspace} projectId={projectId} + onSendSelectionToTask={modalManager.openNewTaskWithDescription} /> )} @@ -424,12 +420,6 @@ export function AppModals({ anchorRect={modalManager.usageAnchorRect} /> - - {modalManager.schedulesOpen && ( diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 5ce373ad8d..7430d23246 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -52,7 +52,7 @@ interface BoardProps { * Called when the user clicks the "Subtask" button in the inline create card. */ onSubtaskBreakdown?: (description: string) => void; - onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void; + onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void; favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; diff --git a/packages/dashboard/app/components/BranchGroupCard.css b/packages/dashboard/app/components/BranchGroupCard.css index edc625c28c..86278c28c9 100644 --- a/packages/dashboard/app/components/BranchGroupCard.css +++ b/packages/dashboard/app/components/BranchGroupCard.css @@ -28,7 +28,7 @@ } .branch-group-card-badge { - background: var(--surface-elevated); + background: var(--surface-1); } .branch-group-card-header-meta .btn { @@ -45,14 +45,14 @@ .branch-group-card-progress-text { color: var(--text-muted); - font-size: var(--font-size-sm); + font-size: 0.875rem; } .branch-group-card-progress { width: 100%; height: var(--space-xs); border-radius: var(--radius-pill); - background: var(--surface-elevated); + background: var(--surface-1); overflow: hidden; } diff --git a/packages/dashboard/app/components/ChatQuestionResponse.css b/packages/dashboard/app/components/ChatQuestionResponse.css new file mode 100644 index 0000000000..90ea673175 --- /dev/null +++ b/packages/dashboard/app/components/ChatQuestionResponse.css @@ -0,0 +1,194 @@ +.chat-question-response { + display: flex; + flex-direction: column; + gap: var(--space-md); + margin-block: var(--space-sm); + padding: var(--space-md); + border: thin solid color-mix(in srgb, var(--accent) 28%, var(--border)); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--accent) 6%, var(--bg-secondary)); + color: var(--text); +} + +.chat-question-response--compact { + gap: var(--space-sm); + padding: var(--space-sm); + border-radius: var(--radius-md); +} + +.chat-question-response__header, +.chat-question-response__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); +} + +.chat-question-response__eyebrow, +.chat-question-response__answered-label, +.chat-question-response__submitted-label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +.chat-question-response__answered-label { + color: var(--color-success); +} + +.chat-question-response__questions { + display: flex; + flex-direction: column; + gap: var(--space-md); +} + +.chat-question-response__question { + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.chat-question-response__question-header { + margin: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-muted); +} + +.chat-question-response__question-text { + margin: 0; + font-size: 1rem; + line-height: 1.3; + color: var(--text); +} + +.chat-question-response--compact .chat-question-response__question-text { + font-size: 0.875rem; +} + +.chat-question-response__description, +.chat-question-response__hint { + margin: 0; + font-size: 0.875rem; + line-height: 1.45; + color: var(--text-muted); +} + +.chat-question-response__options, +.chat-question-response__confirm-group { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin-block-start: var(--space-xs); +} + +.chat-question-response__confirm-group { + flex-direction: row; + flex-wrap: wrap; +} + +.chat-question-response__option { + display: flex; + align-items: flex-start; + gap: var(--space-sm); + padding: var(--space-sm); + border: thin solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast); +} + +.chat-question-response__option:hover, +.chat-question-response__option--selected, +.chat-question-response__confirm--selected { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--card)); +} + +.chat-question-response__option input { + margin: calc(var(--space-xs) / 2) 0 0; + accent-color: var(--accent); +} + +.chat-question-response__option-content { + display: flex; + flex-direction: column; + gap: calc(var(--space-xs) / 2); + min-width: 0; +} + +.chat-question-response__option-label { + font-size: 0.875rem; + font-weight: 600; + color: var(--text); +} + +.chat-question-response__option-description { + font-size: 0.875rem; + line-height: 1.45; + color: var(--text-muted); +} + +.chat-question-response__textarea { + width: 100%; + min-height: calc(var(--space-xl) * 3); + margin-block-start: var(--space-xs); + resize: vertical; + line-height: 1.45; +} + +.chat-question-response__submit { + flex: 0 0 auto; +} + +.chat-question-response__submitted { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm); + border: thin solid var(--border); + border-radius: var(--radius-md); + background: var(--card); +} + +.chat-question-response__submitted pre { + margin: 0; + white-space: pre-wrap; + font-family: var(--font-mono); + font-size: 0.875rem; + line-height: 1.45; + color: var(--text); +} + +.chat-question-response--compact .chat-question-response__actions { + align-items: stretch; + flex-direction: column; +} + +.chat-question-response--compact .chat-question-response__submit { + width: 100%; +} + +@media (max-width: 768px) { + .chat-question-response { + padding: var(--space-sm); + border-radius: var(--radius-md); + } + + .chat-question-response__header, + .chat-question-response__actions { + align-items: stretch; + flex-direction: column; + } + + .chat-question-response__confirm-group { + flex-direction: column; + } + + .chat-question-response__submit { + width: 100%; + } +} diff --git a/packages/dashboard/app/components/ChatQuestionResponse.tsx b/packages/dashboard/app/components/ChatQuestionResponse.tsx new file mode 100644 index 0000000000..3c54664a13 --- /dev/null +++ b/packages/dashboard/app/components/ChatQuestionResponse.tsx @@ -0,0 +1,247 @@ +import "./ChatQuestionResponse.css"; + +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { useTranslation } from "react-i18next"; +import type { ChatQuestion, ChatQuestionAnswers, ChatQuestionAnswerValue, ParsedQuestionToolCall } from "../utils/parseQuestionToolCall"; +import { formatQuestionAnswer } from "../utils/parseQuestionToolCall"; + +export interface ChatQuestionResponseProps { + parsed: ParsedQuestionToolCall; + answered?: boolean; + submittedAnswer?: string; + compact?: boolean; + disabled?: boolean; + onSubmit: (answerText: string, structured: Record) => void; +} + +/** + * FNXC:ChatQuestionResponse 2026-06-16-19:25: + * In-chat question tools need an attractive shared answer affordance for single-select, multi-select, free-text, and confirm prompts. + * Historical or already-answered messages must render read-only so old assistant questions do not keep duplicate live input boxes in regular chat or quick chat. + */ +export function ChatQuestionResponse({ + parsed, + answered = false, + submittedAnswer, + compact = false, + disabled = false, + onSubmit, +}: ChatQuestionResponseProps) { + const { t } = useTranslation("app"); + const [answers, setAnswers] = useState({}); + const textareaRefs = useRef(new Map()); + + const isValid = useMemo( + () => parsed.questions.every((question) => isQuestionAnswerValid(question, answers[question.id])), + [answers, parsed.questions], + ); + + useLayoutEffect(() => { + for (const textarea of textareaRefs.current.values()) { + textarea.style.height = "0"; + textarea.style.height = `${textarea.scrollHeight}px`; + } + }, [answers]); + + const setQuestionAnswer = useCallback((questionId: string, value: ChatQuestionAnswerValue) => { + setAnswers((current) => ({ ...current, [questionId]: value })); + }, []); + + const toggleMultiSelect = useCallback((questionId: string, optionId: string, checked: boolean) => { + setAnswers((current) => { + const currentValue = current[questionId]; + const selected = Array.isArray(currentValue) ? currentValue : []; + return { + ...current, + [questionId]: checked ? [...selected, optionId] : selected.filter((id) => id !== optionId), + }; + }); + }, []); + + const handleSubmit = useCallback(() => { + if (!isValid || answered || disabled) { + return; + } + + const answerText = formatQuestionAnswer(parsed.questions, answers); + onSubmit(answerText, answers); + }, [answers, answered, disabled, isValid, onSubmit, parsed.questions]); + + return ( +
+
+ {t("chat.questionResponseEyebrow", "Assistant question")} + {answered && {t("chat.questionAnsweredLabel", "Answered")}} +
+ +
+ {parsed.questions.map((question, questionIndex) => ( +
+ {question.header &&

{question.header}

} +

{question.question}

+ {question.description &&

{question.description}

} + + {answered ? null : ( + + )} +
+ ))} +
+ + {answered ? ( +
+ {t("chat.questionSubmittedAnswerLabel", "Submitted answer")} +
{submittedAnswer || t("chat.questionAnsweredWithoutContent", "A later user reply answered this question.")}
+
+ ) : ( +
+

{t("chat.questionSelectHint", "Answer all questions to continue the chat.")}

+ +
+ )} +
+ ); +} + +interface QuestionControlsProps { + question: ChatQuestion; + questionIndex: number; + value: ChatQuestionAnswerValue | undefined; + disabled: boolean; + setQuestionAnswer: (questionId: string, value: ChatQuestionAnswerValue) => void; + toggleMultiSelect: (questionId: string, optionId: string, checked: boolean) => void; + textareaRefs: MutableRefObject>; +} + +function QuestionControls({ + question, + questionIndex, + value, + disabled, + setQuestionAnswer, + toggleMultiSelect, + textareaRefs, +}: QuestionControlsProps) { + const { t } = useTranslation("app"); + + if (question.type === "text") { + return ( +