diff --git a/.changeset/FN-7040-align-task-detail-model-display.md b/.changeset/FN-7040-align-task-detail-model-display.md new file mode 100644 index 0000000000..f7b6e2a07b --- /dev/null +++ b/.changeset/FN-7040-align-task-detail-model-display.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep task-detail Chat and Workflow tabs aligned on displayed model names. +category: fix +dev: Extracts dashboard effective model display resolution for shared Chat, Agent Log, and Workflow tab use. diff --git a/.changeset/fix-global-settings-reset-project-central-db.md b/.changeset/fix-global-settings-reset-project-central-db.md new file mode 100644 index 0000000000..795e6a23f0 --- /dev/null +++ b/.changeset/fix-global-settings-reset-project-central-db.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix global settings (including the global concurrency cap) intermittently resetting to defaults. +category: fix +dev: Several production call sites built `new CentralCore(store.getFusionDir())`, pointing the central/global DB at the project's `.fusion/` instead of `~/.fusion/` and spawning stray per-project central DBs seeded with default global settings that shadowed real global state. Added `TaskStore.getGlobalSettingsDir()`, routed the secrets store plus the secrets/proxy/node/secrets-sync/settings-sync dashboard routes through it, and added a `resolveGlobalDir()` guard that throws on a project-local `.fusion/` dir (parent is a git repo) so the regression can't silently recur. Existing stray DBs were operator-quarantined. diff --git a/.changeset/fn-6706-signal-connectors.md b/.changeset/fn-6706-signal-connectors.md new file mode 100644 index 0000000000..bfdd7d0c57 --- /dev/null +++ b/.changeset/fn-6706-signal-connectors.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Record signed signal connectors in Command Center incident metrics. +category: feature +dev: Adds connector incident ingestion and /api/command-center/signals/connectors configuration status. diff --git a/.changeset/fn-6969-provider-icons.md b/.changeset/fn-6969-provider-icons.md new file mode 100644 index 0000000000..6f53dd3a4f --- /dev/null +++ b/.changeset/fn-6969-provider-icons.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Show provider icons next to Command Center model names. +category: feature +dev: Infers provider icons from model ids for Command Center model tables and bar charts; pie charts remain text-only. diff --git a/.changeset/fn-7011-engine-downtime-timing.md b/.changeset/fn-7011-engine-downtime-timing.md new file mode 100644 index 0000000000..bf1d2b8fb8 --- /dev/null +++ b/.changeset/fn-7011-engine-downtime-timing.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Exclude engine-down time from task duration badge and stats. +category: fix +dev: Adds engineLastActiveAt heartbeat and startup reconcile-engine-downtime-active-timing recovery. diff --git a/.changeset/fn-7016-optional-group-edges.md b/.changeset/fn-7016-optional-group-edges.md new file mode 100644 index 0000000000..26ae938ee3 --- /dev/null +++ b/.changeset/fn-7016-optional-group-edges.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix workflow editor so the Browser Verification block shows connected edges. +category: fix +dev: optional-group/foreach/loop container nodes now render connectable handles without adjacent layer overlap in WorkflowNodeEditor. diff --git a/.changeset/fn-7019-command-center-range.md b/.changeset/fn-7019-command-center-range.md new file mode 100644 index 0000000000..2d7250a7b8 --- /dev/null +++ b/.changeset/fn-7019-command-center-range.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Command Center date-range presets now correctly filter charts. +category: fix +dev: Honors open-ended Command Center analytics bounds and serializes All time explicitly. diff --git a/.changeset/fn-7022-mcp-core-foundation.md b/.changeset/fn-7022-mcp-core-foundation.md new file mode 100644 index 0000000000..47a7168d33 --- /dev/null +++ b/.changeset/fn-7022-mcp-core-foundation.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add core MCP server settings model with project/global precedence and secret references. +category: feature +dev: New @fusion/core MCP config types, validators, resolveEffectiveMcpServers, secret-resolver seam, and Claude Desktop import/export. Secret material stored only as Fusion-managed secret references. diff --git a/.changeset/fn-7023-mcp-runtime-forwarding.md b/.changeset/fn-7023-mcp-runtime-forwarding.md new file mode 100644 index 0000000000..075f977f79 --- /dev/null +++ b/.changeset/fn-7023-mcp-runtime-forwarding.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Forward configured MCP servers to all AI lanes and add reachability validation. +category: feature +dev: Adds runtime MCP support gating, materialized MCP forwarding, and POST /api/mcp/validate. diff --git a/.changeset/fn-7024-mcp-cli.md b/.changeset/fn-7024-mcp-cli.md new file mode 100644 index 0000000000..a2b41af042 --- /dev/null +++ b/.changeset/fn-7024-mcp-cli.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add `fn mcp` CLI to manage MCP servers, import Claude Desktop config, and export Fusion MCP JSON. +category: feature +dev: New `packages/cli/src/commands/mcp.ts`; reuses @fusion/core resolveEffectiveMcpServers, validation, and import/export; sensitive fields stored as secret references via SecretsStore, never plaintext. diff --git a/.changeset/fn-7025-mcp-dashboard-ui.md b/.changeset/fn-7025-mcp-dashboard-ui.md new file mode 100644 index 0000000000..40d931af88 --- /dev/null +++ b/.changeset/fn-7025-mcp-dashboard-ui.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add MCP server management UI in Settings with global/project scopes, validation, and import/export. +category: feature +dev: New SettingsModal sections global-mcp/mcp + McpServersCard; consumes @fusion/core MCP foundation and POST /api/mcp/validate; sensitive fields bind to secret references only. diff --git a/.changeset/fn-7030-graph-task-popout.md b/.changeset/fn-7030-graph-task-popout.md new file mode 100644 index 0000000000..305858d32a --- /dev/null +++ b/.changeset/fn-7030-graph-task-popout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Open dependency Graph tasks in the shared movable task pop-out. +category: fix +dev: Routes graph plugin task-open callbacks through MainContent popOutTaskDetail while preserving non-graph plugin modal behavior. diff --git a/.changeset/fn-7031-dock-files-binary-preview.md b/.changeset/fn-7031-dock-files-binary-preview.md new file mode 100644 index 0000000000..83fa0f7d07 --- /dev/null +++ b/.changeset/fn-7031-dock-files-binary-preview.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preview image, video, audio, and PDF files natively in the right-dock Files viewer. +category: fix +dev: Reuses the shared file-preview classification and download route in DockFilesView. diff --git a/.changeset/fn-7034-steps-dropdown-style.md b/.changeset/fn-7034-steps-dropdown-style.md new file mode 100644 index 0000000000..046899d2ae --- /dev/null +++ b/.changeset/fn-7034-steps-dropdown-style.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Match the optional steps dropdown trigger to shared task creation buttons. +category: fix +dev: Reuses the dashboard `.btn .btn-sm` trigger styling for WorkflowOptionalStepsDropdown. diff --git a/.changeset/fn-7036-automation-modal-floating.md b/.changeset/fn-7036-automation-modal-floating.md new file mode 100644 index 0000000000..72691b5073 --- /dev/null +++ b/.changeset/fn-7036-automation-modal-floating.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Automations popup is now movable and resizable like other Fusion pop-outs. +category: feature +dev: ScheduledTasksModal modal presentation now renders inside the shared FloatingWindow (windowKey "automation", persistGeometryKey "floating-window:automation"); embedded presentation unchanged. Mobile stays full-screen by CSS. diff --git a/.changeset/fn-7038-automation-tools-live-output.md b/.changeset/fn-7038-automation-tools-live-output.md new file mode 100644 index 0000000000..63f1eb3de7 --- /dev/null +++ b/.changeset/fn-7038-automation-tools-live-output.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Automation AI steps now run with all tools by default, with a per-step tool selector and live run output. +category: feature +dev: Adds AutomationStep.allowedTools + AUTOMATION_SELECTABLE_TOOLS (core); toolsAllowlist on createFnAgent (engine); SSE GET /automations/:id/run/stream and /routines/:id/run/stream (dashboard). diff --git a/.changeset/fn-7039-workflow-execution-model.md b/.changeset/fn-7039-workflow-execution-model.md new file mode 100644 index 0000000000..69cacabf14 --- /dev/null +++ b/.changeset/fn-7039-workflow-execution-model.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Workflow and automation steps now use the configured project Execution model instead of the default. +category: fix +dev: Workflow/AI-prompt step model resolution now consults the execution lane (resolveExecutorSessionModel / resolveExecutionSettingsModel) instead of resolveProjectDefaultModel, fixing executeWorkflowStep (executor.ts), cron-runner.ts, and dashboard routes.ts. FN-7039. diff --git a/.changeset/fn-7041-workflow-step-definition-not-found.md b/.changeset/fn-7041-workflow-step-definition-not-found.md new file mode 100644 index 0000000000..46a190af76 --- /dev/null +++ b/.changeset/fn-7041-workflow-step-definition-not-found.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix task Workflow tab showing "Step definition not found." for Code Review and other optional steps. +category: fix +dev: WorkflowResultsTab configuredSteps now shows the not-found message only when a step id is absent from the step lookup, not when a found optional-group step has an empty description. diff --git a/.changeset/fn-7042-quick-entry-no-refocus.md b/.changeset/fn-7042-quick-entry-no-refocus.md new file mode 100644 index 0000000000..7832ffd107 --- /dev/null +++ b/.changeset/fn-7042-quick-entry-no-refocus.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Quick task input no longer refocuses itself after you add a task. +category: fix +dev: Removed QuickEntryBox post-submit focus restoration (FNXC:QuickEntryFocus); supersedes FN-6217/FN-6219. diff --git a/.changeset/fn-7045-code-review-step-name.md b/.changeset/fn-7045-code-review-step-name.md new file mode 100644 index 0000000000..554a1a46e1 --- /dev/null +++ b/.changeset/fn-7045-code-review-step-name.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Capitalize the built-in Code Review step name consistently. +category: fix +dev: Updates the compound-engineering built-in workflow node display name and regression coverage. diff --git a/.changeset/fn-7047-quick-entry-hint-removal.md b/.changeset/fn-7047-quick-entry-hint-removal.md new file mode 100644 index 0000000000..e6b4967376 --- /dev/null +++ b/.changeset/fn-7047-quick-entry-hint-removal.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Remove the quick-entry keyboard hint from the task creation surface. +category: internal +dev: Removes the retired quickEntryHint locale key and QuickEntryBox hint shell/CSS. diff --git a/.changeset/fn-7048-task-detail-mobile-tab-touch-action.md b/.changeset/fn-7048-task-detail-mobile-tab-touch-action.md new file mode 100644 index 0000000000..efab54a76e --- /dev/null +++ b/.changeset/fn-7048-task-detail-mobile-tab-touch-action.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore mobile swipe scrolling when touching task-detail tab buttons. +category: fix +dev: Adds detail-tab touch-action pan-x coverage to override the global mobile pan-y lock. diff --git a/.changeset/fn-7052-agent-detail-mobile-tab-touch-action.md b/.changeset/fn-7052-agent-detail-mobile-tab-touch-action.md new file mode 100644 index 0000000000..73f7066602 --- /dev/null +++ b/.changeset/fn-7052-agent-detail-mobile-tab-touch-action.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore horizontal swiping on Agent Detail tabs on mobile touch devices. +category: fix +dev: Adds `.agent-detail-tab` touch-action pan-x coverage because the global mobile pan-y lock is non-inherited. diff --git a/.changeset/fn-7054-skill-slash-names.md b/.changeset/fn-7054-skill-slash-names.md new file mode 100644 index 0000000000..ec3bd785dc --- /dev/null +++ b/.changeset/fn-7054-skill-slash-names.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix slash/namespaced skill commands not loading in chat and agent sessions. +category: fix +dev: skill-resolver requested-name matching now reduces a/b, a/b/SKILL.md, and source::a/b forms to the bare token like the dashboard bareSkillName, scoped to requested-name matching (allow/exclude path matching unchanged). diff --git a/.changeset/fn-7055-workflow-tab-graph-step-details.md b/.changeset/fn-7055-workflow-tab-graph-step-details.md new file mode 100644 index 0000000000..b9c23c89a7 --- /dev/null +++ b/.changeset/fn-7055-workflow-tab-graph-step-details.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix task-detail Workflow tabs so inherited workflow graphs and step details populate. +category: fix +dev: Resets stale task workflow selection/results on task switches and aliases optional step template IDs. diff --git a/.changeset/fn-7057-graph-workflow-filter.md b/.changeset/fn-7057-graph-workflow-filter.md new file mode 100644 index 0000000000..d278a72803 --- /dev/null +++ b/.changeset/fn-7057-graph-workflow-filter.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Graph tasks visible when cached workflow assignments reference deleted workflows. +category: fix +dev: Treat stale Graph `taskWorkflowIds` entries as default-workflow assignments during workflow filtering. diff --git a/.changeset/global-concurrency-slider-and-scope-grouping.md b/.changeset/global-concurrency-slider-and-scope-grouping.md new file mode 100644 index 0000000000..4c02c88be9 --- /dev/null +++ b/.changeset/global-concurrency-slider-and-scope-grouping.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Adjust the global concurrency cap from the footer and dashboard; settings grouped by global vs project scope. +category: feature +dev: Added a Global Max Concurrent slider (wired to fetch/updateGlobalConcurrency) to EngineControlMenu (footer) and the dashboard CommandCenterControls Concurrency card, with debounced saves matching the existing project sliders. SchedulingSection now groups fields under labeled "Global — all projects" and "This project" subheadings with scope badges so the global cap is not mistaken for a per-project setting (clearer on mobile). diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8dd63c157a..3c1d69f153 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -359,16 +359,28 @@ jobs: packages/desktop/dist-electron/latest-linux.yml - # ── Build Android APK artifact ─────────────────────────────────────── + # ── Build Android APK/AAB artifacts ────────────────────────────────── # FNXC:Release 2026-06-25-12:00: # Android release assets used to be limited to the manual mobile workflow's # short-lived CI artifacts. Tagged binary releases now build the Capacitor # Android shell in this workflow so the public GitHub Release includes a # stable APK and checksum beside desktop and CLI binaries. + # FNXC:Release 2026-06-25-18:10: + # Android signing is optional and secret-gated on ANDROID_KEYSTORE_BASE64, + # ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD. + # The Capacitor Android project is gitignored/regenerated, so CI injects + # signing with android.injected.signing.* Gradle properties instead of + # committing native build.gradle edits. When the keystore is absent, keep the + # FN-7014 unsigned debug APK fallback; Play Store upload remains out of scope + # and is tracked separately from sideload release artifacts. build-android: - name: Build Android APK + name: Build Android APK/AAB runs-on: ubuntu-latest timeout-minutes: 30 + # Job-level env mirrors the desktop signing pattern: step `if:` conditions + # can inspect env values, but cannot read secrets.* directly. + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} steps: - name: Checkout @@ -407,18 +419,67 @@ jobs: fi pnpm --filter @fusion/mobile cap sync android - - name: Build Android APK + - name: Decode Android signing keystore + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/fusion-release.keystore" + + - name: Build signed Android release APK and AAB + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + cd packages/mobile/android + chmod +x gradlew + ./gradlew assembleRelease bundleRelease \ + -Pandroid.injected.signing.store.file="$RUNNER_TEMP/fusion-release.keystore" \ + -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" + + - name: Normalize signed Android release assets + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + APK="packages/mobile/android/app/build/outputs/apk/release/app-release.apk" + AAB="packages/mobile/android/app/build/outputs/bundle/release/app-release.aab" + if [ ! -f "$APK" ]; then + echo "::error::Expected signed Android APK missing at $APK" >&2 + exit 1 + fi + if [ ! -f "$AAB" ]; then + echo "::error::Expected signed Android AAB missing at $AAB" >&2 + exit 1 + fi + mkdir -p packages/mobile/dist + cp "$APK" packages/mobile/dist/fusion-android-release.apk + cp "$AAB" packages/mobile/dist/fusion-android-release.aab + + - name: Verify signed Android APK signature + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + APK="packages/mobile/dist/fusion-android-release.apk" + APKSIGNER="" + if [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "$ANDROID_SDK_ROOT/build-tools" ]; then + APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -maxdepth 2 -type f -name apksigner | sort -V | tail -n 1 || true) + fi + if [ -n "$APKSIGNER" ]; then + "$APKSIGNER" verify --verbose "$APK" + else + jarsigner -verify -strict "$APK" + fi + + - name: Build unsigned Android debug APK + if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | cd packages/mobile/android chmod +x gradlew ./gradlew assembleDebug - - name: Normalize Android APK asset + - name: Normalize unsigned Android APK asset + if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | - # FNXC:Release 2026-06-25-12:00: - # Ship the secret-free debug APK because this repo has no Android - # signing keystore configured; signed release APK/AAB distribution is a - # separate product task, not a binary-release plumbing prerequisite. APK="packages/mobile/android/app/build/outputs/apk/debug/app-debug.apk" if [ ! -f "$APK" ]; then echo "::error::Expected Android APK missing at $APK" >&2 @@ -427,18 +488,23 @@ jobs: mkdir -p packages/mobile/dist cp "$APK" packages/mobile/dist/fusion-android.apk - - name: Generate Android APK checksum + - name: Generate Android artifact checksums run: | cd packages/mobile/dist - sha256sum fusion-android.apk > fusion-android.apk.sha256 + for file in fusion-android*.apk fusion-android-release.aab; do + [ -f "$file" ] || continue + sha256sum "$file" > "$file.sha256" + done - - name: Upload Android APK artifact + - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: name: fusion-android-apk path: | - packages/mobile/dist/fusion-android.apk - packages/mobile/dist/fusion-android.apk.sha256 + packages/mobile/dist/fusion-android*.apk + packages/mobile/dist/fusion-android*.apk.sha256 + packages/mobile/dist/fusion-android-release.aab + packages/mobile/dist/fusion-android-release.aab.sha256 # ── Create GitHub Release ───────────────────────────────────────────── github-release: @@ -476,7 +542,7 @@ jobs: id: collect run: | mkdir release-files - find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} release-files/ \; + find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.aab" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} release-files/ \; ls -la release-files/ count=$(find release-files -type f | wc -l | tr -d ' ') echo "count=$count" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index 1acd26e0be..a314be59a1 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -346,15 +346,27 @@ jobs: packages/desktop/dist-electron/latest-linux.yml - # ── Build Android APK artifact ─────────────────────────────────────── + # ── Build Android APK/AAB artifacts ────────────────────────────────── # FNXC:Release 2026-06-25-12:00: # Keep the tag-less rehearsal workflow in parity with release.yml so APK # generation, checksum output, and artifact collection are validated before a # version tag tries to publish the Android asset publicly. + # FNXC:Release 2026-06-25-18:10: + # Android signing is optional and secret-gated on ANDROID_KEYSTORE_BASE64, + # ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD. + # The Capacitor Android project is gitignored/regenerated, so CI injects + # signing with android.injected.signing.* Gradle properties instead of + # committing native build.gradle edits. When the keystore is absent, keep the + # FN-7014 unsigned debug APK fallback; Play Store upload remains out of scope + # and is tracked separately from sideload release artifacts. build-android: - name: Build Android APK + name: Build Android APK/AAB runs-on: ubuntu-latest timeout-minutes: 30 + # Job-level env mirrors the desktop signing pattern: step `if:` conditions + # can inspect env values, but cannot read secrets.* directly. + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} steps: - name: Checkout @@ -393,18 +405,67 @@ jobs: fi pnpm --filter @fusion/mobile cap sync android - - name: Build Android APK + - name: Decode Android signing keystore + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/fusion-release.keystore" + + - name: Build signed Android release APK and AAB + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + cd packages/mobile/android + chmod +x gradlew + ./gradlew assembleRelease bundleRelease \ + -Pandroid.injected.signing.store.file="$RUNNER_TEMP/fusion-release.keystore" \ + -Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" + + - name: Normalize signed Android release assets + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + APK="packages/mobile/android/app/build/outputs/apk/release/app-release.apk" + AAB="packages/mobile/android/app/build/outputs/bundle/release/app-release.aab" + if [ ! -f "$APK" ]; then + echo "::error::Expected signed Android APK missing at $APK" >&2 + exit 1 + fi + if [ ! -f "$AAB" ]; then + echo "::error::Expected signed Android AAB missing at $AAB" >&2 + exit 1 + fi + mkdir -p packages/mobile/dist + cp "$APK" packages/mobile/dist/fusion-android-release.apk + cp "$AAB" packages/mobile/dist/fusion-android-release.aab + + - name: Verify signed Android APK signature + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + APK="packages/mobile/dist/fusion-android-release.apk" + APKSIGNER="" + if [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "$ANDROID_SDK_ROOT/build-tools" ]; then + APKSIGNER=$(find "$ANDROID_SDK_ROOT/build-tools" -maxdepth 2 -type f -name apksigner | sort -V | tail -n 1 || true) + fi + if [ -n "$APKSIGNER" ]; then + "$APKSIGNER" verify --verbose "$APK" + else + jarsigner -verify -strict "$APK" + fi + + - name: Build unsigned Android debug APK + if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | cd packages/mobile/android chmod +x gradlew ./gradlew assembleDebug - - name: Normalize Android APK asset + - name: Normalize unsigned Android APK asset + if: ${{ env.ANDROID_KEYSTORE_BASE64 == '' }} run: | - # FNXC:Release 2026-06-25-12:00: - # Ship the secret-free debug APK because this repo has no Android - # signing keystore configured; signed release APK/AAB distribution is a - # separate product task, not a binary-release plumbing prerequisite. APK="packages/mobile/android/app/build/outputs/apk/debug/app-debug.apk" if [ ! -f "$APK" ]; then echo "::error::Expected Android APK missing at $APK" >&2 @@ -413,18 +474,23 @@ jobs: mkdir -p packages/mobile/dist cp "$APK" packages/mobile/dist/fusion-android.apk - - name: Generate Android APK checksum + - name: Generate Android artifact checksums run: | cd packages/mobile/dist - sha256sum fusion-android.apk > fusion-android.apk.sha256 + for file in fusion-android*.apk fusion-android-release.aab; do + [ -f "$file" ] || continue + sha256sum "$file" > "$file.sha256" + done - - name: Upload Android APK artifact + - name: Upload Android artifacts uses: actions/upload-artifact@v4 with: name: fusion-android-apk path: | - packages/mobile/dist/fusion-android.apk - packages/mobile/dist/fusion-android.apk.sha256 + packages/mobile/dist/fusion-android*.apk + packages/mobile/dist/fusion-android*.apk.sha256 + packages/mobile/dist/fusion-android-release.aab + packages/mobile/dist/fusion-android-release.aab.sha256 # ── Collect all artifacts ───────────────────────────────────────────── collect: @@ -441,7 +507,7 @@ jobs: - name: Combine artifacts run: | mkdir combined - find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} combined/ \; + find artifacts -type f \( -name "fn-*" -o -name "*.sha256" -o -name "*.asc" -o -name "*.exe" -o -name "*.exe.sha256" -o -name "*.blockmap" -o -name "*.dmg" -o -name "*.dmg.sha256" -o -name "*.zip" -o -name "*.zip.sha256" -o -name "*.apk" -o -name "*.aab" -o -name "*.AppImage" -o -name "*.AppImage.sha256" -o -name "*.deb" -o -name "*.deb.sha256" -o -name "*.tar.gz" -o -name "*.tar.gz.sha256" -o -name "latest*.yml" \) -exec cp {} combined/ \; ls -la combined/ - name: Upload combined archive diff --git a/AGENTS.md b/AGENTS.md index 4c5742d1cd..61da3d0d63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,7 +107,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 verify:fast # TEST-FREE verification: typecheck + build (scoped to changed packages) + boot smoke; recommended non-test verification/testCommand. Additive — changes no default +pnpm verify:fast # TEST-FREE verification: artifact bootstrap + scoped typecheck/build + CLI build + boot smoke; recommended non-test verification/testCommand. Additive — changes no default 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 @@ -115,7 +115,7 @@ pnpm build pnpm verify:workspace # deep opt-in verification (lint -> test:full -> build); NOT the merge gate ``` -`pnpm verify:fast` is the recommended **test-free verification** path: typecheck + build scoped to the changed packages (it reuses `pnpm test`'s changed-package resolution) plus the boot smoke once, with **no test run**. It is deterministic and flake-free, suitable as a project `testCommand`/verification command when you want non-test verification; the full suite stays available and runs non-blocking. It is additive and does not change `pnpm test`, the gate, or CI. See `docs/testing.md`. +`pnpm verify:fast` is the recommended **test-free verification** path: bootstrap missing/stale workspace dist artifacts, typecheck + build scoped to the changed packages (it reuses `pnpm test`'s changed-package resolution), an always-on `@runfusion/fusion` CLI build required by the source-checkout boot smoke, plus the boot smoke once, with **no test run**. It is deterministic and flake-free, suitable as a project `testCommand`/verification command when you want non-test verification; the full suite stays available and runs non-blocking. It is additive and does not change `pnpm test`, the gate, or CI. See `docs/testing.md`. ### Standing Rule: Flaky Tests Are Quarantined on Sight (Deletion Ratchet) @@ -219,6 +219,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme ### Run Audit +- FN-7011: self-healing emits `task:reconcile-engine-downtime-active-timing` when startup recovery shifts active task segment anchors to exclude proven engine-process downtime, and `task:reconcile-engine-downtime-active-timing-no-action` when no active task qualifies. - FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes. - FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move. - FN-6736: self-healing emits `task:reclaim-phantom-executor-binding` when it proves an in-memory executor-active binding is stale, clears the binding, and requeues the in-progress task with worktree/progress preserved. @@ -239,6 +240,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - `./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. - `./docs/settings-reference.md` — model-selection hierarchy, mock provider mode, token budget precedence, presets. +- `./docs/signals-connectors.md` — setup, HMAC auth, payload mapping, and security notes for Command Center external signal connectors. - `./docs/storage.md` — hybrid storage model details, including per-task `agent-log.jsonl` storage and retention semantics. - `./docs/multi-project.md` — central/per-project DB and isolation modes. - `./docs/missions.md` — mission/milestone/slice/feature model. diff --git a/MOBILE.md b/MOBILE.md index 419b8047e1..11ea34a507 100644 --- a/MOBILE.md +++ b/MOBILE.md @@ -111,7 +111,23 @@ Mobile CI is defined in `.github/workflows/mobile.yml`. - `build-ios` (sync/build iOS when `packages/mobile/ios/` exists) - `build-android` (sync/build Android when `packages/mobile/android/` exists) -Artifacts from the Mobile Builds workflow are retained for 30 days. Tagged binary releases also run the Android build leg in `.github/workflows/release.yml` and publish `fusion-android.apk` plus `fusion-android.apk.sha256` as GitHub Release assets; `.github/workflows/test-release.yml` mirrors that path in its tag-less rehearsal artifact. +Artifacts from the Mobile Builds workflow are retained for 30 days. Tagged binary releases also run the Android build leg in `.github/workflows/release.yml`; `.github/workflows/test-release.yml` mirrors that path in its tag-less rehearsal artifact. + +When the repository has Android signing secrets configured (`ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`), the release pipeline publishes signed `fusion-android-release.apk` and `fusion-android-release.aab` assets plus `.sha256` checksums. Without those secrets, the pipeline preserves the secret-free fallback and publishes the unsigned debug APK as `fusion-android.apk` plus `fusion-android.apk.sha256`. + +Install the signed APK by enabling **Install unknown apps** for the transfer source on the device, then sideloading it: + +```bash +adb install fusion-android-release.apk +``` + +Verify the APK signer before distribution when Android SDK build-tools are available: + +```bash +apksigner verify --print-certs fusion-android-release.apk +``` + +The `.aab` file is for Play distribution and is not directly sideloadable with `adb install`. Automated Play Store / Play Console upload remains out of scope for now because it needs a Google service-account JSON secret, a published Play listing, and fastlane or `r0adkll/upload-google-play` wiring; that work is tracked separately in FN-7043 from the sideload-first release assets. ## Replacing PWA Icons diff --git a/RELEASING.md b/RELEASING.md index 2b9caa3e1e..22194a7403 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -57,17 +57,17 @@ When you merge the Version Packages PR: - It creates a git tag `v{version}` based on the `kb` CLI package version - The tag push triggers `release.yml`, which: - Builds platform-specific binaries for Linux x64, macOS x64, macOS arm64, and Windows x64 - - Builds the Android APK as `fusion-android.apk` - - Signs macOS binaries (codesign + notarization) and Windows binaries (Authenticode) - - Generates SHA256 checksums for all binaries and the Android APK - - Creates a **GitHub Release** with all binaries, the Android APK, and checksums attached + - Builds Android release assets: signed `fusion-android-release.apk` + `fusion-android-release.aab` when Android signing secrets are configured, otherwise unsigned debug `fusion-android.apk` + - Signs macOS binaries (codesign + notarization), Windows binaries (Authenticode), and Android release artifacts when keystore secrets are available + - Generates SHA256 checksums for all binaries and Android artifacts + - Creates a **GitHub Release** with all binaries, Android artifacts, and checksums attached ## Release channels | Channel | Workflow | Trigger | Output | |---------|----------|---------|--------| | npm | `version.yml` | Push to `main` | npm packages with provenance | -| GitHub Release | `release.yml` | Version tag (`v*`) | Signed platform binaries, Android APK + checksums | +| GitHub Release | `release.yml` | Version tag (`v*`) | Signed platform binaries, Android APK/AAB + checksums | ## Platform binaries @@ -76,16 +76,36 @@ When you merge the Version Packages PR: | Linux x64 | `fusion-linux-x64` | — | | macOS arm64 | `fusion-darwin-arm64` | ✓ (codesign + notarization) | | Windows x64 | `fusion-windows-x64.exe` | ✓ (Authenticode) | -| Android | `fusion-android.apk` | — (debug/unsigned APK) | +| Android | `fusion-android-release.apk`, `fusion-android-release.aab` | ✓ when Android keystore secrets are configured | +| Android fallback | `fusion-android.apk` | — (debug/unsigned APK when Android keystore secrets are absent) | > macOS Intel (`darwin-x64`) is intentionally not shipped: the CLI is Apple-Silicon-only because `macos-13` GitHub runners are too scarce to build reliably. The desktop macOS DMG/ZIP remains universal. +## Android release signing + +`release.yml` and the tag-less `test-release.yml` rehearsal workflow publish signed Android release artifacts when all Android signing secrets are configured: + +- `ANDROID_KEYSTORE_BASE64` — base64-encoded `.jks` / `.keystore` file +- `ANDROID_KEYSTORE_PASSWORD` +- `ANDROID_KEY_ALIAS` +- `ANDROID_KEY_PASSWORD` + +Encode the keystore before saving it as a GitHub Actions secret: + +```bash +base64 -w0 release.keystore +``` + +The Android native project under `packages/mobile/android/` is generated and gitignored, so CI does not commit signing configuration into Gradle files. Instead, the release job injects signing at build time with Android Gradle Plugin `android.injected.signing.*` properties, builds `assembleRelease` and `bundleRelease`, verifies the APK signature, and uploads `fusion-android-release.apk`, `fusion-android-release.aab`, and matching `.sha256` files. If `ANDROID_KEYSTORE_BASE64` is absent, the workflow preserves the secret-free path by building the unsigned debug APK as `fusion-android.apk` with `fusion-android.apk.sha256`. + +Automated Play Store / Play Console upload is intentionally out of scope for the release pipeline right now. It needs a Google service-account JSON secret, a published Play listing, and fastlane or `r0adkll/upload-google-play` wiring; that work is tracked separately in FN-7043 while Fusion remains sideload-first for pre-1.0 Android distribution. + ## Testing binary builds Use the **Test Release** workflow (`test-release.yml`) to manually test binary builds without creating a real release: 1. Go to **Actions** → **Test Release** → **Run workflow** -2. The workflow builds all 4 platform binaries plus the Android APK, runs smoke tests, and uploads artifacts +2. The workflow builds all 4 platform binaries plus the Android APK/AAB path (signed when Android signing secrets are available, unsigned debug APK otherwise), runs smoke tests, and uploads artifacts 3. Download the `all-binaries` artifact to inspect the output ## Manual release (fallback) diff --git a/docs/README.md b/docs/README.md index d6a5a9edb0..b6bb837b72 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,6 +46,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow | Guide | Description | |---|---| | [Settings Reference](./settings-reference.md) | Global/project settings, workflow setting values, model/fallback lane hierarchy, defaults, and API endpoints | +| [MCP](./mcp.md) | Model Context Protocol server configuration, secret references, validation, CLI, dashboard, and import/export workflows | | [Agents](./agents.md) | Agent management, presets, prompts, heartbeat behavior, spawning, and mailbox workflows | ### Architecture & Development diff --git a/docs/agents.md b/docs/agents.md index 9b0a73533f..699724e8be 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -30,7 +30,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - 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. +- 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. Slash and catalog-style names such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token across chat and agent session lanes. 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`. - Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change. - Executor, heartbeat, and dashboard chat sessions expose artifact registry tools so agents can publish and inspect multi-type deliverables without relying on the dashboard gallery. Planning sessions intentionally exclude artifact tools until they can thread the existing `MessageStore` dependency. @@ -570,6 +570,8 @@ Assignment-triggered heartbeats are completion-resilient: if an `agent:assigned` Self-healing also covers abnormal run/session loss for assigned `in-progress` work. If the task remains assigned but the durable agent has no active heartbeat run and no active executor session after the orphan grace window, `reattach-orphaned-assigned-executions` re-dispatches the task forward via `executor.resumeTaskForAgent(agentId)` without pausing, failing, or moving the task backward. +Agent runtime sessions receive the project/global effective MCP server set when the selected runtime supports MCP (pi/Claude/ACP-compatible runtimes). Mock and unsupported runtimes skip MCP forwarding with content-free structured logs. MCP secret references are materialized only at session creation and are never stored on the agent or written to logs. + Heartbeat values are validated and minimum-clamped to 5 minutes (300,000 ms). Project setting `heartbeatMultiplier` (default `1`) scales resolved heartbeat timing globally: both the heartbeat interval (`pollIntervalMs`) and unresponsive timeout base (`heartbeatTimeoutMs`) are multiplied. Per-agent `heartbeatIntervalMs`/`heartbeatTimeoutMs` remain base values before multiplier scaling. This setting is configured from the **Agents** screen's **Controls** popup under "Heartbeat Speed". diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0a84e66cd4..4023e81709 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -986,6 +986,55 @@ fn settings import [--scope global|project|both] [--merge] [--yes] --- +## `fn mcp` + +Manage Fusion MCP server definitions for stdio, SSE, and streamable HTTP transports. See [MCP](./mcp.md) for the full configuration and usage guide, including dashboard flows and secret-reference behavior. + +```bash +fn mcp list [--project ] [--json] +fn mcp add --scope global|project --transport stdio --command [--arg ...] +fn mcp add --scope global|project --transport sse|http --url +fn mcp edit [--scope global|project] [--transport stdio|sse|http] [--command |--url ] +fn mcp remove [--scope global|project] +fn mcp enable [--scope global|project] +fn mcp disable [--scope global|project] +fn mcp import [--scope global|project] [--yes] +fn mcp export [--scope global|project|effective] [--output ] [--json] +fn mcp validate [--scope global|project|effective] [--json] +``` + +Scope semantics: +- `--scope global` writes shared MCP declarations in global settings. +- `--scope project` writes the selected project's declarations. Project servers override same-named global servers; a project server with `enabled:false` disables the inherited global server without deleting it. +- `list`, `export`, and `validate` can show the `effective` resolution, which is global plus project overrides after disabled entries are removed. + +Secret handling: +- Fusion never persists raw MCP env/header/token-like values in settings. Sensitive fields are stored only as Fusion secret references (`{ secretRef, scope }`). +- Use `--env KEY=SECRET_REF` or `--header NAME=SECRET_REF` to attach existing secrets. Add `--secret-scope global|project` when the referenced secret lives outside the command's default scope. +- Use `--create-secret-env KEY=VALUE` or `--create-secret-header NAME=VALUE` when you want the CLI to create a Fusion secret and persist only the resulting reference. +- `--env-raw` and `--header-raw` are rejected by design; they exist only to produce an explicit no-plaintext error for scripts that try to pass inline sensitive values. +- `fn mcp import` accepts Claude Desktop-style `{ "mcpServers": { ... } }` JSON, creates Fusion secrets for imported plaintext env/header values, and writes only secret references. +- `list`, `export`, and `validate` print descriptors/summaries, never decrypted secret values. + +| Option | Description | +|---|---| +| `--scope` | `global` or `project` for writes; `global`, `project`, or `effective` for read/export/validate commands. | +| `--transport` | Server transport for add/edit: `stdio`, `sse`, `http`, or `streamable-http`. | +| `--command` | Command path/name for `stdio` servers. | +| `--arg ` / `--args ` | Arguments for `stdio` servers. Repeat `--arg`; `--args` accepts a space-separated string. | +| `--url` | URL for `sse`, `http`, or `streamable-http` servers. | +| `--env KEY=SECRET_REF` | Attach an env var to an existing secret reference. | +| `--header NAME=SECRET_REF` | Attach an HTTP/SSE header to an existing secret reference. | +| `--secret-scope` | Scope used when resolving `--env`, `--header`, or `--secret-ref` (default: command scope). | +| `--secret-ref` | Existing secret reference for token-like single-secret flows. | +| `--create-secret-env KEY=VALUE` | Create a Fusion secret for an env var and store only the reference. | +| `--create-secret-header NAME=VALUE` | Create a Fusion secret for a header and store only the reference. | +| `--output ` | Write `fn mcp export` output to a file instead of stdout. | +| `--json` | Print machine-readable output for list/export/validate where supported. | +| `--yes` | Skip confirmation during import. | + +--- + ## `fn git` Project git operations. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 1946c032c6..4ba773017a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -52,7 +52,7 @@ The **Right Dock Panel** experiment is enabled by default. To disable it, open * When enabled on desktop or tablet project screens, the right dock is a persistent far-right tools sidebar in the project content row. Use the in-dock collapse control to switch between the full tool panel and the compact far-right rail; the selected tool, expanded/collapsed state, width, and expanded modal size persist across reloads. -The dock toolbar has built-in inline tool panels for **Activity**, **Activity Log**, **Git Manager**, **Files**, and project tool launchers such as **Import from GitHub** / **Import Tasks** workflow entry points and **Automation** actions when available. **Activity**, **Activity Log**, **Git Manager**, and **Files** render in embedded mode inside the dock instead of opening fixed popup overlays; **Files** opens by default and is the fallback when browser storage points at a removed dock key. Inline dock views have an expand button that opens the same view in a resizable modal for more room. Plugin overflow views may add additional right-dock tool tabs, except plugin destinations that explicitly belong in the left sidebar. +The dock toolbar has built-in inline tool panels for **Activity**, **Activity Log**, **Git Manager**, **Files**, and project tool launchers such as **Import from GitHub** / **Import Tasks** workflow entry points and **Automation** actions when available. **Activity**, **Activity Log**, **Git Manager**, and **Files** render in embedded mode inside the dock instead of opening fixed popup overlays; **Files** opens by default and is the fallback when browser storage points at a removed dock key. Inline dock views have an expand button that opens the same view in a resizable modal for more room. The right-dock **Files** viewer and its expanded pop-out match the Files modal for browser-previewable file types: image, video/movie, audio, and PDF selections render as native browser previews, while editable text files keep the editor and save flow. Plugin overflow views may add additional right-dock tool tabs, except plugin destinations that explicitly belong in the left sidebar. Use the desktop/tablet right dock this way: @@ -71,6 +71,15 @@ Content views such as Artifacts, Research, Insights, Skills, Memory, Evals, Goal On mobile viewports, the Right Dock never renders. The compact Header actions and bottom `MobileNavBar` keep their existing mobile behavior even when the experiment is enabled. +## Automations + + + + +Open **Automations** from the left sidebar (or the mobile More surfaces) to create cron, webhook, API, or manual routines. AI Prompt steps now run with all selectable coding tools by default: **Read**, **Bash**, **Edit**, **Write**, **Grep**, **Find**, and **Ls**. In the routine editor, use **Allowed tools** on a simple AI Prompt action or any multi-step AI Prompt step to clear or re-select tools. Leaving every tool selected stores the legacy default, so existing schedules continue to run with full tool access; clearing every box is an explicit no-tools configuration. + +When you choose **Run now**, the routine card opens a **Live output** panel while the manual run is active. The panel appends step status, AI text deltas, and tool start/finish activity as the run executes, then the card falls back to the persisted final run output and run history once the server records the result. The same `RoutineCard` surface is used by the floating modal and embedded Automations view, so live output appears in both presentations and collapses into a single-column card layout on mobile. + ## Deep Links Use deep links to open a specific task directly from notifications, chat, or external tools. @@ -181,8 +190,8 @@ Behavior: - Pan limits are zoom-aware and based on full graph extents (including negative auto-layout origins), so zoomed-in views can still pan to every rendered node instead of getting trapped by fixed viewport-only bounds - Dependency graph nodes reuse the same `TaskCard` UI as board/list views, so status badges, progress/steps, mission badges, retry/archive controls, and active-task glow stay visually consistent - Active graph nodes also add a dedicated top status indicator bar and current-step row highlighting so in-progress execution state stays visible even when zoomed out -- Clicking a graph card opens task details via the host detail handler (`onOpenDetail`, with `onOpenTaskDetail` fallback), while clicking the same card again or empty canvas clears selection -- On touch devices, single-tap is reserved for pan/drag gestures, so double-tapping a node opens its task detail modal; this does not change selection state. +- Clicking a graph card opens task details in the shared movable/resizable task pop-out via the host detail handler (`onOpenDetail`, with `onOpenTaskDetail` fallback), while clicking the same card again or empty canvas clears selection. +- On touch devices, single-tap is reserved for pan/drag gestures, so double-tapping a node opens the same shared task pop-out; this does not change selection state. - Hovering or selecting a node highlights its full upstream and downstream dependency chain; highlighted nodes and connecting edges are emphasized while non-chain nodes are dimmed, and highlight clears when hover/selection is removed - 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 @@ -215,7 +224,7 @@ Behavior: Custom Providers live in **Settings → Authentication → Custom Providers**, inside the **Advanced: Custom Providers** disclosure. Use this section to add user-defined model providers that speak an OpenAI-compatible API, the OpenAI Responses API, an Anthropic-compatible API, or Google Generative AI. After a provider is saved with models, those models become selectable in model dropdowns, including **Settings → Project Models** lanes and workflow model lanes. -Settings → Global Models also includes **Model pricing overrides** for Command Center estimates. Add or edit rows with lowercased `provider:model` keys (or bare `:model` fallback keys), USD-per-1M token prices for input/output/cache read/cache write, and optional source text. **Fetch LiteLLM pricing** performs an explicit one-click refresh from LiteLLM's published model pricing JSON, replaces the override table only after a successful parse, and records the fetched timestamp/source; failed fetches keep the existing overrides. +Settings → Global Models also includes **Model pricing overrides** for Command Center estimates. The section shows a compact pricing snapshot/override-count summary; use **View pricing table** to add or edit rows with lowercased `provider:model` keys (or bare `:model` fallback keys), USD-per-1M token prices for input/output/cache read/cache write, and optional source text. **Fetch LiteLLM pricing** remains available from the collapsed summary and performs an explicit one-click refresh from LiteLLM's published model pricing JSON, replaces the override table only after a successful parse, and records the fetched timestamp/source; failed fetches keep the existing overrides. Supported **API type** values match the dropdown in the form: @@ -388,7 +397,7 @@ Quick Chat is an optional fast, project-scoped assistant surface for conversatio - 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 +- Supports `/skill:{name}` in model-loop chat to request a specific enabled skill for that session; slash/catalog forms such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token, and 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) @@ -827,7 +836,8 @@ Navigation: - Deep link: `?view=command-center` Features: -- Global date-range picker in the header scopes the analytics tabs; **Mission Control** remains live rather than historical. +- Global date-range picker in the header scopes the analytics tabs; **Last 24h**, **Last 7 days**, **Last 30 days**, **All time**, and custom/open-ended ranges each request their selected analytics window. **Mission Control** remains live rather than historical. + - **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner. - **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. 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 sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. 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. @@ -841,7 +851,7 @@ Features: - **Ecosystem** shows active model breadth, per-model task activity, and real plugin activations for the selected range. Plugin activation counts come from project-scoped plugin/extension load events via `/api/command-center/plugin-activations`; if no activation rows exist in range, the metric renders unavailable (`—`) rather than fabricating zero. The tab still 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, a by-repository bar breakdown, and a **Resolved issues** detail list. Resolved rows include the Fusion task, repository, source issue number, optional issue link, resolved timestamp, and whether that timestamp is exact (`sourceIssueClosedAt`) or the documented `updatedAt` approximation; missing issue URLs render as plain text rather than empty anchors or click targets. The same resolved rows are available from the GitHub analytics payload as `resolved` and from the CSV export. -- **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. +- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. 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. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets. - **System** is the canonical system-telemetry destination. It reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It 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. Host memory uses OS-available memory (Node `process.availableMemory()` when available, with a flagged `freemem` fallback) so macOS inactive/cache pages are not reported as used. 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)`. @@ -859,7 +869,7 @@ Data states: - 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, preserves the node selector when a selected remote node fails to refresh, 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. +- Signals is best-effort over local incidents data: if the project has no incidents, the Signals area shows either the setup empty state (no signal connector secret configured) or the quiet empty state (at least one connector configured but no rows in range), 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 @@ -965,6 +975,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, 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 **Workflow** tab resolves the effective workflow for both explicitly selected and default-inherited tasks. Its overview, expandable graph preview, configured step details, and live step results refresh when switching tasks or projects without showing stale rows from the previous task. - 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. @@ -1499,6 +1510,20 @@ Breakpoints: 768px (primary mobile), 1024px (tablet `min-width: 769px and max-wi Manage project and global secrets directly inside **Settings → Project → Secrets**. This section embeds the existing Secrets UI in the settings content panel so you no longer need a footer "Manage secrets" link to leave the modal. +### MCP server management in Settings + +Manage Model Context Protocol servers from the existing Settings modal; no new top-level dashboard view is introduced. See [MCP](./mcp.md) for the full setup, validation, CLI, import, and export guide. + +- **Settings → Global → MCP Servers** stores global MCP defaults shared by projects. +- **Settings → Project → MCP Servers** stores project-level MCP settings. Project entries override global servers by matching `name`, and a same-named disabled project entry suppresses the inherited global server. The project list marks inherited, overridden, project-local, and disabled-global states so operators can see which scope owns the effective entry. +- Supported transports are `stdio`, `sse`, and `streamable-http`. The editor shows the transport-specific command, URL, args, env, and header fields. +- Sensitive MCP values are secret references only. Environment values, HTTP/SSE header values, and tokens must be selected from or created in Fusion secrets; plaintext values are never persisted into the settings blob. +- Each server row has a **Test** control that calls the MCP validation API and renders pending, valid, unreachable, or error status inline using the standard status-dot convention and semantic status colors. +- The import panel accepts Claude Desktop-style `{ "mcpServers": { ... } }` JSON by paste or upload. Imported plaintext sensitive values are converted into Fusion secret references before the settings draft is saved. +- The export panel produces Fusion MCP JSON for the active scope and offers copy/download actions. + +The MCP sections reuse Settings form/card primitives and include mobile layouts for `(max-width: 768px)` so validate, override, disable, import, and export controls remain usable in the Settings sheet. + ### Lazy-Loaded Heavy Views These 20 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.** The user-facing **Artifacts** section is still implemented by the `DocumentsView` component name. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000000..ba41f23108 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,221 @@ +# MCP (Model Context Protocol) + +[← Back to docs index](./README.md) + +MCP (Model Context Protocol) is a standard way to attach external tool servers to AI runtimes. Fusion stores trusted MCP server definitions in settings, resolves the effective global/project configuration, materializes any referenced secrets only at use time, and forwards enabled servers to MCP-capable AI lanes so those lanes can use the same operator-approved tools. + + + +## Overview + +MCP support lets Fusion operators configure external stdio, SSE, or streamable HTTP MCP servers once and make them available to AI sessions that support MCP. Enabled servers are treated as **trusted once configured**: after an operator saves a server definition, Fusion may forward it to supported AI lanes without asking again for each session. + +MCP configuration lives in the `mcpServers` settings key at both scopes: + +- **Global settings** hold shared MCP declarations. +- **Project settings** hold project-specific declarations. +- Effective resolution is project-over-global by server `name`: global servers load first, same-named project servers replace them, and same-named project servers with `enabled:false` disable the inherited global server. +- The project-level `mcpServers.enabled` flag overrides the global flag when it is set. If the effective flag is false, no MCP servers are active. + +Expected outcome: when `mcpServers.enabled` resolves to true and at least one enabled server definition is valid, supported AI runtimes receive the effective server set for new sessions. + +## Server definitions and transports + +Every server has a unique `name`, an optional per-server `enabled` flag, and exactly one transport: + +| Transport | Required fields | Optional sensitive fields | Notes | +|---|---|---|---| +| `stdio` | `command` | `env` | `args` may provide command arguments. | +| `sse` | `url` | `headers` | Uses an SSE endpoint. | +| `streamable-http` | `url` | `headers` | The CLI also accepts `http` as an alias and stores `streamable-http`. | + +Definitions use these shapes: + +```json +{ "name": "local-tools", "enabled": true, "transport": "stdio", "command": "node", "args": ["server.js"], "env": { "API_KEY": { "secretRef": "sec_...", "scope": "project" } } } +{ "name": "docs-sse", "transport": "sse", "url": "https://example.test/sse", "headers": { "Authorization": { "secretRef": "sec_...", "scope": "global" } } } +{ "name": "docs-http", "transport": "streamable-http", "url": "https://example.test/mcp", "headers": { "Authorization": { "secretRef": "sec_...", "scope": "project" } } } +``` + +Expected outcome: settings validation accepts only the required fields for the selected transport, rejects duplicate server names within one stored settings array, and rejects plaintext sensitive values. + +## Secret references + +Fusion never persists raw MCP environment values, header values, or token-like material in settings. Sensitive maps store only Fusion-managed secret references: + +```json +{ "secretRef": "sec_...", "scope": "project" } +``` + +Use `scope: "project"` for secrets stored in the current project and `scope: "global"` for secrets stored in the global secrets database. The plaintext value lives in the encrypted [Secrets](./secrets.md) store, not in `mcpServers`. + +Fusion materializes MCP secret references only at the use seam: + +- when creating an AI session for an MCP-capable runtime; +- when running a bounded validation/reachability probe; +- when importing plaintext Claude Desktop env/header values and immediately creating Fusion secrets. + +Expected outcome: API responses, CLI output, settings JSON, exports, and structured logs show secret references or counts/status metadata only; they do not include decrypted env/header values. + +## Validation and reachability + +The dashboard **Test** control calls `POST /api/mcp/validate` for one server. The route accepts a JSON body with either: + +- `name` — resolve a configured server by name in the current project context; or +- `server` / `definition` — validate and probe the supplied server definition. + +`timeoutMs` is optional, must be positive, and is capped at 30000 milliseconds. The response is: + +```json +{ "status": "valid", "message": "..." } +``` + +`status` is one of: + +| Status | Meaning | +|---|---| +| `valid` | The definition resolved, secrets materialized, and the bounded probe reached the server. | +| `unreachable` | The definition resolved, but the probe could not reach the server within the bounded check. | +| `error` | Validation, secret resolution, spawn, fetch, or protocol setup failed. | + +Expected outcome: validation returns only `{ status, message? }`; resolved `env` and `headers` values are never returned. + +Note: `fn mcp validate` currently validates stored definitions and reports whether they satisfy Fusion's schema. It does not perform the dashboard/API reachability probe. + +## Managing servers in the dashboard + +1. Open **Settings → Global → MCP Servers** for shared defaults, or **Settings → Project → MCP Servers** for project-specific servers. Expected outcome: the **Global MCP servers** or **Project MCP servers** card appears. +2. Turn on **Enable MCP servers for this scope**. Expected outcome: the current scope's `mcpServers.enabled` draft becomes true; project scope overrides global enablement when saved. +3. Click **Add server**. Choose `stdio`, `SSE`, or `HTTP`, then enter the required `command` or `url`. Expected outcome: the editor only asks for fields used by that transport and saves `HTTP` as `streamable-http`. +4. Add sensitive values under **Environment secret refs** for `stdio` or **Header secret refs** for `sse` / `streamable-http`. Choose an existing secret or create a new secret with **Create secret**. Expected outcome: the settings draft receives only `{ secretRef, scope }`; the plaintext creation value is stored in Secrets, not in settings. +5. Save the server. Expected outcome: the row appears with its transport, state badge, and validation status of **Not tested**. +6. In project settings, review inherited rows from global settings. Use **Override** to replace an inherited server or **Disable** to add a same-named project disabled entry. Expected outcome: state badges identify inherited, overridden, project-local, and disabled-global behavior before you save. +7. Click **Test** on a server row. Expected outcome: the row shows **Testing…** while pending, then `valid`, `unreachable`, or `error` with the returned message. +8. Use the **Import** pane to paste JSON or choose **Upload JSON**. Expected outcome: Claude Desktop-style servers are added to the draft, duplicate names are rejected, and plaintext env/header values are converted into newly created Fusion secrets plus secret references. +9. Use **Copy Fusion MCP JSON** and then **Download JSON** when needed. Expected outcome: the export contains Fusion MCP JSON with secret references, and no plaintext secret values. +10. Save the Settings modal. Expected outcome: the selected global or project `mcpServers` settings are persisted and used by subsequent MCP-capable AI sessions. + +## Managing servers from the CLI + +1. List configured servers: + + ```bash + fn mcp list [--project ] [--json] + ``` + + Expected outcome: Fusion prints global, project, and effective servers with secret summaries such as `project secret`, never decrypted values. + +2. Add a stdio server: + + ```bash + fn mcp add local-tools --scope project --transport stdio --command node --arg server.js --env API_KEY=my-existing-secret --secret-scope project + ``` + + Expected outcome: Fusion resolves `my-existing-secret` by id or key, stores it as `{ secretRef, scope }`, and prints `✓ Added MCP server "local-tools" to project scope`. + +3. Add an SSE or streamable HTTP server: + + ```bash + fn mcp add docs --scope global --transport sse --url https://example.test/sse --header Authorization=docs-token --secret-scope global + fn mcp add http-docs --scope project --transport http --url https://example.test/mcp --secret-ref docs-token --secret-scope project + ``` + + Expected outcome: `sse` stores an SSE server, `http` is normalized to `streamable-http`, and `--secret-ref` supplies a token-like default secret field when no explicit `--env` or `--header` is present. + +4. Create secrets while adding or editing: + + ```bash + fn mcp add private-docs --scope project --transport streamable-http --url https://example.test/mcp --create-secret-header Authorization=Bearer-token-value + ``` + + Expected outcome: the CLI creates a Fusion secret with a suggested MCP key and persists only the new secret reference in settings. + +5. Edit a scoped server: + + ```bash + fn mcp edit local-tools --scope project --command node --args '["server.js","--verbose"]' + ``` + + Expected outcome: only the selected global or project declaration changes; effective project-over-global behavior is recomputed later by name. + +6. Enable or disable a server: + + ```bash + fn mcp enable local-tools --scope project + fn mcp disable local-tools --scope project + ``` + + Expected outcome: Fusion flips the selected declaration's `enabled` flag. At project scope, disabling a same-named inherited server masks the global declaration without deleting it. + +7. Remove a scoped declaration: + + ```bash + fn mcp remove local-tools --scope project + ``` + + Expected outcome: the selected declaration is deleted. If you remove a project override, a same-named global server may become effective again. + +8. Validate stored definitions: + + ```bash + fn mcp validate [--scope global|project|effective] [--json] + ``` + + Expected outcome: Fusion reports whether stored definitions satisfy the MCP settings schema. Use the dashboard **Test** control or `POST /api/mcp/validate` for reachability. + +## Importing Claude Desktop configuration + +1. Prepare a Claude Desktop-style JSON file or paste payload: + + ```json + { + "mcpServers": { + "docs": { + "command": "node", + "args": ["server.js"], + "env": { "API_KEY": "plaintext-from-claude-config" } + } + } + } + ``` + + Expected outcome: Fusion recognizes the `mcpServers` object and maps each entry to a named MCP server. + +2. Import from the dashboard by opening **Settings → Global → MCP Servers** or **Settings → Project → MCP Servers**, pasting the JSON into **Import**, or choosing **Upload JSON**, then clicking **Import**. Expected outcome: plaintext env/header values are converted into Fusion secrets with `prompt` access policy and settings receive only secret references. + +3. Import from the CLI: + + ```bash + fn mcp import ./claude_desktop_config.json --scope project --yes + ``` + + Expected outcome: the CLI prints an import summary, creates Fusion secrets for plaintext env/header values, replaces them with secret references, and imports the definitions into the selected scope. + +4. Review the imported rows with `fn mcp list` or the dashboard card before saving/applying broader settings changes. Expected outcome: duplicate names are visible, secret fields show as references, and effective project-over-global behavior is clear. + +## Exporting Fusion MCP configuration + +1. Export from the dashboard by opening the MCP settings card and clicking **Copy Fusion MCP JSON**. Expected outcome: the JSON is copied when clipboard access is available, and the export text appears for manual copy. +2. Click **Download JSON** after generating the dashboard export. Expected outcome: the browser downloads `fusion-mcp-servers.json`. +3. Export from the CLI: + + ```bash + fn mcp export --scope effective --output fusion-mcp-servers.json + fn mcp export --scope global --json + ``` + + Expected outcome: `--output` writes the JSON to a file; without `--output`, Fusion prints JSON to stdout. `global`, `project`, and `effective` choose stored global declarations, stored project declarations, or resolved project-over-global output. +4. Inspect the exported secret fields. Expected outcome: env/header values remain `{ secretRef, scope }` references and are not decrypted into plaintext. + +## How MCP servers reach AI lanes + +When an AI lane starts a session, Fusion resolves the effective `mcpServers` settings, materializes secret references through the scoped secrets store, and passes the resulting in-memory server declarations to runtimes that support MCP. The forwarding path covers chat/planning, executor, reviewer, validator, merger, workflow model nodes, summarization, evaluator, research, cron/automation, mission, and reflection paths. + +Runtime support is guarded. Claude/pi/ACP-compatible runtimes receive MCP servers; mock or unsupported runtimes skip forwarding and emit only structured count/provider/runtime metadata. Skipped forwarding is not a settings error: it means the selected runtime does not accept MCP server declarations. + +Expected outcome: enabling a server makes it available to subsequent supported AI sessions, while unsupported sessions continue without MCP tools and without logging secret-bearing server definitions. + +See [Settings Reference](./settings-reference.md) for the `mcpServers` settings contract and [Agents](./agents.md) for runtime/model lane behavior. diff --git a/docs/secrets.md b/docs/secrets.md index 4cb59c2985..e6502abd3e 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -38,8 +38,10 @@ Threat-model baseline: - Secret plaintext is **not** stored in SQLite. - Ciphertext + nonce are persisted; plaintext exists only in process memory during create/reveal. - Secret values must never be logged. +- MCP server settings store only secret references for sensitive env/header/token fields; imports surface plaintext as secret-creation descriptors instead of persisting it in settings. +- MCP server secret references are materialized only at session/probe creation time for MCP-capable AI lanes and `POST /api/mcp/validate`; responses and structured logs include status/count metadata only, never resolved env/header values. -See also: [Storage](./storage.md), [Multi-project](./multi-project.md), [Architecture](./architecture.md), [Settings reference](./settings-reference.md). +See also: [Storage](./storage.md), [Multi-project](./multi-project.md), [Architecture](./architecture.md), [Settings reference](./settings-reference.md), and [MCP](./mcp.md) for MCP-specific secret-reference workflows. ## Architecture @@ -133,7 +135,7 @@ Fusion can materialize env-exportable secrets into each acquired task worktree w - Fingerprint sidecar: successful writes persist `.fusion-secrets-env.fingerprint` containing `\n\n` (mode `0o600`) so teardown can verify file integrity before deletion. - Teardown cleanup: when a worktree is removed, Fusion deletes the managed env file only when the on-disk fingerprint still matches; edited files are preserved and only the sidecar is removed. -Settings shape is split by scope: project-level secrets settings are limited to `ProjectSettings.secretsEnv`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry the plaintext passphrase. +Settings shape is split by scope: project-level secrets settings include `ProjectSettings.secretsEnv` and MCP secret references in `ProjectSettings.mcpServers`, while cross-node sync passphrase state is stored only as the reserved `__sync_passphrase__` row in `secrets_global` and exposed read-only through `GlobalSettings.secretsSyncPassphraseConfigured` (`packages/core/src/types.ts`). Settings never carry plaintext passphrases or MCP credentials; MCP env/header/token fields use `{ secretRef, scope }` and materialize through `SecretsStore.revealSecret(...)` only at the runtime use seam. ### Test locations diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 4523fced94..180b9a4c74 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -25,6 +25,22 @@ At runtime, settings are merged. **Project settings override global settings** w --- +## Signal connector environment variables + +Command Center signal connectors are configured with process environment variables read by the dashboard/API server. These values are secrets and are never returned by the connectors-status endpoint; `GET /api/command-center/signals/connectors` reports only per-provider `configured` booleans. + +| Environment variable | Connector | Used by | Notes | +|---|---|---|---| +| `FUSION_SIGNAL_WEBHOOK_SECRET` | Generic webhook | `POST /api/signals/webhook` | Verifies `X-Fusion-Signature` (`sha256=`-prefixed HMAC-SHA256 hex) plus `X-Fusion-Timestamp`. | +| `FUSION_SIGNAL_SENTRY_SECRET` | Sentry | `POST /api/signals/sentry` | Verifies `Sentry-Hook-Signature` against Sentry issue webhook payloads. | +| `FUSION_SIGNAL_DATADOG_SECRET` | Datadog | `POST /api/signals/datadog` | Verifies the custom `X-Datadog-Signature` HMAC header; optional `X-Datadog-Timestamp` bounds replay. | +| `FUSION_SIGNAL_PAGERDUTY_SECRET` | PagerDuty | `POST /api/signals/pagerduty` | Verifies `X-PagerDuty-Signature` (`v1=`). | +| `FUSION_MONITOR_INGEST_SECRET` | Monitor incidents API | `POST /api/monitor/incidents` | Separate bearer-token path for direct monitor ingestion; it is not used by `/api/signals/:provider`. | + +See [Signals Connectors](./signals-connectors.md) for setup, signing, payload, and open/resolved mapping details. + +--- + ## Global Settings Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. @@ -38,8 +54,8 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | | `defaultProvider` | `string` | `undefined` | Default AI provider. | | `defaultModelId` | `string` | `undefined` | Default AI model ID. | -| `modelPricingOverrides` | `Record` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable in Settings → Global Models. | -| `modelPricingFetchedAt` | `string` | `undefined` | ISO timestamp for the last successful one-click pricing refresh from the Settings → Global Models pricing editor. | +| `modelPricingOverrides` | `Record` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable from Settings → Global Models → View pricing table. | +| `modelPricingFetchedAt` | `string` | `undefined` | ISO timestamp for the last successful one-click pricing refresh from the Settings → Global Models pricing summary. | | `modelPricingSource` | `string` | `undefined` | Source label/URL for the current pricing override set, currently the LiteLLM model pricing JSON when fetched through the dashboard. | | `fallbackProvider` | `string` | `undefined` | Fallback provider when the primary default model hits transient provider failures or model-compatibility/auth-tier rejections. | | `fallbackModelId` | `string` | `undefined` | Fallback model ID (must pair with `fallbackProvider`). | @@ -122,8 +138,36 @@ Fusion automatically falls back to ntfy's JSON publish format when a notificatio | `researchGlobalUserAgent` | `string` | `"FusionResearchBot/1.0"` | User-Agent header for HTTP requests made by research providers. | | `experimentalFeatures` | `Record` | `{}` | Global-scoped experimental feature flags. Includes `experimentalFeatures.researchView`, which gates all Research surfaces and tools (dashboard view, engine task-session tools, and CLI `fn_research_*` tools), and `experimentalFeatures.evalsView`, which gates Evals surfaces (dashboard view, Settings → Scheduled Evals, and scheduled-eval cron execution). | | `remoteAccess` | `RemoteAccessSettings` | `{ activeProvider: null, providers: {...}, tokenStrategy: {...}, lifecycle: {...} }` | Global-scoped remote access provider + token strategy configuration used by Remote Access routes and tunnel lifecycle controls. | +| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Global MCP server declarations shared across projects. Project `mcpServers` can enable/disable the effective set, override a same-named global server, or disable a global server with a same-named `enabled:false` entry. Sensitive env/header/token values must be `{ secretRef, scope }` references to Fusion-managed secrets, never plaintext. | | `worktrunk` | `WorktrunkSettings` | `{ enabled: false, binaryPath: undefined, installedBinaryPath: undefined, onFailure: "fail" }` | Global defaults for worktrunk integration. Merged field-by-field with project `worktrunk` values; project values override global values for matching fields. | +### MCP server settings + +`mcpServers` is available in both global and project settings: + +```ts +type McpServersSettings = { + enabled?: boolean; + servers?: McpServerDefinition[]; +}; +``` + +Each server is named and uses one transport: + +- `stdio`: `{ name, enabled?, transport: "stdio", command, args?, env? }` +- `sse`: `{ name, enabled?, transport: "sse", url, headers? }` +- `streamable-http`: `{ name, enabled?, transport: "streamable-http", url, headers? }` + +Resolution uses project-over-global precedence by server name. The project-level `enabled` flag overrides the global flag when set; if the effective flag is false, no MCP servers are active. When enabled, global servers are loaded first, project servers with the same `name` replace them, and a project server with `enabled:false` removes the inherited server. + +Enabled MCP servers are trusted once configured. Fusion materializes the effective server set at AI session creation and forwards it to every MCP-capable AI lane, including chat/planning, executor, reviewer, validator, merger, workflow model nodes, summarization, evaluator, research, cron/automation, mission, and reflection paths. Runtime support is guarded: Claude/pi/ACP-compatible runtimes receive MCP servers, while mock or unsupported runtimes skip forwarding and emit only a structured count/provider/runtime log entry, never server definitions or secret values. + +Secret rule: `env` and `headers` maps are sensitive. Values must be Fusion secret references such as `{ "secretRef": "sec_...", "scope": "project" }` or `{ "secretRef": "sec_...", "scope": "global" }`. Write-boundary sanitizers and validators reject plaintext strings in these fields. Claude Desktop-style imports return `secretsToCreate` descriptors for plaintext env/header values and replace those values with secret refs in the imported definitions. At runtime, secret references are revealed through the scoped secrets store immediately before forwarding or validation, kept only in memory, and never echoed in API responses. + +`POST /api/mcp/validate` validates an MCP server definition or configured server name against the current project context. The route resolves and materializes the target server with the same secret rules, then performs a bounded reachability probe (`stdio` supervised spawn, `sse`/`streamable-http` bounded fetch) and returns only `{ status, message? }` without resolved env/header contents. + +See [MCP](./mcp.md) for the full configuration and usage guide, including dashboard, CLI, Claude Desktop import, Fusion export, and reachability procedures. + ### Notification providers (pluggable) Fusion now supports a provider-list notification model via `notificationProviders` while keeping legacy flat ntfy/webhook settings intact. @@ -324,6 +368,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` | `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). | | `secretsEnv` | `{ enabled?: boolean; filename?: string; overwritePolicy?: "skip" \| "merge" \| "replace"; keyPrefix?: string; requireGitignored?: boolean }` | `undefined` | Per-project secrets `.env` materialization configuration. When `enabled`, the engine writes `secretsEnv.filename` (default `.env`) into each acquired task worktree from secrets marked `env_exportable=true`. `overwritePolicy` controls merge/skip/replace against an existing file; `requireGitignored` (default `true`) refuses to write a non-gitignored path; `keyPrefix` filters which exported keys are included. See [Secrets](./secrets.md#env-auto-write-into-worktrees). | +| `mcpServers` | `McpServersSettings` | `{ enabled: false, servers: [] }` | Project-scoped MCP server settings. Project entries override global entries by `name`; `enabled:false` on a same-named project entry disables the inherited global server. Sensitive env/header/token material must be Fusion secret references only. See [MCP server settings](#mcp-server-settings). | | `owningNodeHandoffPolicy` | `"block" \| "reassign-to-local" \| "reassign-any-healthy"` | `"reassign-to-local"` | Policy for tasks already checked out by an unavailable owning node. `"block"` parks, `"reassign-to-local"` takes over on local node, `"reassign-any-healthy"` makes takeover eligible on healthy peers. | | `groupOverlappingFiles` | `boolean` | `true` | Serialize execution when file scopes overlap. | @@ -854,6 +899,8 @@ Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` envi 6. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no task/lane/default pair is configured 7. Automatic provider/model resolution +Workflow prompt steps and scheduled/manual AI-prompt automation steps use the same executor lane before falling back to project/global defaults; explicit step-level `modelProvider` + `modelId` values still take precedence for that individual step. + ### Heartbeat model (durable agents) Heartbeat sessions for durable agents use this order: diff --git a/docs/signals-connectors.md b/docs/signals-connectors.md new file mode 100644 index 0000000000..fcd2bd61db --- /dev/null +++ b/docs/signals-connectors.md @@ -0,0 +1,140 @@ +# Signals Connectors + +Fusion can receive signed external signals from Sentry, Datadog, PagerDuty, or a generic webhook at: + +```text +POST /api/signals/:provider +``` + +Supported providers are `webhook`, `sentry`, `datadog`, and `pagerduty`. Every connector requires an HMAC signing secret configured in the Fusion dashboard process environment. Verified signals still create triage tasks, and they also write to the project-scoped `incidents` table so Command Center → Signals can show source, severity, and open/resolved status breakdowns. + +## Runtime behavior + +- **Open events** create or absorb an incident occurrence by `groupingKey` and preserve the normalized `source`, `severity`, optional `link`, and capped `meta` fields. +- **Resolved events** write/absorb the incident and then mark the matching `groupingKey` as `resolved`. Cold resolves are retained as resolved signal metrics instead of being dropped. +- **Duplicate deliveries** with the same provider external id are accepted as deduped and do not create a second task or incident write. +- **Re-fired incidents** with the same `groupingKey` are absorbed by the incidents store rather than double-counted as separate incidents. +- The connectors status endpoint, `GET /api/command-center/signals/connectors`, returns only `{ provider, configured }` booleans. It never returns secret values. + +## Security model + +- Secrets are environment variables; do not commit them to source control. +- HMAC verification uses the raw request body and constant-time comparison. +- Requests are capped at about 1 MB. +- Replay protection rejects stale timestamps where the provider supplies one and rejects repeated delivery ids within the replay window. +- Normalized `title`, `body`, `groupingKey`, `link`, and `meta` fields are capped by `signal-source.ts` before storage. +- Signal `link` values are SSRF-untrusted. Fusion stores safe external URLs as data for the UI and never fetches connector links server-side. +- `meta` is stored as JSON data only and must not be rendered as raw HTML. + +## Generic webhook + +Set: + +```bash +export FUSION_SIGNAL_WEBHOOK_SECRET="replace-with-a-long-random-secret" +``` + +Headers: + +- `X-Fusion-Signature`: `sha256=`-prefixed HMAC-SHA256 hex digest of the raw JSON body. +- `X-Fusion-Timestamp`: epoch milliseconds, used for the replay window. + +Payload contract: + +```json +{ + "id": "delivery-123", + "title": "API error rate above threshold", + "body": "5xx rate exceeded 10% for 5 minutes", + "severity": "critical", + "groupingKey": "api-error-rate", + "link": "https://example.com/incidents/api-error-rate", + "timestamp": 1790294400000, + "status": "open", + "meta": { "service": "api" } +} +``` + +Resolution mapping: `status: "resolved"`, `action: "resolved"`, or `action: "resolve"` resolves the grouped incident. Any other value opens/absorbs the incident. + +Example signed request: + +```bash +body='{"id":"delivery-123","title":"API error rate above threshold","severity":"critical","groupingKey":"api-error-rate","timestamp":'"$(date +%s000)"'}' +sig="sha256=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$FUSION_SIGNAL_WEBHOOK_SECRET" -hex | awk '{print $2}')" +curl -X POST "http://127.0.0.1:4040/api/signals/webhook" \ + -H "Content-Type: application/json" \ + -H "X-Fusion-Timestamp: $(date +%s000)" \ + -H "X-Fusion-Signature: $sig" \ + --data-binary "$body" +``` + +## Sentry + +Set: + +```bash +export FUSION_SIGNAL_SENTRY_SECRET="sentry-integration-client-secret" +``` + +Configure the Sentry integration webhook target as `/api/signals/sentry`. Fusion verifies `Sentry-Hook-Signature` as the HMAC-SHA256 hex digest of the raw body. If Sentry sends `Sentry-Hook-Timestamp`, Fusion checks it against the replay window. + +Normalization: + +- `groupingKey`: Sentry `issue.id`. +- `source`: `sentry`. +- `severity`: `fatal`/`critical` → `critical`, `error` → `error`, `warning` → `warning`, `info`/`debug` → `info`. +- Resolution: payload `action === "resolved"` or `issue.status === "resolved"` resolves the grouped issue; other actions open/absorb it. + +## Datadog + +Set: + +```bash +export FUSION_SIGNAL_DATADOG_SECRET="datadog-webhook-shared-secret" +``` + +Datadog webhooks do not provide a built-in HMAC header, so configure a custom header: + +```text +X-Datadog-Signature: +``` + +Optionally include `X-Datadog-Timestamp` as epoch milliseconds for replay-window validation. Configure the Datadog webhook target as `/api/signals/datadog`. + +Normalization: + +- `groupingKey`: `aggreg_key`, `alert_id`, or `id`. +- `source`: `datadog`. +- `severity`: `error` → `critical`, `warning`/`warn` → `warning`, `success`/`recovery`/`info` → `info`, default → `error`. +- Resolution: `alert_type` of `recovery` or `success` resolves the grouped monitor; other alert types open/absorb it. + +## PagerDuty + +Set: + +```bash +export FUSION_SIGNAL_PAGERDUTY_SECRET="pagerduty-webhook-subscription-secret" +``` + +Configure the PagerDuty webhook subscription target as `/api/signals/pagerduty`. Fusion verifies `X-PagerDuty-Signature` and accepts the `v1=` signature form. + +Normalization: + +- `groupingKey`: PagerDuty incident `data.id`. +- `source`: `pagerduty`. +- `severity`: explicit `data.severity` when it is one of Fusion's normalized severities; otherwise high urgency maps to `critical` and other events map to `warning`. +- Resolution: `event.event_type === "incident.resolved"` or `data.status === "resolved"` resolves the grouped incident; other incident events open/absorb it. + +## Command Center Signals + +Command Center → Signals reads aggregated incidents through `GET /api/command-center/signals`. Once a connector secret is configured and signed events arrive, the area displays total, open, resolved, MTTR, by-source, by-severity, and by-status metrics from local incident rows. + +The empty state is intentionally explicit: + +- no configured connector secret: prompt operators to connect Sentry, Datadog, PagerDuty, or the generic webhook; +- at least one configured connector secret but no rows in the selected range: report that Fusion is configured and awaiting signals. + +## Separate monitor ingest path + +`FUSION_MONITOR_INGEST_SECRET` protects the separate bearer-token route for `/api/monitor/incidents`. It is not used by `/api/signals/:provider`; signal connectors use the provider-specific `FUSION_SIGNAL_*_SECRET` variables above. diff --git a/docs/test-velocity-baseline.md b/docs/test-velocity-baseline.md index 24acadcf93..86dfe4d1b1 100644 --- a/docs/test-velocity-baseline.md +++ b/docs/test-velocity-baseline.md @@ -16,7 +16,7 @@ | Merge gate wall-time (`pnpm test:gate`) | 7.5s | -9.3s | | Boot smoke wall-time (`pnpm smoke:boot`) | 18.2s | -4.7s | | Changed-only test wall-time (`pnpm test`) | 9.4s | -10.2s | -| Quarantine / flake count | 0 | 0 | +| Quarantine / flake count | 3 | +3 | | Deletion-due quarantines | 0 | n/a | ## Measurement failures @@ -56,7 +56,7 @@ | Age bucket | Count | |---|---:| -| 0-6 days | 0 | +| 0-6 days | 3 | | 7-13 days | 0 | | deletion due (>=14 days) | 0 | | unknown/future | 0 | @@ -72,15 +72,15 @@ | Row | Captured at | Gate | Boot smoke | `pnpm test` | Quarantine count | |---|---|---:|---:|---:|---:| | Previous | 2026-06-23T18:43:21.941Z | 16.8s | 22.8s | 19.6s | 0 | -| Latest | 2026-06-25T05:47:54.804Z | 7.5s | 18.2s | 9.4s | 0 | -| Delta | — | -9.3s | -4.7s | -10.2s | 0 | +| Latest | 2026-06-25T05:47:54.804Z | 7.5s | 18.2s | 9.4s | 3 | +| Delta | — | -9.3s | -4.7s | -10.2s | +3 | _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 7.5s (-9.3s), boot smoke 18.2s (-4.7s), pnpm test 9.4s (-10.2s), quarantine ledger 0 (0). Slowest file: packages/dashboard/app/components/__tests__/SettingsModal.test.tsx at 1m 01s. Deletion-due quarantines: 0. +FN-6612 weekly test velocity: gate 7.5s (-9.3s), boot smoke 18.2s (-4.7s), pnpm test 9.4s (-10.2s), quarantine ledger 3 (+3). Slowest file: packages/dashboard/app/components/__tests__/SettingsModal.test.tsx at 1m 01s. Deletion-due quarantines: 0. ``` ## How to refresh diff --git a/docs/testing.md b/docs/testing.md index e7144edec4..c0786c1a3d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -32,7 +32,7 @@ Use the narrowest command that exercises the behavior you changed, then broaden 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 verify:fast # TEST-FREE verification: typecheck + build (scoped to changed packages) + boot smoke +pnpm verify:fast # TEST-FREE verification: artifact bootstrap + scoped typecheck/build + CLI build + boot smoke pnpm test:full # full workspace suite — explicit opt-in only pnpm lint # lint all packages pnpm build # build workspace packages (excludes desktop/mobile) @@ -40,7 +40,8 @@ pnpm verify:workspace # deep opt-in verification: lint -> test:full -> build (N ``` -`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command: it runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`) followed by the existing **boot smoke** once — and runs **no test suite**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs the boot smoke only. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). + +`pnpm verify:fast` (`scripts/verify-fast.mjs`) is the recommended **test-free verification** command: it bootstraps missing/stale workspace dist artifacts, runs **typecheck + build scoped to the changed packages** (reusing the same git-diff / changed-package resolution as `pnpm test`), always builds the `@runfusion/fusion` CLI package required by the source-checkout boot smoke, then runs the existing **boot smoke** once — and runs **no test suite**. It gives deterministic, flake-free signal in seconds, so it is a sound project `testCommand`/verification command when you want non-test verification. With no affected package (root/docs-only diff) it runs only the artifact bootstrap, CLI prerequisite build, and boot smoke. Each step is bounded by the shared `runWithWatchdog` (class `changed`) so a hang fails fast, and it exits nonzero on the first failing step. This is purely additive: it does not change `pnpm test`, the merge gate, or CI, and the full suite stays available (`pnpm test:full`, non-blocking on push to main). `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. @@ -58,6 +59,11 @@ Agents running verification through `fn_run_verification` are bounded by default 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. +## Engine static process guards + + +`packages/engine/src/__tests__/user-configured-command-no-execsync.test.ts` guards user-configured command execution helpers against accidental `execSync` usage or dropped async bounds. Its registry covers verification helpers, `fn_run_verification`, executor configured-command execution, merger post-merge script execution, routine command execution, and the native/bubblewrap/sandbox-exec sandbox backends. Each protected slice must keep the appropriate bounded async safeguard (`timeout`/`timeoutMs`, `maxBuffer`, or `maxLifetimeMs`). The test intentionally slices named function bodies instead of scanning whole files; deterministic git-plumbing `execSync` in merger/self-healing/already-merged/integration/worktree-prune paths and the executor git ancestry check are explicitly out of scope. + ## Dashboard Test Lanes ```bash diff --git a/packages/cli/src/__tests__/ci-workflow.test.ts b/packages/cli/src/__tests__/ci-workflow.test.ts index 8ec939b65b..1ce84698f4 100644 --- a/packages/cli/src/__tests__/ci-workflow.test.ts +++ b/packages/cli/src/__tests__/ci-workflow.test.ts @@ -33,7 +33,10 @@ describe("Merge gate (.github/workflows/pr-checks.yml)", () => { let compositeAction: any; let contributingContent: string; let readmeContent: string; + let rootPackageJson: any; + let enginePackageJson: any; let cliPackageJsonContent: string; + let engineVitestConfigContent: string; let extensionSuiteContent: string; let agentExportSuiteContent: string; let buildExeSuiteContent: string; @@ -45,7 +48,10 @@ describe("Merge gate (.github/workflows/pr-checks.yml)", () => { compositeAction = loadYamlFile(".github", "actions", "setup-node-pnpm", "action.yml").parsed; contributingContent = readFileSync(join(workspaceRoot, "docs", "contributing.md"), "utf-8"); readmeContent = readFileSync(join(workspaceRoot, "README.md"), "utf-8"); + rootPackageJson = JSON.parse(readFileSync(join(workspaceRoot, "package.json"), "utf-8")); + enginePackageJson = JSON.parse(readFileSync(join(workspaceRoot, "packages", "engine", "package.json"), "utf-8")); cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8"); + engineVitestConfigContent = readFileSync(join(workspaceRoot, "packages", "engine", "vitest.config.ts"), "utf-8"); extensionSuiteContent = readFileSync( join(workspaceRoot, "packages", "cli", "src", "__tests__", "extension-integration.test.ts"), "utf-8", @@ -101,6 +107,26 @@ describe("Merge gate (.github/workflows/pr-checks.yml)", () => { ).toBe(true); }); + /* + FNXC:CITestGate 2026-06-26-06:40: + The merge gate is the thin trusted CI surface. ci-workflow.test.ts must pin not only that the Gate job invokes `pnpm test:gate`, but also test:gate's internal composition (guards + engine test:core + cli test:ci-shape) and that engine test:core references the engine-core vitest project — otherwise a rename could hollow the gate while this CI-shape test stays green (FN-7059). + */ + it("pins test:gate to the audited guard scripts and curated suites", () => { + const testGateScript = rootPackageJson.scripts?.["test:gate"] ?? ""; + + expect(testGateScript).toContain("node scripts/check-no-nohup.mjs"); // process-supervisor-allowlist: asserts the gate wires the checker; not a real spawn + expect(testGateScript).toContain("node scripts/check-no-kill-4040.mjs"); // port-4040-allowlist: asserts the gate wires the checker; not a real port bind + expect(testGateScript).toContain("node scripts/check-no-test-timeout-appeasement.mjs"); + expect(testGateScript).toContain("node scripts/check-changeset-format.mjs"); + expect(testGateScript).toContain("pnpm --filter @fusion/engine test:core"); + expect(testGateScript).toContain("pnpm --filter @runfusion/fusion test:ci-shape"); + }); + + it("pins engine test:core to the engine-core vitest project", () => { + expect(enginePackageJson.scripts?.["test:core"] ?? "").toContain("--project=engine-core"); + expect(engineVitestConfigContent).toContain('name: "engine-core"'); + }); + it("pins dependency bootstrap to frozen lockfile in every job", () => { for (const jobName of ["lint", "typecheck", "build", "gate"]) { expect(findCompositeSetupStep(workflow.jobs?.[jobName]?.steps ?? [])).toBeDefined(); @@ -418,6 +444,18 @@ describe("Binary release workflow (.github/workflows/release.yml)", () => { expect(workflow.jobs["github-release"].needs).toContain("build-binaries"); expect(workflow.jobs["github-release"].needs).toContain("build-android"); }); + + it("wires signed Android AAB artifacts into release aggregation", () => { + const androidJob = workflow.jobs["build-android"]; + const collectStep = workflow.jobs["github-release"].steps.find((step: any) => step.name === "Collect release files"); + + expect(androidJob.env.ANDROID_KEYSTORE_BASE64).toBe("${{ secrets.ANDROID_KEYSTORE_BASE64 }}"); + expect(content).toContain("./gradlew assembleRelease bundleRelease"); + expect(content).toContain("fusion-android-release.aab"); + expect(collectStep.run).toContain('-name "*.apk"'); + expect(collectStep.run).toContain('-name "*.aab"'); + expect(collectStep.run).toContain('-name "*.sha256"'); + }); }); describe("Test-release workflow (.github/workflows/test-release.yml)", () => { @@ -485,6 +523,18 @@ describe("Test-release workflow (.github/workflows/test-release.yml)", () => { expect(workflow.jobs.collect.needs).toContain("build-android"); expect(content).toContain("all-binaries"); }); + + it("wires signed Android AAB artifacts into rehearsal aggregation", () => { + const androidJob = workflow.jobs["build-android"]; + const combineStep = workflow.jobs.collect.steps.find((step: any) => step.name === "Combine artifacts"); + + expect(androidJob.env.ANDROID_KEYSTORE_BASE64).toBe("${{ secrets.ANDROID_KEYSTORE_BASE64 }}"); + expect(content).toContain("./gradlew assembleRelease bundleRelease"); + expect(content).toContain("fusion-android-release.aab"); + expect(combineStep.run).toContain('-name "*.apk"'); + expect(combineStep.run).toContain('-name "*.aab"'); + expect(combineStep.run).toContain('-name "*.sha256"'); + }); }); describe("Code signing — Release workflow secrets", () => { diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 513ddee97e..341819b615 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -126,6 +126,7 @@ async function loadCommandHandlers() { const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js"); const { runSettingsExport } = await import("./commands/settings-export.js"); const { runSettingsImport } = await import("./commands/settings-import.js"); + const { runMcpList, runMcpAdd, runMcpEdit, runMcpRemove, runMcpEnable, runMcpDisable, runMcpImport, runMcpExport, runMcpValidate } = await import("./commands/mcp.js"); const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js"); const { runBranchGroupList, runBranchGroupShow, runBranchGroupPromote, runBranchGroupAbandon } = await import("./commands/branch-group.js"); const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js"); @@ -193,6 +194,15 @@ async function loadCommandHandlers() { runSettingsSet, runSettingsExport, runSettingsImport, + runMcpList, + runMcpAdd, + runMcpEdit, + runMcpRemove, + runMcpEnable, + runMcpDisable, + runMcpImport, + runMcpExport, + runMcpValidate, runGitStatus, runGitFetch, runGitPull, @@ -387,6 +397,17 @@ PR: fn settings set worktrunk.onFailure fn settings export [opts] Export settings to a JSON file fn settings import [opts] Import settings from a JSON file + fn mcp list [--project ] [--json] List MCP servers by scope and effective resolution + fn mcp add --scope --transport [opts] + Add an MCP server using secret references for env/header values + fn mcp edit|remove|enable|disable [--scope ] + Update, remove, or toggle a scoped MCP server + fn mcp import [--scope ] [--yes] + Import Claude Desktop mcpServers JSON and create Fusion secrets + fn mcp export [--scope ] [--output ] + Export Fusion MCP JSON with secret references only + fn mcp validate [--scope ] [--json] + Validate MCP definitions without revealing secrets fn git status Show current branch, commit, dirty state, ahead/behind fn git push Push current branch @@ -679,6 +700,15 @@ async function main() { runSettingsSet, runSettingsExport, runSettingsImport, + runMcpList, + runMcpAdd, + runMcpEdit, + runMcpRemove, + runMcpEnable, + runMcpDisable, + runMcpImport, + runMcpExport, + runMcpValidate, runGitStatus, runGitFetch, runGitPull, @@ -1632,6 +1662,100 @@ async function main() { break; } + case "mcp": { + const subcommand = args[1] ?? "list"; + const scope = getFlagValue(args, "--scope") as "global" | "project" | "effective" | undefined; + const secretScope = getFlagValue(args, "--secret-scope") as "global" | "project" | undefined; + const commonSensitive = { + env: getRepeatedFlagValues(args, "--env"), + headers: getRepeatedFlagValues(args, "--header"), + envRaw: getRepeatedFlagValues(args, "--env-raw"), + headersRaw: getRepeatedFlagValues(args, "--header-raw"), + createEnv: getRepeatedFlagValues(args, "--create-secret-env"), + createHeaders: getRepeatedFlagValues(args, "--create-secret-header"), + secretRef: getFlagValue(args, "--secret-ref"), + secretScope, + }; + switch (subcommand) { + case "list": + case "ls": + await runMcpList({ projectName, json: args.includes("--json") }); + break; + case "add": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp add --scope global|project --transport stdio|sse|http [--command |--url ]"); process.exit(1); } + const argValues = getRepeatedFlagValues(args, "--arg"); + const argsValue = argValues.length > 0 ? argValues : getFlagValue(args, "--args"); + await runMcpAdd(name, { + projectName, + scope: scope === "effective" ? undefined : scope, + transport: getFlagValue(args, "--transport") as "stdio" | "sse" | "http" | "streamable-http" | undefined, + command: getFlagValue(args, "--command"), + args: argsValue, + url: getFlagValue(args, "--url"), + enabled: args.includes("--disabled") ? false : args.includes("--enabled") ? true : undefined, + ...commonSensitive, + }); + break; + } + case "edit": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp edit [--scope global|project] [opts]"); process.exit(1); } + const argValues = getRepeatedFlagValues(args, "--arg"); + const argsValue = argValues.length > 0 ? argValues : getFlagValue(args, "--args"); + await runMcpEdit(name, { + projectName, + scope: scope === "effective" ? undefined : scope, + transport: getFlagValue(args, "--transport") as "stdio" | "sse" | "http" | "streamable-http" | undefined, + command: getFlagValue(args, "--command"), + args: argsValue, + url: getFlagValue(args, "--url"), + enabled: args.includes("--disabled") ? false : args.includes("--enabled") ? true : undefined, + ...commonSensitive, + }); + break; + } + case "remove": + case "rm": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp remove [--scope global|project]"); process.exit(1); } + await runMcpRemove(name, { projectName, scope: scope === "effective" ? undefined : scope }); + break; + } + case "enable": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp enable [--scope global|project]"); process.exit(1); } + await runMcpEnable(name, { projectName, scope: scope === "effective" ? undefined : scope }); + break; + } + case "disable": { + const name = args[2]; + if (!name) { console.error("Usage: fn mcp disable [--scope global|project]"); process.exit(1); } + await runMcpDisable(name, { projectName, scope: scope === "effective" ? undefined : scope }); + break; + } + case "import": { + const file = args[2]; + if (!file) { console.error("Usage: fn mcp import [--scope global|project] [--yes]"); process.exit(1); } + await runMcpImport(file, { projectName, scope: scope === "effective" ? undefined : scope, yes: args.includes("--yes") }); + break; + } + case "export": + await runMcpExport({ projectName, scope, output: getFlagValue(args, "--output"), json: args.includes("--json") }); + break; + case "validate": + case "test": + await runMcpValidate({ projectName, scope, json: args.includes("--json") }); + break; + default: + console.error(`Unknown subcommand: mcp ${subcommand || ""}`); + console.log("Try: fn mcp list | add | edit | remove | enable | disable | import | export | validate"); + process.exit(1); + } + break; + } + + case "git": { const subcommand = args[1]; switch (subcommand) { diff --git a/packages/cli/src/commands/__tests__/mcp.test.ts b/packages/cli/src/commands/__tests__/mcp.test.ts new file mode 100644 index 0000000000..c68809718b --- /dev/null +++ b/packages/cli/src/commands/__tests__/mcp.test.ts @@ -0,0 +1,182 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function makeConstructibleMock unknown>(impl?: T) { + const mock = vi.fn(function () {}); + const originalMockImplementation = mock.mockImplementation.bind(mock); + const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters) { + return nextImpl(...args); + }; + mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation; + if (impl) mock.mockImplementation(impl); + return mock; +} + +type SecretRecord = { id: string; key: string; scope: "global" | "project"; plaintextValue: string }; + +const state = vi.hoisted(() => ({ + globalSettings: { mcpServers: { enabled: false, servers: [] as any[] } }, + projectSettings: { mcpServers: { enabled: false, servers: [] as any[] } }, + secrets: [] as SecretRecord[], + nextSecretId: 1, +})); + +vi.mock("@fusion/core", async (importActual) => { + const actual = await importActual(); + const secretsStore = { + getSecretMetadata: vi.fn((id: string, scope: "global" | "project") => state.secrets.find((secret) => secret.id === id && secret.scope === scope) ?? null), + listSecrets: vi.fn((scope?: "global" | "project") => state.secrets.filter((secret) => !scope || secret.scope === scope)), + createSecret: vi.fn(async (input: { scope: "global" | "project"; key: string; plaintextValue: string }) => { + const created = { id: `sec-${state.nextSecretId++}`, key: input.key, scope: input.scope, plaintextValue: input.plaintextValue }; + state.secrets.push(created); + return created; + }), + }; + return { + ...actual, + GlobalSettingsStore: makeConstructibleMock(() => ({ + init: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn(async () => state.globalSettings), + updateSettings: vi.fn(async (patch: any) => { + state.globalSettings = { ...state.globalSettings, ...patch }; + return state.globalSettings; + }), + })), + TaskStore: makeConstructibleMock(() => ({ + init: vi.fn().mockResolvedValue(undefined), + getSecretsStore: vi.fn(async () => secretsStore), + })), + }; +}); + +vi.mock("../../project-context.js", () => ({ + resolveProject: vi.fn(async () => ({ + projectId: "proj-1", + projectName: "demo", + projectPath: "/tmp/demo", + isRegistered: true, + store: { + getSettingsByScope: vi.fn(async () => ({ global: state.globalSettings, project: state.projectSettings })), + updateSettings: vi.fn(async (patch: any) => { + state.projectSettings = { ...state.projectSettings, ...patch }; + return { ...state.globalSettings, ...state.projectSettings }; + }), + getSecretsStore: vi.fn(async () => ({ + getSecretMetadata: (id: string, scope: "global" | "project") => state.secrets.find((secret) => secret.id === id && secret.scope === scope) ?? null, + listSecrets: (scope?: "global" | "project") => state.secrets.filter((secret) => !scope || secret.scope === scope), + createSecret: async (input: { scope: "global" | "project"; key: string; plaintextValue: string }) => { + const created = { id: `sec-${state.nextSecretId++}`, key: input.key, scope: input.scope, plaintextValue: input.plaintextValue }; + state.secrets.push(created); + return created; + }, + })), + }, + })), +})); + +import { + runMcpAdd, + runMcpDisable, + runMcpEnable, + runMcpExport, + runMcpImport, + runMcpList, + runMcpRemove, +} from "../mcp.js"; + +function resetState() { + state.globalSettings = { mcpServers: { enabled: false, servers: [] } }; + state.projectSettings = { mcpServers: { enabled: false, servers: [] } }; + state.secrets = []; + state.nextSecretId = 1; +} + +function captureConsole() { + const output: string[] = []; + const logSpy = vi.spyOn(console, "log").mockImplementation((...args) => output.push(args.map(String).join(" "))); + return { output, restore: () => logSpy.mockRestore() }; +} + +describe("mcp commands", () => { + let tempDir: string | undefined; + + beforeEach(() => { + resetState(); + tempDir = mkdtempSync(join(tmpdir(), "fusion-mcp-cli-")); + }); + + afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("round-trips add list remove across global and project scopes with effective resolution", async () => { + const consoleCapture = captureConsole(); + await runMcpAdd("github", { scope: "global", transport: "stdio", command: "global-gh" }); + await runMcpAdd("github", { scope: "project", transport: "stdio", command: "project-gh" }); + await runMcpList({ json: true }); + + const listed = JSON.parse(consoleCapture.output.at(-1) ?? "{}"); + expect(listed.global[0]).toMatchObject({ name: "github", command: "global-gh" }); + expect(listed.project[0]).toMatchObject({ name: "github", command: "project-gh" }); + expect(listed.effective).toEqual([expect.objectContaining({ name: "github", command: "project-gh" })]); + + await runMcpDisable("github", { scope: "project" }); + await runMcpList({ json: true }); + expect(JSON.parse(consoleCapture.output.at(-1) ?? "{}").effective).toEqual([]); + + await runMcpRemove("github", { scope: "project" }); + await runMcpList({ json: true }); + expect(JSON.parse(consoleCapture.output.at(-1) ?? "{}").effective).toEqual([expect.objectContaining({ command: "global-gh" })]); + consoleCapture.restore(); + }); + + it("imports Claude Desktop mcpServers JSON by creating secrets and persisting only references", async () => { + const fixture = join(tempDir!, "claude.json"); + writeFileSync(fixture, JSON.stringify({ + mcpServers: { + github: { command: "github-mcp-server", args: ["stdio"], env: { GITHUB_TOKEN: "ghp_raw" } }, + }, + })); + const consoleCapture = captureConsole(); + + await runMcpImport(fixture, { scope: "project", yes: true }); + + expect(state.secrets).toEqual([expect.objectContaining({ key: "mcp.github.env.GITHUB_TOKEN", plaintextValue: "ghp_raw", scope: "project" })]); + expect(JSON.stringify(state.projectSettings)).not.toContain("ghp_raw"); + expect(state.projectSettings.mcpServers.servers[0]).toMatchObject({ + name: "github", + transport: "stdio", + env: { GITHUB_TOKEN: { secretRef: "sec-1", scope: "project" } }, + }); + consoleCapture.restore(); + }); + + it("exports Fusion MCP JSON with secret references instead of raw values", async () => { + state.secrets.push({ id: "sec-existing", key: "GITHUB_TOKEN", scope: "project", plaintextValue: "raw-token" }); + await runMcpAdd("github", { scope: "project", transport: "stdio", command: "github-mcp-server", env: ["GITHUB_TOKEN=GITHUB_TOKEN"] }); + const consoleCapture = captureConsole(); + + await runMcpExport({ scope: "project" }); + + const exported = JSON.parse(consoleCapture.output.at(-1) ?? "{}"); + expect(exported.mcpServers.github.env.GITHUB_TOKEN).toEqual({ secretRef: "sec-existing", scope: "project" }); + expect(JSON.stringify(exported)).not.toContain("raw-token"); + consoleCapture.restore(); + }); + + it("rejects plaintext secrets on add and writes nothing", async () => { + await expect(runMcpAdd("bad", { scope: "project", transport: "stdio", command: "bad", envRaw: ["TOKEN=plaintext"] })).rejects.toThrow(/Plaintext MCP env\/header\/token values are not allowed/); + expect(state.projectSettings.mcpServers.servers).toEqual([]); + }); + + it("toggles enabled state at the chosen scope", async () => { + await runMcpAdd("docs", { scope: "project", transport: "http", url: "https://docs.example.test/mcp" }); + await runMcpDisable("docs", { scope: "project" }); + expect(state.projectSettings.mcpServers.servers[0].enabled).toBe(false); + await runMcpEnable("docs", { scope: "project" }); + expect(state.projectSettings.mcpServers.servers[0].enabled).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts new file mode 100644 index 0000000000..944efe684b --- /dev/null +++ b/packages/cli/src/commands/mcp.ts @@ -0,0 +1,455 @@ +import { existsSync } from "node:fs"; +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + GlobalSettingsStore, + TaskStore, + exportMcpServersJson, + importMcpServersJson, + isMcpSecretRef, + resolveEffectiveMcpServers, + validateMcpServerDefinitionDetailed, + validateMcpServerDefinitionsDetailed, + type GlobalSettings, + type McpSecretRef, + type McpServerDefinition, + type McpServersSettings, + type ProjectSettings, + type SecretScope, + type Settings, +} from "@fusion/core"; +import { resolveProject, type ProjectContext } from "../project-context.js"; + +export type McpScope = "global" | "project"; +export type McpTransportInput = "stdio" | "sse" | "http" | "streamable-http"; + +export interface McpSensitiveInputOptions { + env?: string[]; + headers?: string[]; + envRaw?: string[]; + headersRaw?: string[]; + createEnv?: string[]; + createHeaders?: string[]; + secretRef?: string; + secretScope?: SecretScope; + scope?: McpScope; +} + +export interface McpMutationOptions extends McpSensitiveInputOptions { + projectName?: string; + scope?: McpScope; + transport?: McpTransportInput; + command?: string; + args?: string[] | string; + url?: string; + enabled?: boolean; +} + +interface McpContext { + project?: ProjectContext; + globalStore: GlobalSettingsStore; +} + +const DEFAULT_SCOPE: McpScope = "project"; + +async function createGlobalSettingsStore(): Promise { + const store = new GlobalSettingsStore(); + await store.init(); + return store; +} + +async function loadContext(projectName?: string, requireProject = false): Promise { + const globalStore = await createGlobalSettingsStore(); + let project: ProjectContext | undefined; + try { + project = await resolveProject(projectName); + } catch (error) { + if (requireProject || projectName) throw error; + } + return { project, globalStore }; +} + +function normalizeScope(scope?: McpScope): McpScope { + if (!scope) return DEFAULT_SCOPE; + if (scope !== "global" && scope !== "project") { + throw new Error(`Invalid MCP scope "${scope}". Use global or project.`); + } + return scope; +} + +function normalizeTransport(transport?: McpTransportInput): "stdio" | "sse" | "streamable-http" { + if (!transport) return "stdio"; + if (transport === "http" || transport === "streamable-http") return "streamable-http"; + if (transport === "stdio" || transport === "sse") return transport; + throw new Error(`Invalid MCP transport "${transport}". Use stdio, sse, or http.`); +} + +function ensureProject(context: McpContext): ProjectContext { + if (!context.project) { + throw new Error("Project scope requires --project or running from a Fusion project directory."); + } + return context.project; +} + +function mcpSettings(settings?: Pick | null): McpServersSettings { + return { + enabled: settings?.mcpServers?.enabled ?? false, + servers: Array.isArray(settings?.mcpServers?.servers) ? settings.mcpServers.servers : [], + }; +} + +async function readScopedSettings(context: McpContext, scope: McpScope): Promise { + if (scope === "global") return mcpSettings(await context.globalStore.getSettings()); + const project = ensureProject(context); + const scoped = await project.store.getSettingsByScope(); + return mcpSettings(scoped.project); +} + +async function writeScopedSettings(context: McpContext, scope: McpScope, next: McpServersSettings): Promise { + const validation = validateMcpServerDefinitionsDetailed(next.servers ?? [], "mcpServers.servers"); + if (validation.errors.length > 0) { + throw new Error(formatValidationErrors(validation.errors)); + } + const normalized = { enabled: next.enabled ?? true, servers: validation.value ?? [] }; + if (scope === "global") { + await context.globalStore.updateSettings({ mcpServers: normalized } as Partial & Record); + return; + } + const project = ensureProject(context); + await project.store.updateSettings({ mcpServers: normalized } as Partial); +} + +function upsertServer(servers: McpServerDefinition[], server: McpServerDefinition): McpServerDefinition[] { + const without = servers.filter((entry) => entry.name !== server.name); + return [...without, server]; +} + +function removeServer(servers: McpServerDefinition[], name: string): { servers: McpServerDefinition[]; removed: boolean } { + const next = servers.filter((entry) => entry.name !== name); + return { servers: next, removed: next.length !== servers.length }; +} + +function parseKeyValuePairs(values: string[] | undefined, flag: string): Array<{ key: string; value: string }> { + return (values ?? []).map((entry) => { + const index = entry.indexOf("="); + if (index <= 0 || index === entry.length - 1) { + throw new Error(`Invalid ${flag} value "${entry}". Use KEY=SECRET_REF.`); + } + return { key: entry.slice(0, index).trim(), value: entry.slice(index + 1).trim() }; + }); +} + +function assertNoPlaintextSensitiveOptions(opts: McpSensitiveInputOptions): void { + const raw = [...(opts.envRaw ?? []), ...(opts.headersRaw ?? [])]; + if (raw.length > 0) { + throw new Error("Plaintext MCP env/header/token values are not allowed in settings. Use --secret-ref for an existing Fusion secret or --create-secret-* to store the value in SecretsStore first."); + } +} + +async function getSecretsStore(context: McpContext) { + const project = context.project; + const store = project?.store ?? new TaskStore(process.cwd()); + if (!project) await store.init(); + return store.getSecretsStore(); +} + +async function resolveExistingSecret(context: McpContext, secretRef: string, scope: SecretScope): Promise { + const secrets = await getSecretsStore(context); + const byId = secrets.getSecretMetadata(secretRef, scope); + if (byId) return { secretRef: byId.id, scope }; + const byKey = secrets.listSecrets(scope).find((secret) => secret.key === secretRef); + if (!byKey) { + throw new Error(`Secret "${secretRef}" not found in ${scope} scope. Create it first or use --create-secret-env/--create-secret-header.`); + } + return { secretRef: byKey.id, scope }; +} + +async function createSecretRef(context: McpContext, params: { scope: SecretScope; key: string; plaintextValue: string; description: string }): Promise { + const secrets = await getSecretsStore(context); + const created = await secrets.createSecret({ + scope: params.scope, + key: params.key, + plaintextValue: params.plaintextValue, + description: params.description, + }); + return { secretRef: created.id, scope: params.scope }; +} + +function suggestedSecretKey(serverName: string, field: "env" | "headers", key: string): string { + const clean = (value: string): string => value.trim().replace(/[^A-Za-z0-9_.-]+/gu, "_").replace(/^_+|_+$/gu, ""); + return ["mcp", clean(serverName), field, clean(key)].filter(Boolean).join("."); +} + +async function buildSensitiveMap( + context: McpContext, + serverName: string, + field: "env" | "headers", + refValues: string[] | undefined, + createValues: string[] | undefined, + opts: McpSensitiveInputOptions, +): Promise | undefined> { + const out: Record = {}; + const secretScope = opts.secretScope ?? normalizeScope(opts.scope as McpScope | undefined); + for (const { key, value } of parseKeyValuePairs(refValues, field === "env" ? "--env" : "--header")) { + out[key] = await resolveExistingSecret(context, value, secretScope); + } + const creates = parseKeyValuePairs(createValues, field === "env" ? "--create-secret-env" : "--create-secret-header"); + for (const { key, value } of creates) { + out[key] = await createSecretRef(context, { + scope: secretScope, + key: suggestedSecretKey(serverName, field, key), + plaintextValue: value, + description: `MCP ${field} ${key} for ${serverName}`, + }); + } + if (opts.secretRef && Object.keys(out).length === 0) { + const key = field === "env" ? "TOKEN" : "Authorization"; + out[key] = await resolveExistingSecret(context, opts.secretRef, secretScope); + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function normalizeArgs(value: string[] | string | undefined): string[] | undefined { + if (Array.isArray(value)) return value.length > 0 ? value : undefined; + if (!value) return undefined; + try { + const parsed = JSON.parse(value) as unknown; + if (Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string")) return parsed; + } catch { + // Fall through to comma/space parsing for CLI convenience. + } + return value.split(",").map((entry) => entry.trim()).filter(Boolean); +} + +function formatValidationErrors(errors: Array<{ path: string; message: string }>): string { + return errors.map((error) => `${error.path}: ${error.message}`).join("\n"); +} + +async function buildServerDefinition(context: McpContext, name: string, opts: McpMutationOptions, existing?: McpServerDefinition): Promise { + assertNoPlaintextSensitiveOptions(opts); + const transport = normalizeTransport(opts.transport ?? existing?.transport as McpTransportInput | undefined); + const enabled = opts.enabled ?? existing?.enabled; + const base = { name, ...(enabled !== undefined ? { enabled } : {}) }; + let candidate: unknown; + if (transport === "stdio") { + candidate = { + ...base, + transport, + command: opts.command ?? (existing?.transport === "stdio" ? existing.command : undefined), + args: normalizeArgs(opts.args) ?? (existing?.transport === "stdio" ? existing.args : undefined), + env: await buildSensitiveMap(context, name, "env", opts.env, opts.createEnv, opts) ?? (existing?.transport === "stdio" ? existing.env : undefined), + }; + } else { + candidate = { + ...base, + transport, + url: opts.url ?? (existing?.transport === "sse" || existing?.transport === "streamable-http" ? existing.url : undefined), + headers: await buildSensitiveMap(context, name, "headers", opts.headers, opts.createHeaders, opts) ?? (existing?.transport === "sse" || existing?.transport === "streamable-http" ? existing.headers : undefined), + }; + } + const validation = validateMcpServerDefinitionDetailed(candidate); + if (!validation.value) throw new Error(formatValidationErrors(validation.errors)); + return validation.value; +} + +function sensitiveSummary(server: McpServerDefinition): string { + const values = server.transport === "stdio" ? server.env : server.headers; + if (!values || Object.keys(values).length === 0) return "none"; + return Object.entries(values).map(([key, value]) => `${key}:${isMcpSecretRef(value) ? `${value.scope} secret` : "INVALID plaintext"}`).join(", "); +} + +function serverLine(server: McpServerDefinition, source: string, effectiveNames: Set): string { + const state = server.enabled === false ? "disabled" : effectiveNames.has(server.name) ? "effective" : "overridden"; + const target = server.transport === "stdio" ? server.command : server.url; + return ` ${server.name.padEnd(20)} ${source.padEnd(8)} ${state.padEnd(10)} ${server.transport.padEnd(15)} ${target ?? ""} secrets=${sensitiveSummary(server)}`; +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Listing must show global declarations, project declarations, and the project-over-global effective result without exposing secret material. Sensitive env/header fields are summarized as Fusion secret references only. + */ +export async function runMcpList(opts: { projectName?: string; json?: boolean } = {}): Promise { + const context = await loadContext(opts.projectName, false); + const globalSettings = mcpSettings(await context.globalStore.getSettings()); + const projectSettings = context.project ? mcpSettings((await context.project.store.getSettingsByScope()).project) : undefined; + const effective = resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null); + if (opts.json) { + console.log(JSON.stringify({ global: globalSettings.servers ?? [], project: projectSettings?.servers ?? [], effective }, null, 2)); + return; + } + console.log(); + console.log(" MCP servers"); + console.log(" " + "─".repeat(80)); + const effectiveNames = new Set(effective.map((server) => server.name)); + for (const server of globalSettings.servers ?? []) console.log(serverLine(server, "global", effectiveNames)); + for (const server of projectSettings?.servers ?? []) console.log(serverLine(server, "project", effectiveNames)); + if ((globalSettings.servers?.length ?? 0) === 0 && (projectSettings?.servers?.length ?? 0) === 0) console.log(" No MCP servers configured."); + console.log(); + console.log(` Effective: ${effective.map((server) => server.name).join(", ") || "none"}`); + console.log(); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Add persists an MCP server at the chosen global/project scope and lets the shared resolver decide project-over-global behavior. Env/header/token material must be an existing Fusion secret reference or be created in SecretsStore before validation; raw values never enter settings. + */ +export async function runMcpAdd(name: string, opts: McpMutationOptions = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const current = await readScopedSettings(context, scope); + if ((current.servers ?? []).some((server) => server.name === name)) throw new Error(`MCP server "${name}" already exists in ${scope} scope. Use edit to update it.`); + const server = await buildServerDefinition(context, name, opts); + await writeScopedSettings(context, scope, { enabled: true, servers: upsertServer(current.servers ?? [], server) }); + console.log(`✓ Added MCP server "${name}" to ${scope} scope`); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Edit updates only the selected scope; project definitions override same-named globals and may be disabled locally. Secret-bearing fields are replaced only with Fusion secret references or newly created SecretsStore records. + */ +export async function runMcpEdit(name: string, opts: McpMutationOptions = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const current = await readScopedSettings(context, scope); + const existing = (current.servers ?? []).find((server) => server.name === name); + if (!existing) throw new Error(`MCP server "${name}" not found in ${scope} scope.`); + const server = await buildServerDefinition(context, name, opts, existing); + await writeScopedSettings(context, scope, { enabled: true, servers: upsertServer(current.servers ?? [], server) }); + console.log(`✓ Updated MCP server "${name}" in ${scope} scope`); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Remove deletes only the scoped declaration. Removing a project override can reveal an inherited global declaration again because effective MCP resolution is project-over-global by server name. + */ +export async function runMcpRemove(name: string, opts: { projectName?: string; scope?: McpScope } = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const current = await readScopedSettings(context, scope); + const next = removeServer(current.servers ?? [], name); + if (!next.removed) throw new Error(`MCP server "${name}" not found in ${scope} scope.`); + await writeScopedSettings(context, scope, { enabled: current.enabled ?? true, servers: next.servers }); + console.log(`✓ Removed MCP server "${name}" from ${scope} scope`); +} + +async function setEnabled(name: string, enabled: boolean, opts: { projectName?: string; scope?: McpScope } = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const current = await readScopedSettings(context, scope); + const existing = (current.servers ?? []).find((server) => server.name === name); + if (!existing) throw new Error(`MCP server "${name}" not found in ${scope} scope.`); + await writeScopedSettings(context, scope, { enabled: current.enabled ?? true, servers: upsertServer(current.servers ?? [], { ...existing, enabled }) }); + console.log(`✓ ${enabled ? "Enabled" : "Disabled"} MCP server "${name}" in ${scope} scope`); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Enable flips the scoped server flag only; effective availability is still computed by the foundation resolver. No secret values are read or printed while toggling MCP servers. + */ +export async function runMcpEnable(name: string, opts: { projectName?: string; scope?: McpScope } = {}): Promise { + await setEnabled(name, true, opts); +} + +/** + * FNXC:McpConfig 2026-06-25-20:52: + * Disable records a scoped enabled:false declaration. At project scope this intentionally masks a same-named global server without deleting global configuration or exposing any secret-backed fields. + */ +export async function runMcpDisable(name: string, opts: { projectName?: string; scope?: McpScope } = {}): Promise { + await setEnabled(name, false, opts); +} + +/** + * FNXC:McpConfig 2026-06-25-21:03: + * Claude Desktop imports must delegate parsing to the core importer. Any plaintext env/header values returned by the importer are immediately converted into SecretsStore records, then settings receive only the resulting Fusion secret references. + */ +export async function runMcpImport(filePath: string, opts: { projectName?: string; scope?: McpScope; yes?: boolean } = {}): Promise { + const scope = normalizeScope(opts.scope); + const context = await loadContext(opts.projectName, scope === "project"); + const resolvedPath = resolve(filePath); + if (!existsSync(resolvedPath)) throw new Error(`File not found: ${filePath}`); + const imported = importMcpServersJson(await readFile(resolvedPath, "utf-8"), { scope }); + if (imported.errors.length > 0) throw new Error(`Invalid MCP import file:\n${imported.errors.map((error) => ` - ${error}`).join("\n")}`); + console.log(); + console.log(" MCP Import Summary:"); + console.log(` Source: ${resolvedPath}`); + console.log(` Scope: ${scope}`); + console.log(` Servers: ${imported.definitions.length}`); + console.log(` Secrets to create: ${imported.secretsToCreate.length}`); + console.log(); + if (!opts.yes) throw new Error("Use --yes to confirm this import operation"); + const replacements = new Map(); + for (const secret of imported.secretsToCreate) { + replacements.set(`${secret.serverName}:${secret.field}:${secret.key}:${secret.suggestedKey}`, await createSecretRef(context, { + scope: secret.scope, + key: secret.suggestedKey, + plaintextValue: secret.plaintextValue, + description: `Imported MCP ${secret.field} ${secret.key} for ${secret.serverName}`, + })); + } + const definitions = imported.definitions.map((server) => rewriteImportedSecretRefs(server, replacements)); + const current = await readScopedSettings(context, scope); + await writeScopedSettings(context, scope, { enabled: true, servers: [...(current.servers ?? []).filter((server) => !definitions.some((entry) => entry.name === server.name)), ...definitions] }); + console.log(`✓ Imported ${definitions.length} MCP server(s) into ${scope} scope`); +} + +function rewriteImportedSecretRefs(server: McpServerDefinition, replacements: Map): McpServerDefinition { + const rewrite = (field: "env" | "headers", values: Record | undefined): Record | undefined => { + if (!values) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (!isMcpSecretRef(value)) continue; + out[key] = replacements.get(`${server.name}:${field}:${key}:${value.secretRef}`) ?? value; + } + return Object.keys(out).length > 0 ? out : undefined; + }; + if (server.transport === "stdio") return { ...server, env: rewrite("env", server.env) }; + return { ...server, headers: rewrite("headers", server.headers) }; +} + +/** + * FNXC:McpConfig 2026-06-25-21:03: + * MCP export uses the core JSON exporter so secret-backed fields stay as descriptors and are never materialized. The default export is effective project-over-global configuration; explicit scope exports preserve stored declarations. + */ +export async function runMcpExport(opts: { projectName?: string; scope?: McpScope | "effective"; output?: string; json?: boolean } = {}): Promise { + const context = await loadContext(opts.projectName, opts.scope === "project"); + const scope = opts.scope ?? "effective"; + const globalSettings = mcpSettings(await context.globalStore.getSettings()); + const projectSettings = context.project ? mcpSettings((await context.project.store.getSettingsByScope()).project) : undefined; + const definitions = scope === "global" + ? globalSettings.servers ?? [] + : scope === "project" + ? projectSettings?.servers ?? [] + : resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null); + const exported = exportMcpServersJson(definitions); + const json = JSON.stringify(exported, null, 2); + if (opts.output) { + await writeFile(resolve(opts.output), json); + console.log(`✓ Exported MCP servers to ${resolve(opts.output)}`); + return; + } + console.log(json); +} + +/** + * FNXC:McpConfig 2026-06-25-21:03: + * Validate is intentionally list-only until an optional MCP reachability service exists. It still uses the foundation validator so transport requirements and plaintext-secret rejection match every other MCP settings write path. + */ +export async function runMcpValidate(opts: { projectName?: string; scope?: McpScope | "effective"; json?: boolean } = {}): Promise { + const context = await loadContext(opts.projectName, opts.scope === "project"); + const scope = opts.scope ?? "effective"; + const globalSettings = mcpSettings(await context.globalStore.getSettings()); + const projectSettings = context.project ? mcpSettings((await context.project.store.getSettingsByScope()).project) : undefined; + const definitions = scope === "global" + ? globalSettings.servers ?? [] + : scope === "project" + ? projectSettings?.servers ?? [] + : resolveEffectiveMcpServers({ mcpServers: globalSettings }, projectSettings ? { mcpServers: projectSettings } : null); + const validation = validateMcpServerDefinitionsDetailed(definitions); + const result = { ok: validation.errors.length === 0, servers: definitions.length, errors: validation.errors }; + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + if (result.ok) console.log(`✓ ${definitions.length} MCP server definition(s) valid`); + else throw new Error(formatValidationErrors(validation.errors)); +} diff --git a/packages/core/src/__tests__/agent-store.test.ts b/packages/core/src/__tests__/agent-store.test.ts index a4b08795d6..05b5e0572a 100644 --- a/packages/core/src/__tests__/agent-store.test.ts +++ b/packages/core/src/__tests__/agent-store.test.ts @@ -1897,12 +1897,27 @@ describe("AgentStore", () => { }); it("checkoutTask is idempotent for same agent/node/epoch and renews lease timestamp", async () => { - const first = await store.checkoutTask(holderId, taskId, { nodeId: "node-a", runId: "run-1", leaseEpoch: 0 }); - await new Promise((resolve) => setTimeout(resolve, 5)); + /* + FNXC:CheckoutLeasing 2026-06-25-21:49: + Lease-renewal ordering is asserted via the store's injectable `renewedAt` clock seam + (CheckoutClaimContext.renewedAt → AgentStore.checkoutTask), not a real setTimeout sleep. + Previously a real 5ms wait forced a distinct heartbeat timestamp between the two checkouts; + that wasted wall-clock time and added flake surface (FN-5048: do not add slow tests). + Two explicit, ordered ISO timestamps make the renewal assertion deterministic with zero waiting. + */ + const firstRenewedAt = "2026-01-01T00:00:00.000Z"; + const secondRenewedAt = "2026-01-01T00:00:00.005Z"; + const first = await store.checkoutTask(holderId, taskId, { + nodeId: "node-a", + runId: "run-1", + leaseEpoch: 0, + renewedAt: firstRenewedAt, + }); const second = await store.checkoutTask(holderId, taskId, { nodeId: "node-a", runId: "run-2", leaseEpoch: first.checkoutLeaseEpoch ?? 0, + renewedAt: secondRenewedAt, }); expect(second.checkedOutBy).toBe(holderId); @@ -1910,6 +1925,8 @@ describe("AgentStore", () => { expect(second.checkoutNodeId).toBe("node-a"); expect(second.checkoutRunId).toBe("run-2"); expect(second.checkoutLeaseEpoch).toBe(first.checkoutLeaseEpoch); + expect(first.checkoutLeaseRenewedAt).toBe(firstRenewedAt); + expect(second.checkoutLeaseRenewedAt).toBe(secondRenewedAt); expect(second.checkoutLeaseRenewedAt).not.toBe(first.checkoutLeaseRenewedAt); }); diff --git a/packages/core/src/__tests__/automation.test.ts b/packages/core/src/__tests__/automation.test.ts index 55e01202c3..aaa70f3432 100644 --- a/packages/core/src/__tests__/automation.test.ts +++ b/packages/core/src/__tests__/automation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { CronExpressionParser } from "cron-parser"; import { AUTOMATION_PRESETS, + AUTOMATION_SELECTABLE_TOOLS, MAX_RUN_HISTORY, type AutomationRunResult, type AutomationStep, @@ -115,6 +116,16 @@ describe("ScheduleType", () => { }); }); +describe("AUTOMATION_SELECTABLE_TOOLS", () => { + it("lists the builtin coding tools offered to automation AI steps", () => { + expect(AUTOMATION_SELECTABLE_TOOLS).toEqual(["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"]); + }); + + it("contains unique tool names", () => { + expect(new Set(AUTOMATION_SELECTABLE_TOOLS).size).toBe(AUTOMATION_SELECTABLE_TOOLS.length); + }); +}); + describe("MAX_RUN_HISTORY", () => { it("is set to 50", () => { expect(MAX_RUN_HISTORY).toBe(50); @@ -166,7 +177,7 @@ describe("Interface contracts with AutomationStore", () => { }); }); - it("supports AutomationStep ai-prompt shape", () => { + it("supports AutomationStep ai-prompt shape with optional allowedTools", () => { const aiPromptStep: AutomationStep = { id: "step-ai-1", type: "ai-prompt", @@ -174,6 +185,7 @@ describe("Interface contracts with AutomationStore", () => { prompt: "Summarize the latest run output", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", + allowedTools: ["Read", "Grep"], }; expect(aiPromptStep).toMatchObject({ @@ -183,9 +195,22 @@ describe("Interface contracts with AutomationStore", () => { prompt: "Summarize the latest run output", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", + allowedTools: ["Read", "Grep"], }); }); + it("supports explicit empty allowedTools on AutomationStep ai-prompt shape", () => { + const aiPromptStep: AutomationStep = { + id: "step-ai-empty-tools", + type: "ai-prompt", + name: "No tools", + prompt: "Summarize without tools", + allowedTools: [], + }; + + expect(aiPromptStep.allowedTools).toEqual([]); + }); + it("supports successful AutomationRunResult shape", () => { const runResult: AutomationRunResult = { success: true, diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index 8466b50472..c7c0399c8d 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -39,6 +39,14 @@ describe("built-in workflows", () => { } }); + it("does not expose lowercase Code review step names in built-in workflow nodes", () => { + for (const workflow of BUILTIN_WORKFLOWS) { + for (const node of workflow.ir.nodes) { + expect(node.config?.name, `${workflow.id}:${node.id}`).not.toBe("Code review"); + } + } + }); + it("includes the stepwise coding built-in modeling step inversion (KTD-9)", () => { const stepwise = getBuiltinWorkflow("builtin:stepwise-coding"); expect(stepwise).toBeDefined(); @@ -459,6 +467,7 @@ describe("built-in workflows", () => { const codeReview = byId("code-review"); expect(codeReview?.kind).toBe("gate"); + expect(codeReview?.config?.name).toBe("Code Review"); expect(codeReview?.config?.skillName).toBe("compound-engineering:ce-code-review"); expect(codeReview?.config?.gateMode).toBe("gate"); expect(codeReview?.config?.toolMode).toBe("coding"); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index fa7ed0da74..ff22b388ac 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -120,15 +120,39 @@ afterAll(() => { cleanupTmpDirsSync(); }); +/* +FNXC:CoreDB-LockTest 2026-06-25-21:55: +The write-lock contention helper spawns a real child process that takes a real +SQLite EXCLUSIVE/RESERVED lock — that real OS lock IS the thing under test, so it +must NOT be mocked. The child releases the lock ONLY on an explicit `RELEASE` +stdin message (signal release); there is no fixed wall-clock hold. + +History: a `releaseMode: "timer"` variant fired `setTimeout(release, holdMs)` in +the child to drop the lock after a FIXED real duration (150ms per test). Two +recovery tests used it to release the lock mid-retry, paying ~150ms of dead +wall-clock wait each. That timer was removed: the recovery path retries via +synchronous `sleepSync` (Atomics.wait) on the main thread, so the test cannot +release the lock from its own event loop while blocked. Instead the test sends +`signalRelease()` (a bare stdin write, no await) in the SAME synchronous tick +immediately before `transactionImmediate(...)`. The parent reaches its first +`BEGIN IMMEDIATE` before the child can schedule + read the pipe + COMMIT (a +cross-process IPC+WAL round trip), so attempt 0 deterministically contends with +the still-held lock; the child then commits during the parent's first +`sleepSync` window and the retry recovers. Lock held only as long as needed, +released deterministically, zero fixed sleeps. +*/ async function holdWriteLock( dbPath: string, - options?: { holdMs?: number; releaseMode?: "manual" | "timer" }, + options?: { releaseMode?: "manual" }, ): Promise<{ child: ChildProcessWithoutNullStreams; + // Fire-and-forget: tell the child to drop the lock WITHOUT awaiting its exit. + // Used to release mid-`transactionImmediate` retry, where the main thread is + // synchronously blocked in `sleepSync` and cannot await the child's exit. + signalRelease: () => void; release: () => Promise; }> { - const releaseMode = options?.releaseMode ?? "manual"; - const holdMs = options?.holdMs ?? 0; + void options; const script = ` const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(${JSON.stringify(dbPath)}); @@ -141,14 +165,10 @@ async function holdWriteLock( try { db.close(); } catch {} process.exit(0); }; - if (${JSON.stringify(releaseMode)} === "timer") { - setTimeout(release, ${holdMs}); - } else { - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk) => { - if (chunk.includes("RELEASE")) release(); - }); - } + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + if (chunk.includes("RELEASE")) release(); + }); `; const child = spawn(process.execPath, ["-e", script], { @@ -158,6 +178,14 @@ async function holdWriteLock( child.once("exit", () => { activeLockChildren.delete(child); }); + // FNXC:CoreDB-LockTest 2026-06-25-21:55: A RELEASE write inherently races the + // child's exit — once the child reads RELEASE it COMMITs and exits, closing its + // stdin, so a write that lands just after exit hits a closed pipe (EPIPE). + // That EPIPE is benign: it only means the lock was already released, which is + // the success condition. Swallow it so it never surfaces as an uncaught + // exception. This does NOT weaken the lock test — assertions run before any + // release and are untouched. + child.stdin.on("error", () => {}); const ready = new Promise((resolve, reject) => { let stderr = ""; @@ -179,17 +207,28 @@ async function holdWriteLock( await ready; + // Track whether RELEASE was already sent so `release()` (the cleanup path) + // does not redundantly re-write to a child that `signalRelease()` already told + // to exit — the redundant write is the EPIPE source removed above. + let released = false; + return { child, + signalRelease: () => { + if (released || child.exitCode !== null || child.killed) { + return; + } + released = true; + child.stdin.write("RELEASE\n"); + }, release: async () => { if (child.exitCode !== null || child.killed) { return; } - if (releaseMode === "timer") { - await once(child, "exit"); - return; + if (!released) { + released = true; + child.stdin.write("RELEASE\n"); } - child.stdin.write("RELEASE\n"); await once(child, "exit"); }, }; @@ -1012,10 +1051,14 @@ describe("Database", () => { it("recovers outermost immediate transactions after a transient writer lock", async () => { const dbPath = db.getPath(); db.exec("PRAGMA busy_timeout = 0"); - const lock = await holdWriteLock(dbPath, { releaseMode: "timer", holdMs: 150 }); + const lock = await holdWriteLock(dbPath, { releaseMode: "manual" }); let callbackCalls = 0; try { + // FNXC:CoreDB-LockTest 2026-06-25-21:55: signal release in the SAME tick as + // transactionImmediate so attempt 0 contends with the still-held lock and the + // child commits during the first sleepSync retry window (no fixed wall-clock hold). + lock.signalRelease(); db.transactionImmediate(() => { callbackCalls += 1; db.prepare( @@ -1036,10 +1079,14 @@ describe("Database", () => { it("preserves nested savepoint rollback semantics after recovering the outer immediate writer lock", async () => { const dbPath = db.getPath(); db.exec("PRAGMA busy_timeout = 0"); - const lock = await holdWriteLock(dbPath, { releaseMode: "timer", holdMs: 150 }); + const lock = await holdWriteLock(dbPath, { releaseMode: "manual" }); let callbackCalls = 0; try { + // FNXC:CoreDB-LockTest 2026-06-25-21:55: same signal-release-then-recover pattern as + // the recovery test above; verifies nested savepoint rollback survives the outer + // immediate-lock recovery without paying a fixed 150ms hold. + lock.signalRelease(); db.transactionImmediate(() => { callbackCalls += 1; db.prepare( diff --git a/packages/core/src/__tests__/global-settings-guard.test.ts b/packages/core/src/__tests__/global-settings-guard.test.ts index 10610d3699..6f0f7f884d 100644 --- a/packages/core/src/__tests__/global-settings-guard.test.ts +++ b/packages/core/src/__tests__/global-settings-guard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveGlobalDir } from "../global-settings.js"; @@ -68,3 +68,60 @@ describe("resolveGlobalDir() VITEST guard", () => { }); }); }); + +/* +FNXC:GlobalDirGuard 2026-06-25-22:30: +Regression for the "all my global settings reset" bug: production code that passed a project's `.fusion/` dir (e.g. store.getFusionDir()) to CentralCore/global stores spun up stray per-project central DBs seeded with default global settings that shadowed ~/.fusion. resolveGlobalDir() must refuse a project-local `.fusion/` dir (named `.fusion` inside a git repo) while still accepting the real home global dir and arbitrary non-repo custom dirs. Guard is intentionally inert under VITEST, so these tests clear VITEST to exercise it. +*/ +describe("resolveGlobalDir() project-local .fusion guard", () => { + it("throws when handed a project-local .fusion dir inside a git repo", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const projectRoot = join(homeDir, "code", "my-project"); + mkdirSync(join(projectRoot, ".git"), { recursive: true }); + const projectFusionDir = join(projectRoot, ".fusion"); + mkdirSync(projectFusionDir, { recursive: true }); + + expect(() => resolveGlobalDir(projectFusionDir)).toThrow( + /refusing project-local '\.fusion' directory/, + ); + }); + }); + }); + + it("also catches a git-worktree project (.git file, not dir)", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const worktreeRoot = join(homeDir, "worktrees", "feature"); + mkdirSync(worktreeRoot, { recursive: true }); + writeFileSync(join(worktreeRoot, ".git"), "gitdir: /somewhere/.git/worktrees/feature\n"); + const worktreeFusionDir = join(worktreeRoot, ".fusion"); + mkdirSync(worktreeFusionDir, { recursive: true }); + + expect(() => resolveGlobalDir(worktreeFusionDir)).toThrow( + /refusing project-local '\.fusion' directory/, + ); + }); + }); + }); + + it("allows the real home global dir", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const homeGlobal = join(homeDir, ".fusion"); + expect(resolveGlobalDir(homeGlobal)).toBe(homeGlobal); + }); + }); + }); + + it("allows a custom non-repo global dir (no .git parent)", () => { + withVitestEnv(undefined, () => { + withTempHome((homeDir) => { + const customDir = join(homeDir, "custom-global", ".fusion"); + mkdirSync(customDir, { recursive: true }); + // Parent has no `.git`, so it is not a project worktree. + expect(resolveGlobalDir(customDir)).toBe(customDir); + }); + }); + }); +}); diff --git a/packages/core/src/__tests__/mcp-config.test.ts b/packages/core/src/__tests__/mcp-config.test.ts new file mode 100644 index 0000000000..d2cbb83de6 --- /dev/null +++ b/packages/core/src/__tests__/mcp-config.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + exportMcpServersJson, + importMcpServersJson, + materializeMcpServerSecrets, + resolveEffectiveMcpServers, +} from "../mcp-config.js"; +import { + validateMcpServerDefinition, + validateMcpServerDefinitions, + validateMcpServerDefinitionsDetailed, +} from "../settings-validation.js"; +import type { McpServerDefinition } from "../types.js"; + +const projectSecret = { secretRef: "project-token", scope: "project" as const }; +const globalSecret = { secretRef: "global-token", scope: "global" as const }; + +describe("MCP core config", () => { + it("resolves project servers over global servers by name", () => { + const globalServer: McpServerDefinition = { + name: "github", + transport: "stdio", + command: "global-gh", + env: { TOKEN: globalSecret }, + }; + const projectServer: McpServerDefinition = { + name: "github", + transport: "stdio", + command: "project-gh", + args: ["serve"], + env: { TOKEN: projectSecret }, + }; + + expect( + resolveEffectiveMcpServers( + { mcpServers: { enabled: true, servers: [globalServer] } }, + { mcpServers: { enabled: true, servers: [projectServer] } }, + ), + ).toEqual([projectServer]); + }); + + it("lets a project disabled entry remove a global server", () => { + const globalServer: McpServerDefinition = { + name: "global-only", + transport: "stdio", + command: "global-command", + }; + const disabledProjectOverride: McpServerDefinition = { + name: "global-only", + enabled: false, + transport: "stdio", + command: "ignored", + }; + + expect( + resolveEffectiveMcpServers( + { mcpServers: { enabled: true, servers: [globalServer] } }, + { mcpServers: { enabled: true, servers: [disabledProjectOverride] } }, + ), + ).toEqual([]); + }); + + it("rejects plaintext sensitive env and header values while accepting secret refs", () => { + expect( + validateMcpServerDefinition({ + name: "bad-env", + transport: "stdio", + command: "node", + env: { TOKEN: "plaintext" }, + }), + ).toBeUndefined(); + + expect( + validateMcpServerDefinition({ + name: "bad-header", + transport: "sse", + url: "https://example.test/sse", + headers: { Authorization: "Bearer plaintext" }, + }), + ).toBeUndefined(); + + expect( + validateMcpServerDefinition({ + name: "good", + transport: "sse", + url: "https://example.test/sse", + headers: { Authorization: projectSecret }, + }), + ).toEqual({ + name: "good", + transport: "sse", + url: "https://example.test/sse", + headers: { Authorization: projectSecret }, + }); + }); + + it("validates required fields by transport and rejects duplicate names", () => { + expect(validateMcpServerDefinition({ name: "stdio", transport: "stdio" })).toBeUndefined(); + expect(validateMcpServerDefinition({ name: "sse", transport: "sse" })).toBeUndefined(); + expect(validateMcpServerDefinition({ name: "http", transport: "streamable-http" })).toBeUndefined(); + + const duplicateResult = validateMcpServerDefinitionsDetailed([ + { name: "dup", transport: "stdio", command: "one" }, + { name: "dup", transport: "stdio", command: "two" }, + ]); + expect(duplicateResult.value).toBeUndefined(); + expect(duplicateResult.errors.map((error) => error.code)).toContain("duplicate-name"); + expect( + validateMcpServerDefinitions([ + { name: "one", transport: "stdio", command: "one" }, + { name: "two", transport: "streamable-http", url: "https://example.test/mcp" }, + ]), + ).toHaveLength(2); + }); + + it("imports plaintext sensitive values as secret descriptors and round-trips exported refs", () => { + const imported = importMcpServersJson({ + mcpServers: { + github: { + command: "github-mcp-server", + args: ["stdio"], + env: { GITHUB_TOKEN: "ghp_secret" }, + }, + docs: { + transport: "streamable-http", + url: "https://docs.example.test/mcp", + headers: { Authorization: globalSecret }, + }, + }, + }); + + expect(imported.errors).toEqual([]); + expect(imported.secretsToCreate).toEqual([ + { + serverName: "github", + field: "env", + key: "GITHUB_TOKEN", + scope: "project", + suggestedKey: "mcp.github.env.GITHUB_TOKEN", + plaintextValue: "ghp_secret", + }, + ]); + expect(imported.definitions[0]).toMatchObject({ + name: "github", + transport: "stdio", + env: { GITHUB_TOKEN: { secretRef: "mcp.github.env.GITHUB_TOKEN", scope: "project" } }, + }); + + const original: McpServerDefinition[] = [ + { + name: "docs", + transport: "streamable-http", + url: "https://docs.example.test/mcp", + headers: { Authorization: globalSecret }, + }, + ]; + const roundTrip = importMcpServersJson(exportMcpServersJson(original)); + expect(roundTrip.errors).toEqual([]); + expect(roundTrip.secretsToCreate).toEqual([]); + expect(roundTrip.definitions).toEqual(original); + }); + + it("materializes secret refs through an injected reader and omits failed refs", async () => { + const calls: Array<{ id: string; scope: string; userId?: string | null }> = []; + const server: McpServerDefinition = { + name: "secure", + transport: "stdio", + command: "secure-mcp", + env: { + OK: { secretRef: "ok", scope: "project" }, + MISSING: { secretRef: "missing", scope: "global" }, + }, + }; + + const resolved = await materializeMcpServerSecrets( + server, + { + async revealSecret(id, scope, reader) { + calls.push({ id, scope, userId: reader.userId }); + if (id === "missing") throw new Error("not found"); + return { key: id, plaintextValue: "resolved-value" }; + }, + }, + { userId: "tester" }, + ); + + expect(calls).toEqual([ + { id: "ok", scope: "project", userId: "tester" }, + { id: "missing", scope: "global", userId: "tester" }, + ]); + expect(resolved.value).toMatchObject({ + name: "secure", + transport: "stdio", + env: { OK: "resolved-value" }, + }); + expect((resolved.value as Extract)?.env).not.toHaveProperty("MISSING"); + expect(resolved.errors).toHaveLength(1); + }); +}); diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index c74e57e1ac..0c4cdfd823 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from "vitest"; import { MissionStore, deriveMilestoneAcceptanceCriteriaFromFeatures } from "../mission-store.js"; import { installInMemoryDbSnapshot, clearInMemoryDbSnapshot } from "./store-test-helpers.js"; import { GoalStore } from "../goal-store.js"; @@ -134,19 +134,33 @@ describe("MissionStore", () => { expect(result).toBeUndefined(); }); - it("lists missions ordered by createdAt desc", async () => { - const m1 = store.createMission({ title: "Mission 1" }); - await new Promise((r) => setTimeout(r, 10)); // Ensure different timestamps - const m2 = store.createMission({ title: "Mission 2" }); - await new Promise((r) => setTimeout(r, 10)); - const m3 = store.createMission({ title: "Mission 3" }); + // FNXC:CoreTests 2026-06-25-21:50: MissionStore stamps createdAt/updatedAt + // via new Date().toISOString() with no injectable clock seam, and ordering + // queries (ORDER BY createdAt DESC) have no tiebreak. Tests previously slept + // real wall-clock (setTimeout 5-10ms) just to force distinct timestamps — + // pure dead time (FN-5048). Drive the system clock with fake timers + + // setSystemTime instead: zero real waiting, deterministic ordering. Scoped + // per-test (useRealTimers in finally) so the file's real-async paths and the + // async afterEach db.close() keep real timers. + it("lists missions ordered by createdAt desc", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-25T00:00:00.000Z")); + const m1 = store.createMission({ title: "Mission 1" }); + vi.setSystemTime(new Date("2026-06-25T00:00:00.010Z")); + const m2 = store.createMission({ title: "Mission 2" }); + vi.setSystemTime(new Date("2026-06-25T00:00:00.020Z")); + const m3 = store.createMission({ title: "Mission 3" }); - const list = store.listMissions(); + const list = store.listMissions(); - expect(list).toHaveLength(3); - expect(list[0].id).toBe(m3.id); // Newest first - expect(list[1].id).toBe(m2.id); - expect(list[2].id).toBe(m1.id); + expect(list).toHaveLength(3); + expect(list[0].id).toBe(m3.id); // Newest first + expect(list[1].id).toBe(m2.id); + expect(list[2].id).toBe(m1.id); + } finally { + vi.useRealTimers(); + } }); it("round-trips mission branchStrategy on create", () => { @@ -178,21 +192,29 @@ describe("MissionStore", () => { expect(store.getMission(mission.id)?.branchStrategy).toBeUndefined(); }); - it("updates a mission", async () => { - const mission = store.createMission({ title: "Original" }); - await new Promise((r) => setTimeout(r, 5)); // Ensure timestamp difference - const updated = store.updateMission(mission.id, { - title: "Updated", - status: "active", - }); + // FNXC:CoreTests 2026-06-25-21:50: real-sleep removed (FN-5048); advance the + // fake clock between create and update so updatedAt > createdAt deterministically. + it("updates a mission", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-25T00:00:00.000Z")); + const mission = store.createMission({ title: "Original" }); + vi.setSystemTime(new Date("2026-06-25T00:00:00.005Z")); + const updated = store.updateMission(mission.id, { + title: "Updated", + status: "active", + }); - expect(updated.title).toBe("Updated"); - expect(updated.status).toBe("active"); - expect(updated.id).toBe(mission.id); - expect(updated.createdAt).toBe(mission.createdAt); - expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan( - new Date(mission.updatedAt).getTime() - ); + expect(updated.title).toBe("Updated"); + expect(updated.status).toBe("active"); + expect(updated.id).toBe(mission.id); + expect(updated.createdAt).toBe(mission.createdAt); + expect(new Date(updated.updatedAt).getTime()).toBeGreaterThan( + new Date(mission.updatedAt).getTime() + ); + } finally { + vi.useRealTimers(); + } }); it("throws when updating non-existent mission", () => { @@ -540,7 +562,12 @@ describe("MissionStore", () => { }); }); - it("computes correct health for multiple missions with varying states", async () => { + // FNXC:CoreTests 2026-06-25-21:50: real-sleep removed (FN-5048); fake clock + // advanced between the two missions to keep their createdAt distinct/ordered. + it("computes correct health for multiple missions with varying states", () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-25T00:00:00.000Z")); // Mission 1: 1 milestone (active), 1 slice (active), 4 features (1 done, 2 in-flight, 1 failed) const m1 = store.createMission({ title: "Mission 1" }); store.updateMission(m1.id, { status: "active" }); @@ -562,7 +589,7 @@ describe("MissionStore", () => { const f1Failed = store.addFeature(sl1.id, { title: "F1-failed" }); store.linkFeatureToTask(f1Failed.id, "FN-FAILED-1"); - await new Promise((r) => setTimeout(r, 10)); + vi.setSystemTime(new Date("2026-06-25T00:00:00.010Z")); // Mission 2: 2 milestones (1 complete, 1 active), 0 features const m2 = store.createMission({ title: "Mission 2" }); @@ -617,6 +644,9 @@ describe("MissionStore", () => { autopilotEnabled: false, lastActivityAt: undefined, }); + } finally { + vi.useRealTimers(); + } }); it("counts failed tasks across missions correctly", () => { @@ -4520,6 +4550,3 @@ describe("MissionStore", () => { }); }); }); - -// vi import for vitest mocking -import { vi } from "vitest"; diff --git a/packages/core/src/__tests__/model-resolution.test.ts b/packages/core/src/__tests__/model-resolution.test.ts index c48ef1e1e5..94ff91521e 100644 --- a/packages/core/src/__tests__/model-resolution.test.ts +++ b/packages/core/src/__tests__/model-resolution.test.ts @@ -35,6 +35,18 @@ describe("model-resolution", () => { ).toEqual({ provider: "google", modelId: "gemini-2.5-pro" }); }); + it("selects the project execution lane over the base default for workflow-step callers", () => { + const resolved = resolveExecutionSettingsModel({ + executionProvider: "openai", + executionModelId: "gpt-4o", + defaultProvider: "anthropic", + defaultModelId: "claude-3-5-sonnet", + }); + + expect(resolved).toEqual({ provider: "openai", modelId: "gpt-4o" }); + expect(resolved).not.toEqual({ provider: "anthropic", modelId: "claude-3-5-sonnet" }); + }); + it("falls back from planning global to the project default override", () => { expect( resolvePlanningSettingsModel({ diff --git a/packages/core/src/__tests__/signals-analytics.test.ts b/packages/core/src/__tests__/signals-analytics.test.ts index ede56ccf9d..9d73c40fdb 100644 --- a/packages/core/src/__tests__/signals-analytics.test.ts +++ b/packages/core/src/__tests__/signals-analytics.test.ts @@ -30,9 +30,9 @@ function insertIncident( incidentId, `group-${incidentId}`, `Signal ${incidentId}`, - fields.severity ?? "error", + fields.severity === undefined ? "error" : fields.severity, fields.status, - fields.source ?? "webhook", + fields.source === undefined ? "webhook" : fields.source, fields.openedAt, fields.resolvedAt ?? null, now, @@ -56,7 +56,7 @@ describe("signals-analytics", () => { await rm(tmpDir, { recursive: true, force: true }); }); - it("aggregates real incident signals by source, severity, status, and MTTR", () => { + it("aggregates real incident signals by source, severity, and MTTR", () => { insertIncident(db, { status: "open", openedAt: "2026-03-02T10:00:00.000Z", @@ -98,6 +98,21 @@ describe("signals-analytics", () => { ]); }); + it("buckets missing source and severity as unknown", () => { + insertIncident(db, { + status: "open", + openedAt: "2026-03-02T10:00:00.000Z", + source: null, + severity: null, + }); + + const result = aggregateSignalsAnalytics(db, RANGE); + + expect(result.bySource).toEqual([{ source: "unknown", count: 1 }]); + expect(result.bySeverity).toEqual([{ severity: "unknown", count: 1 }]); + expect(result.byStatus).toEqual([{ status: "open", count: 1 }]); + }); + it("keeps MTTR as the unavailable sentinel when no incident resolved in range", () => { insertIncident(db, { status: "open", @@ -111,4 +126,20 @@ describe("signals-analytics", () => { expect(result.totalSignals).toBe(1); expect(result.mttr).toEqual({ value: null, unavailable: true, sampleCount: 0 }); }); + + it("returns zeroed analytics when incidents table is absent", () => { + db.prepare("DROP TABLE incidents").run(); + + expect(aggregateSignalsAnalytics(db, RANGE)).toEqual({ + from: RANGE.from, + to: RANGE.to, + totalSignals: 0, + open: 0, + resolved: 0, + mttr: { value: null, unavailable: true, sampleCount: 0 }, + bySource: [], + bySeverity: [], + byStatus: [], + }); + }); }); diff --git a/packages/core/src/__tests__/store-execution-timing.test.ts b/packages/core/src/__tests__/store-execution-timing.test.ts index 515e8e01ac..b79ee6a58a 100644 --- a/packages/core/src/__tests__/store-execution-timing.test.ts +++ b/packages/core/src/__tests__/store-execution-timing.test.ts @@ -88,4 +88,107 @@ describe("TaskStore execution timing semantics", () => { expect(done.cumulativeActiveMs).toBe(5 * 60_000); }); + + /* + FNXC:TaskTiming 2026-06-26-10:14: + Per-stage dwell instrumentation regression. Asserts columnDwellMs accumulates the correct + wall-clock per column across a full todo->in-progress->in-review->done sequence, that a + re-entered column (second in-progress / second todo visit) ADDS to the existing bucket rather + than overwriting it, and that the JSON map survives the SQLite round-trip (getTask rehydration). + */ + it("accumulates per-column dwell across a multi-column, multi-visit sequence", async () => { + vi.useFakeTimers(); + // todo entry anchor. Create + first move share this instant => leaving the + // creation column is a 0ms dwell and records no spurious bucket. + vi.setSystemTime(new Date("2026-06-26T10:00:00.000Z")); + + const task = await store.createTask({ description: "per-stage dwell" }); + await store.moveTask(task.id, "todo"); + + // todo dwell visit #1: 5 min + vi.setSystemTime(new Date("2026-06-26T10:05:00.000Z")); + await store.moveTask(task.id, "in-progress"); + + // in-progress dwell visit #1: 3 min + vi.setSystemTime(new Date("2026-06-26T10:08:00.000Z")); + await store.moveTask(task.id, "in-review"); + + // in-review dwell: 10 min + vi.setSystemTime(new Date("2026-06-26T10:18:00.000Z")); + await store.moveTask(task.id, "done"); + + // done dwell: 2 min (reopen leaves done) + vi.setSystemTime(new Date("2026-06-26T10:20:00.000Z")); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + + // todo dwell visit #2: 1 min => bucket adds to the prior 5 min + vi.setSystemTime(new Date("2026-06-26T10:21:00.000Z")); + await store.moveTask(task.id, "in-progress"); + + // in-progress dwell visit #2: 4 min => bucket adds to the prior 3 min + vi.setSystemTime(new Date("2026-06-26T10:25:00.000Z")); + const final = await store.moveTask(task.id, "in-review"); + + expect(final.columnDwellMs).toEqual({ + todo: 6 * 60_000, // 5 + 1 + "in-progress": 7 * 60_000, // 3 + 4 + "in-review": 10 * 60_000, + done: 2 * 60_000, + }); + + // JSON map survives the DB round-trip. + const reloaded = await store.getTask(task.id); + expect(reloaded?.columnDwellMs).toEqual(final.columnDwellMs); + }); + + it("reconciles engine-down time without changing firstExecutionAt or accrued active time", async () => { + /* + FNXC:TaskTiming 2026-06-25-00:00: + Surface Enumeration: proves the core downtime helper, completion accrual, multi-task shifts, missing/future/below-threshold no-ops, after-heartbeat task exclusion, repeated restart idempotence, and legacy missing executionStartedAt tolerance. + */ + vi.useFakeTimers(); + const t0 = new Date("2026-06-25T00:00:00.000Z"); + vi.setSystemTime(t0); + + const task = await store.createTask({ description: "engine downtime symptom" }); + await store.moveTask(task.id, "todo"); + const running = await store.moveTask(task.id, "in-progress"); + const second = await store.createTask({ description: "second active" }); + await store.moveTask(second.id, "todo"); + await store.moveTask(second.id, "in-progress"); + const legacy = await store.createTask({ description: "legacy active" }); + await store.moveTask(legacy.id, "todo"); + await store.moveTask(legacy.id, "in-progress"); + await store.updateTask(legacy.id, { executionStartedAt: null }); + + await store.updateSettings({ engineLastActiveAt: new Date(t0.getTime() + 5 * 60_000).toISOString(), pollIntervalMs: 15_000 }); + vi.setSystemTime(new Date(t0.getTime() + 65 * 60_000)); + const result = await store.reconcileActiveTimingForEngineDowntime(); + + expect(result.downtimeMs).toBe(60 * 60_000); + expect(result.shiftedTaskIds.sort()).toEqual([task.id, second.id].sort()); + const shifted = await store.getTask(task.id); + expect(shifted?.executionStartedAt).toBe(new Date(t0.getTime() + 60 * 60_000).toISOString()); + expect(shifted?.firstExecutionAt).toBe(running.firstExecutionAt); + expect(shifted?.cumulativeActiveMs).toBe(0); + + vi.setSystemTime(new Date(t0.getTime() + 67 * 60_000)); + const done = await store.moveTask(task.id, "done"); + expect(done.cumulativeActiveMs).toBe(7 * 60_000); + + await store.updateSettings({ engineLastActiveAt: undefined }); + expect((await store.reconcileActiveTimingForEngineDowntime()).shiftedTaskIds).toEqual([]); + await store.updateSettings({ engineLastActiveAt: new Date(t0.getTime() + 90 * 60_000).toISOString() }); + expect((await store.reconcileActiveTimingForEngineDowntime()).shiftedTaskIds).toEqual([]); + await store.updateSettings({ engineLastActiveAt: new Date(t0.getTime() + 66 * 60_000).toISOString() }); + expect((await store.reconcileActiveTimingForEngineDowntime()).shiftedTaskIds).toEqual([]); + + await store.moveTask(second.id, "done"); + await store.updateSettings({ engineLastActiveAt: new Date(t0.getTime() + 65 * 60_000).toISOString() }); + const afterHeartbeat = await store.createTask({ description: "started after heartbeat" }); + await store.moveTask(afterHeartbeat.id, "todo"); + await store.moveTask(afterHeartbeat.id, "in-progress"); + vi.setSystemTime(new Date(t0.getTime() + 70 * 60_000)); + expect((await store.reconcileActiveTimingForEngineDowntime()).shiftedTaskIds).toEqual([]); + }); }); diff --git a/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts b/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts new file mode 100644 index 0000000000..2f6cd1c4ab --- /dev/null +++ b/packages/core/src/__tests__/store-secrets-store-global-dir.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "../store.js"; + +/* +FNXC:GlobalDirGuard 2026-06-25-23:05: +Symptom-based regression for the "all my global settings reset" bug. The root cause was getSecretsStore() (and dashboard routes) constructing CentralCore with `store.getFusionDir()` (the project's `.fusion/`), which created a stray per-project `fusion-central.db` seeded with default global state that shadowed the real global DB. These tests assert the INVARIANT directly: the secrets store's central DB lands in the resolved GLOBAL dir and NOT inside the project `.fusion/` dir, and that getGlobalSettingsDir() is distinct from getFusionDir(). Surface enumeration: this covers the store/secrets surface; the resolveGlobalDir guard surfaces are covered in global-settings-guard.test.ts. +*/ +describe("TaskStore.getSecretsStore() central DB location (global, not project-local)", () => { + let root: string; + let globalDir: string; + let store: TaskStore; + + beforeEach(async () => { + root = mkdtempSync(join(tmpdir(), "fn-secrets-global-dir-")); + globalDir = join(root, ".fusion-global-settings"); + store = new TaskStore(root, globalDir, { inMemoryDb: true }); + await store.init(); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("resolves getGlobalSettingsDir() to the global dir, distinct from getFusionDir()", () => { + expect(store.getGlobalSettingsDir()).toBe(globalDir); + expect(store.getFusionDir()).toBe(join(root, ".fusion")); + expect(store.getGlobalSettingsDir()).not.toBe(store.getFusionDir()); + }); + + it("creates the secrets central DB in the global dir and never in the project .fusion/", async () => { + await store.getSecretsStore(); + + // The central DB must live in the resolved global dir... + expect(existsSync(join(globalDir, "fusion-central.db"))).toBe(true); + // ...and must NOT have spawned a stray per-project central DB (the original bug). + expect(existsSync(join(store.getFusionDir(), "fusion-central.db"))).toBe(false); + }); + + it("returns a stable singleton secrets store across calls", async () => { + const a = await store.getSecretsStore(); + const b = await store.getSecretsStore(); + expect(a).toBe(b); + }); +}); diff --git a/packages/core/src/activity-analytics.ts b/packages/core/src/activity-analytics.ts index d17b69b1ad..7d0875fe48 100644 --- a/packages/core/src/activity-analytics.ts +++ b/packages/core/src/activity-analytics.ts @@ -76,6 +76,49 @@ export interface MonitorMetrics { deployments: number; } + +/** Command Center Signals source breakdown from incidents opened in range. */ +export interface SignalSourceCount { + source: string; + count: number; +} + +/** Command Center Signals severity breakdown from incidents opened in range. */ +export interface SignalSeverityCount { + severity: string; + count: number; +} + +/** Command Center Signals status breakdown from incidents opened in range. */ +export interface SignalStatusCount { + status: string; + count: number; +} + +/** + * External signal analytics for the Command Center Signals area. Counts are + * sourced from the `incidents` table so connector ingestion, monitor metrics, + * and UI pressure indicators share one durable signal record. + */ +export interface SignalsAnalytics { + from: string | null; + to: string | null; + /** Incidents opened (by `openedAt`) within the range. */ + totalSignals: number; + /** Open incidents opened within the range. */ + open: number; + /** Incidents resolved (by `resolvedAt`) within the range. */ + resolved: number; + /** Mean-time-to-resolve over incidents resolved in range. */ + mttr: MttrSummary; + /** Incidents opened in range grouped by source; null/blank values are `unknown`. */ + bySource: SignalSourceCount[]; + /** Incidents opened in range grouped by severity; null/blank values are `unknown`. */ + bySeverity: SignalSeverityCount[]; + /** Incidents opened in range grouped by status so connector recoveries are visible. */ + byStatus: SignalStatusCount[]; +} + export interface ActivityAnalytics { from: string | null; to: string | null; @@ -656,25 +699,8 @@ export function aggregateMonitorMetrics( ) .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, + mttr: mttrFromResolvedRows(resolvedRows), incidentsOpened, incidentsResolved, openIncidents, @@ -701,3 +727,126 @@ function tableExists(db: Database, table: string): boolean { .get(table) as { name: string } | undefined; return row !== undefined; } + +/* ------------------------------------------------------------------------- */ +/* FN-6706 — Command Center Signals analytics from incidents */ +/* ------------------------------------------------------------------------- */ + +interface SignalsGroupRow { + key: string | null; + count: number; +} + +function emptySignalsAnalytics(query: ActivityAnalyticsQuery): 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 mttrFromResolvedRows(rows: ResolvedIncidentRow[]): MttrSummary { + let totalMs = 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)) continue; + const delta = resolved - opened; + if (delta < 0) continue; + totalMs += delta; + sampleCount += 1; + } + return sampleCount === 0 + ? { value: null, unavailable: true, sampleCount: 0 } + : { value: totalMs / sampleCount / 60_000, unavailable: false, sampleCount }; +} + +function signalsBreakdown( + db: Database, + column: "source" | "severity" | "status", + openedWhere: string, + params: string[], +): Array<{ key: string; count: number }> { + const rows = db + .prepare( + `SELECT COALESCE(NULLIF(TRIM(${column}), ''), 'unknown') AS key, COUNT(*) AS count + FROM incidents ${openedWhere} + GROUP BY key + ORDER BY count DESC, key ASC`, + ) + .all(...params) as SignalsGroupRow[]; + return rows.map((row) => ({ key: row.key ?? "unknown", count: row.count })); +} + +/** + * Aggregate Command Center Signals data from verified connector incidents. + * + * FNXC:CommandCenterSignals 2026-06-19-00:00: + * FN-6706 requires the Signals area to read real connector pressure from the project-scoped incidents table. Use openedAt for total/open/source/severity, resolvedAt for resolved/MTTR, bucket missing source/severity as `unknown`, and degrade to an empty unavailable-MTTR shape on older schemas without incidents. + * + * FNXC:CommandCenterSignals 2026-06-25-23:35: + * Connector resolution events must surface as a status breakdown, not only top-line open/resolved counts, so the UI and API can prove provider recovery signals changed incident state. + */ +export function aggregateSignalsAnalytics( + db: Database, + query: ActivityAnalyticsQuery = {}, +): SignalsAnalytics { + if (!tableExists(db, "incidents")) return emptySignalsAnalytics(query); + + const openedRange = rangeClauses("openedAt", query); + const resolvedRange = rangeClauses("resolvedAt", query); + const openWhere = openedRange.where + ? `${openedRange.where} AND status = 'open'` + : "WHERE status = 'open'"; + const resolvedWhere = resolvedRange.where + ? `${resolvedRange.where} AND resolvedAt IS NOT NULL` + : "WHERE resolvedAt IS NOT NULL"; + + const totalSignals = ( + db + .prepare(`SELECT COUNT(*) AS count FROM incidents ${openedRange.where}`) + .get(...openedRange.params) as CountRow + ).count; + const open = ( + db + .prepare(`SELECT COUNT(*) AS count FROM incidents ${openWhere}`) + .get(...openedRange.params) as CountRow + ).count; + const resolved = ( + db + .prepare(`SELECT COUNT(*) AS count FROM incidents ${resolvedWhere}`) + .get(...resolvedRange.params) as CountRow + ).count; + + const resolvedRows = db + .prepare(`SELECT openedAt, resolvedAt FROM incidents ${resolvedWhere}`) + .all(...resolvedRange.params) as ResolvedIncidentRow[]; + + return { + from: query.from ?? null, + to: query.to ?? null, + totalSignals, + open, + resolved, + mttr: mttrFromResolvedRows(resolvedRows), + bySource: signalsBreakdown(db, "source", openedRange.where, openedRange.params).map((row) => ({ + source: row.key, + count: row.count, + })), + bySeverity: signalsBreakdown(db, "severity", openedRange.where, openedRange.params).map((row) => ({ + severity: row.key, + count: row.count, + })), + byStatus: signalsBreakdown(db, "status", openedRange.where, openedRange.params).map((row) => ({ + status: row.key, + count: row.count, + })), + }; +} diff --git a/packages/core/src/automation.ts b/packages/core/src/automation.ts index 6a4b01fd04..7f4548e76a 100644 --- a/packages/core/src/automation.ts +++ b/packages/core/src/automation.ts @@ -17,6 +17,17 @@ export const AUTOMATION_PRESETS: Record, string> // ── Automation Step Types ──────────────────────────────────────────── +/** + * Builtin tool names that automation AI-prompt steps can expose in the dashboard selector. + * + * FNXC:AutomationTools 2026-06-26-00:00: + * Automation AI steps default to every selectable coding tool for backward-compatible legacy schedules. Persist an explicit allowlist only when the operator narrows the set; an empty allowlist intentionally means no tools. + */ +export const AUTOMATION_SELECTABLE_TOOLS = ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"] as const; + +/** Selectable automation AI tool name. */ +export type AutomationSelectableTool = (typeof AUTOMATION_SELECTABLE_TOOLS)[number]; + /** The type of an automation step. */ export type AutomationStepType = "command" | "ai-prompt" | "create-task"; @@ -36,6 +47,13 @@ export interface AutomationStep { modelProvider?: string; /** AI model ID (for ai-prompt steps). */ modelId?: string; + /** + * Optional tool allowlist for ai-prompt steps. + * + * FNXC:Automations 2026-06-26-00:00: + * Undefined means the agent receives all automation coding tools by default. A provided array restricts the agent to those tool names, and an empty array deliberately runs the prompt with no tools. + */ + allowedTools?: string[]; /** Task title for the created task (for create-task steps). */ taskTitle?: string; /** Task description for the created task (for create-task steps). */ diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 5b32fafe80..d0e2844c49 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -216,7 +216,11 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ id: "code-review", kind: "gate", config: { - name: "Code review", + /* + * FNXC:Workflows 2026-06-25-00:00: + * FN-7045 requires every built-in code-review step display name to be title-case "Code Review" so compound-engineering matches WORKFLOW_STEP_TEMPLATES and the optional Code Review group. + */ + name: "Code Review", executor: "skill", skillName: "compound-engineering:ce-code-review", gateMode: "gate", diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 44ee3814e4..5cf342f39b 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -167,7 +167,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 131; +const SCHEMA_VERSION = 132; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -301,6 +301,12 @@ CREATE TABLE IF NOT EXISTS tasks ( columnMovedAt TEXT, firstExecutionAt TEXT, cumulativeActiveMs INTEGER, + -- FNXC:TaskTiming 2026-06-26-10:14: per-column dwell map (JSON text) accumulated at the + -- column-transition seam (store.ts moveTaskInternal). Fills the gap left by cumulativeActiveMs + -- (in-progress only) so todo/in-review/done wall-clock is queryable per stage. Source of truth + -- for getSchemaCompatibilityTableSchemas(); fresh DBs get it here, existing DBs are backfilled + -- by the version-130 migration / ensureSchemaCompatibility() at boot. + columnDwellMs TEXT, executionStartedAt TEXT, executionCompletedAt TEXT, -- JSON columns for nested arrays/objects @@ -5340,7 +5346,18 @@ export class Database { }); } - // Migration 130: post-merge graph-native cutover (U7b) — legacy enable-id normalization. + if (version < 130) { + // FNXC:TaskTiming 2026-06-26-10:14: add the columnDwellMs column so existing DBs durably + // persist per-stage dwell going forward. Backfill is also covered by ensureSchemaCompatibility() + // (SCHEMA_SQL is its source of truth); this versioned migration keeps migrated and + // fresh-from-SCHEMA_SQL DBs converged. No data backfill: pre-existing rows start with NULL + // (= undefined map) and accumulate from their next column transition. + this.applyMigration(130, () => { + this.addColumnIfMissing("tasks", "columnDwellMs", "TEXT"); + }); + } + + // Migration 131: post-merge graph-native cutover (U7b) — legacy enable-id normalization. // FNXC:WorkflowPostMerge 2026-06-26-12:00: // Graph-native post-merge is now default-ON and the graph is the single post-merge owner. // A task's `enabledWorkflowSteps` must reference GRAPH node ids so the graph enables the @@ -5351,8 +5368,8 @@ export class Database { // untouched. Data DML, so wrapped in a transaction; identity-stable + idempotent (a second // run finds node ids already in place and no WS-row left to rewrite). The `workflow_steps` // table is intentionally KEPT (dropped in U7c once all readers are gone). - if (version < 130) { - this.applyMigration(130, () => { + if (version < 131) { + this.applyMigration(131, () => { // FNXC:WorkflowPostMerge 2026-06-26-14:00: U7c removed `workflow_steps` from // SCHEMA_SQL, so a DB stamped between the table's creation migration and 130 can // legitimately lack the table (nothing to normalize). Guard the SELECT — absence @@ -5416,17 +5433,17 @@ export class Database { }); } - // Migration 131: drop the legacy `workflow_steps` table (U7c). + // Migration 132: drop the legacy `workflow_steps` table (U7c). // FNXC:WorkflowStepCRUD 2026-06-26-14:00: // Pre-merge and post-merge workflow steps run graph-native and record into - // `task.workflowStepResults`. Migration 130 already normalized legacy compiled-step + // `task.workflowStepResults`. Migration 131 already normalized legacy compiled-step // enable ids (WS-xxx) in `tasks.enabledWorkflowSteps` to their built-in optional-group // node ids, so the table holds nothing read at runtime. All store CRUD, the // workflow-compilation materializer, the merger post-merge path, and the executor // recovery table read have been removed. Drop the table. Idempotent // (`DROP TABLE IF EXISTS`); a fresh DB never created it (removed from SCHEMA_SQL). - if (version < 131) { - this.applyMigration(131, () => { + if (version < 132) { + this.applyMigration(132, () => { this.db.exec("DROP TABLE IF EXISTS workflow_steps"); }); } diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index 02a1474c14..3054849753 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -14,9 +14,9 @@ */ import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises"; -import { existsSync, mkdirSync, renameSync } from "node:fs"; +import { existsSync, mkdirSync, realpathSync, renameSync } from "node:fs"; import type { GlobalSettings } from "./types.js"; import { DEFAULT_GLOBAL_SETTINGS } from "./types.js"; import { sanitizeCliAgentsSettings } from "./settings-schema.js"; @@ -90,7 +90,44 @@ export function resolveGlobalDir(dir?: string): string { ); } - if (hasExplicitDir) return dir; + if (hasExplicitDir) { + /* + FNXC:GlobalDirGuard 2026-06-25-22:10: + Production code must never point the central/global store at a project's `.fusion/` directory. Doing so silently spins up a stray per-project central DB seeded with DEFAULT global settings (globalMaxConcurrent=4, empty global secrets, default centralSettings), which then shadows the real `~/.fusion/fusion-central.db` and manifests as "all my global settings reset". Root cause was call sites passing `store.getFusionDir()` instead of the resolved global dir. + Guard heuristic: a project `.fusion` dir is named `.fusion` and lives inside a git repo (its parent has a `.git` dir or worktree file), whereas the home global dir's parent (the home dir) is not a repo. We only flag dirs that differ from the home-resolved global dir, so legitimately-threaded global dirs and test temp dirs are unaffected. Skipped under VITEST (tests pass explicit temp dirs by design). + + FNXC:GlobalDirGuard 2026-06-25-22:55: + The heuristic is intentionally conservative but can't perfectly distinguish a project `.fusion` from a legitimately version-controlled custom global dir (e.g. a dotfiles repo with `~/dotfiles/.fusion` + `.git`). To avoid hard-crashing that rare setup, honor an explicit opt-out env var `FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true`. This is not reachable via normal production call sites (they resolve to ~/.fusion); it only matters for operators who deliberately configure a custom global dir inside a repo. + + FNXC:GlobalDirGuard 2026-06-26-06:25: + Order matters and the home-dir comparison must be normalized: + - Run the CHEAP, read-only checks first (basename is `.fusion` AND its parent contains a `.git`). Only if both hold do we call `resolveGlobalDirForHome()` — which can perform a one-time legacy-dir rename — so we never trigger that filesystem side effect on the hot path (every explicit-dir call, e.g. getGlobalSettingsDir() in dashboard routes, previously hit it). + - Compare against the home global dir using normalized real paths (realpathSync when the path exists, else resolve()), so a trailing slash, doubled separator, or symlinked home dir doesn't make the legitimate home global dir look like a foreign project dir and trip the guard. + */ + if (process.env.VITEST !== "true" && process.env.FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR !== "true") { + const isFusionDirInsideRepo = + basename(dir) === ".fusion" && existsSync(join(dirname(dir), ".git")); + if (isFusionDirInsideRepo) { + const homeGlobalDir = resolveGlobalDirForHome(getHomeDir()); + const normalize = (p: string): string => { + try { + return realpathSync.native(p); + } catch { + return resolve(p); + } + }; + if (normalize(dir) !== normalize(homeGlobalDir)) { + throw new Error( + `resolveGlobalDir(): refusing project-local '.fusion' directory '${dir}' for the central/global store. ` + + "This would create a stray per-project central database seeded with default global settings and silently reset them. " + + "Pass the resolved global dir (or omit the argument so it defaults to ~/.fusion); see TaskStore.getGlobalSettingsDir(). " + + "If this really is your intended global dir (e.g. a version-controlled dotfiles repo), set FUSION_ALLOW_PROJECT_LOCAL_GLOBAL_DIR=true to override.", + ); + } + } + } + return dir; + } return resolveGlobalDirForHome(getHomeDir()); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8465db59dd..3e336fafab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, isMcpSecretRef } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings, McpSecretRef, McpSensitiveValue, McpStdioTransport, McpSseTransport, McpStreamableHttpTransport, McpTransport, McpServerDefinition, McpServersSettings } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { resolveEntryPointBranchAssignment, @@ -37,6 +37,25 @@ export { validateWorktrunkSettings, DEFAULT_WORKTRUNK_SETTINGS, } from "./worktrunk-settings.js"; +export { + resolveEffectiveMcpServers, + materializeMcpServerSecrets, + materializeMcpServersSecrets, + importMcpServersJson, + exportMcpServersJson, +} from "./mcp-config.js"; +export type { + McpSecretReaderIdentity, + McpSecretReader, + ResolvedMcpStdioTransport, + ResolvedMcpSseTransport, + ResolvedMcpStreamableHttpTransport, + ResolvedMcpServerDefinition, + McpSecretResolutionError, + McpSecretResolutionResult, + McpSecretImportDescriptor, + McpServersImportResult, +} from "./mcp-config.js"; export { resolveAgentMemoryInclusionMode, type AgentMemoryInclusionModeSource, @@ -622,14 +641,13 @@ export type { GithubIssueRepoBreakdown, GithubResolvedIssue, } from "./github-issue-analytics.js"; -export { aggregateSignalsAnalytics } from "./signals-analytics.js"; +export { aggregateSignalsAnalytics } from "./activity-analytics.js"; export type { + SignalSourceCount, + SignalSeverityCount, SignalsAnalytics, - SignalsAnalyticsQuery, - SignalsBreakdown, - SignalsSeverityBreakdown, - SignalsStatusBreakdown, -} from "./signals-analytics.js"; + ActivityAnalyticsQuery as SignalsAnalyticsQuery, +} from "./activity-analytics.js"; export { composeLiveSnapshot } from "./command-center-live.js"; export type { LiveSnapshot, @@ -858,8 +876,8 @@ export { resolveTaskGithubTracking, } from "./github-tracking.js"; export type { RepoSlug, ResolvedTaskGithubTracking } from "./github-tracking.js"; -export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js"; -export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js"; +export { AUTOMATION_PRESETS, AUTOMATION_SELECTABLE_TOOLS, MAX_RUN_HISTORY } from "./automation.js"; +export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult, AutomationSelectableTool } from "./automation.js"; export { AutomationStore } from "./automation-store.js"; export type { AutomationStoreEvents } from "./automation-store.js"; export { runCommandAsync } from "./run-command.js"; @@ -918,8 +936,15 @@ export { validateSandboxFailureMode, validateSandboxPolicy, validateSandboxProjectSettings, + validateMcpServerDefinition, + validateMcpServerDefinitionDetailed, + validateMcpServerDefinitions, + validateMcpServerDefinitionsDetailed, + validateMcpServersSettings, + validateMcpServersSettingsDetailed, validateUnavailableNodePolicy, } from "./settings-validation.js"; +export type { McpValidationError, McpValidationResult } from "./settings-validation.js"; export { parseSandboxPromptOverride, resolveSandboxBackend } from "./sandbox-prompt-override.js"; diff --git a/packages/core/src/mcp-config.ts b/packages/core/src/mcp-config.ts new file mode 100644 index 0000000000..aef147595a --- /dev/null +++ b/packages/core/src/mcp-config.ts @@ -0,0 +1,366 @@ +import type { + GlobalSettings, + McpSecretRef, + McpServerDefinition, + McpServersSettings, + McpStdioTransport, + McpSseTransport, + McpStreamableHttpTransport, + ProjectSettings, +} from "./types.js"; +import { isMcpSecretRef } from "./types.js"; +import type { SecretScope } from "./secrets-store.js"; +import { validateMcpServerDefinition } from "./settings-validation.js"; + +export interface McpSecretImportDescriptor { + serverName: string; + field: "env" | "headers" | "token"; + key: string; + scope: SecretScope; + suggestedKey: string; + plaintextValue: string; +} + +export interface McpServersImportResult { + definitions: McpServerDefinition[]; + secretsToCreate: McpSecretImportDescriptor[]; + errors: string[]; +} + +export type McpSecretReaderIdentity = { agentId?: string | null; userId?: string | null }; + +export interface McpSecretReader { + revealSecret( + id: string, + scope: SecretScope, + reader: McpSecretReaderIdentity, + ): Promise<{ key: string; plaintextValue: string }>; +} + +export interface ResolvedMcpStdioTransport extends Omit { + env?: Record; +} + +export interface ResolvedMcpSseTransport extends Omit { + headers?: Record; +} + +export interface ResolvedMcpStreamableHttpTransport extends Omit { + headers?: Record; +} + +export type ResolvedMcpServerDefinition = { + name: string; + enabled?: boolean; +} & (ResolvedMcpStdioTransport | ResolvedMcpSseTransport | ResolvedMcpStreamableHttpTransport); + +export interface McpSecretResolutionError { + serverName: string; + path: string; + secretRef: McpSecretRef; + message: string; +} + +export interface McpSecretResolutionResult { + value?: T; + errors: McpSecretResolutionError[]; +} + +function normalizeMcpServersSettings(settings?: McpServersSettings): McpServersSettings { + return { + enabled: settings?.enabled === true, + servers: Array.isArray(settings?.servers) ? settings.servers : [], + }; +} + +function validServers(settings?: McpServersSettings): McpServerDefinition[] { + return ( + normalizeMcpServersSettings(settings).servers + ?.map(validateMcpServerDefinition) + .filter((server): server is McpServerDefinition => Boolean(server)) ?? [] + ); +} + +/** + * FNXC:McpConfig 2026-06-25-00:00: + * Effective MCP configuration is project-over-global by server name. A project server with enabled:false removes the inherited global declaration, while a project enabled declaration replaces it. The resolver is pure and never throws so settings reads cannot break task scheduling. + */ +export function resolveEffectiveMcpServers( + globalSettings?: Pick | null, + projectSettings?: Pick | null, +): McpServerDefinition[] { + try { + const globalMcp = normalizeMcpServersSettings(globalSettings?.mcpServers); + const projectMcp = projectSettings?.mcpServers; + const effectiveEnabled = typeof projectMcp?.enabled === "boolean" ? projectMcp.enabled : globalMcp.enabled; + if (!effectiveEnabled) return []; + + const byName = new Map(); + for (const server of validServers(globalSettings?.mcpServers)) { + if (server.enabled === false) continue; + byName.set(server.name, server); + } + for (const server of validServers(projectMcp)) { + if (server.enabled === false) { + byName.delete(server.name); + continue; + } + byName.set(server.name, server); + } + return [...byName.values()].filter((server) => server.enabled !== false); + } catch { + return []; + } +} + +async function materializeSensitiveMap(params: { + serverName: string; + path: string; + values?: Record; + secrets: McpSecretReader; + reader: McpSecretReaderIdentity; +}): Promise | undefined>> { + const { values, secrets, reader, serverName, path } = params; + if (!values) return { value: undefined, errors: [] }; + const resolved: Record = {}; + const errors: McpSecretResolutionError[] = []; + for (const [key, value] of Object.entries(values)) { + if (!isMcpSecretRef(value)) { + errors.push({ + serverName, + path: `${path}.${key}`, + secretRef: { secretRef: "", scope: "project" }, + message: "MCP sensitive values must be secret references; plaintext was not materialized", + }); + continue; + } + try { + const revealed = await secrets.revealSecret(value.secretRef, value.scope, reader); + resolved[key] = revealed.plaintextValue; + } catch (error) { + errors.push({ + serverName, + path: `${path}.${key}`, + secretRef: value, + message: error instanceof Error ? error.message : String(error), + }); + } + } + return { value: Object.keys(resolved).length > 0 ? resolved : undefined, errors }; +} + +/** + * FNXC:McpConfig 2026-06-25-00:00: + * MCP secret materialization happens only at the use seam by calling the injected SecretsStore-compatible revealSecret method. Failed references are reported and omitted; the function never logs or returns unresolved secret material as plaintext. + */ +export async function materializeMcpServerSecrets( + server: McpServerDefinition, + secrets: McpSecretReader, + reader: McpSecretReaderIdentity, +): Promise> { + if (server.transport === "stdio") { + const env = await materializeSensitiveMap({ + serverName: server.name, + path: "env", + values: server.env, + secrets, + reader, + }); + return { + value: { + name: server.name, + ...(server.enabled !== undefined ? { enabled: server.enabled } : {}), + transport: "stdio", + command: server.command, + ...(server.args ? { args: server.args } : {}), + ...(env.value ? { env: env.value } : {}), + }, + errors: env.errors, + }; + } + + const headers = await materializeSensitiveMap({ + serverName: server.name, + path: "headers", + values: server.headers, + secrets, + reader, + }); + return { + value: { + name: server.name, + ...(server.enabled !== undefined ? { enabled: server.enabled } : {}), + transport: server.transport, + url: server.url, + ...(headers.value ? { headers: headers.value } : {}), + }, + errors: headers.errors, + }; +} + +export async function materializeMcpServersSecrets( + servers: McpServerDefinition[], + secrets: McpSecretReader, + reader: McpSecretReaderIdentity, +): Promise> { + const values: ResolvedMcpServerDefinition[] = []; + const errors: McpSecretResolutionError[] = []; + for (const server of servers) { + const resolved = await materializeMcpServerSecrets(server, secrets, reader); + if (resolved.value) values.push(resolved.value); + errors.push(...resolved.errors); + } + return { value: values, errors }; +} + +function parseMcpJson(json: string | unknown): { data?: unknown; error?: string } { + if (typeof json !== "string") return { data: json }; + try { + return { data: JSON.parse(json) as unknown }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +function suggestedSecretKey(serverName: string, field: "env" | "headers" | "token", key: string): string { + const clean = (value: string): string => value.trim().replace(/[^A-Za-z0-9_.-]+/gu, "_").replace(/^_+|_+$/gu, ""); + return ["mcp", clean(serverName), clean(field), clean(key)].filter(Boolean).join("."); +} + +function importSensitiveMap(params: { + value: unknown; + serverName: string; + field: "env" | "headers"; + scope: SecretScope; + secretsToCreate: McpSecretImportDescriptor[]; + errors: string[]; +}): Record | undefined { + const { value, serverName, field, scope, secretsToCreate, errors } = params; + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + errors.push(`${serverName}.${field} must be an object`); + return undefined; + } + const out: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + if (isMcpSecretRef(raw)) { + out[key] = { secretRef: raw.secretRef.trim(), scope: raw.scope }; + continue; + } + if (typeof raw === "string") { + const secretRef = suggestedSecretKey(serverName, field, key); + out[key] = { secretRef, scope }; + secretsToCreate.push({ + serverName, + field, + key, + scope, + suggestedKey: secretRef, + plaintextValue: raw, + }); + continue; + } + errors.push(`${serverName}.${field}.${key} must be a string or MCP secret reference`); + } + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Import Claude Desktop-style `{ mcpServers: { [name]: ... } }` JSON into Fusion + * definitions. Plain env/header strings are surfaced as secret creation + * descriptors and replaced with secret references; plaintext is never stored in + * the returned definitions. + */ +export function importMcpServersJson(json: string | unknown, options: { scope?: SecretScope } = {}): McpServersImportResult { + const parsed = parseMcpJson(json); + if (parsed.error) return { definitions: [], secretsToCreate: [], errors: [parsed.error] }; + const errors: string[] = []; + const secretsToCreate: McpSecretImportDescriptor[] = []; + const scope = options.scope ?? "project"; + const root = parsed.data; + if (!root || typeof root !== "object" || Array.isArray(root)) { + return { definitions: [], secretsToCreate, errors: ["MCP import data must be an object"] }; + } + const servers = (root as Record).mcpServers; + if (!servers || typeof servers !== "object" || Array.isArray(servers)) { + return { definitions: [], secretsToCreate, errors: ["MCP import data must contain an mcpServers object"] }; + } + + const definitions: McpServerDefinition[] = []; + const names = new Set(); + for (const [name, rawServer] of Object.entries(servers as Record)) { + if (!rawServer || typeof rawServer !== "object" || Array.isArray(rawServer)) { + errors.push(`${name} must be an object`); + continue; + } + const raw = rawServer as Record; + const enabled = typeof raw.enabled === "boolean" ? raw.enabled : undefined; + const base = { name: typeof raw.name === "string" && raw.name.trim() ? raw.name.trim() : name, ...(enabled !== undefined ? { enabled } : {}) }; + const transport = typeof raw.transport === "string" ? raw.transport : typeof raw.command === "string" ? "stdio" : undefined; + let candidate: McpServerDefinition | undefined; + if (transport === "stdio") { + candidate = validateMcpServerDefinition({ + ...base, + transport: "stdio", + command: raw.command, + args: raw.args, + env: importSensitiveMap({ value: raw.env, serverName: base.name, field: "env", scope, secretsToCreate, errors }), + }); + } else if (transport === "sse" || transport === "streamable-http") { + candidate = validateMcpServerDefinition({ + ...base, + transport, + url: raw.url, + headers: importSensitiveMap({ value: raw.headers, serverName: base.name, field: "headers", scope, secretsToCreate, errors }), + }); + } else { + errors.push(`${name}.transport must be stdio, sse, or streamable-http`); + } + if (!candidate) { + errors.push(`${name} is not a valid MCP server definition`); + continue; + } + if (names.has(candidate.name)) { + errors.push(`Duplicate MCP server name: ${candidate.name}`); + continue; + } + names.add(candidate.name); + definitions.push(candidate); + } + return { definitions, secretsToCreate, errors }; +} + +function exportSensitiveMap(values: Record | undefined): Record | undefined { + if (!values) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (isMcpSecretRef(value)) out[key] = { secretRef: value.secretRef, scope: value.scope }; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +/** Export Fusion MCP definitions as JSON-safe `mcpServers` data with secret refs preserved and never resolved. */ +export function exportMcpServersJson(definitions: McpServerDefinition[]): { mcpServers: Record } { + const mcpServers: Record = {}; + for (const definition of definitions) { + const server = validateMcpServerDefinition(definition); + if (!server) continue; + if (server.transport === "stdio") { + mcpServers[server.name] = { + transport: "stdio", + ...(server.enabled !== undefined ? { enabled: server.enabled } : {}), + command: server.command, + ...(server.args ? { args: server.args } : {}), + ...(server.env ? { env: exportSensitiveMap(server.env) } : {}), + }; + continue; + } + mcpServers[server.name] = { + transport: server.transport, + ...(server.enabled !== undefined ? { enabled: server.enabled } : {}), + url: server.url, + ...(server.headers ? { headers: exportSensitiveMap(server.headers) } : {}), + }; + } + return { mcpServers }; +} diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 817a576160..32297c588c 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -1,5 +1,5 @@ import { DEFAULT_MAX_AUTO_MERGE_RETRIES } from "./in-review-stall.js"; -import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js"; +import type { CliAgentSettings, GlobalSettings, McpSecretRef, McpServerDefinition, ProjectSettings, Settings } from "./types.js"; export interface MergeRequestContractShadowSettingsSource { mergeRequestContractShadowEnabled?: boolean; @@ -200,6 +200,10 @@ export const DEFAULT_GLOBAL_SETTINGS = { researchGlobalMaxSearchResults: 10, researchGlobalFetchTimeoutMs: 30_000, researchGlobalUserAgent: "FusionResearchBot/1.0", + mcpServers: { + enabled: false, + servers: [], + }, remoteAccess: { activeProvider: null, providers: { @@ -263,6 +267,7 @@ export const DEFAULT_PROJECT_SETTINGS = { approvedWorkflowCliCommands: undefined, approvedCliAutonomyAdapters: undefined, enginePaused: false, + engineLastActiveAt: undefined, maxConcurrent: 2, maxTriageConcurrent: 2, globalMaxConcurrent: 4, @@ -295,6 +300,10 @@ export const DEFAULT_PROJECT_SETTINGS = { owningNodeHandoffPolicy: "reassign-to-local", defaultNodeId: undefined, secretsEnv: undefined, + mcpServers: { + enabled: false, + servers: [], + }, worktreeInitCommand: undefined, /* FNXC:WorktreeCopyFiles 2026-06-24-00:00: @@ -698,3 +707,83 @@ export function sanitizeCliAgentsSettings(value: unknown): Record; + if (typeof input.secretRef !== "string") return undefined; + const secretRef = input.secretRef.trim(); + if (!secretRef || (input.scope !== "project" && input.scope !== "global")) return undefined; + return { secretRef, scope: input.scope }; +} + +function sanitizeMcpSensitiveMap(value: unknown): Record | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const out: Record = {}; + for (const [rawKey, rawValue] of Object.entries(value as Record)) { + const key = rawKey.trim(); + if (!key) continue; + const ref = sanitizeMcpSecretRef(rawValue); + if (ref) out[key] = ref; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function sanitizeMcpServerDefinition(value: unknown): McpServerDefinition | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const input = value as Record; + if (typeof input.name !== "string") return undefined; + const name = input.name.trim(); + if (!name) return undefined; + const enabled = typeof input.enabled === "boolean" ? input.enabled : undefined; + const base = { name, ...(enabled !== undefined ? { enabled } : {}) }; + + if (input.transport === "stdio") { + if (typeof input.command !== "string" || input.command.trim().length === 0) return undefined; + const args = sanitizeStringArray(input.args); + const env = sanitizeMcpSensitiveMap(input.env); + return { + ...base, + transport: "stdio", + command: input.command.trim(), + ...(args ? { args } : {}), + ...(env ? { env } : {}), + }; + } + + if (input.transport === "sse" || input.transport === "streamable-http") { + if (typeof input.url !== "string" || input.url.trim().length === 0) return undefined; + const headers = sanitizeMcpSensitiveMap(input.headers); + return { + ...base, + transport: input.transport, + url: input.url.trim(), + ...(headers ? { headers } : {}), + }; + } + + return undefined; +} + +/** + * Sanitize MCP settings at the write boundary. Malformed server declarations are + * dropped, duplicate names collapse to the last valid declaration, and sensitive + * env/header values survive only as Fusion secret references. Pure — no I/O. + */ +export function sanitizeMcpServers(value: unknown): { enabled?: boolean; servers: McpServerDefinition[] } { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { enabled: false, servers: [] }; + } + const input = value as Record; + const byName = new Map(); + if (Array.isArray(input.servers)) { + for (const rawServer of input.servers) { + const server = sanitizeMcpServerDefinition(rawServer); + if (server) byName.set(server.name, server); + } + } + return { + enabled: typeof input.enabled === "boolean" ? input.enabled : false, + servers: [...byName.values()], + }; +} diff --git a/packages/core/src/settings-validation.ts b/packages/core/src/settings-validation.ts index 2ca592b6a9..a9e050b092 100644 --- a/packages/core/src/settings-validation.ts +++ b/packages/core/src/settings-validation.ts @@ -4,13 +4,16 @@ import type { HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, Locale, + McpSensitiveValue, + McpServerDefinition, + McpServersSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, UnavailableNodePolicy, } from "./types.js"; -import { isLocale } from "./types.js"; +import { isLocale, isMcpSecretRef } from "./types.js"; const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const; const DIRECT_MERGE_COMMIT_STRATEGIES: readonly DirectMergeCommitStrategy[] = ["auto", "always-squash", "always-rebase"] as const; @@ -204,3 +207,170 @@ export function validateSandboxProjectSettings(value: unknown): SandboxProjectSe ...(failureMode !== undefined ? { failureMode } : {}), }; } + +export interface McpValidationError { + path: string; + code: + | "invalid-shape" + | "invalid-name" + | "duplicate-name" + | "invalid-transport" + | "missing-command" + | "missing-url" + | "invalid-args" + | "invalid-sensitive-map" + | "plaintext-secret"; + message: string; +} + +export interface McpValidationResult { + value?: T; + errors: McpValidationError[]; +} + +function mcpError(path: string, code: McpValidationError["code"], message: string): McpValidationError { + return { path, code, message }; +} + +function validateMcpStringArray(value: unknown, path: string): McpValidationResult { + if (value === undefined) return { value: undefined, errors: [] }; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim().length > 0)) { + return { errors: [mcpError(path, "invalid-args", "Expected an array of non-empty strings")] }; + } + return { value: value.map((entry) => entry.trim()), errors: [] }; +} + +function validateMcpSensitiveMap( + value: unknown, + path: string, +): McpValidationResult | undefined> { + if (value === undefined) return { value: undefined, errors: [] }; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { errors: [mcpError(path, "invalid-sensitive-map", "Expected an object whose values are secret references")] }; + } + const out: Record = {}; + const errors: McpValidationError[] = []; + for (const [key, entry] of Object.entries(value as Record)) { + if (!key.trim()) { + errors.push(mcpError(`${path}.${key}`, "invalid-sensitive-map", "Sensitive field names must be non-empty")); + continue; + } + if (typeof entry === "string") { + errors.push(mcpError(`${path}.${key}`, "plaintext-secret", "Sensitive MCP values must be Fusion secret references, never plaintext strings")); + continue; + } + if (!isMcpSecretRef(entry)) { + errors.push(mcpError(`${path}.${key}`, "invalid-sensitive-map", "Sensitive MCP values must be { secretRef, scope } objects")); + continue; + } + out[key.trim()] = { secretRef: entry.secretRef.trim(), scope: entry.scope }; + } + return errors.length > 0 ? { errors } : { value: out, errors: [] }; +} + +export function validateMcpServerDefinitionDetailed(value: unknown, path = "server"): McpValidationResult { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { errors: [mcpError(path, "invalid-shape", "MCP server definition must be an object")] }; + } + const input = value as Record; + const errors: McpValidationError[] = []; + if (typeof input.name !== "string" || input.name.trim().length === 0) { + errors.push(mcpError(`${path}.name`, "invalid-name", "MCP server name is required")); + } + const enabled = typeof input.enabled === "boolean" ? input.enabled : undefined; + + if (input.transport === "stdio") { + if (typeof input.command !== "string" || input.command.trim().length === 0) { + errors.push(mcpError(`${path}.command`, "missing-command", "stdio MCP servers require a command")); + } + const args = validateMcpStringArray(input.args, `${path}.args`); + const env = validateMcpSensitiveMap(input.env, `${path}.env`); + errors.push(...args.errors, ...env.errors); + if (errors.length > 0) return { errors }; + return { + value: { + name: (input.name as string).trim(), + ...(enabled !== undefined ? { enabled } : {}), + transport: "stdio", + command: (input.command as string).trim(), + ...(args.value ? { args: args.value } : {}), + ...(env.value ? { env: env.value } : {}), + }, + errors: [], + }; + } + + if (input.transport === "sse" || input.transport === "streamable-http") { + if (typeof input.url !== "string" || input.url.trim().length === 0) { + errors.push(mcpError(`${path}.url`, "missing-url", `${input.transport} MCP servers require a url`)); + } + const headers = validateMcpSensitiveMap(input.headers, `${path}.headers`); + errors.push(...headers.errors); + if (errors.length > 0) return { errors }; + return { + value: { + name: (input.name as string).trim(), + ...(enabled !== undefined ? { enabled } : {}), + transport: input.transport, + url: (input.url as string).trim(), + ...(headers.value ? { headers: headers.value } : {}), + }, + errors: [], + }; + } + + errors.push(mcpError(`${path}.transport`, "invalid-transport", "MCP transport must be stdio, sse, or streamable-http")); + return { errors }; +} + +/** Returns a normalized MCP server definition, or undefined with rejection details available from validateMcpServerDefinitionDetailed. */ +export function validateMcpServerDefinition(value: unknown): McpServerDefinition | undefined { + return validateMcpServerDefinitionDetailed(value).value; +} + +export function validateMcpServerDefinitionsDetailed(value: unknown, path = "servers"): McpValidationResult { + if (!Array.isArray(value)) { + return { errors: [mcpError(path, "invalid-shape", "MCP servers must be an array")] }; + } + const errors: McpValidationError[] = []; + const out: McpServerDefinition[] = []; + const names = new Set(); + value.forEach((entry, index) => { + const result = validateMcpServerDefinitionDetailed(entry, `${path}.${index}`); + errors.push(...result.errors); + if (!result.value) return; + if (names.has(result.value.name)) { + errors.push(mcpError(`${path}.${index}.name`, "duplicate-name", `Duplicate MCP server name: ${result.value.name}`)); + return; + } + names.add(result.value.name); + out.push(result.value); + }); + return errors.length > 0 ? { errors } : { value: out, errors: [] }; +} + +/** Returns unique normalized MCP server definitions, otherwise undefined. */ +export function validateMcpServerDefinitions(value: unknown): McpServerDefinition[] | undefined { + return validateMcpServerDefinitionsDetailed(value).value; +} + +export function validateMcpServersSettingsDetailed(value: unknown, path = "mcpServers"): McpValidationResult { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { errors: [mcpError(path, "invalid-shape", "MCP settings must be an object")] }; + } + const input = value as Record; + const servers = input.servers === undefined ? { value: [], errors: [] } : validateMcpServerDefinitionsDetailed(input.servers, `${path}.servers`); + if (servers.errors.length > 0) return { errors: servers.errors }; + return { + value: { + enabled: typeof input.enabled === "boolean" ? input.enabled : undefined, + servers: servers.value ?? [], + }, + errors: [], + }; +} + +/** Returns normalized MCP settings, otherwise undefined. */ +export function validateMcpServersSettings(value: unknown): McpServersSettings | undefined { + return validateMcpServersSettingsDetailed(value).value; +} diff --git a/packages/core/src/signals-analytics.ts b/packages/core/src/signals-analytics.ts index b874aff86c..2899addfb4 100644 --- a/packages/core/src/signals-analytics.ts +++ b/packages/core/src/signals-analytics.ts @@ -1,195 +1,14 @@ -import type { Database } from "./db.js"; -import type { MttrSummary } from "./activity-analytics.js"; +import type { SignalSeverityCount, SignalSourceCount } 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 { aggregateSignalsAnalytics } from "./activity-analytics.js"; +export type { + ActivityAnalyticsQuery as SignalsAnalyticsQuery, + SignalSourceCount, + SignalSeverityCount, + SignalsAnalytics, +} from "./activity-analytics.js"; -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, - }; -} +/** Back-compat alias for the original pre-FN-6706 signals module name. */ +export type SignalsBreakdown = SignalSourceCount; +/** Back-compat alias for the original pre-FN-6706 signals module name. */ +export type SignalsSeverityBreakdown = SignalSeverityCount; diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index d783bf92fb..a3781312a3 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -142,7 +142,7 @@ import { normalizeTaskPriority } from "./task-priority.js"; import { validateBranchGroupBranchName, filterTasksByBranchGroup } from "./branch-assignment.js"; import { allowsAutoMergeProcessing } from "./task-merge.js"; import { canAgentTakeImplementationTaskForExplicitRouting } from "./agent-role-policy.js"; -import { GlobalSettingsStore } from "./global-settings.js"; +import { GlobalSettingsStore, resolveGlobalDir } from "./global-settings.js"; import { Database, SCHEMA_VERSION, toJson, toJsonNullable, fromJson } from "./db.js"; import { ArchiveDatabase } from "./archive-db.js"; import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js"; @@ -279,6 +279,10 @@ interface TaskRow { columnMovedAt: string | null; firstExecutionAt: string | null; cumulativeActiveMs: number | null; + // FNXC:TaskTiming 2026-06-26-10:14: per-column dwell map (JSON text), populated by the + // column-transition seam in moveTaskInternal. Persisted alongside cumulativeActiveMs so + // per-stage wall-clock survives the SQLite round-trip getChangedTaskColumns/rowToTask use. + columnDwellMs: string | null; executionStartedAt: string | null; executionCompletedAt: string | null; dependencies: string | null; @@ -438,6 +442,8 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("columnMovedAt", (task) => task.columnMovedAt ?? null), defineTaskColumn("firstExecutionAt", (task) => task.firstExecutionAt ?? null), defineTaskColumn("cumulativeActiveMs", (task) => task.cumulativeActiveMs ?? null), + // FNXC:TaskTiming 2026-06-26-10:14: serialize per-column dwell map as JSON text (same as mergeDetails/workspaceWorktrees). + defineTaskColumn("columnDwellMs", (task) => toJsonNullable(task.columnDwellMs)), defineTaskColumn("executionStartedAt", (task) => task.executionStartedAt ?? null), defineTaskColumn("executionCompletedAt", (task) => task.executionCompletedAt ?? null), defineTaskColumn("dependencies", (task) => toJson(task.dependencies || [])), @@ -2080,6 +2086,11 @@ export class TaskStore extends EventEmitter { columnMovedAt: row.columnMovedAt || undefined, firstExecutionAt: row.firstExecutionAt || undefined, cumulativeActiveMs: row.cumulativeActiveMs ?? undefined, + // FNXC:TaskTiming 2026-06-26-10:14: rehydrate per-column dwell map; drop empty maps to undefined like workspaceWorktrees. + columnDwellMs: (() => { + const d = fromJson>(row.columnDwellMs); + return d && Object.keys(d).length > 0 ? d : undefined; + })(), executionStartedAt: row.executionStartedAt || undefined, executionCompletedAt: row.executionCompletedAt || undefined, dependencies: fromJson(row.dependencies) || [], @@ -2262,6 +2273,7 @@ export class TaskStore extends EventEmitter { columnMovedAt: entry.columnMovedAt, firstExecutionAt: entry.firstExecutionAt, cumulativeActiveMs: entry.cumulativeActiveMs, + columnDwellMs: entry.columnDwellMs, executionStartedAt: entry.executionStartedAt, executionCompletedAt: entry.executionCompletedAt, modelPresetId: entry.modelPresetId, @@ -2398,6 +2410,7 @@ export class TaskStore extends EventEmitter { columnMovedAt: task.columnMovedAt, firstExecutionAt: task.firstExecutionAt, cumulativeActiveMs: task.cumulativeActiveMs, + columnDwellMs: task.columnDwellMs, executionStartedAt: task.executionStartedAt, executionCompletedAt: task.executionCompletedAt, archivedAt, @@ -2608,7 +2621,7 @@ export class TaskStore extends EventEmitter { "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", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", - "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", + "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", @@ -2657,7 +2670,7 @@ export class TaskStore extends EventEmitter { "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", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", - "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", + "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "columnDwellMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "attachments", "steeringComments", "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", @@ -5315,6 +5328,34 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} ); } + async reconcileActiveTimingForEngineDowntime(now: Date = new Date()): Promise<{ shiftedTaskIds: string[]; downtimeMs: number }> { + const settings = await this.getSettings(); + const heartbeatMs = Date.parse(settings.engineLastActiveAt ?? ""); + const nowMs = now.getTime(); + const thresholdMs = Math.max((settings.pollIntervalMs ?? 15_000) * 2, 60_000); + const downtimeMs = Number.isFinite(heartbeatMs) && Number.isFinite(nowMs) ? nowMs - heartbeatMs : 0; + if (!settings.engineLastActiveAt || downtimeMs <= thresholdMs) { + return { shiftedTaskIds: [], downtimeMs: Math.max(0, downtimeMs) }; + } + + const shiftedTaskIds: string[] = []; + const tasks = await this.listTasks({ column: "in-progress", includeArchived: false, slim: true }); + for (const task of tasks) { + const startedMs = Date.parse(task.executionStartedAt ?? ""); + if (!Number.isFinite(startedMs) || startedMs > heartbeatMs) continue; + const shiftedStartedMs = Math.min(nowMs, startedMs + downtimeMs); + if (shiftedStartedMs <= startedMs) continue; + /* + FNXC:TaskTiming 2026-06-25-00:00: + Engine-process downtime is proven only by a stale engineLastActiveAt heartbeat. Advance the current active segment anchor, but preserve firstExecutionAt and cumulativeActiveMs so wall-clock history and already-accrued active work remain intact. + */ + await this.updateTask(task.id, { executionStartedAt: new Date(shiftedStartedMs).toISOString() }); + shiftedTaskIds.push(task.id); + } + + return { shiftedTaskIds, downtimeMs }; + } + // --- Unified PR entity (PR-lifecycle-as-workflow-nodes, U1) --- private rowToPrEntity(row: PrEntityRow): PrEntity { @@ -7188,10 +7229,39 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} } const movedAt = internal.now ?? new Date().toISOString(); + /* + FNXC:TaskTiming 2026-06-26-10:14: + Capture the previous column-entry timestamp BEFORE it is overwritten so we can record + per-stage dwell. `cumulativeActiveMs` only covers `in-progress`; this seam fills the gap + for todo / in-review / done so per-stage wall-clock is measurable going forward without + reconstructing it from agent logs. + */ + const previousColumnMovedAt = task.columnMovedAt; task.column = toColumn; task.columnMovedAt = movedAt; task.updatedAt = movedAt; + /* + FNXC:TaskTiming 2026-06-26-10:14: + Accumulate dwell for the column being LEFT into `columnDwellMs[fromColumn]`, mirroring the + `cumulativeActiveMs` accumulation pattern. Flag-INDEPENDENT (runs for both the workflow-hook + and legacy-inline paths) because it keys off the generic columnMovedAt delta, not in-progress + execution timestamps. Skip when the previous timestamp is missing/unparseable (e.g. first move + or legacy rows), and clamp to >= 0 to defend against clock skew / out-of-order `internal.now`. + Multi-visit columns add to the existing bucket, never decrement. + */ + { + const prevMs = Date.parse(previousColumnMovedAt ?? ""); + const nowMs = Date.parse(movedAt); + if (Number.isFinite(prevMs) && Number.isFinite(nowMs)) { + const dwellMs = Math.max(0, nowMs - prevMs); + if (dwellMs > 0) { + const buckets = (task.columnDwellMs ??= {}); + buckets[fromColumn] = Math.max(0, buckets[fromColumn] ?? 0) + dwellMs; + } + } + } + if (useWorkflow) { // ── Flag-ON: route the legacy per-column side effects through the // default-workflow trait hooks (timing, reset-on-entry, abort-on-exit, @@ -15918,6 +15988,17 @@ ${stepsSection}`; return this.fusionDir; } + /* + FNXC:GlobalDirGuard 2026-06-25-22:12: + The resolved GLOBAL settings dir. Distinct from getFusionDir() which is this project's `.fusion/`. Any CentralCore/global-store construction MUST use this, never getFusionDir(); passing the project dir spins up a stray per-project central DB that shadows ~/.fusion and silently resets global settings. + + FNXC:GlobalDirGuard 2026-06-25-22:50: + Returns a fully-RESOLVED absolute path (string), not the raw optional field. Resolving here (rather than leaking CentralCore's `undefined → ~/.fusion` default to every caller) makes the contract honest and fires the project-local `.fusion` guard at this call site instead of deferring it to CentralCore construction. Under VITEST `this.globalSettingsDir` is always set to a temp dir, so resolveGlobalDir returns it verbatim and never throws the no-explicit-dir test error. + */ + getGlobalSettingsDir(): string { + return resolveGlobalDir(this.globalSettingsDir); + } + getTasksDir(): string { return this.tasksDir; } @@ -15940,14 +16021,16 @@ ${stepsSection}`; return this.secretsStore; } - const central = new CentralCore(this.getFusionDir()); + // FNXC:GlobalDirGuard 2026-06-25-22:13: Secrets live in the GLOBAL central DB (~/.fusion), not this project's `.fusion/`. Use the resolved global dir; passing getFusionDir() created a stray per-project central DB and reset global settings. + const central = new CentralCore(this.getGlobalSettingsDir()); await central.init(); this.secretsCentralCore = central; const centralDb = (central as unknown as { db: import("./central-db.js").CentralDatabase | null }).db; if (!centralDb) { throw new Error("Central database unavailable for secrets store"); } - const masterKeyManager = new MasterKeyManager(); + // FNXC:GlobalDirGuard 2026-06-25-23:00: The master key is GLOBAL — pass the resolved global dir explicitly so it co-locates with the global central DB (matching prod ~/.fusion) and so getSecretsStore() is exercisable under tests (a bare new MasterKeyManager() throws under VITEST because resolveGlobalDir() requires an explicit dir there). + const masterKeyManager = new MasterKeyManager({ globalDir: this.getGlobalSettingsDir() }); const masterKeyProvider = () => masterKeyManager.getOrCreateKey(); this.secretsStore = new SecretsStore(this.db, centralDb, masterKeyProvider); return this.secretsStore; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8235c72254..b1554dfc9c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -5,6 +5,7 @@ import type { StalePausedReviewSignal } from "./stale-paused-review.js"; import type { StalePausedTodoSignal } from "./stale-paused-todo.js"; import type { StalledReviewSignal } from "./stalled-review-detector.js"; import type { TaskAgeStalenessSignal } from "./task-age-staleness.js"; +import type { SecretScope } from "./secrets-store.js"; export { computeCapacityRisk, @@ -12,6 +13,10 @@ export { } from "./capacity.js"; export type { CapacityRiskSignal } from "./capacity.js"; +// FNXC:McpConfig 2026-06-26-02:10: The dashboard Vite build aliases @fusion/core to this browser-safe module, so the pure MCP config helpers are re-exported here for Settings UI import/export, validation, and project-over-global resolution without pulling Node-only stores into the client bundle. +export { exportMcpServersJson, importMcpServersJson, resolveEffectiveMcpServers } from "./mcp-config.js"; +export { validateMcpServerDefinitionDetailed, validateMcpServerDefinitionsDetailed } from "./settings-validation.js"; + /** * Valid thinking effort levels for AI agent sessions, controlling the cost/quality tradeoff of reasoning. * Includes extra-high for maximum-effort requests on reasoning-capable models. @@ -2393,6 +2398,18 @@ export interface Task { * Incremented whenever the task leaves `in-progress`; never decremented and * never cleared by reopen flows. */ cumulativeActiveMs?: number; + /* + FNXC:TaskTiming 2026-06-26-10:14: + Per-stage dwell-time instrumentation. `cumulativeActiveMs` only measures `in-progress`, + so "how long did a task sit in todo / in-review" was unrecoverable without reconstructing + it from agent logs. This map records cumulative wall-clock milliseconds spent in EACH + column (column name -> total ms), accumulated at the column-transition seam in store.ts + exactly like `cumulativeActiveMs`: on every transition we add the dwell of the column being + LEFT (newColumnMovedAt - previousColumnMovedAt, clamped >= 0). Multi-visit columns add to + the existing bucket; never decremented and never cleared by reopen flows. Directly queryable + per stage by consumers like productivity-analytics.ts. + */ + columnDwellMs?: Record; /** ISO-8601 wall-clock timestamp for the current execution attempt. * Set when entering `in-progress`; may be cleared on reopen to * todo/triage when resume state is not preserved. */ @@ -2826,6 +2843,58 @@ export interface WorktrunkSettings { installedBinaryPath?: string; } +/** + * FNXC:McpConfig 2026-06-25-00:00: + * MCP servers are trusted once enabled because downstream runtime slices may launch local commands or connect to operator-provided URLs. Store only declarations here; sensitive env, header, and token material MUST be represented as Fusion-managed secret references, never inline plaintext. + */ +export interface McpSecretRef { + secretRef: string; + scope: SecretScope; +} + +export function isMcpSecretRef(value: unknown): value is McpSecretRef { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + return ( + typeof candidate.secretRef === "string" && + candidate.secretRef.trim().length > 0 && + (candidate.scope === "project" || candidate.scope === "global") + ); +} + +export type McpSensitiveValue = McpSecretRef | string; + +export interface McpStdioTransport { + transport: "stdio"; + command: string; + args?: string[]; + env?: Record; +} + +export interface McpSseTransport { + transport: "sse"; + url: string; + headers?: Record; +} + +export interface McpStreamableHttpTransport { + transport: "streamable-http"; + url: string; + headers?: Record; +} + +export type McpTransport = McpStdioTransport | McpSseTransport | McpStreamableHttpTransport; + +export type McpServerDefinition = { + name: string; + enabled?: boolean; +} & McpTransport; + +export interface McpServersSettings { + enabled?: boolean; + servers?: McpServerDefinition[]; +} + export interface GlobalSettings { /** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */ themeMode?: ThemeMode; @@ -3218,6 +3287,10 @@ export interface GlobalSettings { * Stores both provider configs, active provider selection, token strategy, * and lifecycle restart metadata for remote tunnel orchestration. */ remoteAccess?: RemoteAccessProjectSettings; + /** Global defaults for user-configurable MCP servers. + * Project-level `mcpServers` entries override by server name and may disable + * a global server without deleting the global declaration. */ + mcpServers?: McpServersSettings; /** Global defaults for worktrunk integration. * Merged with project-level `worktrunk` field-by-field in `getSettings()`/ * `getSettingsFast()` so partial project overrides inherit unspecified fields. */ @@ -3385,6 +3458,11 @@ export interface ProjectSettings { * effect when {@link globalPause} is also true (hard stop already * covers everything). */ enginePaused?: boolean; + /** + * FNXC:TaskTiming 2026-06-25-00:00: + * Records the last time the engine process proved it was alive so startup recovery can exclude process-down wall-clock time from active task duration without changing firstExecutionAt. + */ + engineLastActiveAt?: string; /** Maximum number of concurrent AI agents across all activity types * (triage specification, task execution, and merge operations). */ maxConcurrent: number; @@ -3518,6 +3596,10 @@ export interface ProjectSettings { researchSettings?: ResearchProjectSettings; /** Optional per-project `.env` materialization settings for exportable secrets. */ secretsEnv?: SecretsEnvSettings; + /** Project-scoped MCP server overrides. + * Entries override global server declarations by name; `enabled: false` on a + * same-named entry disables that server for this project. */ + mcpServers?: McpServersSettings; /** Sandbox command-execution settings. * When omitted, runtime behavior is preserved via native passthrough defaults. */ sandbox?: SandboxProjectSettings; @@ -4310,6 +4392,7 @@ export { resolvePersistAgentThinkingLog, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, + sanitizeMcpServers, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES, } from "./settings-schema.js"; @@ -4570,6 +4653,9 @@ export interface ArchivedTaskEntry { firstExecutionAt?: string; /** Accumulated active runtime spent in `in-progress` across attempts. */ cumulativeActiveMs?: number; + /** FNXC:TaskTiming 2026-06-26-10:14: per-column cumulative dwell (ms) carried through + * archive/restore so per-stage wall-clock survives archival. See Task.columnDwellMs. */ + columnDwellMs?: Record; /** Current-attempt execution anchor; may be cleared on reopen. */ executionStartedAt?: string; /** First-time completion anchor; may be cleared on reopen. */ @@ -5835,6 +5921,12 @@ export interface AgentHeartbeatEvent { /** What triggered a heartbeat run */ export type HeartbeatInvocationSource = "on_demand" | "timer" | "assignment" | "automation" | "routine"; +/* +FNXC:AutomationTools 2026-06-26-00:00: +Dashboard source-checkout builds alias @fusion/core to this frontend-safe module, so mirror the automation AI-step tool catalog here as a runtime export for UI selectors. +*/ +export const AUTOMATION_SELECTABLE_TOOLS = ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"] as const; + /** Snapshot of the last blocked state for a task, used for dedup comparison. */ export interface BlockedStateSnapshot { /** The task ID that was blocked */ diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index dc02d4c461..c81e265247 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -357,10 +357,10 @@ function AppInner() { } }, [handleChangeTaskView, taskView, pushNav]); - // Tasks hook with project context and search query - // SSE is only enabled for board/list views to free connection slots for mission detail fetches + // FNXC:DashboardLiveUpdates 2026-06-26-01:08: + // SSE remains enabled only for board/list views to free connection slots for mission detail fetches. The false→true missed-event catch-up lives inside useTasks so App keeps the routing gate only and cannot double-fetch on task-view re-entry. const taskSseEnabled = taskView === "board" || taskView === "list"; - const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, refreshTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks( + const { tasks, isStale, createTask, moveTask, pauseTask, unpauseTask, deleteTask, mergeTask, retryTask, resetTask, updateTask, duplicateTask, archiveTask, unarchiveTask, archiveAllDone, loadArchivedTasks, ingestCreatedTasks, lastFetchTimeMs } = useTasks( { ...(currentProject ? { projectId: currentProject.id } : {}), searchQuery: searchQuery || undefined, @@ -384,22 +384,6 @@ function AppInner() { */ const { tasks: poppedOutTasks, popOut: popOutTaskDetail, close: closePoppedOutTask } = usePoppedOutTasks(); - const previousTaskViewRef = useRef(taskView); - - useEffect(() => { - const previousTaskView = previousTaskViewRef.current; - const wasTaskView = previousTaskView === "board" || previousTaskView === "list"; - const isTaskView = taskView === "board" || taskView === "list"; - - // Task SSE is disabled off board/list. Refetch once when returning because - // in-app navigation does not trigger document.visibilitychange. - if (!wasTaskView && isTaskView) { - void refreshTasks(); - } - - previousTaskViewRef.current = taskView; - }, [taskView, refreshTasks]); - const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; const [graphWorkflowSelection, setGraphWorkflowSelection] = useState(null); diff --git a/packages/dashboard/app/__tests__/settings-save-split.test.ts b/packages/dashboard/app/__tests__/settings-save-split.test.ts index 8816f29181..6a4bfb43b7 100644 --- a/packages/dashboard/app/__tests__/settings-save-split.test.ts +++ b/packages/dashboard/app/__tests__/settings-save-split.test.ts @@ -214,6 +214,31 @@ describe("splitSettingsSave", () => { expect(projectPatch).toEqual({ maxConcurrent: 7 }); }); + it("routes shared mcpServers only to the active MCP scope", () => { + expect(isGlobalSettingsKey("mcpServers")).toBe(true); + expect(isProjectSettingsKey("mcpServers")).toBe(true); + const globalMcp = { enabled: true, servers: [{ name: "global-docs", transport: "stdio", command: "docs" }] }; + const projectMcp = { enabled: true, servers: [{ name: "project-docs", transport: "stdio", command: "docs" }] }; + + const globalResult = splitSettingsSave({ + payload: { mcpServers: globalMcp }, + initialValues: null, + initialScopedValues: { global: { mcpServers: { enabled: false, servers: [] } }, project: { mcpServers: projectMcp } } as never, + activeSection: "global-mcp", + }); + expect(globalResult.globalPatch).toEqual({ mcpServers: globalMcp }); + expect(globalResult.projectPatch).toEqual({}); + + const projectResult = splitSettingsSave({ + payload: { mcpServers: projectMcp }, + initialValues: null, + initialScopedValues: { global: { mcpServers: globalMcp }, project: { mcpServers: { enabled: false, servers: [] } } } as never, + activeSection: "mcp", + }); + expect(projectResult.globalPatch).toEqual({}); + expect(projectResult.projectPatch).toEqual({ mcpServers: projectMcp }); + }); + it("routes enabled built-in workflow ids as a changed project setting", () => { const { projectPatch } = splitSettingsSave({ payload: { enabledBuiltinWorkflowIds: ["builtin:coding"] }, diff --git a/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx new file mode 100644 index 0000000000..4e43c0f2ae --- /dev/null +++ b/packages/dashboard/app/__tests__/workflow-selection-cross-surface.test.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api"; +import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../components/HeaderWorkflowSwitcherSlot"; +import { + filterTasksByGraphWorkflowSelection, + GraphWorkflowSwitcherSlot, + type GraphWorkflowSelection, +} from "../components/GraphWorkflowSwitcherSlot"; + +const fetchBoardWorkflowsMock = vi.fn(); +const subscribeSseMock = vi.fn(() => vi.fn()); + +vi.mock("../api", () => ({ + fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args), +})); + +vi.mock("../sse-bus", () => ({ + subscribeSse: (...args: unknown[]) => subscribeSseMock(...args), +})); + +const DEFAULT_WORKFLOW: BoardWorkflowDefinition = { + id: "builtin:coding", + name: "Coding", + columns: [], +}; + +const GRAPH_WORKFLOW: BoardWorkflowDefinition = { + id: "wf-graph", + name: "Graph", + columns: [], +}; + +const HEADER_WORKFLOW: BoardWorkflowDefinition = { + id: "wf-header", + name: "Header", + columns: [], +}; + +const TASKS = [ + { id: "FN-default", title: "Default task" }, + { id: "FN-unassigned", title: "Unassigned task" }, + { id: "FN-graph", title: "Graph task" }, + { id: "FN-deleted", title: "Deleted workflow task" }, +]; + +function workflowPayload(overrides: Partial = {}): BoardWorkflowsPayload { + return { + flagEnabled: true, + defaultWorkflowId: DEFAULT_WORKFLOW.id, + workflows: [DEFAULT_WORKFLOW, GRAPH_WORKFLOW, HEADER_WORKFLOW], + taskWorkflowIds: { + "FN-graph": GRAPH_WORKFLOW.id, + "FN-deleted": "wf-deleted", + }, + ...overrides, + }; +} + +function CrossSurfaceHarness({ projectId = "project-cross" }: { projectId?: string }) { + const [graphSelection, setGraphSelection] = useState(null); + const [headerSelection, setHeaderSelection] = useState(null); + const graphTasks = filterTasksByGraphWorkflowSelection(TASKS, projectId, graphSelection); + + return ( + <> +
+ + + {headerSelection?.selectedWorkflow.id ?? "none"} + {graphSelection?.selectedWorkflow.id ?? "none"} +
    + {graphTasks.map((task) => ( +
  • {task.title}
  • + ))} +
+ + ); +} + +beforeEach(() => { + sessionStorage.clear(); + fetchBoardWorkflowsMock.mockReset(); + subscribeSseMock.mockClear(); + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload()); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("workflow selection across dashboard surfaces", () => { + it("hydrates remounted surfaces from the persisted board-workflows payload", async () => { + const { unmount } = render(); + + expect(await screen.findAllByTestId("workflow-switcher")).toHaveLength(2); + await waitFor(() => { + expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + }); + + const [, graphSwitcher] = screen.getAllByTestId("workflow-switcher"); + fireEvent.click(graphSwitcher); + fireEvent.click(screen.getByTestId(`workflow-switcher-option-${GRAPH_WORKFLOW.id}`)); + await waitFor(() => expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id)); + + unmount(); + fetchBoardWorkflowsMock.mockImplementation(() => new Promise(() => {})); + + render(); + + const remountedSwitchers = screen.getAllByTestId("workflow-switcher"); + expect(remountedSwitchers).toHaveLength(2); + expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross"); + }); + + it("keeps Graph and Header workflow selections isolated while Graph filtering follows only Graph", async () => { + render(); + + const switchers = await screen.findAllByTestId("workflow-switcher"); + await waitFor(() => { + expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + }); + + const graphTasks = screen.getByTestId("graph-tasks"); + expect(within(graphTasks).getByTestId("graph-task-FN-default")).toBeInTheDocument(); + expect(within(graphTasks).getByTestId("graph-task-FN-unassigned")).toBeInTheDocument(); + expect(within(graphTasks).getByTestId("graph-task-FN-deleted")).toBeInTheDocument(); + expect(within(graphTasks).queryByTestId("graph-task-FN-graph")).toBeNull(); + + fireEvent.click(switchers[1]); + fireEvent.click(screen.getByTestId(`workflow-switcher-option-${GRAPH_WORKFLOW.id}`)); + + await waitFor(() => { + expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id); + expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id); + expect(within(graphTasks).getByTestId("graph-task-FN-graph")).toBeInTheDocument(); + expect(within(graphTasks).queryByTestId("graph-task-FN-default")).toBeNull(); + expect(within(graphTasks).queryByTestId("graph-task-FN-deleted")).toBeNull(); + }); + + fireEvent.click(switchers[0]); + fireEvent.click(screen.getByTestId(`workflow-switcher-option-${HEADER_WORKFLOW.id}`)); + + await waitFor(() => { + expect(screen.getByTestId("header-selection")).toHaveTextContent(HEADER_WORKFLOW.id); + expect(screen.getByTestId("graph-selection")).toHaveTextContent(GRAPH_WORKFLOW.id); + expect(within(graphTasks).getByTestId("graph-task-FN-graph")).toBeInTheDocument(); + }); + }); + + it("preserves boundary behavior for disabled, empty, and single-workflow payloads", async () => { + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ flagEnabled: false, workflows: [] })); + const { unmount } = render(); + + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement(); + for (const task of TASKS) { + expect(screen.getByTestId(`graph-task-${task.id}`)).toBeInTheDocument(); + } + + unmount(); + sessionStorage.clear(); + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [] })); + const empty = render(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement(); + empty.unmount(); + + sessionStorage.clear(); + fetchBoardWorkflowsMock.mockResolvedValue(workflowPayload({ workflows: [DEFAULT_WORKFLOW] })); + render(); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledWith("project-cross")); + expect(screen.queryByTestId("workflow-switcher")).toBeNull(); + expect(screen.getByTestId("header-workflow-slot")).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 93226ccc13..103b8246d1 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -5142,6 +5142,21 @@ export function reorderAutomationSteps(id: string, stepIds: string[], options?: export interface RoutineRunResponse { routine: Routine; result: RoutineExecutionResult; + liveRunId?: string; +} + +export type RoutineRunStreamEvent = + | { type: "run"; runId?: string; scheduleId?: string; status?: string } + | { type: "step"; runId?: string; stepIndex?: number; stepId?: string; stepName?: string; stepType?: string; status?: string; success?: boolean; error?: string } + | { type: "output"; runId?: string; text?: string } + | { type: "tool"; runId?: string; status?: string; name?: string; args?: unknown; isError?: boolean; result?: unknown } + | { type: "complete"; runId?: string; result?: RoutineExecutionResult } + | { type: "error"; runId?: string; message?: string; result?: RoutineExecutionResult }; + +export interface RoutineRunStreamHandlers { + onEvent: (event: RoutineRunStreamEvent) => void; + onConnectionStateChange?: (state: StreamConnectionState) => void; + onFatalError?: (message: string) => void; } export function fetchRoutines(options?: SchedulingScopeOptions): Promise { @@ -5180,6 +5195,39 @@ export function runRoutine(id: string, options?: SchedulingScopeOptions): Promis }); } +export function streamRoutineRun(id: string, handlers: RoutineRunStreamHandlers, options?: SchedulingScopeOptions & { runId?: string }) { + const baseUrl = withSchedulingScope(`/routines/${id}/run/stream`, options); + const separator = baseUrl.includes("?") ? "&" : "?"; + const url = options?.runId ? `${baseUrl}${separator}runId=${encodeURIComponent(options.runId)}` : baseUrl; + const parse = (type: RoutineRunStreamEvent["type"], event: MessageEvent) => { + let data: Record = {}; + try { + data = event.data ? JSON.parse(event.data) : {}; + } catch { + data = { message: event.data }; + } + handlers.onEvent({ type, ...data } as RoutineRunStreamEvent); + }; + return createResilientEventSource( + url, + { + events: { + run: (event) => parse("run", event), + step: (event) => parse("step", event), + output: (event) => parse("output", event), + tool: (event) => parse("tool", event), + complete: (event) => parse("complete", event), + error: (event) => parse("error", event), + }, + }, + { + maxReconnectAttempts: 2, + onConnectionStateChange: handlers.onConnectionStateChange, + onFatalError: handlers.onFatalError, + }, + ); +} + export function fetchRoutineRuns(id: string, options?: SchedulingScopeOptions): Promise { return api(withSchedulingScope(`/routines/${id}/runs`, options)); } diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index f4c98aede4..7669fd4c4f 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -274,10 +274,14 @@ Tablet viewports inherit the base tab rule because AgentDetailView has only a mo FNXC:AgentDetailView 2026-06-21-11:03: Agent-detail tabs must be non-shrinking flex children so the overflow strip develops real horizontal scroll on narrow mobile viewports instead of squashing Dashboard / Logs / Mail / Skills / Settings into the available width (FN-6865). + +FNXC:AgentDetailView 2026-06-26-01:00: +`touch-action` is non-inherited, so the tab buttons themselves are the touch targets that must opt into pan-x; the FN-6450/FN-6865 container-only opt-in still left button touches under the global mobile `* { touch-action: pan-y; }` lock. Mirror the FN-7048 task-detail fix for FN-7052 without changing tab layout or labels. */ .agent-detail-tab { display: flex; flex: 0 0 auto; + touch-action: pan-x pan-y; align-items: center; gap: var(--space-xs); padding: calc(var(--space-sm) + var(--space-xs) * 0.5) var(--space-md); @@ -1834,6 +1838,7 @@ Agent-detail tabs must be non-shrinking flex children so the overflow strip deve .agent-detail-tab { padding: var(--space-md) calc(var(--space-md) + var(--space-xs) * 0.5); font-size: 0.875rem; + touch-action: pan-x pan-y; white-space: nowrap; min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs)); } diff --git a/packages/dashboard/app/components/DockFilesView.css b/packages/dashboard/app/components/DockFilesView.css index 26b577d9cb..7fae3b65b8 100644 --- a/packages/dashboard/app/components/DockFilesView.css +++ b/packages/dashboard/app/components/DockFilesView.css @@ -109,6 +109,39 @@ Hidden until a file is selected; when selected it overlays the tree as the singl min-height: 0; } +/* +FNXC:RightDockFiles 2026-06-25-00:00: +The dock Files viewer shares FileBrowserModal's native preview classes but needs dock-scoped flex sizing so image/video/audio/PDF previews fit both the compact single-panel stack and the two-pane pop-out without overflowing or showing the CodeMirror shell. +*/ +.dock-files-preview { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + width: 100%; + overflow: auto; +} + +.dock-files-preview__media { + min-width: 0; +} + +.dock-files-preview .file-browser-preview-media--image, +.dock-files-preview .file-browser-preview-media--video { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.dock-files-preview .file-browser-preview-media--audio { + width: min(100%, calc(var(--space-xl) * 18)); +} + +.dock-files-preview .file-browser-preview-media--pdf { + width: 100%; + min-height: 0; + flex: 1 1 auto; +} + .dock-files-viewer__status { padding: var(--space-md); font-size: var(--font-size-xs); @@ -203,3 +236,15 @@ DETERMINISTIC two-pane layout for the RightDockExpandModal pop-out. Driven by th .dock-files-view--two-pane .dock-files-view__viewer .dock-files-viewer__back { display: none; } + +@media (max-width: 768px) { + .dock-files-preview { + padding: var(--space-md); + } + + .dock-files-preview .file-browser-preview-media--image, + .dock-files-preview .file-browser-preview-media--video, + .dock-files-preview .file-browser-preview-media--pdf { + width: 100%; + } +} diff --git a/packages/dashboard/app/components/DockFilesView.tsx b/packages/dashboard/app/components/DockFilesView.tsx index cd57e368c1..ff16a48b76 100644 --- a/packages/dashboard/app/components/DockFilesView.tsx +++ b/packages/dashboard/app/components/DockFilesView.tsx @@ -1,10 +1,12 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowLeft, Maximize2, Save } from "lucide-react"; import type { PluginDashboardViewContext } from "../plugins/types"; +import { downloadFileUrl } from "../api"; import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser"; import { useWorkspaceFileEditor } from "../hooks/useWorkspaceFileEditor"; import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage"; +import { getFilePreviewKind, IMAGE_PREVIEW_EXTENSIONS, VIDEO_PREVIEW_EXTENSIONS, AUDIO_PREVIEW_EXTENSIONS, PDF_PREVIEW_EXTENSIONS } from "../utils/file-preview-kind"; import { FileBrowser } from "./FileBrowser"; import { FileEditor } from "./FileEditor"; import "./DockFilesView.css"; @@ -28,6 +30,30 @@ Share the current-file path through scoped localStorage (`kb-dashboard-dock-file */ export const DOCK_FILES_CURRENT_KEY = "kb-dashboard-dock-files-current"; +const BINARY_EXTENSIONS = new Set([ + ...IMAGE_PREVIEW_EXTENSIONS, + ...VIDEO_PREVIEW_EXTENSIONS, + ...AUDIO_PREVIEW_EXTENSIONS, + ...PDF_PREVIEW_EXTENSIONS, + ".exe", ".dll", ".so", ".dylib", + ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", + ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".avi", ".mkv", ".flv", + ".woff", ".woff2", ".ttf", ".otf", ".eot", + ".wasm", ".bin", +]); + +function isBinaryFile(filename?: string | null): boolean { + if (!filename) { + return false; + } + const dotIndex = filename.lastIndexOf("."); + if (dotIndex < 0) { + return false; + } + return BINARY_EXTENSIONS.has(filename.slice(dotIndex).toLowerCase()); +} + /* FNXC:RightDockFiles 2026-06-22-00:00: The right-dock Files tool opens a clicked file INLINE inside the dock as a read-only viewer instead of immediately launching the resizable/movable FileBrowserModal. @@ -78,9 +104,22 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile return () => window.removeEventListener("storage", onStorage); }, [projectId]); + const selectedPreviewKind = useMemo(() => getFilePreviewKind(selectedFile), [selectedFile]); + const isPreviewOnlyFile = selectedPreviewKind !== null; + const isReadOnlyBinaryFile = Boolean(selectedFile && !isPreviewOnlyFile && isBinaryFile(selectedFile)); + const previewUrl = useMemo(() => { + if (!selectedFile || !selectedPreviewKind) { + return null; + } + return downloadFileUrl("project", selectedFile, projectId); + }, [projectId, selectedFile, selectedPreviewKind]); + /* FNXC:RightDockFiles 2026-06-22-16:28: The right-sidebar file viewer must be the same editor surface as the modal/mobile file browser: real workspace editor state, visible toolbar options, Preview/Edit for markdown, Line #, and Wrap. Use the shared editor hook instead of the old read-only content fetch so edits can be saved and the toolbar is not a reduced sidebar-only variant. + + FNXC:RightDockFiles 2026-06-25-00:00: + Known image/video/audio/PDF selections render through browser-native previews loaded from the workspace-safe download route. Keep those preview-only files out of CodeMirror so binary bytes are never fetched as editor text; editable text keeps the existing FileEditor + Save path, while known non-preview binary extensions stay read-only without loading bytes as editor content. */ const { content, @@ -90,7 +129,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile error: contentError, save, hasChanges, - } = useWorkspaceFileEditor("project", selectedFile, Boolean(selectedFile), projectId); + } = useWorkspaceFileEditor("project", selectedFile, Boolean(selectedFile) && !isPreviewOnlyFile && !isReadOnlyBinaryFile, projectId); const handleBack = useCallback(() => selectFile(null), [selectFile]); const handlePopOut = useCallback(() => { @@ -99,6 +138,9 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile const handleToggleLineNumbers = useCallback(() => setShowLineNumbers((current) => !current), []); const fileName = selectedFile ? selectedFile.split("/").pop() || selectedFile : ""; + const selectedPreviewTitle = selectedFile + ? t("fileBrowser.previewTitle", "Preview for {{file}}", { file: selectedFile }) + : ""; // FNXC:Files 2026-06-22-00:00: // `data-selected` on the root lets the container query distinguish "no file selected" (narrow: viewer pane hidden so only the tree shows) from "file selected" (narrow: viewer pane covers the stack). When wide both panes are always visible regardless of this flag. @@ -155,7 +197,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile > - {selectedFile ? ( + {selectedFile && !isPreviewOnlyFile && !isReadOnlyBinaryFile ? (
+ ) : previewUrl && selectedPreviewKind ? ( +
+ {selectedPreviewKind === "image" ? ( + {selectedFile} + ) : null} + {selectedPreviewKind === "video" ? ( +