Merge upstream/main (1547 commits) into local main

Resolved 7 conflicts and reconciled local patches with upstream's evolved API:

- packages/core/src/plugin-loader.ts: dropped local createContextFor()/buildContext()
  in favor of upstream's createRouteContext() which provides the same TaskStore-
  override capability our patch added.

- packages/dashboard/src/routes.ts: removed local manual plugin-route mounter
  (upstream's createPluginRouter handles this with richer response support);
  kept registerPluginExemptPath loop so external webhook plugins (telemetry-
  watcher, Grafana/Sentry) still bypass daemon-token auth. Removed obsolete
  resolveHeartbeatMonitorFor (replaced by upstream's resolveHeartbeatMonitor).

- packages/dashboard/src/routes/register-agent-runtime-routes.ts: ported
  4 call sites to upstream's isHeartbeatMonitorForProject + resolveHeartbeatMonitor
  dual-fallback pattern (multi-project monitor resolution).

- packages/cli/package.json: combined upstream's pi-ai 0.73.0 + dockerode
  bumps with our cross-spawn dep for vendored pi-claude-cli.

- pnpm-workspace.yaml: kept telemetry-watcher entry alongside upstream's new
  plugin entries (droid-runtime, cursor-runtime, agent-browser, whatsapp-chat,
  roadmap, even-realities-glasses, even-cards, reports).

- packages/dashboard/app/components/SetupWizardModal.css: kept z-index:110
  fix (wizard stacks above ModelOnboardingModal) and added upstream's
  overflow-y/overscroll-behavior on .setup-wizard-overlay.

- pnpm-lock.yaml: took upstream verbatim; pnpm install regenerated to
  include cross-spawn.

Typechecks: @fusion/core and @fusion/dashboard pass cleanly.
This commit is contained in:
semih
2026-05-11 07:56:13 +00:00
1930 changed files with 349766 additions and 83450 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -50,24 +50,8 @@ npm install -g @runfusion/fusion
fn dashboard # or: fusion dashboard
```
As a [pi](https://github.com/badlogic/pi-mono) extension (bundled skill + `/fn` command):
```bash
pi install npm:@runfusion/fusion
```
If your local Pi install gets stale or broken, open Fusion dashboard **Settings → Pi Extensions** and use **Reinstall Fusion skill** to reinstall `npm:@runfusion/fusion` and refresh discovered extensions.
## Launch the dashboard
Inside a pi session:
```
/fn # start on default port 4040
/fn stop # stop it
/fn 8080 # run on a custom port
```
From a shell:
```bash
@@ -145,7 +129,7 @@ This execution model is heavily based on [Taskplane](https://www.npmjs.com/packa
| 🌳 **Worktree isolation** | Each task runs in its own branch and worktree. Parallel tasks. Zero conflicts. |
| ⚡ **Smart merge** | Passing every gate? Fusion squash-merges and moves on. |
| 🛰️ **Multi-node mesh** | Laptop, server, cloud, phone — all synced. Desktop, mobile, web. |
| 🧩 **Any model** | Anthropic, OpenAI, Ollama — or anything pi-compatible. |
| 🧩 **Any model** | Anthropic, OpenAI, Ollama, and more. |
| 🏢 **Agent companies** | Import pre-built teams — 440+ agents across 16 companies. |
| 📬 **Inter-agent messaging** | Built-in mailbox between agents. Delegate, clarify, coordinate. |
| 🗺️ **Missions** | Hierarchical planning with autopilot and validation contracts. |
@@ -169,13 +153,32 @@ Manage tasks without leaving the conversation:
> "Pause FN-012 — I want to add more context first"
The pi extension exposes tools to create tasks, check progress, attach files, and pause or resume automation.
The Fusion extension exposes tools to create tasks, check progress, attach files, and pause or resume automation.
---
## Standalone CLI
Fusion also works as a standalone CLI outside of pi. See [STANDALONE.md](./STANDALONE.md) for installation and usage without the pi extension.
See [STANDALONE.md](./STANDALONE.md) for additional installation and usage options.
## Optional provider: Factory AI via Droid CLI
`@runfusion/fusion` now ships a vendored `@fusion/droid-cli` extension in the published CLI bundle.
To use it:
1. Install the `droid` binary and ensure it is on your `PATH`
2. Authenticate with Droid CLI (`droid auth login`)
3. In Fusion dashboard, go to **Settings → Authentication** and enable **Factory AI — via Droid CLI**
4. Restart Fusion when prompted so the extension is loaded into the runtime
Once enabled, `droid-cli` models appear in Fusion model selection.
## Maintainer note: workspace plugins in published CLI bundles
When CLI or dashboard runtime code imports workspace plugin packages (for example `@fusion-plugin-examples/roadmap`), those imports must stay statically analyzable and covered by `packages/cli/tsup.config.ts` `noExternal` rules so plugin runtime code is inlined into `dist/bin.js`.
Do not introduce dynamic or variable module specifiers for workspace plugin runtime paths in the published execution path. If a workspace plugin is needed for bundled auto-install, stage a bundled plugin entry (`dist/plugins/<id>/bundled.js`) rather than copying raw TypeScript source into `dist/`.
## Full documentation

View File

@@ -16,6 +16,20 @@ If you don't have pi set up yet: `npm i -g @mariozechner/pi-coding-agent && pi`
## Usage
### Optional provider: Factory AI via Droid CLI
The published `@runfusion/fusion` package includes a vendored `@fusion/droid-cli` provider extension.
To enable it:
1. Install the `droid` CLI binary and confirm it is available on `PATH`
2. Authenticate with `droid auth login`
3. Open Fusion dashboard → **Settings → Authentication** and enable **Factory AI — via Droid CLI**
4. Restart Fusion when prompted to apply provider extension loading
After restart, `droid-cli` models are available in model pickers.
### Start the dashboard
Launch the web UI and AI engine:
@@ -170,11 +184,15 @@ dist/
**Important:** When distributing or moving the binary, ensure the `client/` and `runtime/` directories are copied alongside it. Terminal functionality will gracefully degrade (return HTTP 503) if runtime assets are missing — the dashboard will continue to work but terminal sessions won't be available.
**How it works:**
When the dashboard starts from a Bun-compiled binary, it attempts to set up native module resolution so `node-pty` can find its platform-specific `.node` files. This involves:
1. Copying native assets to a temp directory (`/tmp/kb-bunfs-<pid>/kb/prebuilds/<platform>/`)
When the dashboard starts from a Bun-compiled binary, it attempts to set up native module resolution so `@homebridge/node-pty-prebuilt-multiarch` (aliased as `node-pty`) can find its platform-specific `.node` file. This involves:
1. Copying the staged `pty.node` from `runtime/<platform>/` to a temp directory (`/tmp/fn-bunfs-<pid>/fn/prebuilds/<platform>/`)
2. Attempting to create a symlink at `/$bunfs/root` pointing to the temp directory (Unix platforms)
3. If the symlink can't be created (e.g., macOS permissions), pre-loading the native module via `process.dlopen()`
During the build (`bun run build.ts`), native assets are sourced from:
- **Host platform**: `node_modules/node-pty/build/Release/pty.node` (placed by `prebuild-install` at install time)
- **Linux cross-compile targets**: `node_modules/node-pty/prebuilds/linux-<arch>/node.abi<N>.node` (bundled in the fork's npm tarball)
If all resolution methods fail, terminal creation gracefully returns `null`, which the HTTP layer converts to a 503 Service Unavailable response.
**Cross-compilation:** Native assets are staged per-platform during build. When cross-compiling, only the target platform's assets are included. PTY functionality requires running on a platform with matching native assets.

View File

@@ -19,9 +19,11 @@
*/
import { join, dirname } from "node:path";
import { cpSync, mkdirSync, existsSync, rmSync, writeFileSync } from "node:fs";
import { cpSync, mkdirSync, existsSync, rmSync, writeFileSync, readdirSync } from "node:fs";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const cliRoot = dirname(new URL(import.meta.url).pathname);
const cliRoot = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = join(cliRoot, "..", "..");
const outDir = join(cliRoot, "dist");
const dashboardClientSrc = join(workspaceRoot, "packages", "dashboard", "dist", "client");
@@ -30,8 +32,56 @@ const runtimeDir = join(outDir, "runtime");
const entryPoint = join(cliRoot, "src", "bin.ts");
// ── Native module asset paths ─────────────────────────────────────────
// node-pty prebuilds location in pnpm workspace
const nodePtyRoot = join(workspaceRoot, "node_modules", ".pnpm", "node-pty@1.1.0", "node_modules", "node-pty");
// Resolve the @homebridge/node-pty-prebuilt-multiarch install root dynamically.
// The package is aliased as "node-pty" in package.json of @fusion/dashboard.
// We must create the require from the dashboard package location so Node resolves
// node-pty via the dashboard's node_modules (where the alias is installed).
const dashboardPkgDir = join(workspaceRoot, "packages", "dashboard");
const _require = createRequire(join(dashboardPkgDir, "package.json"));
let nodePtyRoot: string;
try {
const pkgJsonPath = _require.resolve("node-pty/package.json");
nodePtyRoot = dirname(pkgJsonPath);
console.log(` node-pty resolved to: ${nodePtyRoot}`);
} catch {
// Fallback: check pnpm's shared node_modules
const fallback = join(workspaceRoot, "node_modules", ".pnpm", "node_modules", "node-pty");
if (existsSync(fallback)) {
nodePtyRoot = fallback;
console.log(` node-pty fallback resolved to: ${nodePtyRoot}`);
} else {
// Last resort: rely on pnpm symlink structure
nodePtyRoot = join(dashboardPkgDir, "node_modules", "node-pty");
console.log(` node-pty last-resort resolved to: ${nodePtyRoot}`);
}
}
/**
* Pick the highest ABI .node file from a prebuilds/<plat-arch>/ directory
* that is <= the host Node.js ABI, returning its full path (or null).
* The fork names files like: node.abi115.node, node.abi115.musl.node
* We want the non-musl version (glibc) for cross-compile targets.
*/
function pickHighestAbiNode(prebuildDir: string, targetAbi: number): string | null {
let files: string[];
try {
files = readdirSync(prebuildDir);
} catch {
return null;
}
// Match node.abi<N>.node (non-musl)
const abiRe = /^node\.abi(\d+)\.node$/;
let best: { abi: number; file: string } | null = null;
for (const f of files) {
const m = abiRe.exec(f);
if (!m) continue;
const abi = parseInt(m[1], 10);
if (abi <= targetAbi && (!best || abi > best.abi)) {
best = { abi, file: f };
}
}
return best ? join(prebuildDir, best.file) : null;
}
// ── Supported cross-compilation targets ───────────────────────────────
const SUPPORTED_TARGETS = [
@@ -155,56 +205,101 @@ function ensureClientAssets(): ClientAssetMode {
// ── Copy native terminal assets for a specific target ─────────────────
/**
* Stage node-pty native assets for the given target platform.
* Stage @homebridge/node-pty-prebuilt-multiarch native assets for the given target.
* Assets are placed in dist/runtime/<platform-arch>/ alongside client/.
*
* For each target, we copy:
* - prebuilds/<platform>-<arch>/pty.node (the native binary)
* - prebuilds/<platform>-<arch>/spawn-helper (Unix helper, if exists)
*
* This ensures the standalone binary can find these assets at runtime
* without relying on the original node_modules structure.
*
* The fork ships two layouts:
* - build/Release/pty.node — placed here by `prebuild-install` at install time
* (present on the HOST platform only)
* - prebuilds/linux-<arch>/node.abi<N>.node — bundled inside the npm tarball
* (present for Linux targets on any host)
*
* Strategy per target:
* - Host (no --target flag): use build/Release/pty.node + build/Release/spawn-helper
* - bun-linux-x64/arm64: use prebuilds/linux-<arch>/node.abi<N>.node (highest ≤ host ABI)
* - bun-darwin-x64/arm64: prebuilds not bundled; warn and skip (cross-compile unsupported)
* - bun-windows-x64: prebuilds not bundled; warn and skip
*/
function copyNativeAssets(target?: BunTarget) {
function copyNativeAssets(target?: BunTarget): boolean {
const prebuildName = target ? targetToPrebuildName(target) : hostPrebuildName();
const srcPrebuildDir = join(nodePtyRoot, "prebuilds", prebuildName);
if (!existsSync(srcPrebuildDir)) {
console.warn(` ⚠ No prebuilds found for ${prebuildName} at ${srcPrebuildDir}`);
return false;
}
const destDir = join(runtimeDir, prebuildName);
try {
// Clean and recreate
// Clean and recreate dest
if (existsSync(destDir)) {
rmSync(destDir, { recursive: true, force: true });
}
mkdirSync(destDir, { recursive: true });
// Copy pty.node (required)
const ptyNodeSrc = join(srcPrebuildDir, "pty.node");
const ptyNodeDest = join(destDir, "pty.node");
if (existsSync(ptyNodeSrc)) {
cpSync(ptyNodeSrc, ptyNodeDest);
console.log(`${destDir}/pty.node`);
// ── Determine source pty.node ─────────────────────────────────────
let ptyNodeSrc: string | null = null;
let spawnHelperSrc: string | null = null;
if (!target) {
// HOST build: use the prebuild-install output in build/Release/
const releaseDir = join(nodePtyRoot, "build", "Release");
const candidate = join(releaseDir, "pty.node");
if (existsSync(candidate)) {
ptyNodeSrc = candidate;
const helper = join(releaseDir, "spawn-helper");
if (existsSync(helper)) spawnHelperSrc = helper;
} else {
// Fallback: maybe prebuilds/<plat-arch>/ exists (older fork layout or manually extracted)
const prebuildDir = join(nodePtyRoot, "prebuilds", prebuildName);
const hostAbi = parseInt(process.versions.modules, 10);
ptyNodeSrc = pickHighestAbiNode(prebuildDir, hostAbi);
if (!ptyNodeSrc && existsSync(join(prebuildDir, "pty.node"))) {
// Some layouts ship pty.node directly (shouldn't happen with this fork, but guard)
ptyNodeSrc = join(prebuildDir, "pty.node");
}
const helper = join(prebuildDir, "spawn-helper");
if (existsSync(helper)) spawnHelperSrc = helper;
}
} else if (target.startsWith("bun-linux-")) {
// Linux cross-compile: use the pre-bundled prebuilds/ in the npm tarball
const [, , arch] = target.split("-") as [string, string, string]; // bun-linux-<arch>
// Bun's arm64 → arm64, but armv7 is "arm" in prebuilds
const linuxArch = arch === "arm64" ? "arm64" : arch === "x64" ? "x64" : arch;
const prebuildDir = join(nodePtyRoot, "prebuilds", `linux-${linuxArch}`);
const hostAbi = parseInt(process.versions.modules, 10);
ptyNodeSrc = pickHighestAbiNode(prebuildDir, hostAbi);
if (ptyNodeSrc) {
const helper = join(prebuildDir, "spawn-helper");
if (existsSync(helper)) spawnHelperSrc = helper;
}
} else {
console.warn(` ⚠ pty.node not found for ${prebuildName}`);
// darwin or windows cross-compile: prebuilds are NOT bundled in the tarball.
// They are only present in build/Release/ after prebuild-install runs on that host.
// Warn and skip rather than erroring — the binary will start but terminal won't work.
console.warn(
` WARNING: Cross-compiling for ${target} from ${hostPrebuildName()}. ` +
`The @homebridge/node-pty-prebuilt-multiarch package only bundles Linux prebuilds in the npm tarball. ` +
`Darwin/Windows prebuilds are downloaded by prebuild-install at install time on the target host. ` +
`Terminal functionality will be unavailable in this cross-compiled build.`
);
return false;
}
// Copy spawn-helper if it exists (Unix platforms)
const spawnHelperSrc = join(srcPrebuildDir, "spawn-helper");
if (existsSync(spawnHelperSrc)) {
const spawnHelperDest = join(destDir, "spawn-helper");
cpSync(spawnHelperSrc, spawnHelperDest);
if (!ptyNodeSrc) {
console.warn(` WARNING: No pty.node found for target ${prebuildName}. Terminal will be unavailable.`);
console.warn(` Looked in: ${join(nodePtyRoot, "build", "Release")} and ${join(nodePtyRoot, "prebuilds", prebuildName)}`);
return false;
}
// Copy pty.node (renamed to stable "pty.node" so native-patch.ts can find it)
const ptyNodeDest = join(destDir, "pty.node");
cpSync(ptyNodeSrc, ptyNodeDest);
console.log(`${destDir}/pty.node (from ${ptyNodeSrc})`);
// Copy spawn-helper if available (Unix platforms)
if (spawnHelperSrc) {
cpSync(spawnHelperSrc, join(destDir, "spawn-helper"));
console.log(`${destDir}/spawn-helper`);
}
return true;
} catch (err) {
console.error(` Failed to copy native assets for ${prebuildName}:`, err);
console.error(` ERROR: Failed to copy native assets for ${prebuildName}:`, err);
return false;
}
}
@@ -244,6 +339,10 @@ function compileBinary(outFile: string, target: string, isCrossCompile: boolean)
target,
"--minify",
"--conditions=source",
// ink imports react-devtools-core dynamically only when DEV=true; mark
// external so Bun's static bundler doesn't try to resolve it at compile.
"--external",
"react-devtools-core",
],
cwd: workspaceRoot,
stdout: "inherit",

View File

@@ -1,6 +1,6 @@
{
"name": "@runfusion/fusion",
"version": "0.4.1",
"version": "0.26.0",
"license": "MIT",
"description": "Fusion CLI: HTTP API server, daemon, dashboard launcher, and task tooling for the Fusion AI coding agent.",
"homepage": "https://github.com/Runfusion/Fusion#readme",
@@ -34,29 +34,38 @@
"dist/**/*.js.map",
"dist/client/**",
"dist/pi-claude-cli/**",
"dist/droid-cli/**",
"dist/plugins/**",
"skill/**",
"README.md"
],
"scripts": {
"dev": "tsx src/bin.ts",
"prebuild": "node ../../scripts/sync-fusion-skill-tools.mjs",
"prepack": "node ./scripts/prepare-publish-manifest.mjs prepack",
"postpack": "node ./scripts/prepare-publish-manifest.mjs postpack",
"build": "tsup",
"build:exe": "bun run build.ts",
"build:exe:all": "bun run build.ts --all",
"typecheck": "tsc --noEmit",
"test": "vitest run --silent=passed-only --reporter=dot",
"test:build-exe": "FUSION_TEST_BUILD_EXE=1 vitest run --config vitest.build-exe.config.ts --silent=passed-only --reporter=dot"
"test:slow-cli": "cross-env FUSION_TEST_SLOW_CLI=1 vitest run src/commands/__tests__/agent-export.test.ts --silent=passed-only --reporter=dot",
"test:extension-integration": "cross-env FUSION_TEST_EXTENSION_INTEGRATION=1 vitest run src/__tests__/extension-integration.test.ts --silent=passed-only --reporter=dot",
"test:build-exe": "cross-env FUSION_TEST_BUILD_EXE=1 vitest run --config vitest.build-exe.config.ts --silent=passed-only --reporter=dot",
"test:pre-release": "pnpm test:slow-cli && pnpm test:build-exe"
},
"dependencies": {
"@mariozechner/pi-ai": "^0.70.0",
"@mariozechner/pi-coding-agent": "^0.70.0",
"@mariozechner/pi-ai": "^0.73.0",
"@mariozechner/pi-coding-agent": "^0.73.0",
"cross-spawn": "^7.0.6",
"dockerode": "^4.0.12",
"express": "^5.1.0",
"ink": "^6.8.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"ioredis": "^5.6.0",
"multer": "^2.1.1",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"react": "^19.0.0"
},
"peerDependencies": {
@@ -80,9 +89,12 @@
"@fusion/dashboard": "workspace:*",
"@fusion/engine": "workspace:*",
"@fusion/pi-claude-cli": "workspace:*",
"@fusion/pi-llama-cpp": "workspace:*",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@vitest/coverage-v8": "^3.1.0",
"cross-env": "^7.0.0",
"esbuild": "^0.25.12",
"ink-testing-library": "^4.0.0",
"tsup": "^8.5.1",
"tsx": "^4.19.0",

View File

@@ -0,0 +1,44 @@
/* global process, URL, console */
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
const mode = process.argv[2];
const packageJsonPath = new URL("../package.json", import.meta.url);
const backupPath = new URL("../package.json.pack-backup", import.meta.url);
if (mode === "prepack") {
if (existsSync(backupPath)) {
// Clean up stale backup from interrupted runs.
unlinkSync(backupPath);
}
const original = readFileSync(packageJsonPath, "utf8");
writeFileSync(backupPath, original, "utf8");
const pkg = JSON.parse(original);
const devDependencies = { ...(pkg.devDependencies || {}) };
delete devDependencies["@fusion/core"];
delete devDependencies["@fusion/dashboard"];
delete devDependencies["@fusion/engine"];
delete devDependencies["@fusion/pi-claude-cli"];
delete devDependencies["@fusion/pi-llama-cpp"];
delete devDependencies["@fusion-plugin-examples/roadmap"];
pkg.devDependencies = devDependencies;
writeFileSync(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf8");
process.exit(0);
}
if (mode === "postpack") {
if (!existsSync(backupPath)) {
process.exit(0);
}
const backup = readFileSync(backupPath, "utf8");
writeFileSync(packageJsonPath, backup, "utf8");
unlinkSync(backupPath);
process.exit(0);
}
console.error("Usage: node ./scripts/prepare-publish-manifest.mjs <prepack|postpack>");
process.exit(1);

View File

@@ -19,19 +19,21 @@ Fusion is an AI-orchestrated task board. You throw in rough ideas; AI specifies,
**Missions** provide hierarchical planning above tasks:
Mission → Milestone → Slice → Feature → Task
**Available tools:** Fusion registers tools via the pi extension (prefixed `fn_*`). No CLI commands or Bash needed — use the registered tools directly.
**Available tools:** Fusion registers tools (prefixed `fn_*`). No CLI commands or Bash needed — use the registered tools directly.
**Naming boundary:** The published pi-extension skill surface uses `fn_*` tool names (for example `fn_task_create`, `fn_mission_create`). Engine runtime sessions also inject additional `fn_*` tools (for example `fn_review_spec`, `fn_review_step`, `fn_spawn_agent`) that are not user-invokable extension tools.
**Naming boundary:** The published skill surface uses `fn_*` tool names (for example `fn_task_create`, `fn_mission_create`). Engine runtime sessions also inject additional `fn_*` tools (for example `fn_review_spec`, `fn_review_step`, `fn_spawn_agent`) that are not part of the published skill surface.
**Engine runtime tools:** Triage/executor/merger/heartbeat sessions include auto-injected engine tools that do not come from the pi extension registration list. See `references/engine-tools.md` for the canonical runtime-only catalog and usage boundaries.
**Engine runtime tools:** Triage/executor/merger/heartbeat sessions include auto-injected engine tools that are not part of the published skill surface. See `references/engine-tools.md` for the canonical runtime-only catalog and usage boundaries.
**Tool categories:**
<!-- BEGIN: tool-categories (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->
- **Task tools** — `fn_task_create`, `fn_task_update`, `fn_task_list`, `fn_task_show`, `fn_task_attach`, `fn_task_pause`, `fn_task_unpause`, `fn_task_retry`, `fn_task_duplicate`, `fn_task_refine`, `fn_task_archive`, `fn_task_unarchive`, `fn_task_delete`, `fn_task_plan`
- **GitHub tools** — `fn_task_import_github`, `fn_task_import_github_issue`, `fn_task_browse_github_issues`
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_delete`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task`
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`, `fn_agent_create`, `fn_agent_delete`, `fn_list_agents`, `fn_delegate_task`, `fn_agent_show`, `fn_agent_org_chart`
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
- **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show`
- **Other tools** — `fn_web_fetch`, `fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`
<!-- END: tool-categories -->
- **Dashboard** — Use `/fn` command to start/stop the dashboard
@@ -83,7 +85,7 @@ Use `fn_mission_create` for high-level objectives, then add milestones, slices,
<known_limitations>
These operations are **not available** via pi extension tools and require the dashboard or CLI:
These operations are **not available** via extension tools and require the dashboard or CLI:
- **Moving tasks between columns** — No tool for column moves (handled by the AI engine)
- **Workflow steps** — Creating/managing workflow step definitions requires the dashboard
@@ -101,7 +103,7 @@ For these operations, guide the user to the dashboard (`/fn`) or CLI commands do
|-----------|-------------|
| references/cli-commands.md | Full CLI command reference |
| references/task-structure.md | Task file structure and storage |
| references/extension-tools.md | All pi extension tools with parameters |
| references/extension-tools.md | All extension tools with parameters |
| references/best-practices.md | Tips for effective Fusion usage |
| references/fusion-capabilities.md | Complete feature catalog |
| references/engine-tools.md | Engine session-scoped runtime tools (not extension-invokable) |

View File

@@ -89,3 +89,36 @@ For work larger than L, use missions to break it into phases.
2. `fn_task_import_github_issue` for high-priority issues
3. Tasks enter triage and get AI-specified
4. Monitor board as AI works through them
## Agent Management and Delegation
Agents are AI workers in the Fusion system. Each agent has a role, state, and position in an organizational hierarchy. Use the agent management tools to discover, inspect, delegate to, and manage agents.
**Discovering available agents:**
1. `fn_list_agents` — see all agents, optionally filter by role or state
2. `fn_agent_show` — get full details about a specific agent (including hierarchy)
3. `fn_delegate_task` — create and assign a task to the chosen agent
**Checking team structure before delegation:**
1. `fn_agent_org_chart` — visualize the full org tree
2. `fn_agent_show` — inspect a specific agent's capabilities and reports
3. `fn_delegate_task` — assign work to the appropriate agent
**Delegation patterns:**
- **Delegate to reports** — an agent delegates to agents that report to it (downward delegation)
- **Delegate to peers** — an agent delegates to another agent at the same level (lateral delegation)
- **Delegate to other teams** — use `fn_agent_org_chart` to understand cross-team structure
- Always verify the target agent is not ephemeral/runtime before delegating
**Recovery — re-delegating stalled work:**
1. `fn_agent_stop` — pause the stalled agent
2. `fn_list_agents` — find an available agent to take over
3. `fn_delegate_task` — create a new task for the replacement agent
4. The original task can be refined or the new task can depend on it
**Agent lifecycle states:**
- `idle` — agent is available for work
- `active` — agent is running and available for heartbeat cycles
- `running` — agent is currently executing a task
- `paused` — agent has been stopped (use `fn_agent_start` to resume)
- `error` — agent encountered an error

View File

@@ -40,6 +40,20 @@ fn task logs FN-001 --limit 50 # Limit log lines
fn task logs FN-001 --type tool # Filter by log type
```
## Research
```bash
fn research create --query "question" # Create research run
fn research create --query "question" --wait # Wait for completion
fn research list # List runs
fn research ls --status failed --limit 20 # Filter by status
fn research show RR-001 # Show one run
fn research export RR-001 --format json # Export to JSON
fn research export RR-001 --output ./run.md # Export to specific path
fn research cancel RR-001 # Cancel active run
fn research retry RR-001 # Retry failed/cancelled run
```
## Mission Management
```bash

View File

@@ -1,10 +1,11 @@
# Engine Session-Scoped Tools
These tools are **not** part of the pi extension's user-invokable `extension.ts` surface. They are injected by the engine at runtime for specific agent session types.
These tools are **not** part of the user-invokable extension surface. They are injected by the engine at runtime for specific agent session types.
- Source files: `packages/engine/src/agent-tools.ts`, `triage.ts`, `executor.ts`, `merger.ts`, `agent-heartbeat.ts`
- Availability: only when the engine creates a session for the matching agent role
- Important: do not tell users to call these directly from the generic pi extension tool list
- Runtime contract: engine sessions now forward requested skill names (`skillSelection.requestedSkillNames`) into the generic runtime `skills` field so non-pi runtimes can still receive Fusion skill intent.
- Important: do not tell users to call these directly from the generic extension tool list
## Shared runtime tools (`agent-tools.ts`)
@@ -14,14 +15,25 @@ These tools are **not** part of the pi extension's user-invokable `extension.ts`
| `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) |
| `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) |
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
| `fn_memory_search` | triage, executor, heartbeat | Search project/agent memory snippets | `query` (string), `limit?` (number) |
| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window | `path` (string), `startLine?` (number), `lineCount?` (number) |
| `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append long-term/daily memory notes | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) |
| `fn_reflect_on_performance` | executor | Generate reflection insights from prior runs | `focus_area?` (string) |
| `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) |
| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) |
| `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) |
| `fn_web_fetch` | executor, step-session, reviewer, merger, triage, heartbeat | Lightweight HTTP fetch with HTML→text extraction, timeout/size caps, and SSRF guard (no JS rendering) | `url` (string), `prompt?` (string), `timeoutMs?` (number), `maxBytes?` (number) |
| `fn_research_run` | triage, executor | Start a bounded research run (optionally wait for completion) and return structured findings metadata | `query` (string), `wait_for_completion?` (boolean), `max_wait_ms?` (number) |
| `fn_research_list` | triage, executor | List recent research runs with status/summary metadata | `status?` (`pending` \| `running` \| `completed` \| `failed` \| `cancelled`), `limit?` (number) |
| `fn_research_get` | triage, executor | Read one research run's structured findings/citations payload | `id` (string) |
| `fn_research_cancel` | triage, executor | Cancel an active research run via orchestrator cancellation path | `id` (string) |
| `fn_read_evaluations` | heartbeat | Read the current agent's rating summaries, recent comments, and reflections | none |
| `fn_update_identity` | heartbeat | Update the current agent's own `soul`, `instructionsText`, or `memory` fields | `soul?` (string), `instructionsText?` (string), `memory?` (string) |
| `fn_reflect_on_performance` | executor, heartbeat (when reflection service enabled) | Generate reflection insights from prior runs | `focus_area?` (string) |
| `fn_list_agents` | triage, executor, heartbeat | List agents (optionally filtered) | `role?` (string), `state?` (string), `includeEphemeral?` (boolean) |
| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]) |
| `fn_send_message` | executor, heartbeat | Send inbox messages to agents/users | `to_id` (string), `content` (string), `type?` (`agent-to-agent` \| `agent-to-user`), `reply_to_message_id?` (string) |
| `fn_read_messages` | executor, heartbeat | Read inbox messages | `unread_only?` (boolean), `limit?` (number) |
| `fn_delegate_task` | triage, executor, heartbeat | Create and assign a new task to a specific agent | `agent_id` (string), `description` (string), `dependencies?` (string[]), `override?` (boolean) |
| `fn_get_agent_config` | executor, heartbeat | Read full config for a direct-report agent | `agent_id` (string) |
| `fn_update_agent_config` | executor, heartbeat | Update config fields for a direct-report, non-ephemeral agent | `agent_id` (string), optional: `soul`, `instructions_text`, `instructions_path`, `heartbeat_procedure_path`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` |
| `fn_agent_create` | executor, heartbeat | Create a non-ephemeral direct-report agent | `name` (string), `role` (string), optional: `soul`, `instructions_text`, `instructions_path`, `reportsTo`, `heartbeat_interval_ms`, `heartbeat_timeout_ms`, `max_concurrent_runs`, `message_response_mode` |
| `fn_agent_delete` | executor, heartbeat | Delete a non-ephemeral direct-report agent | `agent_id` (string), optional: `force` (boolean), `reassign_to` (string) |
| `fn_send_message` | executor, step-session, heartbeat | Send inbox messages to agents/users | `to_id` (string), `content` (string), `type?` (`agent-to-agent` \| `agent-to-user`), `reply_to_message_id?` (string) |
| `fn_read_messages` | executor, step-session, heartbeat | Read inbox messages | `unread_only?` (boolean), `limit?` (number) |
## Triage-only runtime tools (`triage.ts`)
@@ -33,6 +45,8 @@ These tools are **not** part of the pi extension's user-invokable `extension.ts`
## Executor-only runtime tools (`executor.ts`)
Note: step-session execution (`step-session-executor.ts`) reuses executor coordination tools (`fn_send_message`, `fn_read_messages`, `fn_list_agents`, `fn_delegate_task`, task-document tools, and memory tools) so spawned/session-sliced execution keeps parity with main executor runs.
| Tool | Purpose | Parameters |
|---|---|---|
| `fn_task_update` | Update a spec step status (`pending`/`in-progress`/`done`/`skipped`) | `step` (number), `status` (enum) |

View File

@@ -1,6 +1,6 @@
# Fusion Pi Extension Tools
# Fusion Extension Tools
All tools are registered via the pi extension. They are available in any pi agent session when the Fusion extension is installed.
All tools are registered via the Fusion extension. They are available in any agent session when Fusion is configured.
> Naming contract: all externally exposed Fusion extension tools are `fn_*` (for example `fn_task_create`). Engine runtime sessions also inject additional `fn_*` tools (for example `fn_review_step`, `fn_spawn_agent`, `fn_task_document_write`) that are separate from this extension surface and documented in `engine-tools.md`.
@@ -29,6 +29,7 @@ Update fields on an existing task. Supports modifying the title, description, de
| `description` | string | — | New task description |
| `depends` | array | — | New dependency list — replaces existing dependencies (e.g. ['FN-001', 'FN-002']) |
| `agentId` | union | — | Agent ID to assign this task to, or null to clear (e.g. 'agent-abc123') |
| `nodeId` | union | — | Node ID override for this task, or null to clear |
### fn_task_list
@@ -74,7 +75,7 @@ Unpause a task — resumes automated agent and scheduler interaction.
### fn_task_retry
Retry a failed task — clears the error state and moves it back to the todo column for re-execution.
Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
@@ -262,6 +263,71 @@ Start a stopped agent — resumes its execution. Transitions the agent from paus
|-----------|------|----------|-------------|
| `id` | string | ✓ | Agent ID to start (e.g., agent-abc123) |
### fn_agent_create
Create a new non-ephemeral agent.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `name` | string | ✓ | Agent name |
| `role` | union | ✓ | Agent role/capability |
| `soul` | string | — | Agent personality/identity text |
| `instructions_text` | string | — | Inline custom instructions |
| `instructions_path` | string | — | Path to instructions markdown |
| `reportsTo` | string | — | Manager agent ID |
| `heartbeat_interval_ms` | number | — | |
| `heartbeat_timeout_ms` | number | — | |
| `max_concurrent_runs` | number | — | |
| `message_response_mode` | union | — | |
### fn_agent_delete
Delete a non-ephemeral agent.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agent_id` | string | ✓ | Agent ID to delete |
| `force` | boolean | — | Force delete when holding checkout |
| `reassign_to` | string | — | Optional replacement agent for assigned tasks |
### fn_list_agents
List all available agents in the system. Shows each agent's name, role, state, personality (soul), and current assignment. Use this to discover which agents exist and what they specialize in before delegating work.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `role` | string | — | Filter by agent role/capability (e.g., 'executor', 'reviewer', 'qa') |
| `state` | string | — | Filter by agent state (e.g., 'idle', 'active', 'running') |
| `includeEphemeral` | boolean | — | Include ephemeral/runtime agents (default: false) |
### fn_delegate_task
Create a new task and assign it to a specific agent for execution. The task goes to 'todo' and will be picked up by the target agent on their next heartbeat cycle. Use fn_list_agents first to find available agents and their capabilities.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agent_id` | string | ✓ | The agent ID to delegate work to |
| `description` | string | ✓ | What needs to be done |
| `dependencies` | array | — | Task IDs this new task depends on (e.g. [\"KB-001\"] |
| `override` | boolean | — | Set true to bypass executor-role assignment policy |
### fn_agent_show
Show detailed information about a single agent, including their role, state, position in the org hierarchy (reports-to, direct reports), skills, and current assignment.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Agent ID or resolvable name |
### fn_agent_org_chart
Show the organizational tree of agents, displaying the role hierarchy. Optionally filter to a subtree rooted at a specific agent.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `root_agent_id` | string | — | If provided, show only the subtree rooted at this agent |
| `include_ephemeral` | boolean | — | Include ephemeral/runtime agents (default: false) |
## Skills Tools
### fn_skills_search
@@ -282,12 +348,109 @@ Install an agent skill from skills.sh into the current project. Downloads skill
| `source` | string | ✓ | GitHub source in owner/repo format (e.g., 'firebase/agent-skills') |
| `skill` | string | — | Specific skill name to install (e.g., 'firebase-basics'). Omit to install all skills from the source. |
## Insight Tools
### fn_insight_list
List persisted project insights with optional category/status filters.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `category` | string(enum) | — | Filter by insight category |
| `status` | string(enum) | — | Filter by insight status |
| `runId` | string | — | Filter to insights linked to a specific run ID |
| `limit` | number | — | Max insights to return |
| `offset` | number | — | Number of rows to skip |
### fn_insight_show
Show a single persisted insight by ID.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Insight ID (e.g. INS-XXXXX) |
### fn_insight_run_list
List recent insight-generation runs with optional status/trigger filters.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string(enum) | — | Filter by run status |
| `trigger` | string(enum) | — | Filter by run trigger |
| `limit` | number | — | Max runs to return |
| `offset` | number | — | Number of runs to skip |
### fn_insight_run_show
Show a single insight-generation run by ID.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Insight run ID (e.g. INSR-XXXXX) |
## Other Tools
### fn_web_fetch
Lightweight URL fetch (no JS rendering). Use agent-browser skill for JS-heavy pages. URL to fetch (http/https) Optional extraction hint for downstream summarization Timeout in milliseconds (default: 30000) Max bytes to return (default: 512000)
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `url` | string | ✓ | URL to fetch (http/https) |
| `prompt` | string | — | Optional extraction hint for downstream summarization |
| `timeoutMs` | number | — | Timeout in milliseconds (default: 30000) |
| `maxBytes` | number | — | Max bytes to return (default: 512000) |
### fn_research_run
Start a bounded research run and optionally wait for findings.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | string | ✓ | Research query or question |
| `wait_for_completion` | boolean | — | Wait for the run to complete before returning (default: false) |
| `max_wait_ms` | number | — | Max wait time when wait_for_completion=true (default: 90000, capped by settings) |
### fn_research_list
List recent research runs.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string(enum) | — | Filter by run status |
| `limit` | number | — | Max runs to return (default: 10) |
### fn_research_get
Get one research run and structured findings.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Research run ID |
### fn_research_cancel
Cancel an in-flight research run. Terminal runs return INVALID_TRANSITION.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Research run ID |
### fn_research_retry
Retry a failed research run when lifecycle marks it retryable.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Research run ID |
<!-- END: extension-tools -->
## Dashboard Command
### /fn
Start or stop the Fusion dashboard from within a pi session.
Start or stop the Fusion dashboard from within an agent session.
| Command | Description |
|---------|-------------|

View File

@@ -5,7 +5,7 @@
Fusion is an AI-orchestrated task board. Tasks flow through columns:
Triage → Todo → In Progress → In Review → Done → Archived
## Pi Extension Tools (Available to Agents)
## Extension Tools (Available to Agents)
All skill/extension tool invocations in this catalog use the public `fn_*` namespace. Engine runtime sessions also have additional runtime-only `fn_*` tools that are intentionally not listed here (see `references/engine-tools.md`).
@@ -19,7 +19,7 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_task_attach` | Attach a file to a task. Supports images (png, jpg, gif, webp) and text files (txt, log, json, yaml, yml, toml, csv, xml). |
| `fn_task_pause` | Pause a task — stops all automated agent and scheduler interaction for this task. |
| `fn_task_unpause` | Unpause a task — resumes automated agent and scheduler interaction. |
| `fn_task_retry` | Retry a failed task — clears the error state and moves it back to the todo column for re-execution. |
| `fn_task_retry` | Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry. |
| `fn_task_duplicate` | Duplicate an existing task, creating a fresh copy in planning. Copies the title and description but resets all execution state. The AI planning agent will replan the new task. |
| `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. |
| `fn_task_archive` | Archive a done task (move from done → archived). Archived tasks are preserved for historical reference but moved out of the main board view. |
@@ -29,6 +29,16 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. |
| `fn_task_browse_github_issues` | List open GitHub issues from a repository to browse before importing. Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue to import specific issues by number. |
| `fn_task_plan` | Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. |
| `fn_web_fetch` | Lightweight URL fetch (no JS rendering). Use agent-browser skill for JS-heavy pages. URL to fetch (http/https) Optional extraction hint for downstream summarization Timeout in milliseconds (default: 30000) Max bytes to return (default: 512000) |
| `fn_research_run` | Start a bounded research run and optionally wait for findings. |
| `fn_research_list` | List recent research runs. |
| `fn_research_get` | Get one research run and structured findings. |
| `fn_research_cancel` | Cancel an in-flight research run. Terminal runs return INVALID_TRANSITION. |
| `fn_research_retry` | Retry a failed research run when lifecycle marks it retryable. |
| `fn_insight_list` | List persisted project insights with optional category/status filters. |
| `fn_insight_show` | Show a single persisted insight by ID. |
| `fn_insight_run_list` | List recent insight-generation runs with optional status/trigger filters. |
| `fn_insight_run_show` | Show a single insight-generation run by ID. |
| `fn_mission_create` | Create a new mission — a high-level objective that can span multiple milestones. Missions contain milestones that break down work into phases. |
| `fn_mission_list` | List all missions with their current status. |
| `fn_mission_show` | Show mission details with full hierarchy: milestones → slices → features. |
@@ -40,6 +50,12 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_feature_link_task` | Link a feature to a fn task for implementation. Updates the feature status to 'triaged' and associates it with the task. |
| `fn_agent_stop` | Stop a running agent — pauses its execution. Transitions the agent from running/active to paused state. |
| `fn_agent_start` | Start a stopped agent — resumes its execution. Transitions the agent from paused to active state. |
| `fn_agent_create` | Create a new non-ephemeral agent. |
| `fn_agent_delete` | Delete a non-ephemeral agent. |
| `fn_list_agents` | List all available agents in the system. Shows each agent's name, role, state, personality (soul), and current assignment. Use this to discover which agents exist and what they specialize in before delegating work. |
| `fn_delegate_task` | Create a new task and assign it to a specific agent for execution. The task goes to 'todo' and will be picked up by the target agent on their next heartbeat cycle. Use fn_list_agents first to find available agents and their capabilities. |
| `fn_agent_show` | Show detailed information about a single agent, including their role, state, position in the org hierarchy (reports-to, direct reports), skills, and current assignment. |
| `fn_agent_org_chart` | Show the organizational tree of agents, displaying the role hierarchy. Optionally filter to a subtree rooted at a specific agent. |
| `fn_skills_search` | Search the skills.sh directory for agent skills. Returns matching skills with names, sources (owner/repo), install counts, and install commands. Use fn_skills_install to install a selected skill. |
| `fn_skills_install` | Install an agent skill from skills.sh into the current project. Downloads skill files into the project's skill directories (.fusion/skills/, legacy .pi/skills/, .agents/skills/). The skill becomes available to AI agents in subsequent sessions. |
<!-- END: fusion-capabilities-tool-table -->

View File

@@ -30,7 +30,7 @@
## Key Takeaways for Fusion Skill
1. **Use router pattern** — Fusion has multiple distinct workflows (task management, lifecycle, specs, dashboard/CLI)
2. **No `allowed-tools` needed** — Fusion tools are registered via pi extension, not Bash CLI
2. **No `allowed-tools` needed** — Fusion tools are registered via the extension, not Bash CLI
3. **Inline essential concepts** — Task columns, workflow overview in SKILL.md
4. **Progressive disclosure** — SKILL.md routes to workflows, workflows reference detailed docs
5. **Pure XML structure** — No markdown headings (#, ##, ###) in body

View File

@@ -3,14 +3,14 @@
</required_reading>
<objective>
Guide the agent through using the Fusion dashboard and CLI for operations not available via pi extension tools.
Guide the agent through using the Fusion dashboard and CLI for operations not available via extension tools.
</objective>
<process>
**Starting the dashboard:**
Use the `/fn` command (registered by the pi extension):
Use the `/fn` command (registered by the Fusion extension):
- `/fn` or `/fn 4040` — Start dashboard + AI engine on specified port (default 4040)
- `/fn stop` — Stop the dashboard
- `/fn status` — Check if dashboard is running
@@ -28,7 +28,7 @@ The dashboard provides:
**Operations that require CLI or dashboard:**
These cannot be done with pi extension tools:
These cannot be done with extension tools:
| Operation | CLI Command | Dashboard |
|-----------|-------------|-----------|

View File

@@ -4,7 +4,7 @@
</required_reading>
<objective>
Guide the agent through creating, viewing, and managing tasks on the Fusion board using pi extension tools.
Guide the agent through creating, viewing, and managing tasks on the Fusion board using extension tools.
Use only the public `fn_*` extension tools in this workflow. Do not substitute internal engine runtime tools like `task_create`, `task_update`, `task_log`, or `task_done`.
</objective>

View File

@@ -32,6 +32,8 @@ const commandMocks = vi.hoisted(() => ({
runTaskComment: vi.fn(),
runTaskComments: vi.fn(),
runTaskSteer: vi.fn(),
runTaskSetNode: vi.fn(),
runTaskClearNode: vi.fn(),
runTaskPrCreate: vi.fn(),
runSettingsShow: vi.fn(),
@@ -89,7 +91,19 @@ const commandMocks = vi.hoisted(() => ({
runPluginUninstall: vi.fn(),
runPluginEnable: vi.fn(),
runPluginDisable: vi.fn(),
runPluginSetupStatus: vi.fn(),
runPluginSetup: vi.fn(),
runPluginAvailable: vi.fn(),
runPluginSettings: vi.fn(),
runPluginRescan: vi.fn(),
runPluginCreate: vi.fn(),
runResearchCreate: vi.fn(),
runResearchList: vi.fn(),
runResearchShow: vi.fn(),
runResearchExport: vi.fn(),
runResearchCancel: vi.fn(),
runResearchRetry: vi.fn(),
}));
vi.mock("../commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
@@ -122,6 +136,8 @@ vi.mock("../commands/task.js", () => ({
runTaskComment: commandMocks.runTaskComment,
runTaskComments: commandMocks.runTaskComments,
runTaskSteer: commandMocks.runTaskSteer,
runTaskSetNode: commandMocks.runTaskSetNode,
runTaskClearNode: commandMocks.runTaskClearNode,
runTaskPrCreate: commandMocks.runTaskPrCreate,
}));
@@ -200,12 +216,26 @@ vi.mock("../commands/plugin.js", () => ({
runPluginUninstall: commandMocks.runPluginUninstall,
runPluginEnable: commandMocks.runPluginEnable,
runPluginDisable: commandMocks.runPluginDisable,
runPluginSetupStatus: commandMocks.runPluginSetupStatus,
runPluginSetup: commandMocks.runPluginSetup,
runPluginAvailable: commandMocks.runPluginAvailable,
runPluginSettings: commandMocks.runPluginSettings,
runPluginRescan: commandMocks.runPluginRescan,
}));
vi.mock("../commands/plugin-scaffold.js", () => ({
runPluginCreate: commandMocks.runPluginCreate,
}));
vi.mock("../commands/research.js", () => ({
runResearchCreate: commandMocks.runResearchCreate,
runResearchList: commandMocks.runResearchList,
runResearchShow: commandMocks.runResearchShow,
runResearchExport: commandMocks.runResearchExport,
runResearchCancel: commandMocks.runResearchCancel,
runResearchRetry: commandMocks.runResearchRetry,
}));
const originalArgv = process.argv;
const originalExit = process.exit;
const originalPiPackageDir = process.env.PI_PACKAGE_DIR;
@@ -395,16 +425,31 @@ describe("bin command routing and fallbacks", () => {
expect(commandMocks.runPluginInstall).toHaveBeenNthCalledWith(1, "fusion-plugin-hermes-runtime", {
projectName: "demo",
aiScan: false,
});
expect(commandMocks.runPluginInstall).toHaveBeenNthCalledWith(2, "fusion-plugin-hermes-runtime", {
projectName: "demo",
aiScan: false,
});
});
it("routes plugin available and settings", async () => {
await runBin(["plugin", "available"]);
await runBin(["plugin", "settings", "fusion-plugin-hermes-runtime", "enabled", "true", "-P", "demo"]);
expect(commandMocks.runPluginAvailable).toHaveBeenCalledWith();
expect(commandMocks.runPluginSettings).toHaveBeenCalledWith(
"fusion-plugin-hermes-runtime",
"enabled",
"true",
{ projectName: "demo" },
);
});
it("errors when plugin install source is missing", async () => {
await expect(runBin(["plugin", "add"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(
"Usage: fn plugin install <path-or-package> (alias: fn plugin add <path-or-package>)",
"Usage: fn plugin install <path-or-package> [--ai-scan] (alias: fn plugin add <path-or-package>)",
);
});
@@ -412,7 +457,7 @@ describe("bin command routing and fallbacks", () => {
await expect(runBin(["plugin", "oops"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops");
expect(logSpy).toHaveBeenCalledWith(
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | create",
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create",
);
});
@@ -500,6 +545,66 @@ describe("bin command routing and fallbacks", () => {
});
});
it("routes research create with options", async () => {
await runBin(["research", "create", "--query", "hello world", "--wait", "--max-wait-ms", "1200", "--json", "--project", "alpha"]);
expect(commandMocks.runResearchCreate).toHaveBeenCalledWith({
query: "hello world",
waitForCompletion: true,
maxWaitMs: 1200,
json: true,
projectName: "alpha",
});
});
it("supports positional research query and rejects missing query", async () => {
await runBin(["research", "create", "hello", "world"]);
expect(commandMocks.runResearchCreate).toHaveBeenCalledWith({
query: "hello world",
waitForCompletion: false,
maxWaitMs: undefined,
json: false,
projectName: undefined,
});
await expect(runBin(["research", "create", "--wait"]))
.rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Usage: fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]");
});
it("routes research export", async () => {
await runBin(["research", "export", "RR-001", "--format", "json", "--output", "./out.json"]);
expect(commandMocks.runResearchExport).toHaveBeenCalledWith({
runId: "RR-001",
format: "json",
output: "./out.json",
json: false,
projectName: undefined,
});
});
it("routes research list/show/cancel/retry", async () => {
await runBin(["research", "ls", "--status", "failed", "--limit", "5", "--json"]);
await runBin(["research", "show", "RR-001", "--json"]);
await runBin(["research", "cancel", "RR-001"]);
await runBin(["research", "retry", "RR-002", "--json"]);
expect(commandMocks.runResearchList).toHaveBeenCalledWith({
status: "failed",
limit: 5,
json: true,
projectName: undefined,
});
expect(commandMocks.runResearchShow).toHaveBeenCalledWith("RR-001", { json: true, projectName: undefined });
expect(commandMocks.runResearchCancel).toHaveBeenCalledWith("RR-001", { json: false, projectName: undefined });
expect(commandMocks.runResearchRetry).toHaveBeenCalledWith("RR-002", { json: true, projectName: undefined });
});
it("shows research subcommand guidance on unknown subcommand", async () => {
await expect(runBin(["research", "oops"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: research oops");
expect(logSpy).toHaveBeenCalledWith("Try: fn research create | list | show | export | cancel | retry");
});
it("routes desktop flags to runDesktop", async () => {
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
expect(commandMocks.runDesktop).toHaveBeenCalledWith({

View File

@@ -41,7 +41,9 @@ function nativeTarget(): string | null {
// Cross-compiling native binaries pegs CPU for ~60s per target. Skip by
// default locally; opt in with FUSION_TEST_BUILD_EXE=1 or run on CI.
const SHOULD_RUN_BUILD_EXE =
Boolean(process.env.FUSION_TEST_BUILD_EXE) || Boolean(process.env.CI);
process.env.FUSION_TEST_BUILD_EXE === "1" ||
process.env.FUSION_TEST_BUILD_EXE === "true" ||
Boolean(process.env.CI);
describe.skipIf(!SHOULD_RUN_BUILD_EXE)("build-exe-cross: single target", () => {
beforeAll(() => {

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = {
existingPaths: new Set<string>(),
indexHtml: "",
};
vi.mock("node:fs", () => ({
existsSync: (path: string) => state.existingPaths.has(path),
readFileSync: () => state.indexHtml,
execFileSync: vi.fn(),
execSync: vi.fn(),
}));
import {
bundlePath,
clientIndexPath,
dashboardClientStubMarker,
droidPluginMcpServerPath,
hasBuiltDashboardAssets,
openclawMcpSchemaServerPath,
} from "./bundle-output-helpers";
const cursorPluginManifestPath = bundlePath.replace(
"dist/bin.js",
"dist/plugins/fusion-plugin-cursor-runtime/manifest.json",
);
const roadmapPluginBundledPath = bundlePath.replace(
"dist/bin.js",
"dist/plugins/fusion-plugin-roadmap/bundled.js",
);
describe("hasBuiltDashboardAssets", () => {
beforeEach(() => {
state.existingPaths.clear();
state.indexHtml = "<html><body><script src=\"assets/app.js\"></script></body></html>";
});
it("returns false when openclaw mcp-schema-server.cjs is missing", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
expect(hasBuiltDashboardAssets()).toBe(false);
});
it("returns false when droid mcp-schema-server.cjs is missing", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
state.existingPaths.add(openclawMcpSchemaServerPath);
expect(hasBuiltDashboardAssets()).toBe(false);
});
it("returns true when all required assets exist and dashboard stub marker is absent", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
state.existingPaths.add(openclawMcpSchemaServerPath);
state.existingPaths.add(droidPluginMcpServerPath);
expect(hasBuiltDashboardAssets()).toBe(true);
});
it("returns false when dashboard client index contains stub marker", () => {
state.existingPaths.add(bundlePath);
state.existingPaths.add(clientIndexPath);
state.existingPaths.add(cursorPluginManifestPath);
state.existingPaths.add(roadmapPluginBundledPath);
state.existingPaths.add(openclawMcpSchemaServerPath);
state.existingPaths.add(droidPluginMcpServerPath);
state.indexHtml = dashboardClientStubMarker;
expect(hasBuiltDashboardAssets()).toBe(false);
});
});

View File

@@ -6,12 +6,28 @@ export const cliRoot = join(__dirname, "..", "..");
export const workspaceRoot = join(cliRoot, "..", "..");
export const bundlePath = join(cliRoot, "dist", "bin.js");
export const clientIndexPath = join(cliRoot, "dist", "client", "index.html");
const cursorPluginManifestPath = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime", "manifest.json");
const roadmapPluginBundledPath = join(cliRoot, "dist", "plugins", "fusion-plugin-roadmap", "bundled.js");
export const openclawMcpSchemaServerPath = join(
cliRoot,
"dist",
"plugins",
"fusion-plugin-openclaw-runtime",
"mcp-schema-server.cjs",
);
export const droidPluginMcpServerPath = join(
cliRoot,
"dist",
"plugins",
"fusion-plugin-droid-runtime",
"mcp-schema-server.cjs",
);
export const dashboardClientStubMarker = "Dashboard assets not built";
function runBuildCommand(command: string, cwd: string) {
const npmExecPath = process.env.npm_execpath;
if (npmExecPath) {
if (npmExecPath && existsSync(npmExecPath)) {
execFileSync(process.execPath, [npmExecPath, ...command.split(" ")], {
cwd,
stdio: "pipe",
@@ -27,8 +43,15 @@ function runBuildCommand(command: string, cwd: string) {
});
}
function hasBuiltDashboardAssets(): boolean {
if (!existsSync(bundlePath) || !existsSync(clientIndexPath)) {
export function hasBuiltDashboardAssets(): boolean {
if (
!existsSync(bundlePath) ||
!existsSync(clientIndexPath) ||
!existsSync(cursorPluginManifestPath) ||
!existsSync(roadmapPluginBundledPath) ||
!existsSync(openclawMcpSchemaServerPath) ||
!existsSync(droidPluginMcpServerPath)
) {
return false;
}
@@ -44,8 +67,18 @@ export function buildCliWithRealDashboardAssets() {
return;
}
runBuildCommand(`node ${join(workspaceRoot, "scripts", "ensure-test-artifacts.mjs")}`, workspaceRoot);
runBuildCommand("pnpm --filter @fusion/dashboard build:client", workspaceRoot);
runBuildCommand("pnpm build", cliRoot);
if (hasBuiltDashboardAssets()) {
return;
}
// Fallback for environments where build:client alone does not refresh the
// dashboard dist/client bundle consumed by the CLI copy step.
runBuildCommand("pnpm --filter @fusion/dashboard build", workspaceRoot);
runBuildCommand("pnpm build", cliRoot);
}
export function readClientIndexHtml() {

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import {
buildCliWithRealDashboardAssets,
bundlePath,
@@ -9,6 +10,8 @@ import {
dashboardClientStubMarker,
readClientIndexHtml,
} from "./bundle-output-helpers";
import { resolveClaudeCliExtensionFromModuleUrl } from "../commands/claude-cli-extension";
import { resolveDroidCliExtensionFromModuleUrl } from "../commands/droid-cli-extension";
const tsupConfigPath = join(cliRoot, "tsup.config.ts");
@@ -34,6 +37,17 @@ describe("CLI bundle output", () => {
expect(content).not.toMatch(/from\s+["']@fusion\/core["']/);
expect(content).not.toMatch(/from\s+["']@fusion\/dashboard["']/);
expect(content).not.toMatch(/from\s+["']@fusion\/engine["']/);
expect(content).not.toMatch(/from\s+["']@fusion-plugin-examples\/roadmap["']/);
expect(content).not.toContain('"@fusion/core"');
expect(content).not.toContain('"@fusion/dashboard"');
expect(content).not.toContain('"@fusion/engine"');
expect(content).not.toContain('"@fusion-plugin-examples/roadmap"');
});
it("does not contain runtime memory-backend side-load imports", () => {
const content = readFileSync(bundlePath, "utf-8");
expect(content).not.toMatch(/await\s+import\(\s*["']\.\/memory-backend\.js["']\s*\)/);
expect(content).not.toMatch(/await\s+import\(\s*["']\.\.\/memory-backend\.js["']\s*\)/);
});
it("contains inlined workspace code", () => {
@@ -68,13 +82,29 @@ describe("CLI bundle output", () => {
expect(tsupConfig).toContain("cpSync(dashboardClientSrc, dashboardClientDest, { recursive: true });");
});
it("preserves node: prefix in node:sqlite imports", () => {
it("keeps native module loaders externalized in tsup config", () => {
const tsupConfig = readFileSync(tsupConfigPath, "utf-8");
expect(tsupConfig).toContain('"dockerode"');
expect(tsupConfig).toContain('"ssh2"');
expect(tsupConfig).toContain('"cpu-features"');
});
it("loads sqlite from Node built-ins and never from bare sqlite npm package", () => {
const content = readFileSync(bundlePath, "utf-8");
// Should have node:sqlite, not bare "sqlite"
expect(content).toContain('from "node:sqlite"');
// The bundle must resolve sqlite through Node's built-in module.
expect(content).toMatch(/["']node:sqlite["']/);
// Bun-native sqlite support is optional in this artifact depending on runtime-targeted code paths.
// No bare "sqlite" import (we never want to pull in an npm package named sqlite).
expect(content).not.toMatch(/from\s+["']sqlite["'][^s]/);
});
it("does not inline native artifact filenames into the bundled CLI", () => {
const content = readFileSync(bundlePath, "utf-8");
expect(content).not.toContain("sshcrypto.node");
expect(content).not.toContain("cpufeatures.node");
});
it("provides require via createRequire banner", () => {
const content = readFileSync(bundlePath, "utf-8");
// Banner should inject createRequire for ESM CJS interop
@@ -94,6 +124,170 @@ describe("CLI bundle output", () => {
expect(content).toMatch(/from\s+["']node:path["']/);
});
it("resolveClaudeCliExtension succeeds against the staged dist/ layout", () => {
const result = resolveClaudeCliExtensionFromModuleUrl(pathToFileURL(bundlePath).href);
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.path).toBe(join(cliRoot, "dist", "pi-claude-cli", "index.ts"));
expect(result.packageVersion).toMatch(/\d+\.\d+\.\d+/);
}
});
it("dist/pi-claude-cli/ is staged with correct files", () => {
const stagedRoot = join(cliRoot, "dist", "pi-claude-cli");
expect(existsSync(join(stagedRoot, "package.json"))).toBe(true);
expect(existsSync(join(stagedRoot, "index.ts"))).toBe(true);
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
});
it("resolveDroidCliExtension succeeds against the staged dist/ layout", () => {
const result = resolveDroidCliExtensionFromModuleUrl(pathToFileURL(bundlePath).href);
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.path).toBe(join(cliRoot, "dist", "droid-cli", "index.ts"));
expect(result.packageVersion).toMatch(/\d+\.\d+\.\d+/);
}
});
it("dist/droid-cli/ is staged with correct files", () => {
const stagedRoot = join(cliRoot, "dist", "droid-cli");
expect(existsSync(join(stagedRoot, "package.json"))).toBe(true);
expect(existsSync(join(stagedRoot, "index.ts"))).toBe(true);
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
});
it("dist/plugins/fusion-plugin-dependency-graph/ is staged as bundled runtime output", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-dependency-graph");
const manifestPath = join(stagedRoot, "manifest.json");
const packageJsonPath = join(stagedRoot, "package.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
expect(manifest.id).toBe("fusion-plugin-dependency-graph");
expect(typeof manifest.name).toBe("string");
expect(manifest.name?.length).toBeGreaterThan(0);
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
expect(existsSync(join(stagedRoot, "src"))).toBe(false);
const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
exports?: { "."?: { import?: string } };
};
expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js");
});
it("dist/plugins/fusion-plugin-roadmap/ is staged as bundled runtime output", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-roadmap");
const manifestPath = join(stagedRoot, "manifest.json");
const packageJsonPath = join(stagedRoot, "package.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
expect(manifest.id).toBe("roadmap-planner");
expect(typeof manifest.name).toBe("string");
expect(manifest.name?.length).toBeGreaterThan(0);
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
expect(existsSync(join(stagedRoot, "src"))).toBe(false);
const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
exports?: { "."?: { import?: string } };
};
expect(stagedPkg.exports?.["."]?.import).toBe("./bundled.js");
});
it("dist/plugins/fusion-plugin-whatsapp-chat/ is staged with a valid manifest", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-whatsapp-chat");
const manifestPath = join(stagedRoot, "manifest.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
expect(manifest.id).toBe("fusion-plugin-whatsapp-chat");
expect(typeof manifest.name).toBe("string");
expect(manifest.name?.length).toBeGreaterThan(0);
});
it("dist/plugins/fusion-plugin-cli-printing-press/ is staged with source entry for bundled install", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-cli-printing-press");
const manifestPath = join(stagedRoot, "manifest.json");
const packageJsonPath = join(stagedRoot, "package.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
expect(manifest.id).toBe("fusion-plugin-cli-printing-press");
expect(typeof manifest.name).toBe("string");
expect(manifest.name?.length).toBeGreaterThan(0);
expect(existsSync(join(stagedRoot, "src", "index.ts"))).toBe(true);
const stagedPkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
exports?: { "."?: { import?: string } };
};
expect(stagedPkg.exports?.["."]?.import).toBe("./src/index.ts");
});
it("dist/plugins/fusion-plugin-openclaw-runtime/ is staged with required bridge assets", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-openclaw-runtime");
const manifestPath = join(stagedRoot, "manifest.json");
expect(existsSync(manifestPath)).toBe(true);
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
expect(existsSync(join(stagedRoot, "mcp-schema-server.cjs"))).toBe(true);
});
it("dist/plugins/fusion-plugin-droid-runtime/ is staged with required bridge assets", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-droid-runtime");
const manifestPath = join(stagedRoot, "manifest.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string };
expect(manifest.id).toBe("fusion-plugin-droid-runtime");
expect(existsSync(join(stagedRoot, "bundled.js"))).toBe(true);
expect(existsSync(join(stagedRoot, "mcp-schema-server.cjs"))).toBe(true);
});
it("dist/plugins/fusion-plugin-cursor-runtime/ is staged with a valid manifest", () => {
const stagedRoot = join(cliRoot, "dist", "plugins", "fusion-plugin-cursor-runtime");
const manifestPath = join(stagedRoot, "manifest.json");
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { id?: string; name?: string };
expect(manifest.id).toBe("fusion-plugin-cursor-runtime");
expect(typeof manifest.name).toBe("string");
expect(manifest.name?.length).toBeGreaterThan(0);
});
it("pi-claude-cli source imports child process helpers from node:child_process", () => {
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
expect(processManagerSource).toMatch(/import\s+\{[^}]*\bspawn\b[^}]*\}\s+from\s*["']node:child_process["']/);
});
it("pi-claude-cli source does not import cross-spawn directly", () => {
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
expect(processManagerSource).not.toMatch(/from\s*["']cross-spawn["']/);
});
it("staged pi-claude-cli package.json keeps pi extension entry and excludes cross-spawn deps", () => {
const stagedPkg = JSON.parse(
readFileSync(join(cliRoot, "dist", "pi-claude-cli", "package.json"), "utf-8"),
) as {
pi?: { extensions?: unknown };
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
expect(stagedPkg.pi?.extensions).toEqual(["index.ts"]);
expect(stagedPkg.dependencies?.["cross-spawn"]).toBeUndefined();
expect(stagedPkg.dependencies?.["@types/cross-spawn"]).toBeUndefined();
expect(stagedPkg.devDependencies?.["cross-spawn"]).toBeUndefined();
expect(stagedPkg.devDependencies?.["@types/cross-spawn"]).toBeUndefined();
});
it("runtime native assets are staged after build:exe", () => {
const runtimeDir = join(cliRoot, "dist", "runtime");
if (!existsSync(runtimeDir)) return;

View File

@@ -22,21 +22,40 @@ function loadWorkflow(name: string): any {
describe("CI workflow (.github/workflows/ci.yml)", () => {
let workflow: any;
let content: string;
let ciSteps: any[];
let buildSteps: any[];
let testShardJob: any;
let contributingContent: string;
let readmeContent: string;
let cliPackageJsonContent: string;
let extensionSuiteContent: string;
let agentExportSuiteContent: string;
let buildExeSuiteContent: string;
beforeAll(() => {
const result = loadWorkflow("ci.yml");
workflow = result.parsed;
content = result.content;
ciSteps = workflow.jobs?.ci?.steps ?? [];
buildSteps = workflow.jobs?.build?.steps ?? [];
testShardJob = workflow.jobs?.["test-shards"];
contributingContent = readFileSync(join(workspaceRoot, "docs", "contributing.md"), "utf-8");
readmeContent = readFileSync(join(workspaceRoot, "README.md"), "utf-8");
cliPackageJsonContent = readFileSync(join(workspaceRoot, "packages", "cli", "package.json"), "utf-8");
extensionSuiteContent = readFileSync(
join(workspaceRoot, "packages", "cli", "src", "__tests__", "extension-integration.test.ts"),
"utf-8",
);
agentExportSuiteContent = readFileSync(
join(workspaceRoot, "packages", "cli", "src", "commands", "__tests__", "agent-export.test.ts"),
"utf-8",
);
buildExeSuiteContent = readFileSync(
join(workspaceRoot, "packages", "cli", "src", "__tests__", "build-exe-cross.test.ts"),
"utf-8",
);
});
const findStepByRun = (runSnippet: string) => ciSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet));
const findStepIndexByRun = (runSnippet: string) =>
ciSteps.findIndex((step) => typeof step.run === "string" && step.run.includes(runSnippet));
const findBuildStepByRun = (runSnippet: string) =>
buildSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet));
it("is valid YAML", () => {
expect(workflow).toBeDefined();
@@ -58,38 +77,69 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
expect(content).not.toContain("--no-frozen-lockfile");
});
it("uses verify:workspace as the single lint/test/build contract", () => {
const verifyStep = findStepByRun("pnpm verify:workspace");
expect(verifyStep).toBeDefined();
expect(verifyStep.name).toContain("bootstrap contract");
it("uses deterministic test sharding and keeps lint/build as explicit jobs", () => {
expect(workflow.jobs?.lint).toBeDefined();
expect(testShardJob).toBeDefined();
expect(workflow.jobs?.build).toBeDefined();
const directLintStep = findStepByRun("pnpm lint");
const directTestStep = findStepByRun("pnpm test");
const directBuildStep = findStepByRun("pnpm build");
expect(directLintStep).toBeUndefined();
expect(directTestStep).toBeUndefined();
expect(directBuildStep).toBeUndefined();
expect(testShardJob.strategy?.matrix?.shard).toEqual([1, 2, 3]);
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3");
expect(content).not.toContain("pnpm verify:workspace");
});
it("runs workspace verification before binary packaging", () => {
const verifyIdx = findStepIndexByRun("pnpm verify:workspace");
const buildExeIdx = findStepIndexByRun("build:exe");
expect(verifyIdx).toBeGreaterThanOrEqual(0);
expect(buildExeIdx).toBeGreaterThan(verifyIdx);
it("runs build job after lint and sharded tests, then executes slow lane and binary packaging", () => {
expect(workflow.jobs?.build?.needs).toEqual(["lint", "test-shards"]);
expect(findBuildStepByRun("pnpm build")).toBeDefined();
expect(findBuildStepByRun("pnpm test:slow-cli")).toBeDefined();
expect(findBuildStepByRun("build:exe")).toBeDefined();
});
it("keeps contributing docs aligned with the clean-worktree verification contract", () => {
expect(contributingContent).toContain("pnpm test` must be runnable in a clean worktree without requiring a prior `pnpm build`.");
it("keeps contributing docs aligned with verification and slow-lane contracts", () => {
expect(contributingContent).toContain("pnpm test:full` must be runnable in a clean worktree without requiring a prior `pnpm build`.");
expect(contributingContent).toContain("`pnpm verify:workspace` is the canonical pre-merge gate");
expect(contributingContent).toContain("1. `pnpm lint`");
expect(contributingContent).toContain("2. `pnpm test`");
expect(contributingContent).toContain("2. `pnpm test:full`");
expect(contributingContent).toContain("3. `pnpm build`");
expect(contributingContent).toContain("`pnpm test` now uses a changed-only entrypoint");
expect(contributingContent).toContain("pnpm test:slow-cli");
expect(contributingContent).toContain("test:pre-release");
expect(contributingContent).toContain("test:extension-integration");
});
it("keeps docs aligned with default and explicit build commands", () => {
expect(readmeContent).toContain("pnpm build # Build default workspace packages (excludes desktop/mobile)");
expect(readmeContent).toContain("pnpm build:all # Build all packages (including desktop/mobile)");
expect(contributingContent).toContain("pnpm build # default build (excludes desktop/mobile)");
expect(contributingContent).toContain("pnpm build:all # full recursive build including desktop/mobile");
});
it("includes binary build step", () => {
expect(content).toContain("build:exe");
});
it("keeps explicit gating for audited CLI integration suites", () => {
expect(cliPackageJsonContent).toContain('"test:slow-cli"');
expect(cliPackageJsonContent).toContain("FUSION_TEST_SLOW_CLI=1");
expect(cliPackageJsonContent).toContain('"test:extension-integration"');
expect(cliPackageJsonContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION=1");
expect(cliPackageJsonContent).toContain("extension-integration.test.ts");
expect(cliPackageJsonContent).toContain('"test:build-exe"');
expect(cliPackageJsonContent).toContain("FUSION_TEST_BUILD_EXE=1");
expect(extensionSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)");
expect(extensionSuiteContent).toContain("FUSION_TEST_EXTENSION_INTEGRATION");
expect(extensionSuiteContent).toContain("dist/extension.js");
expect(agentExportSuiteContent).toContain("describe.skipIf(!SHOULD_RUN_SLOW_CLI)");
expect(agentExportSuiteContent).toContain("FUSION_TEST_SLOW_CLI");
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "1"');
expect(buildExeSuiteContent).toContain('process.env.FUSION_TEST_BUILD_EXE === "true"');
expect(buildExeSuiteContent).not.toContain("Boolean(process.env.FUSION_TEST_BUILD_EXE)");
});
it("includes Bun setup", () => {
expect(content).toContain("oven-sh/setup-bun");
});
@@ -99,6 +149,54 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
});
});
describe("PR checks workflow (.github/workflows/pr-checks.yml)", () => {
let workflow: any;
let content: string;
beforeAll(() => {
const result = loadWorkflow("pr-checks.yml");
workflow = result.parsed;
content = result.content;
});
it("is valid YAML", () => {
expect(workflow).toBeDefined();
expect(typeof workflow).toBe("object");
});
it("runs on pull requests targeting main", () => {
expect(workflow.on?.pull_request?.branches).toContain("main");
});
it("uses the same deterministic test sharding command as manual CI", () => {
expect(workflow.jobs?.lint).toBeDefined();
expect(workflow.jobs?.typecheck).toBeDefined();
expect(workflow.jobs?.build).toBeDefined();
expect(workflow.jobs?.["test-shards"]).toBeDefined();
expect(workflow.jobs?.["test-shards"]?.strategy?.matrix?.shard).toEqual([1, 2, 3]);
expect(content).toContain("pnpm test:ci:shard --shard ${{ matrix.shard }} --total 3");
expect(content).not.toContain("run: pnpm test\n");
});
it("keeps build coverage as an explicit PR gate", () => {
const buildSteps = workflow.jobs?.build?.steps ?? [];
expect(
buildSteps.some(
(step: any) => step.name === "Build" && typeof step.run === "string" && step.run.includes("pnpm build"),
),
).toBe(true);
});
it("does not spend PR action minutes on a pre-test workspace build", () => {
const testSteps = workflow.jobs?.["test-shards"]?.steps ?? [];
expect(
testSteps.some(
(step: any) => step.name === "Build" || (typeof step.run === "string" && step.run.includes("pnpm build")),
),
).toBe(false);
});
});
describe("Version & Release workflow (.github/workflows/version.yml)", () => {
let workflow: any;
let content: string;

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { buildDevNodeArgs } from "../../../../scripts/dev-with-memory-lib.mjs";
describe("buildDevNodeArgs", () => {
it("enables source-condition resolution before loading the tsx runtime", () => {
const args = buildDevNodeArgs({
inspectFlags: ["--inspect=9230"],
preload: "/tmp/preflight.cjs",
loader: "/tmp/loader.mjs",
entry: "/tmp/bin.ts",
args: ["dashboard", "--host", "0.0.0.0"],
});
expect(args).toEqual([
"--inspect=9230",
"--conditions=source",
"--require",
"/tmp/preflight.cjs",
"--import",
"file:///tmp/loader.mjs",
"/tmp/bin.ts",
"dashboard",
"--host",
"0.0.0.0",
]);
});
});

View File

@@ -6,6 +6,7 @@ const workspaceRoot = resolve(import.meta.dirname, "../../../..");
const dockerfilePath = resolve(workspaceRoot, "Dockerfile");
const dockerignorePath = resolve(workspaceRoot, ".dockerignore");
const dockerDocsPath = resolve(workspaceRoot, "docs", "docker.md");
const architectureDocsPath = resolve(workspaceRoot, "docs", "architecture.md");
describe("Docker configuration", () => {
it("has a Dockerfile with required production instructions", () => {
@@ -63,6 +64,31 @@ describe("Docker configuration", () => {
expect(docs).toContain("environment variables");
});
it("documents managed docker node provisioning architecture and endpoint boundaries", () => {
expect(existsSync(architectureDocsPath)).toBe(true);
const docs = readFileSync(architectureDocsPath, "utf8");
expect(docs).toContain("### Docker Node Provisioning");
expect(docs).toContain("register-docker-provisioning-routes.ts");
expect(docs).toContain("register-docker-node-routes.ts");
expect(docs).toContain("/api/docker/provision");
expect(docs).toContain("/api/docker/deprovision");
expect(docs).toContain("/api/docker/containers/:containerId/start");
expect(docs).toContain("/api/docker/containers/:containerId/stop");
expect(docs).toContain("/api/docker/containers/:containerId/restart");
expect(docs).toContain("/api/docker/containers/:containerId/status");
});
it("documents mesh node port convention and docker guide cross-reference", () => {
const architectureDocs = readFileSync(architectureDocsPath, "utf8");
const dockerDocs = readFileSync(dockerDocsPath, "utf8");
expect(architectureDocs).toContain("default to **`4041`**");
expect(architectureDocs).toContain("**`4040` remains reserved**");
expect(dockerDocs).toContain("[Architecture → Docker Node Provisioning]");
expect(dockerDocs).toContain("This document is about containerizing Fusion itself");
});
it("does not patch the CLI bundle at build time", () => {
const dockerfile = readFileSync(dockerfilePath, "utf8");
// The tsup bundle should be self-contained without runtime patches

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const workspaceRoot = resolve(import.meta.dirname, "../../../..");
const docsReadmePath = resolve(workspaceRoot, "docs", "README.md");
const requiredDocs = [
"docs/beads-dolt-sync-evaluation.md",
"docs/dev-server-modules.md",
"docs/research/pi-autoresearch-analysis.md",
"docs/research/research-hardening-preflight.md",
] as const;
describe("docs README index", () => {
it("includes links for required docs and those files exist", () => {
expect(existsSync(docsReadmePath)).toBe(true);
const docsReadme = readFileSync(docsReadmePath, "utf8");
for (const relativePath of requiredDocs) {
const readmeLinkPath = `./${relativePath.replace(/^docs\//, "")}`;
expect(docsReadme).toContain(`(${readmeLinkPath})`);
expect(existsSync(resolve(workspaceRoot, relativePath))).toBe(true);
}
});
});

View File

@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import kbExtension from "../extension.js";
function createMockAPI() {
const tools = new Map<string, any>();
return {
registerTool(def: any) {
tools.set(def.name, def);
},
registerCommand() {},
registerShortcut() {},
registerFlag() {},
on() {},
tools,
} as any;
}
describe("extension agent provisioning tools", () => {
it("creates and deletes agents as privileged user caller", async () => {
const cwd = await mkdtemp(join(tmpdir(), "fn-ext-provision-"));
try {
const api = createMockAPI();
kbExtension(api);
const createTool = api.tools.get("fn_agent_create");
const deleteTool = api.tools.get("fn_agent_delete");
const name = `Provisioned-${Date.now()}`;
const createResult = await createTool.execute("call-1", { name, role: "executor" }, undefined, undefined, { cwd });
expect(createResult.details.outcome).toBe("created");
const createdId = createResult.details.agentId as string;
expect(createdId).toBeTruthy();
const deleteResult = await deleteTool.execute("call-2", { agent_id: createdId }, undefined, undefined, { cwd });
expect(deleteResult.details.outcome).toBe("deleted");
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import kbExtension from "../extension.js";
import { TaskStore } from "@fusion/core";
interface RegisteredTool {
name: string;
execute: (
toolCallId: string,
params: any,
signal: AbortSignal | undefined,
onUpdate: ((update: any) => void) | undefined,
ctx: any,
) => Promise<any>;
}
function createMockAPI() {
const tools = new Map<string, RegisteredTool>();
return {
registerTool(def: RegisteredTool) {
tools.set(def.name, def);
},
registerCommand() {},
registerShortcut() {},
registerFlag() {},
on() {},
tools,
} as any;
}
function makeCtx(cwd: string) {
return { cwd } as any;
}
describe("fn insight extension tools", () => {
let tmpDir: string;
let api: ReturnType<typeof createMockAPI>;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-insights-test-"));
api = createMockAPI();
kbExtension(api);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("registers all insight tools", () => {
expect(api.tools.has("fn_insight_list")).toBe(true);
expect(api.tools.has("fn_insight_show")).toBe(true);
expect(api.tools.has("fn_insight_run_list")).toBe(true);
expect(api.tools.has("fn_insight_run_show")).toBe(true);
});
it("lists and shows persisted insights", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const insightStore = store.getInsightStore();
const created = insightStore.createInsight("", {
title: "Agent-visible insight",
category: "quality",
status: "generated",
provenance: { trigger: "manual" },
content: "Ensure this appears in extension output",
});
store.close();
const listTool = api.tools.get("fn_insight_list")!;
const listResult = await listTool.execute("call-1", { category: "quality" }, undefined, undefined, makeCtx(tmpDir));
expect(listResult.content[0].text).toContain(created.id);
expect(listResult.details.insights).toHaveLength(1);
const showTool = api.tools.get("fn_insight_show")!;
const showResult = await showTool.execute("call-2", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
expect(showResult.content[0].text).toContain("Agent-visible insight");
expect(showResult.details.insight.id).toBe(created.id);
});
it("lists and shows insight runs", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const insightStore = store.getInsightStore();
const run = insightStore.createRun("", { trigger: "manual" });
insightStore.updateRun(run.id, { status: "completed", insightsCreated: 2, insightsUpdated: 1 });
store.close();
const listTool = api.tools.get("fn_insight_run_list")!;
const listResult = await listTool.execute("call-3", { status: "completed" }, undefined, undefined, makeCtx(tmpDir));
expect(listResult.content[0].text).toContain(run.id);
expect(listResult.details.runs).toHaveLength(1);
const showTool = api.tools.get("fn_insight_run_show")!;
const showResult = await showTool.execute("call-4", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
expect(showResult.content[0].text).toContain("Status: completed");
expect(showResult.details.run.id).toBe(run.id);
});
it("returns helpful errors for invalid pagination and missing IDs", async () => {
const listTool = api.tools.get("fn_insight_list")!;
const invalidList = await listTool.execute("call-5", { limit: 0 }, undefined, undefined, makeCtx(tmpDir));
expect(invalidList.isError).toBe(true);
expect(invalidList.content[0].text).toContain("Invalid limit");
const showTool = api.tools.get("fn_insight_show")!;
const missing = await showTool.execute("call-6", { id: "INS-MISSING" }, undefined, undefined, makeCtx(tmpDir));
expect(missing.isError).toBe(true);
expect(missing.content[0].text).toContain("not found");
});
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
import { AgentStore, TaskStore } from "@fusion/core";
import {
buildCliWithRealDashboardAssets,
extensionBundlePath,
} from "./bundle-output-helpers";
vi.setConfig({ testTimeout: 30000, hookTimeout: 30000 });
const SHOULD_RUN_EXTENSION_INTEGRATION =
process.env.FUSION_TEST_EXTENSION_INTEGRATION === "1" ||
process.env.FUSION_TEST_EXTENSION_INTEGRATION === "true";
interface RegisteredTool {
name: string;
execute: (
toolCallId: string,
params: any,
signal: AbortSignal | undefined,
onUpdate: ((update: any) => void) | undefined,
ctx: any,
) => Promise<any>;
}
type EventHandler = (...args: any[]) => unknown | Promise<unknown>;
interface MockExtensionApi {
tools: Map<string, RegisteredTool>;
commands: Map<string, any>;
events: Map<string, EventHandler>;
registerTool: (def: RegisteredTool) => void;
registerCommand: (name: string, def: any) => void;
registerShortcut: ReturnType<typeof vi.fn>;
registerFlag: ReturnType<typeof vi.fn>;
on: (event: string, handler: EventHandler) => void;
}
function createMockAPI(): MockExtensionApi {
const tools = new Map<string, RegisteredTool>();
const commands = new Map<string, any>();
const events = new Map<string, EventHandler>();
return {
registerTool(def: RegisteredTool) {
tools.set(def.name, def);
},
registerCommand(name: string, def: any) {
commands.set(name, def);
},
registerShortcut: vi.fn(),
registerFlag: vi.fn(),
on(event: string, handler: EventHandler) {
events.set(event, handler);
},
tools,
commands,
events,
};
}
function makeCtx(cwd: string) {
return { cwd } as any;
}
async function importBuiltExtension() {
const mod = await import(`${pathToFileURL(extensionBundlePath).href}?t=${Date.now()}`);
const extension = mod.default;
if (typeof extension !== "function") {
throw new Error("dist/extension.js did not export the pi extension function");
}
return extension as (api: MockExtensionApi) => void;
}
async function removeDirWithRetries(path: string) {
for (let attempt = 1; attempt <= 4; attempt += 1) {
try {
await rm(path, { recursive: true, force: true });
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOTEMPTY" && code !== "EBUSY") {
throw error;
}
if (attempt === 4) {
throw error;
}
await delay(25 * attempt);
}
}
}
async function seedAgent(cwd: string, options: { name: string; ephemeral?: boolean }) {
const agentStore = new AgentStore({ rootDir: join(cwd, ".fusion") });
await agentStore.init();
return agentStore.createAgent({
name: options.name,
role: "executor",
metadata: options.ephemeral ? { agentKind: "task-worker" } : {},
});
}
describe.skipIf(!SHOULD_RUN_EXTENSION_INTEGRATION)("built fn pi extension integration", () => {
let tmpDir: string;
let api: MockExtensionApi;
let extension: (api: MockExtensionApi) => void;
beforeAll(async () => {
buildCliWithRealDashboardAssets();
extension = await importBuiltExtension();
}, 300_000);
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "fusion-built-ext-"));
await mkdir(join(tmpDir, ".fusion"), { recursive: true });
api = createMockAPI();
extension(api);
});
afterEach(async () => {
const shutdown = api.events.get("session_shutdown");
if (shutdown) {
await shutdown();
}
await removeDirWithRetries(tmpDir);
});
it("registers the current public extension surface from dist/extension.js", () => {
expect(api.commands.has("fn")).toBe(true);
expect(api.events.has("session_shutdown")).toBe(true);
for (const toolName of [
"fn_task_create",
"fn_task_list",
"fn_task_show",
"fn_agent_create",
"fn_agent_delete",
"fn_list_agents",
"fn_delegate_task",
"fn_agent_show",
"fn_research_run",
"fn_skills_install",
]) {
expect(api.tools.has(toolName), `${toolName} should be registered`).toBe(true);
}
for (const internalToolName of [
"fn_task_move",
"fn_task_update_step",
"fn_task_log",
"fn_task_merge",
]) {
expect(api.tools.has(internalToolName), `${internalToolName} should stay engine-internal`).toBe(false);
}
});
it("exposes a callable session_shutdown handler", async () => {
const shutdown = api.events.get("session_shutdown");
expect(typeof shutdown).toBe("function");
await expect(shutdown?.()).resolves.toBeUndefined();
});
it("creates and lists tasks through the built extension", async () => {
const createTool = api.tools.get("fn_task_create")!;
const created = await createTool.execute(
"create-1",
{ description: "Ship the packed CLI contract" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(created.details.taskId).toMatch(/^[A-Z]+-\d+$/);
expect(created.details.column).toBe("triage");
const listTool = api.tools.get("fn_task_list")!;
const listed = await listTool.execute("list-1", {}, undefined, undefined, makeCtx(tmpDir));
expect(listed.content[0].text).toContain(created.details.taskId);
expect(listed.content[0].text).toContain("Ship the packed CLI contract");
const store = new TaskStore(tmpDir);
await store.init();
const persisted = await store.getTask(created.details.taskId);
expect(persisted?.description).toBe("Ship the packed CLI contract");
});
it("runs provisioning tools through the built extension", async () => {
const createTool = api.tools.get("fn_agent_create")!;
const created = await createTool.execute(
"create-agent-1",
{ name: "built-ext-agent", role: "executor" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(created.details.outcome).toBe("created");
expect(created.details.agentId).toMatch(/^agent-/);
const deleteTool = api.tools.get("fn_agent_delete")!;
const deleted = await deleteTool.execute(
"delete-agent-1",
{ id: created.details.agentId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(deleted.details.outcome).toBe("deleted");
expect(deleted.details.agentId).toBe(created.details.agentId);
});
it("delegates to real non-ephemeral agents and rejects runtime workers", async () => {
const agent = await seedAgent(tmpDir, { name: "release-agent" });
const runtimeWorker = await seedAgent(tmpDir, { name: "runtime-worker", ephemeral: true });
const listAgentsTool = api.tools.get("fn_list_agents")!;
const listedAgents = await listAgentsTool.execute("agents-1", {}, undefined, undefined, makeCtx(tmpDir));
expect(listedAgents.content[0].text).toContain("release-agent");
expect(listedAgents.content[0].text).not.toContain("runtime-worker");
const delegateTool = api.tools.get("fn_delegate_task")!;
const delegated = await delegateTool.execute(
"delegate-1",
{ agent_id: agent.id, description: "Verify release locally" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(delegated.details.agentId).toBe(agent.id);
expect(delegated.content[0].text).toContain("release-agent");
const rejected = await delegateTool.execute(
"delegate-2",
{ agent_id: runtimeWorker.id, description: "Should not assign" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(rejected.isError).toBe(true);
expect(rejected.content[0].text).toContain("ephemeral/runtime agent");
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
const fetchWebContentMock = vi.hoisted(() => vi.fn());
vi.mock("@fusion/engine", () => ({
fetchWebContent: fetchWebContentMock,
}));
import kbExtension from "../extension.js";
describe("extension fn_web_fetch", () => {
it("registers and executes fn_web_fetch", async () => {
const tools = new Map<string, any>();
const api = {
registerTool(def: any) {
tools.set(def.name, def);
},
registerCommand: vi.fn(),
registerShortcut: vi.fn(),
registerFlag: vi.fn(),
on: vi.fn(),
} as any;
fetchWebContentMock.mockResolvedValue({
finalUrl: "https://example.com/final",
status: 200,
contentType: "text/plain",
title: "Example",
content: "hello world",
truncated: false,
bytesRead: 11,
});
kbExtension(api);
const tool = tools.get("fn_web_fetch");
expect(tool).toBeTruthy();
const result = await tool.execute("id", { url: "https://example.com" }, undefined, undefined, { cwd: process.cwd() });
expect(fetchWebContentMock).toHaveBeenCalledWith("https://example.com", { timeoutMs: undefined, maxBytes: undefined });
expect(result.content[0].text).toContain("https://example.com/final");
expect(result.details.status).toBe(200);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { builtinModules } from "node:module";
import { parse } from "yaml";
const workspaceRoot = join(__dirname, "..", "..", "..", "..");
@@ -21,8 +22,19 @@ function loadRootPackageJson(): any {
return JSON.parse(readFileSync(path, "utf-8"));
}
function loadCliPrepackScript(): string {
const path = join(workspaceRoot, "packages", "cli", "scripts", "prepare-publish-manifest.mjs");
return readFileSync(path, "utf-8");
}
function hasProjectArg(script: string | undefined, project: string): boolean {
const parts = script?.trim().split(/\s+/) ?? [];
return parts.some((part, index) => part === "--project" && parts[index + 1] === project);
}
describe("CLI package.json publishing config", () => {
const pkg = loadPackageJson("cli");
const prepackScript = loadCliPrepackScript();
it('has "bin" field with fn pointing to ./dist/bin.js', () => {
expect(pkg.bin).toBeDefined();
@@ -78,6 +90,113 @@ describe("CLI package.json publishing config", () => {
const deps = Object.keys(pkg.dependencies || {});
expect(deps).toContain("ioredis");
});
it("prepack manifest rewrite strips workspace-only plugin/tooling devDependencies", () => {
expect(prepackScript).toContain('delete devDependencies["@fusion/pi-claude-cli"]');
expect(prepackScript).toContain('delete devDependencies["@fusion/pi-llama-cpp"]');
expect(prepackScript).toContain('delete devDependencies["@fusion-plugin-examples/roadmap"]');
});
// Generalized guard derived from tsup.config.ts. Any non-builtin module
// marked `external` MUST be a runtime dep (so `npm install @runfusion/fusion`
// can resolve it after publish), and any module pulled in via `noExternal`
// (i.e. inlined into the bundle) MUST NOT leak into runtime deps.
// pnpm hoisting masks the missing-dep case in the workspace, so a hardcoded
// allowlist isn't enough — this iterates the live config instead.
describe("tsup external/noExternal vs published deps", () => {
const tsupRaw = readFileSync(
join(workspaceRoot, "packages", "cli", "tsup.config.ts"),
"utf-8",
);
function extractStringArray(name: string): string[] {
const matches = [...tsupRaw.matchAll(new RegExp(`${name}:\\s*\\[([\\s\\S]*?)\\]`, "gm"))];
const values = matches.flatMap((m) =>
[...m[1].matchAll(/["']([^"']+)["']/g)].map((mm) => mm[1]),
);
return [...new Set(values)];
}
function extractRegexes(name: string): RegExp[] {
const matches = [...tsupRaw.matchAll(new RegExp(`${name}:\\s*\\[([\\s\\S]*?)\\]`, "gm"))];
// Match `/PATTERN/flags` where PATTERN may contain escaped slashes (`\/`).
return matches.flatMap((m) =>
[...m[1].matchAll(/\/((?:\\\/|[^/\n])+)\/[gimsuy]*/g)].map(
(mm) => new RegExp(mm[1].replace(/\\\//g, "/")),
),
);
}
const externals = extractStringArray("external");
const noExternalRegexes = extractRegexes("noExternal");
const noExternalStrings = extractStringArray("noExternal");
// Externals that intentionally aren't direct deps. Each entry needs a reason —
// when adding to this list, document *why* it doesn't need to be a runtime dep
// (transitive via another dep, only used by the Bun binary, etc.) so future
// edits don't silently re-introduce the dockerode-class bug.
const TRANSITIVE_EXTERNALS: Record<string, string> = {
ssh2: "transitive dep of dockerode",
"cpu-features": "transitive dep of dockerode (via ssh2)",
"@homebridge/node-pty-prebuilt-multiarch":
"aliased as node-pty in dependencies; the alias entry satisfies the import",
"@fusion/core": "plugin-entry bundling external only; not a runtime dep of the CLI bin",
"@fusion/engine": "plugin-entry bundling external only; not a runtime dep of the CLI bin",
};
it("parses externals from tsup.config.ts", () => {
expect(externals.length).toBeGreaterThan(0);
expect(externals).toContain("dockerode");
});
it.each(externals.filter(
(e) =>
!builtinModules.includes(e) &&
!e.startsWith("node:") &&
!(e in TRANSITIVE_EXTERNALS),
))(
'external "%s" is declared as a runtime dependency',
(external) => {
const deps = Object.keys(pkg.dependencies || {});
const devDeps = Object.keys(pkg.devDependencies || {});
expect(
deps,
`tsup external "${external}" must be in @runfusion/fusion dependencies — otherwise \`npx runfusion.ai\` fails with ERR_MODULE_NOT_FOUND on a clean install. If this is a transitive dep, add it to TRANSITIVE_EXTERNALS with a reason.`,
).toContain(external);
expect(
devDeps,
`tsup external "${external}" must not be only a devDependency`,
).not.toContain(external);
},
);
it("TRANSITIVE_EXTERNALS entries still appear in tsup external (otherwise stale)", () => {
for (const name of Object.keys(TRANSITIVE_EXTERNALS)) {
expect(
externals,
`TRANSITIVE_EXTERNALS["${name}"] is no longer in tsup external — remove the allowlist entry.`,
).toContain(name);
}
});
it("noExternal (bundled) modules are not also runtime deps", () => {
const deps = Object.keys(pkg.dependencies || {});
for (const dep of deps) {
for (const re of noExternalRegexes) {
expect(
re.test(dep),
`dep "${dep}" matches noExternal pattern ${re} — bundled code should not also be a runtime dep`,
).toBe(false);
}
for (const s of noExternalStrings) {
expect(
dep,
`dep "${dep}" is listed in noExternal — bundled code should not also be a runtime dep`,
).not.toBe(s);
}
}
});
});
});
describe("Scoped @fusion/* packages publishing config", () => {
@@ -113,26 +232,58 @@ describe("Scoped @fusion/* packages publishing config", () => {
describe("Workspace bootstrap script contract", () => {
const rootPkg = loadRootPackageJson();
const dashboardPkg = loadPackageJson("dashboard");
it("keeps root test self-sufficient (no implicit pre-build dependency)", () => {
const testScript = rootPkg.scripts?.test;
expect(testScript).toBeDefined();
expect(testScript).toContain("pnpm -r");
expect(testScript).not.toContain("pnpm build");
it("makes root test changed-only while keeping explicit full-suite and CI-shard commands", () => {
expect(rootPkg.scripts?.test).toBe("node scripts/test-changed.mjs");
expect(rootPkg.scripts?.["test:full"]).toBe("node scripts/test-changed.mjs --full --no-cache");
expect(rootPkg.scripts?.["test:full"]).not.toContain("pnpm build");
expect(rootPkg.scripts?.["test:ci:shard"]).toBe("node scripts/ci-test-shard.mjs");
});
it("defines verify:workspace in lint -> test -> build order", () => {
it("defines verify:workspace in lint -> test:full -> build order", () => {
const verifyScript = rootPkg.scripts?.["verify:workspace"];
expect(verifyScript).toBe("pnpm lint && pnpm test && pnpm build");
expect(verifyScript).toBe("pnpm lint && pnpm test:full && pnpm build");
const lintIdx = verifyScript.indexOf("pnpm lint");
const testIdx = verifyScript.indexOf("pnpm test");
const testIdx = verifyScript.indexOf("pnpm test:full");
const buildIdx = verifyScript.indexOf("pnpm build");
expect(lintIdx).toBeGreaterThanOrEqual(0);
expect(testIdx).toBeGreaterThan(lintIdx);
expect(buildIdx).toBeGreaterThan(testIdx);
});
it("keeps default build CLI-first by excluding desktop/mobile", () => {
expect(rootPkg.scripts?.build).toBe(
"pnpm -r --filter=!@fusion/desktop --filter=!@fusion/mobile build",
);
});
it("keeps explicit opt-in scripts for full, desktop, and mobile builds", () => {
expect(rootPkg.scripts?.["build:all"]).toBe("pnpm -r build");
expect(rootPkg.scripts?.["build:desktop"]).toBe(
"pnpm --filter @fusion/desktop build",
);
expect(rootPkg.scripts?.["mobile:build"]).toBe(
"pnpm --filter @fusion/dashboard build && pnpm --filter @fusion/mobile cap sync",
);
});
it("keeps dashboard's default test lane curated with explicit deep coverage", () => {
const defaultTest = dashboardPkg.scripts?.test;
const deepTest = dashboardPkg.scripts?.["test:deep"];
expect(hasProjectArg(defaultTest, "dashboard-app-quality")).toBe(true);
expect(hasProjectArg(defaultTest, "dashboard-api-quality")).toBe(true);
expect(hasProjectArg(defaultTest, "dashboard-app")).toBe(false);
expect(hasProjectArg(defaultTest, "dashboard-api")).toBe(false);
expect(hasProjectArg(deepTest, "dashboard-app")).toBe(true);
expect(hasProjectArg(deepTest, "dashboard-api")).toBe(true);
expect(hasProjectArg(deepTest, "dashboard-app-quality")).toBe(false);
expect(hasProjectArg(deepTest, "dashboard-api-quality")).toBe(false);
});
});
describe("Workflow YAML validity", () => {

View File

@@ -117,6 +117,26 @@ describe("project-context", () => {
expect(found?.path).toBe(resolve(projectPath));
expect(found?.name).toBe("legacy-project");
});
it("should not inherit an unregistered parent project from a nested cwd", async () => {
const projectPath = createMockProject("legacy-project");
const nestedDir = join(projectPath, "src", "components");
mkdirSync(nestedDir, { recursive: true });
const found = await detectProjectFromCwd(nestedDir, central);
expect(found).toBeUndefined();
});
it("should ignore invalid fusion.db files in the cwd", async () => {
const projectPath = join(tempDir, "invalid-project");
mkdirSync(join(projectPath, ".fusion"), { recursive: true });
writeFileSync(join(projectPath, ".fusion", "fusion.db"), "SQLite format 3\x00");
const found = await detectProjectFromCwd(projectPath, central);
expect(found).toBeUndefined();
});
});
describe("formatProjectLine", () => {

View File

@@ -2,6 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { existsSync, statSync } from "node:fs";
import { TaskStore } from "@fusion/core";
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
mockIsValidSqliteDatabaseFile: vi.fn(),
}));
// Mock fs module
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
@@ -23,6 +27,8 @@ vi.mock("@fusion/core", async () => {
getProjectHealth = vi.fn().mockResolvedValue(undefined);
isInitialized = vi.fn().mockReturnValue(true);
},
isValidSqliteDatabaseFile: (...args: Parameters<typeof mockIsValidSqliteDatabaseFile>) =>
mockIsValidSqliteDatabaseFile(...args),
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
@@ -69,6 +75,7 @@ describe("Project Resolver", () => {
beforeEach(() => {
vi.clearAllMocks();
resetProjectResolution();
mockIsValidSqliteDatabaseFile.mockReturnValue(false);
vi.mocked(TaskStore).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
listTasks: vi.fn().mockResolvedValue([]),
@@ -81,49 +88,38 @@ describe("Project Resolver", () => {
describe("findKbDir", () => {
it("should find .fusion directory in current path", () => {
vi.mocked(existsSync)
.mockReturnValueOnce(true)
.mockReturnValue(false);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db");
const result = findKbDir("/project");
expect(result).toBe("/project");
});
it("should walk up parent directories to find .fusion", () => {
vi.mocked(existsSync)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
.mockReturnValue(false);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/a/b/.fusion/fusion.db");
const result = findKbDir("/a/b/c");
expect(result).toBe("/a/b");
});
it("should return null if no .fusion found", () => {
vi.mocked(existsSync).mockReturnValue(false);
const result = findKbDir("/some/path");
expect(result).toBeNull();
});
it("should return null if .fusion is not a directory", () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => false } as any);
it("should return null if fusion.db is not a valid SQLite database", () => {
mockIsValidSqliteDatabaseFile.mockReturnValue(false);
const result = findKbDir("/project");
expect(result).toBeNull();
});
});
describe("isKbProject", () => {
it("should return true if .fusion directory exists", () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
it("should return true if fusion.db is a valid SQLite database", () => {
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/project/.fusion/fusion.db");
expect(isKbProject("/project")).toBe(true);
});
it("should return false if .fusion directory does not exist", () => {
vi.mocked(existsSync).mockReturnValue(false);
it("should return false if fusion.db is invalid or missing", () => {
expect(isKbProject("/project")).toBe(false);
});
});
@@ -200,8 +196,7 @@ describe("Project Resolver", () => {
});
it("should throw NOT_REGISTERED if .fusion exists but project not registered", async () => {
vi.mocked(existsSync).mockReturnValueOnce(true).mockReturnValue(true);
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/unregistered/.fusion/fusion.db");
const core = await getCentralCore();
core.listProjects.mockResolvedValue([]);
@@ -213,8 +208,6 @@ describe("Project Resolver", () => {
});
it("should throw NO_PROJECTS when no projects registered and no .fusion found", async () => {
vi.mocked(existsSync).mockReturnValue(false);
const core = await getCentralCore();
core.listProjects.mockResolvedValue([]);
@@ -283,11 +276,8 @@ describe("Project Resolver", () => {
updatedAt: "",
};
vi.mocked(existsSync).mockImplementation((path) => {
const p = String(path);
return p === "/workspace/cwd-match/.fusion" || p === "/workspace/cwd-match";
});
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/workspace/cwd-match/.fusion/fusion.db");
vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/cwd-match");
const core = await getCentralCore();
core.listProjects.mockResolvedValue([mockProject]);
@@ -329,8 +319,8 @@ describe("Project Resolver", () => {
updatedAt: "",
};
vi.mocked(existsSync).mockImplementation((path) => String(path) === "/workspace/missing-cwd/.fusion");
vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any);
mockIsValidSqliteDatabaseFile.mockImplementation((path) => String(path) === "/workspace/missing-cwd/.fusion/fusion.db");
vi.mocked(existsSync).mockReturnValue(false);
const core = await getCentralCore();
core.listProjects.mockResolvedValue([match]);

View File

@@ -0,0 +1,286 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import kbExtension from "../extension.js";
import { TaskStore } from "@fusion/core";
interface RegisteredTool {
name: string;
execute: (
toolCallId: string,
params: any,
signal: AbortSignal | undefined,
onUpdate: ((update: any) => void) | undefined,
ctx: any,
) => Promise<any>;
}
function createMockAPI() {
const tools = new Map<string, RegisteredTool>();
return {
registerTool(def: RegisteredTool) {
tools.set(def.name, def);
},
registerCommand() {},
registerShortcut() {},
registerFlag() {},
on() {},
tools,
} as any;
}
function makeCtx(cwd: string) {
return { cwd } as any;
}
describe("research extension tools", () => {
let tmpDir: string;
let api: ReturnType<typeof createMockAPI>;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-research-test-"));
api = createMockAPI();
kbExtension(api);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it("registers research extension tools", () => {
expect(api.tools.has("fn_research_run")).toBe(true);
expect(api.tools.has("fn_research_list")).toBe(true);
expect(api.tools.has("fn_research_get")).toBe(true);
expect(api.tools.has("fn_research_cancel")).toBe(true);
expect(api.tools.has("fn_research_retry")).toBe(true);
});
it("returns feature-disabled response when experimental research flag is off", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record<string, boolean> });
const runTool = api.tools.get("fn_research_run")!;
const result = await runTool.execute("call-1", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
expect(result.details.setup.code).toBe("feature-disabled");
expect(result.content[0].text).toContain("disabled");
});
it("returns feature-disabled contract for list/get/cancel/retry when flag is off", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateSettings({ researchSettings: { enabled: true }, experimentalFeatures: { researchView: false } as Record<string, boolean> });
const listResult = await api.tools.get("fn_research_list")!.execute("call-list", {}, undefined, undefined, makeCtx(tmpDir));
expect(listResult.details.setup.code).toBe("feature-disabled");
const getResult = await api.tools.get("fn_research_get")!.execute("call-get", { id: "RR-1" }, undefined, undefined, makeCtx(tmpDir));
expect(getResult.details.setup.code).toBe("feature-disabled");
const cancelResult = await api.tools.get("fn_research_cancel")!.execute("call-cancel", { id: "RR-1" }, undefined, undefined, makeCtx(tmpDir));
expect(cancelResult.isError).toBe(true);
expect(cancelResult.details.setup.code).toBe("feature-disabled");
const retryResult = await api.tools.get("fn_research_retry")!.execute("call-retry", { id: "RR-1" }, undefined, undefined, makeCtx(tmpDir));
expect(retryResult.isError).toBe(true);
expect(retryResult.details.setup.code).toBe("feature-disabled");
});
it("treats builtin as configured when no provider is explicitly set", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
});
await store.updateSettings({
researchSettings: { enabled: true },
});
const runTool = api.tools.get("fn_research_run")!;
const result = await runTool.execute("call-builtin", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
expect(result.details.setup).toBeNull();
expect(result.details.status).toBe("queued");
});
it("returns actionable missing-credentials response", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "tavily",
researchGlobalDefaults: { searchProvider: "tavily" },
});
await store.updateSettings({
researchSettings: { enabled: true },
});
const runTool = api.tools.get("fn_research_run")!;
const result = await runTool.execute("call-0", { query: "fusion" }, undefined, undefined, makeCtx(tmpDir));
expect(result.details.setup.code).toBe("missing-credentials");
expect(result.content[0].text).toContain("Missing credentials");
});
it("creates, reads, lists, and cancels runs", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const created = store.getResearchStore().createRun({ query: "fusion architecture", topic: "fusion architecture" });
const listTool = api.tools.get("fn_research_list")!;
const listResult = await listTool.execute("call-2", {}, undefined, undefined, makeCtx(tmpDir));
expect(listResult.details.runs.length).toBeGreaterThan(0);
const getTool = api.tools.get("fn_research_get")!;
const getResult = await getTool.execute("call-3", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
expect(getResult.details.runId).toBe(created.id);
const cancelTool = api.tools.get("fn_research_cancel")!;
const cancelResult = await cancelTool.execute("call-4", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
expect(["cancelling", "cancelled"]).toContain(cancelResult.details.status);
const retryTool = api.tools.get("fn_research_retry")!;
const retryBlocked = await retryTool.execute("call-5", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
expect(retryBlocked.isError).toBe(true);
});
it("returns structured missing-run details for get and cancel", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const getTool = api.tools.get("fn_research_get")!;
const getResult = await getTool.execute("call-missing-get", { id: "RR-404" }, undefined, undefined, makeCtx(tmpDir));
expect(getResult.details.runId).toBe("RR-404");
expect(getResult.details.status).toBe("missing");
expect(getResult.details.setup.code).toBe("NOT_FOUND");
const cancelTool = api.tools.get("fn_research_cancel")!;
const cancelResult = await cancelTool.execute("call-missing-cancel", { id: "RR-404" }, undefined, undefined, makeCtx(tmpDir));
expect(cancelResult.isError).toBe(true);
expect(cancelResult.details.runId).toBe("RR-404");
expect(cancelResult.details.setup.code).toBe("NOT_FOUND");
});
it("returns completed-run structured findings and citations", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const run = store.getResearchStore().createRun({ query: "fusion", topic: "fusion" });
store.getResearchStore().setResults(run.id, {
summary: "Summary text",
findings: [{ heading: "Finding A", content: "Detail A", sources: ["https://example.com/a"] }],
citations: [{ title: "Source A", url: "https://example.com/a" }],
} as any);
store.getResearchStore().updateStatus(run.id, "running");
store.getResearchStore().updateStatus(run.id, "completed");
const getTool = api.tools.get("fn_research_get")!;
const result = await getTool.execute("call-complete", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.details.runId).toBe(run.id);
expect(result.details.status).toBe("completed");
expect(result.details.summary).toBe("Summary text");
expect(result.details.findings).toHaveLength(1);
expect(result.details.findings[0]).toMatchObject({ heading: "Finding A", content: "Detail A" });
expect(result.details.citations).toHaveLength(1);
expect(result.details.citations[0]).toMatchObject({ title: "Source A", url: "https://example.com/a" });
});
it("retries failed run and returns retry linkage metadata", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const run = store.getResearchStore().createRun({
query: "fusion",
topic: "fusion",
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
});
store.getResearchStore().updateStatus(run.id, "running", {
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
});
store.getResearchStore().updateStatus(run.id, "failed", {
lifecycle: { retryable: true, attempt: 1, maxAttempts: 3, failureClass: "retryable_transient" },
});
const retryTool = api.tools.get("fn_research_retry")!;
const retryResult = await retryTool.execute("call-retry", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
expect(retryResult.isError).not.toBe(true);
expect(["queued", "retry_waiting"]).toContain(retryResult.details.status);
expect(retryResult.details.runId).not.toBe(run.id);
const retried = store.getResearchStore().getRun(retryResult.details.runId);
expect(retried?.status).toBe("retry_waiting");
expect(retried?.lifecycle?.retryOfRunId).toBe(run.id);
expect(retried?.lifecycle?.rootRunId).toBe(run.id);
expect(retried?.lifecycle?.attempt).toBe(2);
});
it("returns INVALID_TRANSITION for cancel on terminal run", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.updateGlobalSettings({
experimentalFeatures: { researchView: true } as Record<string, boolean>,
researchGlobalEnabled: true,
researchGlobalWebSearchProvider: "searxng",
researchGlobalSearxngUrl: "http://localhost:8888",
researchGlobalDefaults: { searchProvider: "searxng" },
});
await store.updateSettings({
researchSettings: { enabled: true, searchProvider: "searxng" },
});
const run = store.getResearchStore().createRun({ query: "fusion", topic: "fusion" });
store.getResearchStore().updateStatus(run.id, "running");
store.getResearchStore().updateStatus(run.id, "completed");
const cancelTool = api.tools.get("fn_research_cancel")!;
const result = await cancelTool.execute("call-6", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
expect(result.isError).toBe(true);
expect(result.details.setup.code).toBe("INVALID_TRANSITION");
});
});

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import {
decideExecutionPlan,
normalizeForwardedArgs,
resolveAffectedPackages,
shouldForceFullSuite,
} from "../../../../scripts/test-changed.mjs";
import { parseShardArgs, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
describe("root test command changed-only planning", () => {
it("uses changed mode when package-only changes are detected", () => {
const packageMap = new Map([
["packages/core", "@fusion/core"],
["packages/engine", "@fusion/engine"],
]);
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: ["packages/core/src/store.ts", "packages/engine/src/index.ts"],
packageNameByDir: packageMap,
});
expect(plan).toEqual({ mode: "changed", packages: ["@fusion/core", "@fusion/engine"] });
});
it("falls back to full suite when shared test infra changes", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: ["scripts/test-with-lock.mjs"],
packageNameByDir: new Map([["packages/core", "@fusion/core"]]),
});
expect(plan).toEqual({ mode: "full", reason: "shared-infra-changed" });
});
it("falls back to full suite when comparison base cannot be resolved", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: null,
changedFiles: null,
packageNameByDir: new Map(),
});
expect(plan).toEqual({ mode: "full", reason: "missing-comparison-base" });
});
it("treats unknown package directories as full-suite fallback", () => {
const resolved = resolveAffectedPackages(["packages/unknown/src/index.ts"], new Map());
expect(resolved).toBeNull();
});
it("marks root workflow/config changes as full-suite triggers", () => {
expect(shouldForceFullSuite([".github/workflows/ci.yml"])).toBe(true);
expect(shouldForceFullSuite(["package.json"])).toBe(true);
expect(shouldForceFullSuite(["packages/core/src/store.ts"])).toBe(false);
});
it("strips forwarded silent flags so package vitest scripts do not receive duplicates", () => {
expect(
normalizeForwardedArgs(["--full", "--silent", "--silent=passed-only", "--reporter=dot"]),
).toEqual(["--reporter=dot"]);
});
});
describe("CI shard test planner", () => {
it("parses valid shard args", () => {
expect(parseShardArgs(["--shard", "2", "--total", "3"], {} as NodeJS.ProcessEnv)).toEqual({
shard: 2,
total: 3,
});
});
it("rejects invalid shard args", () => {
expect(() => parseShardArgs(["--shard", "4", "--total", "3"], {} as NodeJS.ProcessEnv)).toThrow(
"Usage: node scripts/ci-test-shard.mjs --shard <1..N> --total <N>",
);
});
it("selects deterministic package partitions", () => {
const packages = ["a", "b", "c", "d", "e"];
expect(selectShardPackages(packages, 1, 3)).toEqual(["a", "d"]);
expect(selectShardPackages(packages, 2, 3)).toEqual(["b", "e"]);
expect(selectShardPackages(packages, 3, 3)).toEqual(["c"]);
});
});

View File

@@ -1,10 +1,4 @@
/**
* Global test isolation for CLI package.
* @see packages/core/src/__tests__/setup-test-isolation.ts
* Deprecated shim: canonical test isolation is in @fusion/core vitest-setup.
*/
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const tempHome = mkdtempSync(join(tmpdir(), "fn-test-home-"));
process.env.HOME = tempHome;
import "../../../core/src/__test-utils__/vitest-setup";

View File

@@ -413,6 +413,7 @@ describe("runTaskPlan", () => {
description: "A well-planned task",
column: "triage",
dependencies: ["FN-001"],
source: { sourceType: "cli" },
});
});

View File

@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { readFileSync } from "node:fs";
const CLI_PACKAGE_VERSION = (
JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as { version: string }
).version;
const cacheDir = "/tmp/fusion-update-cache-test";
const { mockResolveGlobalDir } = vi.hoisted(() => ({
mockResolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-update-cache-test"),
}));
vi.mock("@fusion/core", () => ({
resolveGlobalDir: mockResolveGlobalDir,
GlobalSettingsStore: vi.fn(),
}));
const { getCachedUpdateStatus } = await import("../update-cache.js");
function writeUpdateCache(payload: { updateAvailable: boolean; latestVersion: string; currentVersion: string }): void {
mkdirSync(cacheDir, { recursive: true });
writeFileSync(`${cacheDir}/update-check.json`, JSON.stringify(payload), "utf-8");
}
beforeEach(() => {
rmSync(cacheDir, { recursive: true, force: true });
mockResolveGlobalDir.mockReset();
mockResolveGlobalDir.mockReturnValue(cacheDir);
});
describe("getCachedUpdateStatus", () => {
it("returns the cached update when it matches the installed CLI version", () => {
writeUpdateCache({
updateAvailable: true,
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: "9.9.9",
});
expect(getCachedUpdateStatus(CLI_PACKAGE_VERSION)).toEqual({
updateAvailable: true,
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: "9.9.9",
});
});
it("ignores stale cached updates from a different installed CLI version", () => {
writeUpdateCache({
updateAvailable: true,
currentVersion: "0.0.1",
latestVersion: "9.9.9",
});
expect(getCachedUpdateStatus(CLI_PACKAGE_VERSION)).toBeNull();
});
});

View File

@@ -24,13 +24,22 @@ describe("Changeset configuration", () => {
});
it("should have changeset scripts in root package.json", () => {
// These scripts drive the changesets CLI workflow: changeset (add), version (bump), release:version (apply)
// These scripts drive the changesets CLI workflow: changeset (add), version (bump), release:version (apply + sync workspace version)
const pkgPath = join(repoRoot, "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
expect(pkg.scripts.changeset).toBe("changeset");
expect(pkg.scripts.version).toBe("changeset version");
expect(pkg.scripts["release:version"]).toBe("changeset version");
expect(pkg.scripts["release:version"]).toBe("changeset version && node scripts/sync-workspace-version.mjs");
});
it("should keep the workspace package.json version aligned with the published CLI package", () => {
const workspacePkgPath = join(repoRoot, "package.json");
const cliPkgPath = join(repoRoot, "packages", "cli", "package.json");
const workspacePkg = JSON.parse(readFileSync(workspacePkgPath, "utf-8"));
const cliPkg = JSON.parse(readFileSync(cliPkgPath, "utf-8"));
expect(workspacePkg.version).toBe(cliPkg.version);
});
it("should have .github/workflows/version.yml configured for manual releases", () => {

View File

@@ -1,24 +1,68 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { existsSync, renameSync, rmSync } from "node:fs";
import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import vitestConfig from "../../vitest.config";
const cliRoot = join(__dirname, "..", "..");
const workspaceRoot = join(cliRoot, "..", "..");
const hiddenDistRootPrefix = ".tmp-fn-vitest-workspace-resolution-";
const hiddenDistRoot = join(workspaceRoot, `${hiddenDistRootPrefix}${process.pid}`);
const internalPackages = ["core", "engine", "dashboard"] as const;
const movedDistDirs: Array<{ from: string; to: string }> = [];
function rmSyncWithRetry(path: string) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
rmSync(path, { recursive: true, force: true });
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOTEMPTY" && code !== "EPERM" && code !== "EEXIST") {
throw error;
}
if (attempt === 4) {
throw error;
}
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1));
}
}
}
function cleanupStaleHiddenDistRoots() {
for (const entry of readdirSync(workspaceRoot)) {
if (!entry.startsWith(hiddenDistRootPrefix)) {
continue;
}
const fullPath = join(workspaceRoot, entry);
try {
if (!lstatSync(fullPath).isDirectory()) {
continue;
}
rmSyncWithRetry(fullPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
throw error;
}
}
}
}
function hideInternalPackageDistDirs() {
cleanupStaleHiddenDistRoots();
mkdirSync(hiddenDistRoot, { recursive: true });
for (const pkg of internalPackages) {
const distPath = join(workspaceRoot, "packages", pkg, "dist");
if (!existsSync(distPath)) {
continue;
}
const hiddenPath = `${distPath}.__fn2360-hidden-${process.pid}`;
const hiddenPath = join(hiddenDistRoot, `${pkg}-dist`);
if (existsSync(hiddenPath)) {
rmSync(hiddenPath, { recursive: true, force: true });
rmSyncWithRetry(hiddenPath);
}
renameSync(distPath, hiddenPath);
movedDistDirs.push({ from: distPath, to: hiddenPath });
@@ -33,14 +77,37 @@ function restoreInternalPackageDistDirs() {
}
if (existsSync(from)) {
rmSync(from, { recursive: true, force: true });
rmSyncWithRetry(from);
}
renameSync(to, from);
}
movedDistDirs.length = 0;
rmSyncWithRetry(hiddenDistRoot);
}
describe("vitest workspace temp dir cleanup", () => {
it("cleans stale workspace-resolution temp dirs without touching unrelated tmp dirs", () => {
const staleDir = join(workspaceRoot, `${hiddenDistRootPrefix}stale-test`);
const staleMarker = join(staleDir, "marker.txt");
const unrelatedTmpDir = join(workspaceRoot, ".tmp-fn-other-marker-test");
rmSyncWithRetry(staleDir);
rmSyncWithRetry(unrelatedTmpDir);
mkdirSync(staleDir, { recursive: true });
writeFileSync(staleMarker, "stale");
mkdirSync(unrelatedTmpDir, { recursive: true });
cleanupStaleHiddenDistRoots();
expect(existsSync(staleDir)).toBe(false);
expect(existsSync(unrelatedTmpDir)).toBe(true);
rmSyncWithRetry(unrelatedTmpDir);
});
});
describe("CLI Vitest workspace resolution", () => {
beforeAll(() => {
hideInternalPackageDistDirs();
@@ -81,6 +148,14 @@ describe("CLI Vitest workspace resolution", () => {
find: String(/^@fusion\/dashboard$/),
replacement: join(workspaceRoot, "packages", "dashboard", "src", "index.ts"),
},
{
find: String(/^@fusion-plugin-examples\/droid-runtime\/probe$/),
replacement: join(workspaceRoot, "plugins", "fusion-plugin-droid-runtime", "src", "probe.ts"),
},
{
find: String(/^@fusion-plugin-examples\/droid-runtime$/),
replacement: join(workspaceRoot, "plugins", "fusion-plugin-droid-runtime", "src", "index.ts"),
},
{
find: String(/^@fusion\/test-utils$/),
replacement: join(workspaceRoot, "packages", "core", "src", "__test-utils__", "workspace.ts"),
@@ -89,7 +164,10 @@ describe("CLI Vitest workspace resolution", () => {
);
for (const entry of normalized) {
expect(entry.replacement).toContain(`${join("packages", "")}`);
expect(
entry.replacement.includes(`${join("packages", "")}`) ||
entry.replacement.includes(`${join("plugins", "")}`),
).toBe(true);
expect(entry.replacement).toContain(`${join("src", "")}`);
expect(entry.replacement).not.toContain(`${join("dist", "")}`);
}

View File

@@ -13,9 +13,10 @@
*/
import { existsSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { join, dirname } from "node:path";
import { join, dirname, resolve } from "node:path";
import { tmpdir } from "node:os";
import { performance } from "node:perf_hooks";
import { fileURLToPath } from "node:url";
// @ts-expect-error -- Bun-only global; undefined in Node
const isBunBinary = typeof Bun !== "undefined" && !!Bun.embeddedFiles;
@@ -117,12 +118,13 @@ async function loadCommandHandlers() {
const { runServe } = await import("./commands/serve.js");
const { runDaemon } = await import("./commands/daemon.js");
const { runDesktop } = await import("./commands/desktop.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskPrCreate } = await import("./commands/task.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskPrCreate } = await import("./commands/task.js");
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 { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } = await import("./commands/memory-backup.js");
const { runMissionCreate, runMissionList, runMissionShow, runMissionDelete, runMissionActivateSlice } = await import("./commands/mission.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const { runNodeList, runNodeConnect, runNodeDisconnect, runNodeShow, runNodeHealth, runMeshStatus } = await import("./commands/node.js");
@@ -131,9 +133,11 @@ async function loadCommandHandlers() {
const { runAgentImport } = await import("./commands/agent-import.js");
const { runAgentExport } = await import("./commands/agent-export.js");
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable } = await import("./commands/plugin.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup, runPluginAvailable, runPluginSettings, runPluginRescan } = await import("./commands/plugin.js");
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
const { runUpdate } = await import("./commands/update.js");
return {
runDashboard,
@@ -162,6 +166,8 @@ async function loadCommandHandlers() {
runTaskComment,
runTaskComments,
runTaskSteer,
runTaskSetNode,
runTaskClearNode,
runTaskPrCreate,
runSettingsShow,
runSettingsSet,
@@ -175,6 +181,9 @@ async function loadCommandHandlers() {
runBackupList,
runBackupRestore,
runBackupCleanup,
runMemoryBackupCreate,
runMemoryBackupList,
runMemoryBackupRestore,
runMissionCreate,
runMissionList,
runMissionShow,
@@ -209,9 +218,21 @@ async function loadCommandHandlers() {
runPluginUninstall,
runPluginEnable,
runPluginDisable,
runPluginSetupStatus,
runPluginSetup,
runPluginAvailable,
runPluginSettings,
runPluginRescan,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
runResearchCreate,
runResearchList,
runResearchShow,
runResearchExport,
runResearchCancel,
runResearchRetry,
runUpdate,
};
}
@@ -220,7 +241,7 @@ fn — AI-orchestrated task board
Usage:
fn Launch the dashboard (same as fn dashboard)
fn init [opts] Initialize a new fn project in the current directory
fn init [opts] Initialize a new fn project (--name, --path, --git)
fn dashboard Start the board web UI
fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine)
@@ -233,7 +254,9 @@ Usage:
fn desktop Launch the Fusion desktop app (Electron)
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
fn desktop --paused Launch with automation paused
fn task create [desc] [opts] Create a new task (goes to triage)
fn update [--check] [--global] [--json] Update Fusion to the latest version
fn upgrade Alias for fn update
fn task create [desc] [opts] Create a new task (goes to triage; supports --node <name>)
fn task plan [description] [opts] Create task via AI-guided planning
fn task list List all tasks
fn task show <id> Show task details, steps, log
@@ -254,10 +277,23 @@ Usage:
fn task comment <id> [message] Add task comment (prompts if message omitted)
fn task comments <id> List task comments
fn task steer <id> [message] Add steering comment (prompts if message omitted)
fn task set-node <id> <node-name-or-id> Set a per-task node override
fn task clear-node <id> Clear a per-task node override
fn task retry <id> Retry a failed task (clears error, moves to todo)
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
Create a GitHub PR for an in-review task
fn task import <owner/repo> [opts] Import GitHub issues as tasks
fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]
Create and optionally wait for a research run
fn research list | ls [--status <status>] [--limit <n>] [--json]
List research runs
fn research show <run-id> [--json] Show research run details
fn research export <run-id> [--format <json|markdown|pdf>] [--output <path>] [--json]
Export research run results
fn research cancel <run-id> [--json]
Cancel an active research run
fn research retry <run-id> [--json]
Retry a failed/cancelled research run
fn mission create [title] [desc] Create a new mission
fn mission list | ls List missions
fn mission show | info <id> Show mission details
@@ -281,6 +317,8 @@ Usage:
fn mesh status [--json] Show full mesh state
fn settings Show current Fusion configuration
fn settings set <key> <value> Update a configuration setting
fn settings set defaultNodeId <node-id>
fn settings set unavailableNodePolicy <block|fallback-local>
fn settings export [opts] Export settings to a JSON file
fn settings import <file> [opts] Import settings from a JSON file
@@ -305,12 +343,24 @@ Usage:
fn backup --list List all database backups
fn backup --restore <file> Restore database from a backup file
fn backup --cleanup Remove old backups exceeding retention limit
fn memory-backup --create [--scope <project|agents|all>]
Create a memory backup immediately
fn memory-backup --list List all memory backups
fn memory-backup --restore <dir>
Restore memory from a backup directory snapshot
fn plugin list | ls List installed plugins
fn plugin install <path-or-package> Install a plugin from path or package
fn plugin install <path-or-package> [--ai-scan] Install a plugin from path or package
fn plugin add <path-or-package> Alias for plugin install
fn plugin uninstall <id> [--force] Uninstall a plugin
fn plugin enable <id> Enable a plugin
fn plugin disable <id> Disable a plugin
fn plugin available List built-in plugin catalog entries
fn plugin settings <id> [key] [value]
Read/update installed plugin settings
fn plugin rescan <id> Rescan and reload a plugin
fn plugin setup-status <id> Check plugin setup binary/runtime status
fn plugin setup <id> [--action install|uninstall]
Install or uninstall plugin setup binaries/runtimes
fn plugin create <name> Scaffold a new plugin project
fn skills search <query> Search skills.sh for agent skills
fn skills search <query> --limit 5 Limit results
@@ -338,9 +388,6 @@ Options:
Columns: triage, todo, in-progress, in-review, done, archived
Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv, xml
The AI engine uses pi (github.com/badlogic/pi-mono) for agent sessions.
Requires configured API keys — run "pi" first to set up authentication.
`.trim();
function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; projectName?: string } {
@@ -391,19 +438,62 @@ function getFlagValueNumber(args: string[], flag: string): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
/**
* Locate `@runfusion/fusion`'s own version by walking up from the running
* `bin.js`. Mirrors `packages/dashboard/src/cli-package-version.ts` but is
* inlined here to avoid pulling the dashboard barrel into the bin's static
* import graph (bin keeps app imports dynamic until env bootstrap is done).
*/
function readOwnCliVersion(): string | undefined {
let currentDir: string;
try {
currentDir = dirname(fileURLToPath(import.meta.url));
} catch {
return undefined;
}
for (let i = 0; i < 8; i += 1) {
const pkgPath = resolve(currentDir, "package.json");
if (existsSync(pkgPath)) {
try {
const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
name?: string;
version?: string;
};
if (parsed.name === "@runfusion/fusion" && typeof parsed.version === "string") {
return parsed.version;
}
} catch {
// Ignore malformed manifest and keep walking.
}
}
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) break;
currentDir = parentDir;
}
return undefined;
}
async function main() {
const { cleanedArgs: args, projectName } = extractGlobalProjectFlag(process.argv.slice(2));
// Print version and exit before any application imports. This is what the
// dashboard's CLI Binary panel probes via `<bin> --version`; without an
// early exit, the flag falls through to the default `dashboard` command and
// boots the full server.
if (args.includes("--version") || args.includes("-v")) {
console.log(readOwnCliVersion() ?? "unknown");
process.exit(0);
}
if (args.includes("--help") || args.includes("-h")) {
console.log(HELP);
process.exit(0);
}
if (args.length === 0) {
// No subcommand — launch dashboard on the default port.
const { runDashboard } = await import("./commands/dashboard.js");
await runDashboard(4040);
return;
// No subcommand (or only flags) — default to the dashboard command so flags
// like --no-auth, --port, --host, etc. work without typing `dashboard`.
if (args.length === 0 || args[0]!.startsWith("-")) {
args.unshift("dashboard");
}
const command = args[0];
@@ -435,6 +525,8 @@ async function main() {
runTaskComment,
runTaskComments,
runTaskSteer,
runTaskSetNode,
runTaskClearNode,
runTaskPrCreate,
runSettingsShow,
runSettingsSet,
@@ -448,6 +540,9 @@ async function main() {
runBackupList,
runBackupRestore,
runBackupCleanup,
runMemoryBackupCreate,
runMemoryBackupList,
runMemoryBackupRestore,
runMissionCreate,
runMissionList,
runMissionShow,
@@ -482,9 +577,21 @@ async function main() {
runPluginUninstall,
runPluginEnable,
runPluginDisable,
runPluginSetupStatus,
runPluginSetup,
runPluginAvailable,
runPluginSettings,
runPluginRescan,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
runResearchCreate,
runResearchList,
runResearchShow,
runResearchExport,
runResearchCancel,
runResearchRetry,
runUpdate,
} = await loadCommandHandlers();
try {
@@ -495,8 +602,9 @@ async function main() {
const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined;
const pathIdx = args.indexOf("--path");
const path = pathIdx !== -1 && pathIdx + 1 < args.length ? args[pathIdx + 1] : undefined;
const git = args.includes("--git");
await runInit({ name, path });
await runInit({ name, path, git });
break;
}
@@ -562,6 +670,16 @@ async function main() {
break;
}
case "update":
case "upgrade": {
await runUpdate({
check: args.includes("--check"),
global: args.includes("--global") ? true : undefined,
json: args.includes("--json"),
});
break;
}
case "project": {
const subcommand = args[1];
switch (subcommand) {
@@ -700,6 +818,85 @@ async function main() {
break;
}
case "research": {
const subcommand = args[1];
switch (subcommand) {
case "create": {
const query = getFlagValue(args, "--query") ?? args.slice(2).filter((value) => !value.startsWith("--")).join(" ").trim();
if (!query) {
console.error("Usage: fn research create --query <text> [--wait] [--max-wait-ms <ms>] [--json]");
process.exit(1);
}
await runResearchCreate({
query,
waitForCompletion: args.includes("--wait"),
maxWaitMs: getFlagValueNumber(args, "--max-wait-ms"),
json: args.includes("--json"),
projectName,
});
break;
}
case "list":
case "ls": {
const status = getFlagValue(args, "--status");
await runResearchList({
status,
limit: getFlagValueNumber(args, "--limit"),
json: args.includes("--json"),
projectName,
});
break;
}
case "show": {
const runId = args[2];
if (!runId) {
console.error("Usage: fn research show <run-id> [--json]");
process.exit(1);
}
await runResearchShow(runId, { json: args.includes("--json"), projectName });
break;
}
case "export": {
const runId = args[2];
if (!runId) {
console.error("Usage: fn research export <run-id> [--format <json|markdown|pdf>] [--output <path>] [--json]");
process.exit(1);
}
await runResearchExport({
runId,
format: getFlagValue(args, "--format"),
output: getFlagValue(args, "--output"),
json: args.includes("--json"),
projectName,
});
break;
}
case "cancel": {
const runId = args[2];
if (!runId) {
console.error("Usage: fn research cancel <run-id> [--json]");
process.exit(1);
}
await runResearchCancel(runId, { json: args.includes("--json"), projectName });
break;
}
case "retry": {
const runId = args[2];
if (!runId) {
console.error("Usage: fn research retry <run-id> [--json]");
process.exit(1);
}
await runResearchRetry(runId, { json: args.includes("--json"), projectName });
break;
}
default:
console.error(`Unknown subcommand: research ${subcommand || ""}`);
console.log("Try: fn research create | list | show | export | cancel | retry");
process.exit(1);
}
break;
}
case "task": {
const subcommand = args[1];
switch (subcommand) {
@@ -707,6 +904,7 @@ async function main() {
const createArgs = args.slice(2);
const attachFiles: string[] = [];
const dependsIds: string[] = [];
let nodeName: string | undefined;
const descParts: string[] = [];
for (let i = 0; i < createArgs.length; i++) {
if (createArgs[i] === "--attach" && i + 1 < createArgs.length) {
@@ -715,12 +913,15 @@ async function main() {
} else if (createArgs[i] === "--depends" && i + 1 < createArgs.length) {
dependsIds.push(createArgs[i + 1]);
i++; // skip the value
} else if (createArgs[i] === "--node" && i + 1 < createArgs.length) {
nodeName = createArgs[i + 1];
i++; // skip the value
} else {
descParts.push(createArgs[i]);
}
}
const title = descParts.join(" ");
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined, projectName);
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined, projectName, nodeName);
break;
}
case "plan": {
@@ -888,6 +1089,25 @@ async function main() {
await runTaskSteer(id, message || undefined, projectName);
break;
}
case "set-node": {
const id = args[2];
const nodeName = args[3];
if (!id || !nodeName) {
console.error("Usage: fn task set-node <id> <node-name-or-id>");
process.exit(1);
}
await runTaskSetNode(id, nodeName, projectName);
break;
}
case "clear-node": {
const id = args[2];
if (!id) {
console.error("Usage: fn task clear-node <id>");
process.exit(1);
}
await runTaskClearNode(id, projectName);
break;
}
case "retry": {
const id = args[2];
if (!id) {
@@ -970,7 +1190,7 @@ async function main() {
}
default:
console.error(`Unknown subcommand: task ${subcommand || ""}`);
console.log("Try: fn task create | list | move");
console.log("Try: fn task create | list | move | set-node | clear-node");
process.exit(1);
}
break;
@@ -1124,6 +1344,31 @@ async function main() {
break;
}
case "memory-backup": {
const create = args.includes("--create");
const list = args.includes("--list");
const restoreIdx = args.indexOf("--restore");
const restoreFile = restoreIdx !== -1 && restoreIdx + 1 < args.length ? args[restoreIdx + 1] : undefined;
const scopeIdx = args.indexOf("--scope");
const scope = scopeIdx !== -1 && scopeIdx + 1 < args.length ? args[scopeIdx + 1] : undefined;
if (create) {
if (scope && !["project", "agents", "all"].includes(scope)) {
console.error("Usage: fn memory-backup --create [--scope <project|agents|all>]");
process.exit(1);
}
await runMemoryBackupCreate({ projectName, scope: scope as "project" | "agents" | "all" | undefined });
} else if (list) {
await runMemoryBackupList(projectName);
} else if (restoreFile) {
await runMemoryBackupRestore(restoreFile, projectName);
} else {
console.error("Usage: fn memory-backup --create [--scope <project|agents|all>] | --list | --restore <filename>");
process.exit(1);
}
break;
}
case "agent": {
const subcommand = args[1];
switch (subcommand) {
@@ -1227,10 +1472,10 @@ async function main() {
case "add": {
const source = args[2];
if (!source) {
console.error("Usage: fn plugin install <path-or-package> (alias: fn plugin add <path-or-package>)");
console.error("Usage: fn plugin install <path-or-package> [--ai-scan] (alias: fn plugin add <path-or-package>)");
process.exit(1);
}
await runPluginInstall(source, { projectName });
await runPluginInstall(source, { projectName, aiScan: args.includes("--ai-scan") });
break;
}
case "uninstall": {
@@ -1252,6 +1497,40 @@ async function main() {
await runPluginDisable(id, { projectName });
break;
}
case "available": {
await runPluginAvailable();
break;
}
case "settings": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin settings <id> [key] [value]"); process.exit(1); }
await runPluginSettings(id, args[3], args[4], { projectName });
break;
}
case "rescan": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin rescan <id>"); process.exit(1); }
await runPluginRescan(id, { projectName });
break;
}
case "setup-status": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin setup-status <id>"); process.exit(1); }
await runPluginSetupStatus(id, { projectName });
break;
}
case "setup": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin setup <id> [--action install|uninstall]"); process.exit(1); }
const actionIndex = args.indexOf("--action");
const action = actionIndex >= 0 ? args[actionIndex + 1] : "install";
if (action !== "install" && action !== "uninstall") {
console.error("--action must be install or uninstall");
process.exit(1);
}
await runPluginSetup(id, { action, projectName });
break;
}
case "create": {
const pluginName = args[2];
if (!pluginName) { console.error("Usage: fn plugin create <name>"); process.exit(1); }
@@ -1260,7 +1539,7 @@ async function main() {
}
default:
console.error(`Unknown subcommand: plugin ${sub || ""}`);
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | create");
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | available | settings | rescan | setup-status | setup | create");
process.exit(1);
}
break;

View File

@@ -13,7 +13,13 @@ vi.mock("../../project-context.js", () => ({
import { runAgentExport } from "../agent-export.js";
describe("agent-export", () => {
// Slow lane only: each test spins up a real workspace + AgentStore round-trip
// and totals ~3.3s. Keep default `pnpm test` lean/reliable; run this suite in
// the explicit slow lane (`pnpm --filter @runfusion/fusion test:slow-cli`).
const SHOULD_RUN_SLOW_CLI =
process.env.FUSION_TEST_SLOW_CLI === "1" || process.env.FUSION_TEST_SLOW_CLI === "true";
describe.skipIf(!SHOULD_RUN_SLOW_CLI)("agent-export", () => {
const tmpRoot = join(tmpdir(), `fn-agent-export-test-${process.pid}`);
let projectDir: string;
let outputDir: string;

View File

@@ -16,11 +16,10 @@ vi.mock("@fusion/core", () => ({
})),
AGENT_VALID_TRANSITIONS: {
idle: ["active"],
active: ["running", "paused", "terminated"],
running: ["active", "paused", "error", "terminated"],
paused: ["active", "terminated"],
error: ["active", "terminated"],
terminated: ["idle", "active", "running"],
active: ["running", "paused"],
running: ["active", "paused", "error"],
paused: ["active"],
error: ["active"],
},
}));
@@ -124,14 +123,6 @@ describe("runAgentStop", () => {
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("should reject stopping a terminated agent (invalid transition)", async () => {
mockGetAgent.mockResolvedValue(makeAgent("terminated"));
await expect(runAgentStop("agent-test123")).rejects.toThrow("process.exit");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("cannot transition to 'paused'"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
});
describe("runAgentStart", () => {
@@ -152,16 +143,6 @@ describe("runAgentStart", () => {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should start a terminated agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("terminated"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));
await runAgentStart("agent-test123");
expect(mockUpdateAgentState).toHaveBeenCalledWith("agent-test123", "active");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("✓ Agent agent-test123 started"));
});
it("should start an idle agent", async () => {
mockGetAgent.mockResolvedValue(makeAgent("idle"));
mockUpdateAgentState.mockResolvedValue(makeAgent("active"));

View File

@@ -0,0 +1,167 @@
import { describe, expect, it, vi } from "vitest";
import type { CustomProvider } from "@fusion/core";
import {
registerCustomProviders,
reregisterCustomProviders,
resolveApiType,
} from "../custom-provider-registry.js";
describe("custom-provider-registry", () => {
it.each([
["openai-compatible", "openai-completions"],
["anthropic-compatible", "anthropic"],
])("resolveApiType maps %s -> %s", (apiType, expectedApi) => {
expect(resolveApiType(apiType)).toBe(expectedApi);
});
it("registers providers with expected config shape", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
const logFn = vi.fn();
const providers: CustomProvider[] = [
{
id: "openai-custom",
name: "OpenAI Custom",
apiType: "openai-compatible",
baseUrl: "https://example.test/v1",
apiKey: "CUSTOM_KEY",
models: [{ id: "m1", name: "Model 1" }],
},
{
id: "anthropic-custom",
name: "Anthropic Custom",
apiType: "anthropic-compatible",
baseUrl: "https://anthropic.test",
apiKey: "ANTHROPIC_KEY",
models: [{ id: "claude-x", name: "Claude X" }],
},
];
registerCustomProviders({ registerProvider, refresh }, providers, logFn);
expect(registerProvider).toHaveBeenNthCalledWith(1, "openai-custom", expect.objectContaining({
baseUrl: "https://example.test/v1",
api: "openai-completions",
apiKey: "CUSTOM_KEY",
models: [expect.objectContaining({ id: "m1", name: "Model 1" })],
}));
expect(registerProvider).toHaveBeenNthCalledWith(2, "anthropic-custom", expect.objectContaining({
baseUrl: "https://anthropic.test",
api: "anthropic",
apiKey: "ANTHROPIC_KEY",
models: [expect.objectContaining({ id: "claude-x", name: "Claude X" })],
}));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("handles empty provider list and still refreshes", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
registerCustomProviders({ registerProvider, refresh }, [], vi.fn());
expect(registerProvider).not.toHaveBeenCalled();
expect(refresh).toHaveBeenCalledTimes(1);
});
it("uses empty models when models is missing", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
registerCustomProviders(
{ registerProvider, refresh },
[{
id: "no-models",
name: "No Models",
apiType: "openai-compatible",
baseUrl: "https://nomodels.test",
}],
vi.fn(),
);
expect(registerProvider).toHaveBeenCalledWith("no-models", expect.objectContaining({ models: [] }));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("continues when one provider registration fails", () => {
const registerProvider = vi
.fn()
.mockImplementationOnce(() => {
throw new Error("boom");
})
.mockImplementationOnce(() => undefined);
const refresh = vi.fn();
const logFn = vi.fn();
registerCustomProviders(
{ registerProvider, refresh },
[
{
id: "bad",
name: "Bad",
apiType: "openai-compatible",
baseUrl: "https://bad.test",
},
{
id: "good",
name: "Good",
apiType: "openai-compatible",
baseUrl: "https://good.test",
},
],
logFn,
);
expect(registerProvider).toHaveBeenCalledTimes(2);
expect(logFn).toHaveBeenCalledWith(expect.stringContaining("Failed to register custom provider bad"));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("reregisters new providers", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
reregisterCustomProviders(
{ registerProvider, refresh },
[{ id: "old", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" }],
[
{ id: "old", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" },
{ id: "new", name: "New", apiType: "anthropic-compatible", baseUrl: "https://new.test" },
],
vi.fn(),
);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledWith("new", expect.objectContaining({ api: "anthropic" }));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("reregisters changed providers", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
reregisterCustomProviders(
{ registerProvider, refresh },
[{ id: "same-id", name: "Provider", apiType: "openai-compatible", baseUrl: "https://one.test", apiKey: "A" }],
[{ id: "same-id", name: "Provider", apiType: "openai-compatible", baseUrl: "https://two.test", apiKey: "B" }],
vi.fn(),
);
expect(registerProvider).toHaveBeenCalledTimes(1);
expect(registerProvider).toHaveBeenCalledWith("same-id", expect.objectContaining({
baseUrl: "https://two.test",
apiKey: "B",
}));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("handles empty previous/current arrays", () => {
const registerProvider = vi.fn();
const refresh = vi.fn();
reregisterCustomProviders({ registerProvider, refresh }, [], [], vi.fn());
expect(registerProvider).not.toHaveBeenCalled();
expect(refresh).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
const mocks = vi.hoisted(() => {
type ListenCall = {
port: number;
@@ -47,6 +55,7 @@ const mocks = vi.hoisted(() => {
const missionStore = {
listMissions: vi.fn().mockResolvedValue([]),
};
const pluginStore = pluginStoreCtor();
return {
init: vi.fn().mockResolvedValue(undefined),
@@ -55,6 +64,7 @@ const mocks = vi.hoisted(() => {
getFusionDir: vi.fn().mockReturnValue("/repo/.fusion"),
getRootDir: vi.fn().mockReturnValue("/repo"),
getMissionStore: vi.fn().mockReturnValue(missionStore),
getPluginStore: vi.fn().mockReturnValue(pluginStore),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
recycleWorktrees: false,
@@ -255,6 +265,7 @@ const mocks = vi.hoisted(() => {
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
@@ -451,7 +462,9 @@ const mocks = vi.hoisted(() => {
};
});
vi.mock("@fusion/core", () => ({
vi.mock("@fusion/core", async (importOriginal) => {
const { createCliCoreMock } = await import("../../test/mockCoreEngine");
return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
TaskStore: mocks.taskStoreCtor,
AutomationStore: mocks.automationStoreCtor,
AgentStore: mocks.agentStoreCtor,
@@ -473,7 +486,8 @@ vi.mock("@fusion/core", () => ({
syncInsightExtractionAutomation: mocks.syncInsightExtractionAutomationMock,
INSIGHT_EXTRACTION_SCHEDULE_NAME: "Memory Insight Extraction",
processAndAuditInsightExtraction: mocks.processAndAuditInsightExtractionMock,
}));
});
});
vi.mock("@fusion/dashboard", () => ({
createServer: mocks.createServerMock,
@@ -483,7 +497,9 @@ vi.mock("@fusion/dashboard", () => ({
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
}));
vi.mock("@fusion/engine", () => ({
vi.mock("@fusion/engine", async (importOriginal) => {
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
ProjectEngine: mocks.projectEngineCtor,
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => {
const engines = new Map<string, any>();
@@ -545,7 +561,8 @@ vi.mock("@fusion/engine", () => ({
createAiPromptExecutor: mocks.createAiPromptExecutorMock,
HeartbeatMonitor: mocks.heartbeatMonitorCtor,
HeartbeatTriggerScheduler: mocks.heartbeatTriggerSchedulerCtor,
}));
});
});
vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: {
@@ -577,6 +594,11 @@ vi.mock("../task-lifecycle.js", () => ({
const { runDaemon } = await import("../daemon.js");
describe("runDaemon", () => {
it("invokes shared startup model sync", async () => {
const { runDaemon } = await import("../daemon.js");
await runDaemon({});
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
});
const originalCwd = process.cwd;
const originalExit = process.exit;
@@ -647,6 +669,39 @@ describe("runDaemon", () => {
await triggerSignal("SIGINT");
});
it("auto-loads installed plugins during startup", async () => {
const { PluginLoader } = await import("@fusion/core");
await runDaemon({});
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
| { loadAllPlugins: ReturnType<typeof vi.fn> }
| undefined;
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("continues startup when plugin auto-load fails", async () => {
const { PluginLoader } = await import("@fusion/core");
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
}));
await expect(runDaemon({})).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[plugins] Failed to load plugins: plugin load failed")
);
await triggerSignal("SIGINT");
});
it("passes provided token to createServer daemon option", async () => {
const providedToken = "fn_custom_token_1234567890123456";

View File

@@ -1,5 +1,20 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
const CLI_PACKAGE_VERSION = (
JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf-8")) as { version: string }
).version;
// ── Capture instances & arguments ───────────────────────────────────
@@ -19,6 +34,7 @@ const {
mockGlobalSettingsGetSettings,
mockGlobalSettingsUpdateSettings,
mockDaemonTokenGetOrCreate,
mockGetCliPackageVersion,
} = vi.hoisted(() => {
delete process.env.FUSION_DASHBOARD_TOKEN;
delete process.env.FUSION_DAEMON_TOKEN;
@@ -39,10 +55,11 @@ const {
mockSelfHealingStop: vi.fn(),
mockCheckStuckBudget: vi.fn().mockResolvedValue(true),
mockStuckCheckNow: vi.fn().mockResolvedValue(undefined),
mockResolveGlobalDir: vi.fn().mockReturnValue("/tmp/test-global"),
mockResolveGlobalDir: vi.fn(),
mockGlobalSettingsGetSettings: vi.fn().mockResolvedValue({}),
mockGlobalSettingsUpdateSettings: vi.fn().mockResolvedValue({}),
mockDaemonTokenGetOrCreate: vi.fn().mockResolvedValue("fn_test_dashboard_token"),
mockGetCliPackageVersion: vi.fn(),
};
});
@@ -60,6 +77,17 @@ function makeMockStore() {
listMilestones: vi.fn().mockReturnValue([]),
listFeatures: vi.fn().mockReturnValue([]),
};
const mockPluginStore = {
init: vi.fn().mockResolvedValue(undefined),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
registerPlugin: vi.fn(),
enablePlugin: vi.fn(),
disablePlugin: vi.fn(),
updatePluginSettings: vi.fn(),
unregisterPlugin: vi.fn(),
updatePluginState: vi.fn(),
};
return {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
@@ -78,9 +106,15 @@ function makeMockStore() {
updatePrInfo: vi.fn().mockResolvedValue({}),
logEntry: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue({}),
getRootDir: vi.fn().mockReturnValue("/tmp/test"),
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
getGlobalSettingsStore: vi.fn(() => ({
getSettings: mockGlobalSettingsGetSettings,
updateSettings: mockGlobalSettingsUpdateSettings,
})),
getActiveMergingTask: vi.fn().mockReturnValue(undefined),
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
getPluginStore: vi.fn().mockReturnValue(mockPluginStore),
close: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
@@ -94,7 +128,9 @@ function makeMockStore() {
// ── Mock @fusion/core ──────────────────────────────────────────────────
vi.mock("@fusion/core", () => ({
vi.mock("@fusion/core", async (importOriginal) => {
const { createCliCoreMock } = await import("../../test/mockCoreEngine");
return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
TaskStore: vi.fn().mockImplementation(() => makeMockStore()),
CentralCore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
@@ -173,6 +209,7 @@ vi.mock("@fusion/core", () => ({
getEnabledPiExtensionPaths: vi.fn(() => []),
resolveGlobalDir: mockResolveGlobalDir,
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
getSettings: mockGlobalSettingsGetSettings,
updateSettings: mockGlobalSettingsUpdateSettings,
})),
@@ -193,7 +230,8 @@ vi.mock("@fusion/core", () => ({
}
return undefined;
}),
}));
});
});
// ── Hoisted shared mocks ───────────────────────────────────────────
@@ -275,8 +313,10 @@ vi.mock("@fusion/dashboard", () => ({
mergePr: mockMergePr,
})),
createSkillsAdapter: vi.fn().mockReturnValue(undefined),
getCliPackageVersion: mockGetCliPackageVersion,
getProjectSettingsPath: vi.fn().mockReturnValue("/tmp/project/.fusion/settings.json"),
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
stopAllDevServers: vi.fn().mockResolvedValue(undefined),
}));
// ── Mock node:readline ──────────────────────────────────────────────
@@ -292,6 +332,7 @@ const { WorktreePool } = await import("@fusion/engine");
vi.mock("@fusion/engine", async (importOriginal) => {
const original = await importOriginal<typeof import("@fusion/engine")>();
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
const TriageProcessor = vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
@@ -597,8 +638,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
}
}
return {
...original,
return createCliEngineMock(async () => original, {}, {
// Keep real WorktreePool & AgentSemaphore
WorktreePool: original.WorktreePool,
AgentSemaphore: original.AgentSemaphore,
@@ -666,9 +706,13 @@ vi.mock("@fusion/engine", async (importOriginal) => {
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
})),
PeerExchangeService: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn().mockResolvedValue(undefined),
})),
scanIdleWorktrees: vi.fn().mockResolvedValue([]),
cleanupOrphanedWorktrees: vi.fn().mockResolvedValue(0),
};
});
});
// ── Mock @mariozechner/pi-coding-agent ──────────────────────────────
@@ -713,6 +757,13 @@ async function runDashboard(...args: Parameters<typeof runDashboardImpl>): Retur
// ── Tests ───────────────────────────────────────────────────────────
describe("runDashboard — startup model sync", () => {
it("invokes shared startup model sync", async () => {
await runDashboard(0, { open: false });
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
});
});
function resetGitHubMocks() {
mockFindPrForBranch.mockReset();
mockCreatePr.mockReset();
@@ -755,6 +806,13 @@ function resetGitHubMocks() {
});
}
let updateCacheDir = "";
function writeUpdateCache(payload: { updateAvailable: boolean; latestVersion: string; currentVersion: string }): void {
mkdirSync(updateCacheDir, { recursive: true });
writeFileSync(`${updateCacheDir}/update-check.json`, JSON.stringify(payload), "utf-8");
}
beforeEach(() => {
delete process.env.FUSION_DASHBOARD_TOKEN;
delete process.env.FUSION_DAEMON_TOKEN;
@@ -766,18 +824,28 @@ beforeEach(() => {
mockExec.mockClear();
mockStuckCheckNow.mockReset();
mockStuckCheckNow.mockResolvedValue(undefined);
if (updateCacheDir) {
rmSync(updateCacheDir, { recursive: true, force: true });
}
updateCacheDir = mkdtempSync(join(tmpdir(), "fusion-dashboard-test-"));
mockResolveGlobalDir.mockReset();
mockResolveGlobalDir.mockReturnValue("/tmp/test-global");
mockResolveGlobalDir.mockReturnValue(updateCacheDir);
mockGlobalSettingsGetSettings.mockReset();
mockGlobalSettingsGetSettings.mockResolvedValue({});
mockGlobalSettingsUpdateSettings.mockReset();
mockGlobalSettingsUpdateSettings.mockResolvedValue({});
mockDaemonTokenGetOrCreate.mockReset();
mockDaemonTokenGetOrCreate.mockResolvedValue("fn_test_dashboard_token");
mockGetCliPackageVersion.mockReset();
mockGetCliPackageVersion.mockReturnValue(CLI_PACKAGE_VERSION);
});
afterEach(() => {
disposeTrackedDashboards();
if (updateCacheDir) {
rmSync(updateCacheDir, { recursive: true, force: true });
}
updateCacheDir = "";
});
describe("PR merge helpers", () => {
@@ -833,6 +901,7 @@ describe("processPullRequestMergeTask", () => {
title: "FN-093: Add support for creating pull requests",
body: "Automated PR for FN-093.\n\nImplement PR automation",
head: "fusion/fn-093",
base: "main",
});
expect(store.updatePrInfo).toHaveBeenCalledWith(
"FN-093",
@@ -1058,6 +1127,36 @@ describe("runDashboard — PR-first auto-merge queue", () => {
title: "FN-093: Task",
body: "Automated PR for FN-093.\n\nDescription",
head: "fusion/fn-093",
base: "main",
});
expect(aiMergeTask).not.toHaveBeenCalled();
});
it("manual onMerge still uses PR lifecycle when autoMerge is disabled", async () => {
const { aiMergeTask } = await import("@fusion/engine");
const { createServer } = await import("@fusion/dashboard");
mockStore.getSettings.mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
autoMerge: false,
mergeStrategy: "pull-request",
pollIntervalMs: 60_000,
enginePaused: false,
globalPause: false,
});
await runDashboard(0, { open: false, dev: true });
const createServerCall = (createServer as ReturnType<typeof vi.fn>).mock.calls[0];
const serverOpts = createServerCall[1] as { onMerge: (taskId: string) => Promise<unknown> };
await serverOpts.onMerge("FN-093");
expect(mockCreatePr).toHaveBeenCalledWith({
title: "FN-093: Task",
body: "Automated PR for FN-093.\n\nDescription",
head: "fusion/fn-093",
base: "main",
});
expect(aiMergeTask).not.toHaveBeenCalled();
});
@@ -1990,6 +2089,54 @@ describe("runDashboard — --dev mode", () => {
});
});
describe("runDashboard — plugin auto-load", () => {
let mockStore: ReturnType<typeof makeMockStore>;
beforeEach(async () => {
vi.clearAllMocks();
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
});
it("auto-loads installed plugins during startup", async () => {
const { PluginLoader } = await import("@fusion/core");
await runDashboard(0, { open: false });
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
| { loadAllPlugins: ReturnType<typeof vi.fn> }
| undefined;
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
});
it("continues startup when plugin auto-load fails", async () => {
const { PluginLoader } = await import("@fusion/core");
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
const emitter = new EventEmitter();
return {
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.off(event, handler);
}),
emit: emitter.emit.bind(emitter),
};
});
await expect(runDashboard(0, { open: false })).resolves.toBeDefined();
});
});
describe("runDashboard — merge conflict retry logic", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
@@ -2368,6 +2515,97 @@ describe("runDashboard — lifecycle listener cleanup", () => {
});
});
describe("runDashboard — mesh lifecycle ownership", () => {
function getNewSignalHandler(
signal: "SIGINT" | "SIGTERM",
baseline: Array<(...args: any[]) => unknown>,
): () => void {
const added = process.listeners(signal).find((listener) => !baseline.includes(listener as (...args: any[]) => unknown));
expect(added).toBeDefined();
return added as () => void;
}
it("starts peer exchange and discovery after the dashboard binds a port", async () => {
const { CentralCore } = await import("@fusion/core");
const { PeerExchangeService } = await import("@fusion/engine");
const startDiscovery = vi.fn().mockResolvedValue(undefined);
const updateNode = vi.fn().mockResolvedValue(undefined);
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]),
listNodes: vi.fn().mockResolvedValue([{ id: "node-local", type: "local", status: "offline" }]),
updateNode,
startDiscovery,
stopDiscovery: vi.fn(),
}));
const peerExchangeCtor = PeerExchangeService as unknown as ReturnType<typeof vi.fn>;
const baselineCalls = peerExchangeCtor.mock.calls.length;
const { dispose } = await runDashboard(0, { open: false });
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
expect(peerExchangeCtor.mock.calls.length).toBeGreaterThan(baselineCalls);
const peerExchangeInstance = peerExchangeCtor.mock.results.at(-1)?.value;
expect(peerExchangeInstance.start).toHaveBeenCalledTimes(1);
expect(startDiscovery).toHaveBeenCalledWith(expect.objectContaining({
broadcast: true,
listen: true,
serviceType: "_fusion._tcp",
port: 0,
}));
expect(updateNode).toHaveBeenCalledWith("node-local", { status: "online" });
dispose();
});
it("stops peer exchange and discovery during shutdown", async () => {
const { CentralCore } = await import("@fusion/core");
const { PeerExchangeService } = await import("@fusion/engine");
const stopDiscovery = vi.fn();
const updateNode = vi.fn().mockResolvedValue(undefined);
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
listProjects: vi.fn().mockResolvedValue([{ id: "project-1", path: process.cwd() }]),
listNodes: vi.fn().mockResolvedValue([{ id: "node-local", type: "local", status: "offline" }]),
updateNode,
startDiscovery: vi.fn().mockResolvedValue(undefined),
stopDiscovery,
}));
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
const baselineSigintHandlers = process.listeners("SIGINT");
try {
await runDashboard(0, { open: false });
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
const sigintHandler = getNewSignalHandler("SIGINT", baselineSigintHandlers);
sigintHandler();
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
const peerExchangeInstance = (PeerExchangeService as unknown as ReturnType<typeof vi.fn>).mock.results.at(-1)?.value;
expect(peerExchangeInstance.stop).toHaveBeenCalledTimes(1);
expect(stopDiscovery).toHaveBeenCalledTimes(1);
expect(updateNode).toHaveBeenCalledWith("node-local", { status: "offline" });
expect(exitSpy).toHaveBeenCalledWith(0);
} finally {
exitSpy.mockRestore();
}
});
});
describe("runDashboard — CentralCore cleanup diagnostics", () => {
function getNewSignalHandler(
signal: "SIGINT" | "SIGTERM",
@@ -2855,3 +3093,38 @@ describe("runDashboard runtime logger wiring", () => {
}
});
});
describe("runDashboard update check wiring", () => {
it("suppresses stale cached update status in the TUI after the installed CLI version changes", async () => {
process.env.FUSION_DASHBOARD_TOKEN = "fn_test_dashboard_token";
writeUpdateCache({
updateAvailable: true,
currentVersion: "0.0.1",
latestVersion: "9.9.9",
});
const { DashboardTUI } = await import("../dashboard-tui/index.js");
const originalStdoutIsTTY = process.stdout.isTTY;
const originalStdinIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
const tuiStartSpy = vi.spyOn(DashboardTUI.prototype, "start").mockResolvedValue(undefined);
const tuiStopSpy = vi.spyOn(DashboardTUI.prototype, "stop").mockResolvedValue(undefined);
const setUpdateStatusSpy = vi.spyOn(DashboardTUI.prototype, "setUpdateStatus");
try {
await runDashboard(0, { open: false, dev: true });
expect(setUpdateStatusSpy).toHaveBeenCalledWith(null);
} finally {
Object.defineProperty(process.stdout, "isTTY", { value: originalStdoutIsTTY, configurable: true });
Object.defineProperty(process.stdin, "isTTY", { value: originalStdinIsTTY, configurable: true });
tuiStartSpy.mockRestore();
tuiStopSpy.mockRestore();
setUpdateStatusSpy.mockRestore();
delete process.env.FUSION_DASHBOARD_TOKEN;
}
});
});

View File

@@ -0,0 +1,94 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import {
resolveDroidCliExtension,
resolveDroidCliExtensionPaths,
} from "../droid-cli-extension.js";
describe("resolveDroidCliExtension", () => {
it("finds the bundled @fusion/droid-cli package", () => {
const result = resolveDroidCliExtension();
// In the monorepo test environment, the workspace package MUST resolve.
// If this fails, the vendored package's package.json or pi.extensions
// entry has been broken — a real regression worth surfacing.
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.path).toMatch(/droid-cli[\/\\]index\.ts$/);
expect(result.packageVersion).toMatch(/^\d+\.\d+\.\d+$/);
}
});
});
describe("resolveDroidCliExtensionPaths", () => {
it("returns empty when useDroidCli is off (default)", () => {
const result = resolveDroidCliExtensionPaths({});
expect(result.paths).toEqual([]);
expect(result.warning).toBeUndefined();
expect(result.resolution).toBeNull();
});
it("returns empty when useDroidCli is explicitly false", () => {
const result = resolveDroidCliExtensionPaths({ useDroidCli: false });
expect(result.paths).toEqual([]);
expect(result.resolution).toBeNull();
});
it("returns empty when useDroidCli is a non-boolean truthy value", () => {
// Defensive: API might pass strings, numbers — we only activate on true.
const result = resolveDroidCliExtensionPaths({
useDroidCli: "true" as unknown as boolean,
});
expect(result.paths).toEqual([]);
});
it("returns the resolved path when useDroidCli is on", () => {
const result = resolveDroidCliExtensionPaths({ useDroidCli: true });
expect(result.paths).toHaveLength(1);
expect(result.paths[0]).toMatch(/droid-cli[\/\\]index\.ts$/);
expect(result.resolution?.status).toBe("ok");
});
it("surfaces a warning but does not throw on weird inputs", () => {
// Exercises the defensive null/undefined/garbage handling — callers
// pass settings from disk that could be corrupt.
// @ts-expect-error intentionally bad shape
const result = resolveDroidCliExtensionPaths(null);
expect(result.paths).toEqual([]);
});
});
describe("cached resolution roundtrip", () => {
it("set/get preserves the snapshot", async () => {
const { setCachedDroidCliResolution, getCachedDroidCliResolution } =
await import("../droid-cli-extension.js");
setCachedDroidCliResolution({ status: "not-installed" });
expect(getCachedDroidCliResolution()).toEqual({ status: "not-installed" });
setCachedDroidCliResolution(null);
expect(getCachedDroidCliResolution()).toBeNull();
});
});
// Directory-fixture smoke test: give the resolver a minimal "fake" package
// layout to prove it handles malformed installs gracefully. This doesn't
// use the resolver directly (it's hard-coded to look up
// @fusion/droid-cli), but proves the package.json parsing logic is
// robust when we refactor later.
describe("package.json edge cases (documentation)", () => {
it("fixture layout documents what a broken install looks like", () => {
const root = tempWorkspace("droid-cli-ext-");
// This fixture is not exercised by the current implementation but
// captures the shape we'd need to test if resolveDroidCliExtension
// accepted a custom search path. Keeping it here so the next person
// refactoring has a template.
const pkgDir = join(root, "fake", "node_modules", "@fusion", "droid-cli");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({ pi: { extensions: ["index.ts"] }, version: "0.0.0" }),
);
// No index.ts — would trigger missing-entry if we pointed the resolver here.
expect(true).toBe(true);
});
});

View File

@@ -3,45 +3,61 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
import { mkdtempSync, existsSync, rmSync, writeFileSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runInit } from "../init.js";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const mockCentralInit = vi.fn();
const mockCentralClose = vi.fn();
const mockGetProjectByPath = vi.fn();
const mockRegisterProject = vi.fn();
const mockUpdateProject = vi.fn().mockResolvedValue({});
vi.mock("@fusion/core", () => ({
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
getProjectByPath: mockGetProjectByPath,
registerProject: mockRegisterProject,
updateProject: mockUpdateProject,
})),
isQmdAvailable: vi.fn(() => Promise.resolve(true)),
QMD_INSTALL_COMMAND: "bun install -g @tobilu/qmd",
resolveGlobalDir: vi.fn(),
const { mockIsValidSqliteDatabaseFile } = vi.hoisted(() => ({
mockIsValidSqliteDatabaseFile: vi.fn(),
}));
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
getProjectByPath: mockGetProjectByPath,
registerProject: mockRegisterProject,
updateProject: mockUpdateProject,
})),
isQmdAvailable: vi.fn(() => Promise.resolve(true)),
QMD_INSTALL_COMMAND: "bun install -g @tobilu/qmd",
resolveGlobalDir: vi.fn(),
isValidSqliteDatabaseFile: (...args: Parameters<typeof mockIsValidSqliteDatabaseFile>) =>
mockIsValidSqliteDatabaseFile(...args),
};
});
function tempDir(prefix: string): string {
return mkdtempSync(join(tmpdir(), prefix));
}
async function git(command: string, cwd: string): Promise<string> {
const { stdout } = await execAsync(command, { cwd, timeout: 10_000 });
return stdout.trim();
}
describe("init command", () => {
let tempProjectDir: string;
let tempHomeDir: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
const isolatedHome = process.env.HOME;
const isolatedUserProfile = process.env.USERPROFILE;
beforeEach(() => {
tempProjectDir = tempDir("fn-init-test-");
tempHomeDir = tempDir("fn-init-home-");
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
process.env.HOME = tempHomeDir;
process.env.USERPROFILE = tempHomeDir;
mockCentralInit.mockResolvedValue(undefined);
@@ -53,18 +69,25 @@ describe("init command", () => {
path: tempProjectDir,
isolationMode: "in-process",
});
mockIsValidSqliteDatabaseFile.mockImplementation((dbPath: string) => {
if (!existsSync(dbPath)) {
return false;
}
return readFileSync(dbPath).length === 0;
});
});
afterEach(() => {
if (originalHome === undefined) {
if (isolatedHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
process.env.HOME = isolatedHome;
}
if (originalUserProfile === undefined) {
if (isolatedUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = originalUserProfile;
process.env.USERPROFILE = isolatedUserProfile;
}
if (existsSync(tempProjectDir)) {
@@ -91,6 +114,18 @@ describe("init command", () => {
await runInit({ path: tempProjectDir });
expect(existsSync(dbPath)).toBe(true);
expect(statSync(dbPath).size).toBe(0);
});
it("should reject existing invalid fusion.db files", async () => {
const fusionDir = join(tempProjectDir, ".fusion");
const dbPath = join(fusionDir, "fusion.db");
mkdirSync(fusionDir, { recursive: true });
writeFileSync(dbPath, "not a sqlite database");
await expect(runInit({ path: tempProjectDir })).rejects.toThrow(
`Existing database at ${dbPath} is not a valid SQLite database.`,
);
});
it("should be idempotent - report already initialized", async () => {
@@ -247,4 +282,51 @@ describe("init command", () => {
expect(fusionMatches).toHaveLength(1);
expect(piMatches).toHaveLength(1);
});
it("initializes git when --git is enabled in a non-git directory", async () => {
expect(existsSync(join(tempProjectDir, ".git"))).toBe(false);
await runInit({ path: tempProjectDir, git: true });
expect(existsSync(join(tempProjectDir, ".git"))).toBe(true);
});
it("creates an initial commit when --git initializes a repository", async () => {
await runInit({ path: tempProjectDir, git: true });
const commitCount = await git("git rev-list --count HEAD", tempProjectDir);
expect(Number(commitCount)).toBeGreaterThanOrEqual(1);
});
it("does not reinitialize git when repository already exists", async () => {
await git("git init", tempProjectDir);
await git("git checkout -b main", tempProjectDir);
await git('git config user.name "Existing User"', tempProjectDir);
await git('git config user.email "existing@example.com"', tempProjectDir);
writeFileSync(join(tempProjectDir, "README.md"), "# Existing Repo\n");
await git("git add README.md", tempProjectDir);
await git('git commit -m "existing commit"', tempProjectDir);
await runInit({ path: tempProjectDir, git: true });
const commitCount = await git("git rev-list --count HEAD", tempProjectDir);
expect(Number(commitCount)).toBe(1);
});
it("does not create git repository without --git and logs a hint", async () => {
const originalLog = console.log;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
logs.push(args.join(" "));
};
try {
await runInit({ path: tempProjectDir });
} finally {
console.log = originalLog;
}
expect(existsSync(join(tempProjectDir, ".git"))).toBe(false);
expect(logs.join("\n")).toContain("Not a git repository. Run 'fn init --git' to auto-initialize one.");
});
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
resolveLlamaCppExtension,
resolveLlamaCppExtensionPaths,
} from "../llama-cpp-extension.js";
describe("resolveLlamaCppExtension", () => {
it("finds the bundled @fusion/pi-llama-cpp package", () => {
const result = resolveLlamaCppExtension();
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.path).toMatch(/pi-llama-cpp[\\/]index\.ts$/);
expect(result.packageVersion).toMatch(/^\d+\.\d+\.\d+$/);
}
});
});
describe("resolveLlamaCppExtensionPaths", () => {
it("returns empty when useLlamaCpp is off", () => {
const result = resolveLlamaCppExtensionPaths({});
expect(result.paths).toEqual([]);
expect(result.warning).toBeUndefined();
expect(result.resolution).toBeNull();
});
it("returns extension path when useLlamaCpp is on", () => {
const result = resolveLlamaCppExtensionPaths({ useLlamaCpp: true });
expect(result.paths).toHaveLength(1);
expect(result.paths[0]).toMatch(/pi-llama-cpp[\\/]index\.ts$/);
expect(result.resolution?.status).toBe("ok");
});
});
describe("cached resolution roundtrip", () => {
it("set/get preserves snapshot", async () => {
const { setCachedLlamaCppResolution, getCachedLlamaCppResolution } =
await import("../llama-cpp-extension.js");
setCachedLlamaCppResolution({ status: "not-installed" });
expect(getCachedLlamaCppResolution()).toEqual({ status: "not-installed" });
setCachedLlamaCppResolution(null);
expect(getCachedLlamaCppResolution()).toBeNull();
});
});

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const {
mockListBackups,
mockRestoreBackup,
mockGetSettings,
mockRunMemoryBackupCommand,
mockResolveProject,
} = vi.hoisted(() => ({
mockListBackups: vi.fn(),
mockRestoreBackup: vi.fn(),
mockGetSettings: vi.fn(),
mockRunMemoryBackupCommand: vi.fn(),
mockResolveProject: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
getSettings: mockGetSettings,
fusionDir: "/cwd/.fusion",
})),
createMemoryBackupManager: vi.fn(() => ({
listBackups: mockListBackups,
restoreBackup: mockRestoreBackup,
})),
runMemoryBackupCommand: mockRunMemoryBackupCommand,
}));
vi.mock("../../project-context.js", () => ({
resolveProject: mockResolveProject,
}));
import { runMemoryBackupCreate, runMemoryBackupList, runMemoryBackupRestore } from "../memory-backup.js";
describe("memory-backup commands", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
mockGetSettings.mockResolvedValue({ memoryBackupSchedule: "0 3 * * *" });
mockRunMemoryBackupCommand.mockResolvedValue({ success: true, output: "memory backup created" });
mockListBackups.mockResolvedValue([]);
mockRestoreBackup.mockResolvedValue(undefined);
mockResolveProject.mockResolvedValue({
store: { getSettings: mockGetSettings, fusionDir: "/projects/demo/.fusion" },
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("create path succeeds", async () => {
await expect(runMemoryBackupCreate({ projectName: "demo", scope: "agents" })).rejects.toThrow("process.exit:0");
expect(mockRunMemoryBackupCommand).toHaveBeenCalledWith(
"/projects/demo/.fusion",
expect.objectContaining({ memoryBackupScope: "agents" }),
);
});
it("list path renders entries", async () => {
mockListBackups.mockResolvedValue([
{ filename: "memory-2026-01-01-000000", createdAt: new Date().toISOString(), size: 100, scope: "all", entryCount: 3 },
]);
await runMemoryBackupList("demo");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Found 1 memory backup"));
});
it("restore path calls manager", async () => {
await runMemoryBackupRestore("memory-2026-01-01-000000", "demo");
expect(mockRestoreBackup).toHaveBeenCalledWith("memory-2026-01-01-000000", { overwrite: true });
});
it("create path fails on invalid schedule", async () => {
mockRunMemoryBackupCommand.mockResolvedValue({ success: false, output: "Invalid memory backup schedule: bad" });
await expect(runMemoryBackupCreate({ projectName: "demo" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Invalid memory backup schedule: bad");
});
});

View File

@@ -245,6 +245,7 @@ describe("node commands", () => {
expect(parsed).toHaveLength(1);
expect(parsed[0].name).toBe("json-node");
expect(parsed[0].apiKey).toBe("none");
expect(output).not.toMatch(/\x1b\[[0-9;]*m/);
});
it("runNodeList masks API keys in JSON output", async () => {
@@ -277,9 +278,18 @@ describe("node commands", () => {
await runNodeList();
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("● online");
expect(output).toContain("○ offline");
expect(output).toContain("✕ error");
expect(output).toContain("\x1b[32m●\x1b[0m \x1b[32monline\x1b[0m");
expect(output).toContain("\x1b[31m○\x1b[0m \x1b[31moffline\x1b[0m");
expect(output).toContain("\x1b[31m✕\x1b[0m \x1b[31merror\x1b[0m");
});
it("runNodeList colorizes connecting status", async () => {
mockListNodes.mockResolvedValue([makeNode({ name: "connecting-node", status: "connecting" })]);
await runNodeList();
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("\x1b[33m◐\x1b[0m \x1b[33mconnecting\x1b[0m");
});
// ── runNodeConnect Tests ─────────────────────────────────────────────────

View File

@@ -0,0 +1,337 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const pluginStoreInstances: Array<{
init: ReturnType<typeof vi.fn>;
registerPlugin: ReturnType<typeof vi.fn>;
listPlugins: ReturnType<typeof vi.fn>;
getPlugin: ReturnType<typeof vi.fn>;
updatePluginSettings: ReturnType<typeof vi.fn>;
}> = [];
let loaderTaskStore: { getRootDir?: () => string } | undefined;
let loaderRootDir: string | undefined;
const PluginStore = vi.fn();
const PluginLoader = vi.fn();
const setupDefaults = () => {
PluginStore.mockImplementation(() => {
const instance = {
init: vi.fn().mockResolvedValue(undefined),
registerPlugin: vi.fn().mockResolvedValue({
id: "paperclip-runtime",
enabled: true,
}),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
updatePluginSettings: vi.fn().mockResolvedValue(undefined),
};
pluginStoreInstances.push(instance);
return instance;
});
PluginLoader.mockImplementation((options: { taskStore: { getRootDir?: () => string } }) => {
loaderTaskStore = options.taskStore;
return {
loadPlugin: vi.fn().mockImplementation(async () => {
loaderRootDir = options.taskStore.getRootDir?.();
}),
};
});
};
setupDefaults();
return {
PluginStore,
PluginLoader,
pluginStoreInstances,
getLoaderTaskStore: () => loaderTaskStore,
getLoaderRootDir: () => loaderRootDir,
reset: () => {
loaderTaskStore = undefined;
loaderRootDir = undefined;
pluginStoreInstances.length = 0;
PluginStore.mockReset();
PluginLoader.mockReset();
setupDefaults();
},
};
});
vi.mock("@fusion/core", () => ({
PluginStore: mocks.PluginStore,
PluginLoader: mocks.PluginLoader,
validatePluginManifest: vi.fn().mockReturnValue({ valid: true, errors: [] }),
resolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-global"),
}));
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn().mockResolvedValue({ projectPath: "/tmp/fn-project" }),
}));
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: vi.fn((path: Parameters<typeof actual.existsSync>[0]) => actual.existsSync(path)),
};
});
import {
resolvePluginEntryFile,
runPluginAvailable,
runPluginInstall,
runPluginSettings,
runPluginRescan,
} from "../plugin.js";
import { resolveProject } from "../../project-context.js";
async function createTempPluginFixture(
files: Array<{ path: string; content: string }>,
): Promise<string> {
const pluginDir = await mkdtemp(join(tmpdir(), "fn-plugin-test-"));
for (const file of files) {
const target = join(pluginDir, file.path);
await mkdir(dirname(target), { recursive: true });
await writeFile(target, file.content, "utf-8");
}
return pluginDir;
}
describe("plugin commands", () => {
const tempDirs: string[] = [];
beforeEach(() => {
mocks.reset();
vi.mocked(resolveProject).mockResolvedValue({ projectPath: "/tmp/fn-project" } as never);
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(async () => {
vi.clearAllMocks();
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
tempDirs.length = 0;
});
it("resolves package exports import entry to dist/index.js", async () => {
const pluginDir = await createTempPluginFixture([
{
path: "package.json",
content: JSON.stringify({ exports: { ".": { import: "./dist/index.js" } } }),
},
{ path: "dist/index.js", content: "export default {};" },
]);
tempDirs.push(pluginDir);
await expect(resolvePluginEntryFile(pluginDir)).resolves.toBe(resolve(pluginDir, "dist/index.js"));
});
it("uses resolved TaskStore plugin store when available", async () => {
const contextStore = {
getPluginStore: vi.fn().mockReturnValue({
init: vi.fn().mockResolvedValue(undefined),
registerPlugin: vi.fn().mockResolvedValue({ id: "paperclip-runtime", enabled: true }),
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
updatePluginSettings: vi.fn().mockResolvedValue(undefined),
}),
};
vi.mocked(resolveProject).mockResolvedValue({
projectPath: "/tmp/fn-project",
store: contextStore,
} as never);
const pluginDir = await createTempPluginFixture([
{
path: "manifest.json",
content: JSON.stringify({ id: "paperclip-runtime", name: "Paperclip Runtime", version: "1.0.0" }),
},
{
path: "package.json",
content: JSON.stringify({ exports: { ".": { import: "./dist/index.js" } } }),
},
{
path: "dist/index.js",
content:
"export default { manifest: { id: 'paperclip-runtime', name: 'Paperclip Runtime', version: '1.0.0' }, async onLoad() {}, async onUnload() {} };",
},
]);
tempDirs.push(pluginDir);
await expect(runPluginInstall(pluginDir)).resolves.toBeUndefined();
expect(contextStore.getPluginStore).toHaveBeenCalledTimes(1);
expect(mocks.PluginStore).not.toHaveBeenCalled();
});
it("writes runPluginInstall metadata to central tables only", async () => {
const actualCore = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
const projectDir = await mkdtemp(join(tmpdir(), "fn-plugin-project-"));
const centralDir = await mkdtemp(join(tmpdir(), "fn-plugin-central-"));
tempDirs.push(projectDir, centralDir);
const realStore = new actualCore.PluginStore(projectDir, { centralGlobalDir: centralDir });
await realStore.init();
vi.mocked(resolveProject).mockResolvedValue({
projectPath: projectDir,
store: { getPluginStore: () => realStore },
} as never);
const pluginDir = await createTempPluginFixture([
{
path: "manifest.json",
content: JSON.stringify({ id: "paperclip-runtime", name: "Paperclip Runtime", version: "1.0.0" }),
},
{
path: "package.json",
content: JSON.stringify({ exports: { ".": { import: "./dist/index.js" } } }),
},
{
path: "dist/index.js",
content:
"export default { manifest: { id: 'paperclip-runtime', name: 'Paperclip Runtime', version: '1.0.0' }, async onLoad() {}, async onUnload() {} };",
},
]);
tempDirs.push(pluginDir);
await expect(runPluginInstall(pluginDir)).resolves.toBeUndefined();
const centralDb = new actualCore.CentralDatabase(centralDir);
centralDb.init();
const installCount = centralDb
.prepare("SELECT COUNT(*) as count FROM plugin_installs WHERE id = ?")
.get("paperclip-runtime") as { count: number };
const stateCount = centralDb
.prepare("SELECT COUNT(*) as count FROM project_plugin_states WHERE pluginId = ? AND projectPath = ?")
.get("paperclip-runtime", projectDir) as { count: number };
const localDb = new actualCore.Database(join(projectDir, ".fusion"));
localDb.init();
const legacyCount = localDb
.prepare("SELECT COUNT(*) as count FROM plugins WHERE id = ?")
.get("paperclip-runtime") as { count: number };
expect(installCount.count).toBe(1);
expect(stateCount.count).toBe(1);
expect(legacyCount.count).toBe(0);
centralDb.close();
localDb.close();
});
it("includes getRootDir on the plugin loader taskStore mock (FN-2687)", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${code}`);
}) as never);
const pluginDir = await createTempPluginFixture([
{
path: "manifest.json",
content: JSON.stringify({ id: "paperclip-runtime", name: "Paperclip Runtime", version: "1.0.0" }),
},
{
path: "package.json",
content: JSON.stringify({ exports: { ".": { import: "./dist/index.js" } } }),
},
{
path: "dist/index.js",
content:
"export default { manifest: { id: 'paperclip-runtime', name: 'Paperclip Runtime', version: '1.0.0' }, async onLoad() {}, async onUnload() {} };",
},
]);
tempDirs.push(pluginDir);
await expect(runPluginInstall(pluginDir)).resolves.toBeUndefined();
expect(exitSpy).not.toHaveBeenCalled();
const registerCall = mocks.pluginStoreInstances[0]?.registerPlugin.mock.calls[0]?.[0];
expect(registerCall.path).toBe(resolve(pluginDir, "dist/index.js"));
const taskStore = mocks.getLoaderTaskStore();
expect(taskStore).toBeDefined();
expect(taskStore?.getRootDir).toBeTypeOf("function");
expect(taskStore?.getRootDir?.()).toBe("/tmp/fn-project");
expect(mocks.getLoaderRootDir()).toBe("/tmp/fn-project");
});
it("exits non-zero when plugin entry cannot resolve to built JavaScript", async () => {
const pluginDir = await createTempPluginFixture([
{
path: "manifest.json",
content: JSON.stringify({ id: "paperclip-runtime", name: "Paperclip Runtime", version: "1.0.0" }),
},
{
path: "package.json",
content: JSON.stringify({ exports: { ".": { import: "./src/index.ts" } } }),
},
{ path: "src/index.ts", content: "export default {};" },
]);
tempDirs.push(pluginDir);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`exit:${code}`);
}) as never);
await expect(runPluginInstall(pluginDir)).rejects.toThrow("exit:1");
expect(exitSpy).toHaveBeenCalledWith(1);
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("Build the plugin first"),
);
});
it("prints built-in plugin catalog", async () => {
await expect(runPluginAvailable()).resolves.toBeUndefined();
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("Installable"));
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("fusion-plugin-agent-browser"));
});
it("exits non-zero when rescan verdict is blocked", async () => {
const storeInstance = {
init: vi.fn().mockResolvedValue(undefined),
registerPlugin: vi.fn(),
listPlugins: vi.fn(),
getPlugin: vi
.fn()
.mockResolvedValueOnce({ id: "paperclip-runtime", name: "Paperclip Runtime", enabled: true, state: "started" })
.mockResolvedValueOnce({ id: "paperclip-runtime", name: "Paperclip Runtime", enabled: true, state: "error", lastSecurityScan: { verdict: "blocked", summary: "blocked", findings: [], scannedAt: "now", scannedFiles: [] } }),
updatePluginSettings: vi.fn().mockResolvedValue(undefined),
};
mocks.PluginStore.mockImplementationOnce(() => storeInstance as never);
mocks.PluginLoader.mockImplementationOnce(() => ({ loadPlugin: vi.fn(), reloadPlugin: vi.fn().mockResolvedValue(undefined) }) as never);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`exit:${code}`); }) as never);
await expect(runPluginRescan("paperclip-runtime", { projectName: "demo" })).rejects.toThrow("exit:1");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("reads and updates plugin settings", async () => {
const storeInstance = {
init: vi.fn().mockResolvedValue(undefined),
registerPlugin: vi.fn(),
listPlugins: vi.fn(),
getPlugin: vi.fn().mockResolvedValue({
id: "paperclip-runtime",
settings: { enabled: true, retries: 2 },
}),
updatePluginSettings: vi.fn().mockResolvedValue(undefined),
};
mocks.PluginStore.mockImplementationOnce(() => storeInstance as never);
await runPluginSettings("paperclip-runtime", undefined, undefined, { projectName: "demo" });
mocks.PluginStore.mockImplementationOnce(() => storeInstance as never);
await runPluginSettings("paperclip-runtime", "enabled", undefined, { projectName: "demo" });
mocks.PluginStore.mockImplementationOnce(() => storeInstance as never);
await runPluginSettings("paperclip-runtime", "enabled", "false", { projectName: "demo" });
expect(storeInstance.getPlugin).toHaveBeenCalledTimes(3);
expect(storeInstance.updatePluginSettings).toHaveBeenCalledWith("paperclip-runtime", { enabled: false });
});
});

View File

@@ -101,6 +101,17 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).not.toContain("pi-claude-cli");
});
it("includes research-only API-key providers", () => {
const fusionAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const providerIds = wrapped.getApiKeyProviders().map((provider) => provider.id);
expect(providerIds).toContain("brave");
expect(providerIds).toContain("tavily");
});
it("reads legacy auth JSON without creating missing files", async () => {
const tempDir = tempWorkspace("fusion-provider-auth-");
const legacyAgentDir = join(tempDir, ".pi", "agent");
@@ -136,4 +147,199 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(await storage.getApiKey("openai-codex")).toBe("legacy-access-token");
});
describe("Anthropic provider classification", () => {
it("keeps anthropic in getOAuthProviders when upstream reports it as OAuth", () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic" },
{ id: "github-copilot", name: "GitHub Copilot" },
]);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const oauthProviders = wrapped.getOAuthProviders();
const oauthIds = oauthProviders.map((p) => p.id);
expect(oauthIds).toContain("anthropic");
expect(oauthIds).toContain("github-copilot");
});
it("does not duplicate anthropic in getApiKeyProviders when OAuth-backed", () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic" },
]);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
const apiKeyProviders = wrapped.getApiKeyProviders();
const anthropic = apiKeyProviders.find((p) => p.id === "anthropic");
expect(anthropic).toBeUndefined();
});
it("stores anthropic credentials as api_key type", () => {
const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic" },
]);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
wrapped.setApiKey("anthropic", "sk-ant-api03-test-key");
expect(fusionAuth.set).toHaveBeenCalledWith("anthropic", {
type: "api_key",
key: "sk-ant-api03-test-key",
});
});
it("detects anthropic as authenticated via hasApiKey after storing API key", () => {
const fusionAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "sk-ant-api03-test" },
});
fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic" },
]);
const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
expect(wrapped.hasApiKey("anthropic")).toBe(true);
});
});
describe("logout with fallback credentials", () => {
it("hides fallback credentials after logout", () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
// Before logout, fallback credentials are visible
expect(merged.has("anthropic")).toBe(true);
expect(merged.hasAuth("anthropic")).toBe(true);
expect(merged.get("anthropic")).toEqual({ type: "api_key", key: "claude-access-token" });
// Log out
merged.logout("anthropic");
// After logout, fallback credentials are hidden
expect(merged.has("anthropic")).toBe(false);
expect(merged.hasAuth("anthropic")).toBe(false);
expect(merged.get("anthropic")).toBeUndefined();
});
it("does not resurrect fallback credentials on reload after logout", () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
// reload() should NOT bring back the fallback credential
merged.reload();
expect(merged.has("anthropic")).toBe(false);
expect(merged.hasAuth("anthropic")).toBe(false);
});
it("excludes logged-out providers from getAll()", () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
openrouter: { type: "api_key", key: "openrouter-key" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
const all = merged.getAll();
expect("anthropic" in all).toBe(false);
expect("openrouter" in all).toBe(true);
});
it("excludes logged-out providers from list()", () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
openrouter: { type: "api_key", key: "openrouter-key" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
expect(merged.list()).not.toContain("anthropic");
expect(merged.list()).toContain("openrouter");
});
it("hides fallback getApiKey after logout", async () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
expect(await merged.getApiKey("anthropic")).toBe("claude-access-token");
merged.logout("anthropic");
expect(await merged.getApiKey("anthropic")).toBeUndefined();
});
it("re-enables fallback credentials after re-authentication via set()", () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
// Re-authenticate
merged.set("anthropic", { type: "api_key", key: "new-key" });
// Provider is visible again (from primary storage)
expect(merged.has("anthropic")).toBe(true);
});
it("only hides the logged-out provider, not other fallback providers", () => {
const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
openrouter: { type: "api_key", key: "openrouter-key" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
// anthropic is hidden
expect(merged.hasAuth("anthropic")).toBe(false);
// openrouter is still visible
expect(merged.hasAuth("openrouter")).toBe(true);
});
it("returns false for hasAuth even when underlying storage reports auth via env var", () => {
// Simulate the real AuthStorage which checks env vars in hasAuth
const fusionAuth = makeAuthStorage();
fusionAuth.hasAuth = vi.fn(() => true); // env var would make this true
const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" },
});
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic");
// Even though the underlying storage reports hasAuth=true (env var),
// the logged-out provider must still return false
expect(merged.hasAuth("anthropic")).toBe(false);
expect(merged.has("anthropic")).toBe(false);
});
});
});

View File

@@ -1,9 +1,18 @@
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { tempWorkspace } from "@fusion/test-utils";
import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } from "../provider-settings.js";
// All tests here are pure synchronous FS operations against a temp workspace,
// so they shouldn't take more than a handful of milliseconds. They have
// occasionally tripped vitest's default 5s timeout when the worker pool is
// starved by a parallel FS-heavy suite (one slot stalls long enough that the
// runner gives up before the test body even gets a turn). Bumping the
// per-test cap rules out worker contention as a flake source without
// changing what the tests actually verify.
vi.setConfig({ testTimeout: 30000 });
function writeJson(path: string, value: Record<string, unknown>): void {
writeFileSync(path, JSON.stringify(value, null, 2));
}

View File

@@ -0,0 +1,190 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runResearchCancel, runResearchCreate, runResearchExport, runResearchList, runResearchRetry, runResearchShow } from "../research.js";
const mockRun = {
id: "RR-001",
query: "test query",
topic: "test query",
status: "running",
sources: [],
events: [],
tags: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
results: { summary: "done", findings: [], citations: [] },
};
const researchStoreMock = {
getRun: vi.fn(() => mockRun),
listRuns: vi.fn(() => [mockRun]),
createExport: vi.fn(),
};
const storeMock = {
init: vi.fn(),
getSettings: vi.fn(async () => ({ researchSettings: { enabled: true }, researchGlobalWebSearchProvider: "tavily", researchGlobalTavilyApiKey: "x" })),
getResearchStore: vi.fn(() => researchStoreMock),
};
const orchestratorMock = {
createRun: vi.fn(() => "RR-002"),
startRun: vi.fn(async () => ({ ...mockRun, id: "RR-002", status: "running" })),
cancelRun: vi.fn(() => true),
retryRun: vi.fn(() => "RR-003"),
};
const { resolveResearchSettingsMock, providerRegistryMock, writeFileMock } = vi.hoisted(() => ({
resolveResearchSettingsMock: vi.fn(() => ({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } })),
providerRegistryMock: vi.fn(() => ({ getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) })),
writeFileMock: vi.fn(async () => undefined),
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn(() => storeMock),
resolveResearchSettings: resolveResearchSettingsMock,
RESEARCH_RUN_STATUSES: ["queued", "running", "cancelling", "retry_waiting", "completed", "failed", "cancelled", "timed_out", "retry_exhausted"],
RESEARCH_EXPORT_FORMATS: ["json", "markdown", "pdf"],
}));
vi.mock("@fusion/engine", () => ({
ResearchProviderRegistry: providerRegistryMock,
ResearchStepRunner: vi.fn(),
ResearchOrchestrator: vi.fn(() => orchestratorMock),
}));
vi.mock("../../project-context.js", () => ({ resolveProject: vi.fn(async () => undefined) }));
vi.mock("node:fs/promises", () => ({ writeFile: writeFileMock }));
describe("research commands", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const originalExit = process.exit;
beforeEach(() => {
vi.clearAllMocks();
process.exit = vi.fn(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as typeof process.exit);
resolveResearchSettingsMock.mockReturnValue({ enabled: true, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } });
providerRegistryMock.mockReturnValue({ getAvailableProviders: () => ["tavily"], getProvider: () => ({ type: "tavily" }) });
researchStoreMock.getRun.mockReturnValue(mockRun);
researchStoreMock.listRuns.mockReturnValue([mockRun]);
orchestratorMock.retryRun.mockReturnValue("RR-003");
});
afterEach(() => {
process.exit = originalExit;
});
it("creates a run", async () => {
await runResearchCreate({ query: "hello" });
expect(orchestratorMock.createRun).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Created research run"));
});
it("creates a run when provider is unset by defaulting to builtin", async () => {
storeMock.getSettings.mockResolvedValueOnce({ researchSettings: { enabled: true } });
resolveResearchSettingsMock.mockReturnValueOnce({
enabled: true,
searchProvider: "builtin",
limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 },
});
providerRegistryMock.mockReturnValueOnce({ getAvailableProviders: () => ["web-search"], getProvider: () => ({ type: "web-search" }) });
await runResearchCreate({ query: "hello builtin" });
expect(orchestratorMock.createRun).toHaveBeenCalled();
expect(errorSpy).not.toHaveBeenCalled();
});
it("lists runs as json", async () => {
await runResearchList({ json: true, status: "completed", limit: 3 });
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"runs"'));
expect(researchStoreMock.listRuns).toHaveBeenCalledWith({ status: "completed", limit: 3 });
});
it("rejects invalid list status", async () => {
await expect(runResearchList({ status: "wat" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Invalid status: wat");
});
it("shows one run", async () => {
await runResearchShow("RR-001");
expect(logSpy).toHaveBeenCalledWith("Run: RR-001");
});
it("fails show on missing run", async () => {
researchStoreMock.getRun.mockReturnValue(undefined);
await expect(runResearchShow("RR-404")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Research run not found: RR-404");
});
it("exports with explicit output path", async () => {
await runResearchExport({ runId: "RR-001", format: "json", output: "./out.json" });
const writeArgs = writeFileMock.mock.calls[0]!;
expect(String(writeArgs[0])).toContain("out.json");
expect(String(writeArgs[1])).toContain('"id": "RR-001"');
expect(String(writeArgs[1])).toContain('"status": "running"');
expect(String(writeArgs[1])).toContain('"query": "test query"');
expect(researchStoreMock.createExport).toHaveBeenCalledWith("RR-001", "json", expect.stringContaining('"id": "RR-001"'));
});
it("exports markdown to generated path", async () => {
await runResearchExport({ runId: "RR-001", format: "markdown" });
expect(writeFileMock).toHaveBeenCalledWith(expect.stringContaining("research-rr-001.md"), expect.stringContaining("## Summary"), "utf8");
});
it("cancels a run", async () => {
await runResearchCancel("RR-001", { json: true });
expect(orchestratorMock.cancelRun).toHaveBeenCalledWith("RR-001");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"cancelled"'));
});
it("retries a run", async () => {
researchStoreMock.getRun.mockImplementation((id: string) => (id === "RR-003" ? { ...mockRun, id: "RR-003", status: "queued" } : { ...mockRun, status: "failed" }));
await runResearchRetry("RR-001", { json: true });
expect(orchestratorMock.retryRun).toHaveBeenCalledWith("RR-001");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"retryOf"'));
});
it("errors when research is disabled", async () => {
resolveResearchSettingsMock.mockReturnValue({ enabled: false, limits: { maxConcurrentRuns: 2, maxSourcesPerRun: 5, requestTimeoutMs: 1000, maxDurationMs: 5000 } });
await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: feature-disabled: Research is disabled in settings.");
});
it("errors when providers are unavailable", async () => {
providerRegistryMock.mockReturnValue({ getAvailableProviders: () => [], getProvider: () => undefined });
await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("provider-unavailable"));
});
it("errors when provider credentials are missing", async () => {
storeMock.getSettings.mockResolvedValueOnce({ researchSettings: { enabled: true }, researchGlobalWebSearchProvider: "tavily" });
await expect(runResearchCreate({ query: "hello" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("missing-credentials"));
});
it("errors on cancel for terminal runs", async () => {
researchStoreMock.getRun.mockReturnValueOnce({ ...mockRun, status: "completed" });
await expect(runResearchCancel("RR-001")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("invalid-transition"));
});
it("errors on retry exhausted runs", async () => {
researchStoreMock.getRun.mockReturnValueOnce({ ...mockRun, status: "retry_exhausted", lifecycle: { errorCode: "RETRY_EXHAUSTED" } });
await expect(runResearchRetry("RR-001")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("retry-exhausted"));
});
it("errors on invalid export format", async () => {
await expect(runResearchExport({ runId: "RR-001", format: "xml" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Unsupported export format: xml");
});
it("errors on write failure", async () => {
writeFileMock.mockRejectedValueOnce(new Error("disk full"));
await expect(runResearchExport({ runId: "RR-001", format: "json", output: "./x.json" })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: disk full");
});
});

View File

@@ -1,6 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
const { mockSyncStartupModels } = vi.hoisted(() => ({
mockSyncStartupModels: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../startup-model-sync.js", () => ({
syncStartupModels: mockSyncStartupModels,
}));
// ── Multi-project test fixtures ─────────────────────────────────────────
//
// Test fixtures model at least two registered projects with distinct IDs/paths
@@ -67,13 +75,19 @@ const mocks = vi.hoisted(() => {
const missionStore = {
listMissions: vi.fn().mockResolvedValue([]),
};
const pluginStore = pluginStoreCtor();
return {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
getRootDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}`),
getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`),
getGlobalSettingsStore: vi.fn(() => ({
getSettings: vi.fn().mockResolvedValue({}),
})),
getMissionStore: vi.fn().mockReturnValue(missionStore),
getPluginStore: vi.fn().mockReturnValue(pluginStore),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
recycleWorktrees: false,
@@ -281,6 +295,7 @@ const mocks = vi.hoisted(() => {
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
@@ -507,7 +522,9 @@ const mocks = vi.hoisted(() => {
};
});
vi.mock("@fusion/core", () => ({
vi.mock("@fusion/core", async (importOriginal) => {
const { createCliCoreMock } = await import("../../test/mockCoreEngine");
return createCliCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
TaskStore: mocks.taskStoreCtor,
AutomationStore: mocks.automationStoreCtor,
AgentStore: mocks.agentStoreCtor,
@@ -526,7 +543,8 @@ vi.mock("@fusion/core", () => ({
})),
GlobalSettingsStore: vi.fn().mockImplementation(() => ({})),
resolveGlobalDir: vi.fn().mockReturnValue("/mock/global"),
}));
});
});
vi.mock("@fusion/dashboard", () => ({
createServer: mocks.createServerMock,
@@ -536,7 +554,9 @@ vi.mock("@fusion/dashboard", () => ({
loadTlsCredentialsFromEnv: vi.fn().mockReturnValue(undefined),
}));
vi.mock("@fusion/engine", () => ({
vi.mock("@fusion/engine", async (importOriginal) => {
const { createCliEngineMock } = await import("../../test/mockCoreEngine");
return createCliEngineMock(() => importOriginal<typeof import("@fusion/engine")>(), {
ProjectEngine: mocks.projectEngineCtor,
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => {
const engines = new Map<string, any>();
@@ -602,7 +622,8 @@ vi.mock("@fusion/engine", () => ({
createAiPromptExecutor: mocks.createAiPromptExecutorMock,
HeartbeatMonitor: mocks.heartbeatMonitorCtor,
HeartbeatTriggerScheduler: mocks.heartbeatTriggerSchedulerCtor,
}));
});
});
vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: {
@@ -638,6 +659,11 @@ vi.mock("../task-lifecycle.js", () => ({
const { runServe } = await import("../serve.js");
describe("runServe", () => {
it("invokes shared startup model sync", async () => {
const { runServe } = await import("../serve.js");
await runServe(4040, {});
expect(mockSyncStartupModels).toHaveBeenCalledTimes(1);
});
const originalCwd = process.cwd;
const originalOn = process.on;
const originalExit = process.exit;
@@ -798,6 +824,46 @@ describe("runServe", () => {
});
await triggerSignal("SIGINT");
});
it("uses process.env.PORT as fallback when no explicit CLI port is given", async () => {
const originalPort = process.env.PORT;
process.env.PORT = "4041";
try {
await runServe(4040, {});
expect(mocks.listenCalls[0]).toMatchObject({
port: 4041,
host: "127.0.0.1",
});
await triggerSignal("SIGINT");
} finally {
if (originalPort !== undefined) {
process.env.PORT = originalPort;
} else {
delete process.env.PORT;
}
}
});
it("ignores process.env.PORT when explicit CLI port is not the default", async () => {
const originalPort = process.env.PORT;
process.env.PORT = "4041";
try {
await runServe(3000, {});
expect(mocks.listenCalls[0]).toMatchObject({
port: 3000,
host: "127.0.0.1",
});
await triggerSignal("SIGINT");
} finally {
if (originalPort !== undefined) {
process.env.PORT = originalPort;
} else {
delete process.env.PORT;
}
}
});
});
describe("runServe — Plugin wiring", () => {
@@ -843,13 +909,14 @@ describe("runServe — Plugin wiring", () => {
process.exit = originalExit;
});
it("creates PluginStore and PluginLoader instances", async () => {
it("gets PluginStore from TaskStore and creates PluginLoader", async () => {
const { PluginStore, PluginLoader } = await import("@fusion/core");
await runServe(4040, {});
expect(PluginStore).toHaveBeenCalledTimes(1);
expect(mocks.taskStores[0].getPluginStore).toHaveBeenCalledTimes(1);
expect(PluginLoader).toHaveBeenCalledTimes(1);
expect(PluginStore).toHaveBeenCalled();
await triggerSignal("SIGINT");
});
@@ -869,12 +936,12 @@ describe("runServe — Plugin wiring", () => {
await triggerSignal("SIGINT");
});
it("initializes PluginStore with the task store's fusion directory", async () => {
const { PluginStore } = await import("@fusion/core");
it("initializes the TaskStore-provided PluginStore", async () => {
await runServe(4040, {});
expect(PluginStore).toHaveBeenCalledWith("/repo/.fusion");
expect(mocks.taskStores[0].getPluginStore).toHaveBeenCalledTimes(1);
const taskStorePluginStore = mocks.taskStores[0].getPluginStore.mock.results[0]?.value as { init: ReturnType<typeof vi.fn> };
expect(taskStorePluginStore?.init).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
@@ -892,6 +959,41 @@ describe("runServe — Plugin wiring", () => {
await triggerSignal("SIGINT");
});
it("auto-loads installed plugins during startup", async () => {
const { PluginLoader } = await import("@fusion/core");
await runServe(4040, {});
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
| { loadAllPlugins: ReturnType<typeof vi.fn> }
| undefined;
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("continues startup when plugin auto-load fails", async () => {
const { PluginLoader } = await import("@fusion/core");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
}));
await expect(runServe(4040, {})).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[plugins] Failed to load plugins: plugin load failed")
);
await triggerSignal("SIGINT");
errorSpy.mockRestore();
});
it("includes plugin wiring in headless server", async () => {
const { createServer } = await import("@fusion/dashboard");

View File

@@ -13,6 +13,8 @@ vi.mock("@fusion/core", () => {
githubTokenConfigured: false,
defaultProvider: undefined,
defaultModelId: undefined,
defaultNodeId: undefined,
unavailableNodePolicy: undefined,
};
return {
@@ -55,9 +57,15 @@ describe("settings commands", () => {
it("exposes expected valid settings and parser behavior", () => {
expect(VALID_SETTINGS).toContain("maxConcurrent");
expect(VALID_SETTINGS).toContain("defaultNodeId");
expect(VALID_SETTINGS).toContain("unavailableNodePolicy");
expect(parseValue("ntfyEnabled", "yes")).toBe(true);
expect(parseValue("maxConcurrent", "4")).toBe(4);
expect(parseValue("worktreeNaming", "task-id")).toBe("task-id");
expect(parseValue("defaultNodeId", "node-abc-123")).toBe("node-abc-123");
expect(parseValue("unavailableNodePolicy", "block")).toBe("block");
expect(parseValue("unavailableNodePolicy", "fallback-local")).toBe("fallback-local");
expect(() => parseValue("unavailableNodePolicy", "invalid")).toThrow(/block, fallback-local/);
});
it("runSettingsShow without project uses global settings even if a project could resolve", async () => {
@@ -172,6 +180,24 @@ describe("settings commands", () => {
expect(updateSettings).toHaveBeenCalledWith({ maxParallelSteps: 3 });
});
it("runSettingsSet updates defaultNodeId and unavailableNodePolicy", async () => {
const updateSettings = vi.fn().mockResolvedValue(makeSettings({ defaultNodeId: "my-node", unavailableNodePolicy: "fallback-local" }));
const getSettings = vi.fn().mockResolvedValue(makeSettings({ defaultNodeId: "my-node", unavailableNodePolicy: "fallback-local" }));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { updateSettings, getSettings } as any,
});
await runSettingsSet("defaultNodeId", "my-node", "demo-project");
await runSettingsSet("unavailableNodePolicy", "fallback-local", "demo-project");
expect(updateSettings).toHaveBeenNthCalledWith(1, { defaultNodeId: "my-node" });
expect(updateSettings).toHaveBeenNthCalledWith(2, { unavailableNodePolicy: "fallback-local" });
});
it("rejects maxParallelSteps values outside range", async () => {
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
@@ -205,4 +231,25 @@ describe("settings commands", () => {
expect(output).toContain("Run Steps In New Sessions");
expect(output).toContain("Max Parallel Steps");
});
it("runSettingsShow includes Node Routing section", async () => {
const getSettings = vi.fn().mockResolvedValue(makeSettings({
defaultNodeId: "node-abc",
unavailableNodePolicy: "block",
}));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings } as any,
});
await runSettingsShow("demo-project");
const output = logSpy.mock.calls.map((args) => args.join(" ")).join("\n");
expect(output).toContain("Node Routing");
expect(output).toContain("Default Node Id");
expect(output).toContain("Unavailable Node Policy");
});
});

View File

@@ -0,0 +1,123 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { mockSpawn } = vi.hoisted(() => ({
mockSpawn: vi.fn(),
}));
vi.mock("node:child_process", () => ({
spawn: mockSpawn,
}));
import { parseOpencodeModelsOutput, syncStartupModels } from "../startup-model-sync.js";
type MockProcess = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
};
function createSpawnProcess(): MockProcess {
const proc = new EventEmitter() as MockProcess;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = vi.fn();
return proc;
}
describe("startup-model-sync", () => {
beforeEach(() => {
mockSpawn.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("syncs OpenRouter and opencode-go models", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stdout.emit("data", Buffer.from("Models cache refreshed\nopencode/gpt-5\nopencode-go/custom\n"));
proc.emit("exit", 0);
});
return proc;
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({
data: [{ id: "openai/gpt-4o", name: "GPT-4o", context_length: 128000 }],
}),
}));
const registerProvider = vi.fn();
const log = vi.fn();
const run = syncStartupModels({
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: true }),
authStorage: { getApiKey: vi.fn().mockResolvedValue("key") },
modelRegistry: { registerProvider },
log,
});
await run;
expect(registerProvider).toHaveBeenCalledWith("openrouter", expect.objectContaining({ models: expect.any(Array) }));
expect(registerProvider).toHaveBeenCalledWith("opencode-go", expect.objectContaining({
models: expect.arrayContaining([
expect.objectContaining({ id: "opencode-go/gpt-5" }),
expect.objectContaining({ id: "opencode-go/custom" }),
]),
}));
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Synced"));
expect(log).toHaveBeenCalledWith("opencode-go", expect.stringContaining("Synced"));
});
it("respects disabled settings", async () => {
vi.stubGlobal("fetch", vi.fn());
const registerProvider = vi.fn();
await syncStartupModels({
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: false, opencodeGoModelSync: false }),
authStorage: { getApiKey: vi.fn() },
modelRegistry: { registerProvider },
log: vi.fn(),
});
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
expect(registerProvider).not.toHaveBeenCalled();
});
it("logs failures and continues", async () => {
mockSpawn.mockImplementation(() => {
const proc = createSpawnProcess();
queueMicrotask(() => {
proc.stderr.emit("data", Buffer.from("provider unavailable"));
proc.emit("exit", 1);
});
return proc;
});
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network")));
const log = vi.fn();
const run = syncStartupModels({
getSettings: vi.fn().mockResolvedValue({ openrouterModelSync: true, opencodeGoModelSync: true }),
authStorage: { getApiKey: vi.fn().mockResolvedValue(undefined) },
modelRegistry: { registerProvider: vi.fn() },
log,
});
await run;
expect(log).toHaveBeenCalledWith("openrouter", expect.stringContaining("Failed to sync models"));
expect(log).toHaveBeenCalledWith("opencode-go", expect.stringContaining("Failed to sync models"));
});
it("parses model ids from opencode CLI output", () => {
expect(parseOpencodeModelsOutput("Models cache refreshed\nopencode/gpt-5\nfoo\nopencode-go/custom\n")).toEqual([
"opencode/gpt-5",
"opencode-go/custom",
]);
});
});

View File

@@ -0,0 +1,896 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { EventEmitter } from "node:events";
// Mock child_process so we can intercept the `git push -u origin <branch>`
// call that processPullRequestMergeTask issues before createPr.
const execMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", () => ({
exec: (cmd: string, opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void) => {
try {
const result = execMock(cmd, opts);
cb(null, typeof result === "string" ? result : "", "");
} catch (err) {
cb(err as Error, "", (err as Error).message);
}
},
}));
import { processPullRequestMergeTask, getTaskBranchName } from "../task-lifecycle.js";
interface MockTask {
id: string;
title: string;
description: string;
worktree?: string;
baseBranch?: string;
branchContext?: {
groupId: string;
source: "planning" | "mission";
assignmentMode: "shared" | "per-task-derived";
inheritedBaseBranch?: string;
};
prInfo?: {
number: number;
url: string;
status: "open" | "closed" | "merged";
headBranch?: string;
baseBranch?: string;
title?: string;
commentCount?: number;
lastCheckedAt?: string;
};
column: string;
}
function makeStore(task: MockTask, settings: Record<string, unknown> = {}) {
const emitter = new EventEmitter();
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
return Object.assign(emitter, {
getTask: vi.fn().mockResolvedValue(task),
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
updateTask: vi.fn(async (id: string, patch: Record<string, unknown>) => {
updates.push({ id, patch });
}),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
getActiveMergingTask: vi.fn().mockReturnValue(null),
_updates: updates,
});
}
function makeStatefulStore(task: MockTask, settings: Record<string, unknown> = {}) {
const emitter = new EventEmitter();
let state = structuredClone(task);
return Object.assign(emitter, {
getTask: vi.fn(async () => structuredClone(state)),
getSettings: vi.fn().mockResolvedValue({ requirePrApproval: false, ...settings }),
updateTask: vi.fn(async (_id: string, patch: Record<string, unknown>) => {
state = { ...state, ...patch };
}),
updatePrInfo: vi.fn(async (_id: string, prInfo: MockTask["prInfo"]) => {
state = { ...state, prInfo: prInfo ?? undefined };
return structuredClone(state);
}),
moveTask: vi.fn(async (_id: string, column: string) => {
state = { ...state, column };
}),
logEntry: vi.fn().mockResolvedValue(undefined),
getActiveMergingTask: vi.fn().mockReturnValue(null),
_getState: () => state,
});
}
describe("processPullRequestMergeTask", () => {
beforeEach(() => {
execMock.mockReset();
});
it("pushes the per-task branch to origin before creating a new PR", async () => {
const task: MockTask = {
id: "FN-9001",
title: "test",
description: "desc",
column: "in-review",
};
const branch = getTaskBranchName(task.id); // "fusion/fn-9001"
const store = makeStore(task);
const callOrder: string[] = [];
execMock.mockImplementation((cmd: string) => {
callOrder.push(`exec:${cmd}`);
return "";
});
const github = {
findPrForBranch: vi.fn(async () => {
callOrder.push("findPrForBranch");
return null;
}),
createPr: vi.fn(async () => {
callOrder.push("createPr");
return {
number: 42,
url: "https://github.com/x/y/pull/42",
status: "open" as const,
headBranch: branch,
baseBranch: "main",
};
}),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 42, status: "open" as const, url: "https://github.com/x/y/pull/42" },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("waiting");
expect(github.findPrForBranch).toHaveBeenCalled();
// The git push must happen after findPrForBranch and before createPr.
const pushIdx = callOrder.findIndex((c) => c === `exec:git push -u origin "${branch}"`);
const findIdx = callOrder.indexOf("findPrForBranch");
const createIdx = callOrder.indexOf("createPr");
expect(pushIdx).toBeGreaterThan(-1);
expect(pushIdx).toBeGreaterThan(findIdx);
expect(pushIdx).toBeLessThan(createIdx);
});
it("uses inherited branch-context merge target when creating a PR", async () => {
const task: MockTask = {
id: "FN-9002",
title: "test",
description: "desc",
column: "in-review",
branchContext: {
groupId: "planning:abc",
source: "planning",
assignmentMode: "shared",
inheritedBaseBranch: "develop",
},
};
const branch = getTaskBranchName(task.id);
const store = makeStore(task, { baseBranch: "main" });
execMock.mockImplementation(() => "");
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(async () => ({
number: 7,
url: "https://github.com/x/y/pull/7",
status: "open" as const,
headBranch: branch,
baseBranch: "develop",
})),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 7, status: "open" as const, url: "https://github.com/x/y/pull/7" },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({
base: "develop",
}));
});
it("skips the push when an existing PR already covers the branch", async () => {
const task: MockTask = {
id: "FN-9002",
title: "test",
description: "desc",
column: "in-review",
};
const branch = getTaskBranchName(task.id);
const store = makeStore(task);
const pushed: string[] = [];
execMock.mockImplementation((cmd: string) => {
if (cmd.startsWith("git push")) pushed.push(cmd);
return "";
});
const existingPr = {
number: 7,
url: "https://github.com/x/y/pull/7",
status: "open" as const,
headBranch: branch,
baseBranch: "main",
};
const github = {
findPrForBranch: vi.fn(async () => existingPr),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => ({
prInfo: existingPr,
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(github.createPr).not.toHaveBeenCalled();
expect(pushed).toEqual([]);
});
it("surfaces a clear error when the pre-create push fails", async () => {
const task: MockTask = {
id: "FN-9003",
title: "test",
description: "desc",
column: "in-review",
};
const branch = getTaskBranchName(task.id);
const store = makeStore(task);
execMock.mockImplementation((cmd: string) => {
if (cmd.startsWith("git push")) {
throw new Error("remote rejected: permission denied");
}
return "";
});
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(),
mergePr: vi.fn(),
};
await expect(
processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined),
).rejects.toThrow(new RegExp(`Failed to push branch "${branch}" to origin`));
expect(github.createPr).not.toHaveBeenCalled();
});
it("fails before push when the task branch is missing locally and remotely", async () => {
const task: MockTask = {
id: "FN-9010",
title: "test",
description: "desc",
column: "in-review",
};
const branch = getTaskBranchName(task.id);
const store = makeStore(task);
const commands: string[] = [];
execMock.mockImplementation((cmd: string) => {
commands.push(cmd);
if (cmd.startsWith("git show-ref")) {
const err = new Error("not found") as Error & { code?: number };
err.code = 1;
throw err;
}
if (cmd.startsWith("git ls-remote")) {
const err = new Error("not found") as Error & { code?: number };
err.code = 2;
throw err;
}
return "";
});
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(),
mergePr: vi.fn(),
};
await expect(
processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined),
).rejects.toThrow(`Cannot create PR for missing task branch "${branch}"`);
expect(commands.some((cmd) => cmd.startsWith("git push"))).toBe(false);
expect(github.createPr).not.toHaveBeenCalled();
});
it("rethrows unexpected remote lookup failures instead of treating them as missing branches", async () => {
const task: MockTask = {
id: "FN-9013",
title: "test",
description: "desc",
column: "in-review",
};
const store = makeStore(task);
const commands: string[] = [];
execMock.mockImplementation((cmd: string) => {
commands.push(cmd);
if (cmd.startsWith("git show-ref")) {
const err = new Error("not found") as Error & { code?: number };
err.code = 1;
throw err;
}
if (cmd.startsWith("git ls-remote")) {
const err = new Error("fatal: unable to access remote") as Error & { code?: number };
err.code = 128;
throw err;
}
return "";
});
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(),
mergePr: vi.fn(),
};
await expect(
processPullRequestMergeTask(store as never, "/repo", task.id, github as never, () => undefined),
).rejects.toThrow("fatal: unable to access remote");
expect(commands.some((cmd) => cmd.startsWith("git push"))).toBe(false);
expect(github.createPr).not.toHaveBeenCalled();
});
it("skips push when the local branch is gone but the remote task branch exists", async () => {
const task: MockTask = {
id: "FN-9011",
title: "test",
description: "desc",
column: "in-review",
};
const branch = getTaskBranchName(task.id);
const store = makeStore(task);
const commands: string[] = [];
execMock.mockImplementation((cmd: string) => {
commands.push(cmd);
if (cmd.startsWith("git show-ref")) {
const err = new Error("not found") as Error & { code?: number };
err.code = 1;
throw err;
}
return "";
});
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(async () => ({
number: 43,
url: "https://github.com/x/y/pull/43",
status: "open" as const,
headBranch: branch,
baseBranch: "main",
})),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 43, status: "open" as const, url: "https://github.com/x/y/pull/43" },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("waiting");
expect(commands.some((cmd) => cmd.startsWith("git ls-remote"))).toBe(true);
expect(commands.some((cmd) => cmd.startsWith("git push"))).toBe(false);
expect(github.createPr).toHaveBeenCalledWith(expect.objectContaining({ head: branch }));
});
it("parks no-delta branches instead of retrying into branch push failures", async () => {
const task: MockTask = {
id: "FN-9012",
title: "test",
description: "desc",
column: "in-review",
};
const branch = getTaskBranchName(task.id);
const store = makeStore(task);
execMock.mockImplementation(() => "");
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(async () => {
throw new Error(`GraphQL: No commits between main and ${branch} (createPullRequest)`);
}),
getPrMergeStatus: vi.fn(),
mergePr: vi.fn(),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("skipped");
expect(store.updateTask).toHaveBeenCalledWith(task.id, {
status: "failed",
error: `No pull request created for ${branch}: the branch has no commits relative to the base branch.`,
});
expect(store.logEntry).toHaveBeenCalledWith(
task.id,
`No pull request created for ${branch}: the branch has no commits relative to the base branch.`,
expect.stringContaining("No commits between"),
);
});
it("finalizes task cleanup when PR is already merged on status refresh", async () => {
const task: MockTask = {
id: "FN-9004",
title: "test",
description: "desc",
column: "in-review",
worktree: "/tmp/worktree-fn-9004",
prInfo: {
number: 88,
url: "https://github.com/x/y/pull/88",
status: "open",
headBranch: "fusion/fn-9004",
baseBranch: "main",
},
};
const store = makeStore(task);
execMock.mockImplementation(() => "");
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => ({
prInfo: {
number: 88,
url: "https://github.com/x/y/pull/88",
status: "merged" as const,
headBranch: "fusion/fn-9004",
baseBranch: "main",
},
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("merged");
expect(github.mergePr).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("FN-9004", { status: null, mergeRetries: 0 });
expect(store.moveTask).toHaveBeenCalledWith("FN-9004", "done");
});
it("reconciles to done when PR merges after readiness check but before merge command completes", async () => {
const task: MockTask = {
id: "FN-9104",
title: "test",
description: "desc",
column: "in-review",
worktree: "/tmp/worktree-fn-9104",
prInfo: {
number: 124,
url: "https://github.com/x/y/pull/124",
status: "open",
headBranch: "fusion/fn-9104",
baseBranch: "main",
},
};
const store = makeStore(task);
execMock.mockImplementation(() => "");
const openPr = {
number: 124,
url: "https://github.com/x/y/pull/124",
status: "open" as const,
headBranch: "fusion/fn-9104",
baseBranch: "main",
};
const mergedPr = {
...openPr,
status: "merged" as const,
};
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
getPrMergeStatus: vi
.fn()
.mockResolvedValueOnce({
prInfo: openPr,
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
})
.mockResolvedValueOnce({
prInfo: mergedPr,
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
}),
mergePr: vi.fn(async () => {
throw new Error("Pull request is not mergeable: the merge commit cannot be cleanly created");
}),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("merged");
expect(github.mergePr).toHaveBeenCalledWith({ number: 124, method: "squash" });
expect(github.getPrMergeStatus).toHaveBeenCalledTimes(2);
expect(store.updatePrInfo).toHaveBeenLastCalledWith("FN-9104", expect.objectContaining({ status: "merged" }));
expect(store.updateTask).toHaveBeenCalledWith("FN-9104", { status: null, mergeRetries: 0 });
expect(store.moveTask).toHaveBeenCalledWith("FN-9104", "done");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-9104",
"Pull request already merged after merge command failed; reconciled task state from GitHub",
"PR #124: https://github.com/x/y/pull/124",
);
});
it("rethrows the original merge error when refresh does not confirm merged", async () => {
const task: MockTask = {
id: "FN-9105",
title: "test",
description: "desc",
column: "in-review",
prInfo: {
number: 125,
url: "https://github.com/x/y/pull/125",
status: "open",
headBranch: "fusion/fn-9105",
baseBranch: "main",
},
};
const store = makeStore(task);
const mergeError = new Error("Pull request is not mergeable");
const openPr = {
number: 125,
url: "https://github.com/x/y/pull/125",
status: "open" as const,
headBranch: "fusion/fn-9105",
baseBranch: "main",
};
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
getPrMergeStatus: vi
.fn()
.mockResolvedValueOnce({
prInfo: openPr,
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
})
.mockResolvedValueOnce({
prInfo: openPr,
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
}),
mergePr: vi.fn(async () => {
throw mergeError;
}),
};
await expect(
processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
),
).rejects.toThrow(mergeError.message);
expect(github.mergePr).toHaveBeenCalledWith({ number: 125, method: "squash" });
expect(github.getPrMergeStatus).toHaveBeenCalledTimes(2);
expect(store.updatePrInfo).not.toHaveBeenCalledWith("FN-9105", expect.objectContaining({ status: "merged" }));
expect(store.moveTask).not.toHaveBeenCalled();
});
it("rethrows the original merge error when the post-failure refresh also fails", async () => {
const task: MockTask = {
id: "FN-9106",
title: "test",
description: "desc",
column: "in-review",
prInfo: {
number: 126,
url: "https://github.com/x/y/pull/126",
status: "open",
headBranch: "fusion/fn-9106",
baseBranch: "main",
},
};
const store = makeStore(task);
const mergeError = new Error("merge command failed");
const openPr = {
number: 126,
url: "https://github.com/x/y/pull/126",
status: "open" as const,
headBranch: "fusion/fn-9106",
baseBranch: "main",
};
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
getPrMergeStatus: vi
.fn()
.mockResolvedValueOnce({
prInfo: openPr,
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
})
.mockRejectedValueOnce(new Error("status refresh failed")),
mergePr: vi.fn(async () => {
throw mergeError;
}),
};
await expect(
processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
),
).rejects.toThrow(mergeError.message);
expect(github.mergePr).toHaveBeenCalledWith({ number: 126, method: "squash" });
expect(github.getPrMergeStatus).toHaveBeenCalledTimes(2);
expect(store.moveTask).not.toHaveBeenCalled();
});
it("preserves PR number/url through create, refresh, and merge completion", async () => {
const task: MockTask = {
id: "FN-9103",
title: "test",
description: "desc",
column: "in-review",
};
const store = makeStatefulStore(task);
const createdPr = {
number: 123,
url: "https://github.com/x/y/pull/123",
status: "open" as const,
headBranch: "fusion/fn-9103",
baseBranch: "main",
title: "PR title",
commentCount: 0,
};
const mergedPr = {
...createdPr,
status: "merged" as const,
commentCount: 2,
};
const github = {
findPrForBranch: vi.fn(async () => null),
createPr: vi.fn(async () => createdPr),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { ...createdPr, commentCount: 1 },
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
})),
mergePr: vi.fn(async () => mergedPr),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("merged");
const persisted = (store as { _getState: () => MockTask })._getState();
expect(persisted.column).toBe("done");
expect(persisted.prInfo?.number).toBe(123);
expect(persisted.prInfo?.url).toBe("https://github.com/x/y/pull/123");
expect(store.updatePrInfo).toHaveBeenCalledTimes(3);
});
describe("requirePrApproval", () => {
function makeReadyMergeStatus(reviewDecision: string | null) {
const prInfo = {
number: 100,
url: "https://github.com/x/y/pull/100",
status: "open" as const,
headBranch: "fusion/fn-9100",
baseBranch: "main",
};
// Simulate the "free private repo" case: GitHub reports no required
// checks and no blocking review state, so isPrMergeReady returns
// mergeReady: true. Without the gate this would auto-merge.
return {
prInfo,
reviewDecision,
checks: [],
mergeReady: true,
blockingReasons: [],
};
}
it("holds the merge when requirePrApproval is true and reviewDecision is not APPROVED", async () => {
const task: MockTask = {
id: "FN-9100",
title: "test",
description: "desc",
column: "in-review",
prInfo: {
number: 100,
url: "https://github.com/x/y/pull/100",
status: "open",
headBranch: "fusion/fn-9100",
baseBranch: "main",
},
};
const store = makeStore(task, { requirePrApproval: true });
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => makeReadyMergeStatus(null)),
mergePr: vi.fn(),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("waiting");
expect(github.mergePr).not.toHaveBeenCalled();
const lastUpdate = (store as { _updates: Array<{ patch: Record<string, unknown> }> })._updates.at(-1);
expect(lastUpdate?.patch).toEqual({ status: "awaiting-pr-checks" });
});
it("merges when requirePrApproval is true and reviewDecision is APPROVED", async () => {
const task: MockTask = {
id: "FN-9101",
title: "test",
description: "desc",
column: "in-review",
prInfo: {
number: 100,
url: "https://github.com/x/y/pull/100",
status: "open",
headBranch: "fusion/fn-9101",
baseBranch: "main",
},
};
const store = makeStore(task, { requirePrApproval: true });
const merged = {
number: 100,
url: "https://github.com/x/y/pull/100",
status: "merged" as const,
headBranch: "fusion/fn-9101",
baseBranch: "main",
};
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => makeReadyMergeStatus("APPROVED")),
mergePr: vi.fn(async () => merged),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("merged");
expect(github.mergePr).toHaveBeenCalledWith({ number: 100, method: "squash" });
});
it("preserves existing behavior when requirePrApproval is false", async () => {
const task: MockTask = {
id: "FN-9102",
title: "test",
description: "desc",
column: "in-review",
prInfo: {
number: 100,
url: "https://github.com/x/y/pull/100",
status: "open",
headBranch: "fusion/fn-9102",
baseBranch: "main",
},
};
const store = makeStore(task, { requirePrApproval: false });
const merged = {
number: 100,
url: "https://github.com/x/y/pull/100",
status: "merged" as const,
headBranch: "fusion/fn-9102",
baseBranch: "main",
};
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
// reviewDecision: null but mergeReady: true — without the gate,
// this should still merge (the buggy default that #21's reviewer
// flagged as too aggressive on free private repos).
getPrMergeStatus: vi.fn(async () => makeReadyMergeStatus(null)),
mergePr: vi.fn(async () => merged),
};
const result = await processPullRequestMergeTask(
store as never,
"/repo",
task.id,
github as never,
() => undefined,
);
expect(result).toBe("merged");
expect(github.mergePr).toHaveBeenCalled();
});
});
});

View File

@@ -37,6 +37,8 @@ vi.mock("@fusion/core", () => {
getProject: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue(undefined),
registerProject: vi.fn().mockResolvedValue({ id: "proj_test", name: "test", path: "/test" }),
getNode: vi.fn().mockResolvedValue(undefined),
getNodeByName: vi.fn().mockResolvedValue(undefined),
};
}),
};
@@ -79,9 +81,9 @@ vi.mock("../../project-context.js", () => ({
}));
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@fusion/core";
import { TaskStore, CentralCore } from "@fusion/core";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskSetNode, runTaskClearNode, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "../task.js";
import {
getCurrentRepo,
isGhAuthenticated,
@@ -111,6 +113,13 @@ function makeTask(overrides: Record<string, unknown> = {}) {
describe("runTaskShow", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
const mockTaskStoreGetTask = (task: Record<string, unknown>) => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(task),
}));
};
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
@@ -123,10 +132,7 @@ describe("runTaskShow", () => {
const longDesc = "A".repeat(120); // well over 60 chars
const task = makeTask({ description: longDesc });
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(task),
}));
mockTaskStoreGetTask(task);
await runTaskShow("FN-001");
@@ -146,10 +152,7 @@ describe("runTaskShow", () => {
description: "This is the full description that should not appear in the header",
});
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(task),
}));
mockTaskStoreGetTask(task);
await runTaskShow("FN-001");
@@ -160,6 +163,183 @@ describe("runTaskShow", () => {
expect(headerLine![0]).toContain("My Task Title");
expect(headerLine![0]).not.toContain("This is the full description");
});
it.each([
[{ sourceType: "dashboard_ui" }, "Source: Dashboard"],
[{ sourceType: "agent_heartbeat", sourceAgentId: "agent-123" }, "Source: Agent (agent-123)"],
[{ sourceType: "task_refine", sourceParentTaskId: "FN-2904" }, "Source: Refinement of FN-2904"],
[{ sourceType: "task_duplicate", sourceParentTaskId: "FN-2905" }, "Source: Duplicate of FN-2905"],
[
{ sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42", issueNumber: 42 } },
"Source: GitHub Import (https://github.com/owner/repo/issues/42)",
],
[
{ sourceType: "research", sourceMetadata: { runId: "RR-001", findingLabel: "Latency hotspot" } },
"Source: Research (Latency hotspot)",
],
[{ sourceType: "research", sourceMetadata: { runId: "RR-002" } }, "Source: Research (RR-002)"],
] as const)("prints provenance line for %o", async (overrides, expectedLine) => {
mockTaskStoreGetTask(makeTask(overrides));
await runTaskShow("FN-001");
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain(expectedLine);
});
it.each([{ sourceType: "unknown" }, {}])("omits provenance line for %o", async (overrides) => {
mockTaskStoreGetTask(makeTask(overrides));
await runTaskShow("FN-001");
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).not.toContain("Source:");
});
});
describe("task node overrides", () => {
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("runTaskSetNode resolves node name and updates task", async () => {
const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: "node-123" }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "todo" })),
updateTask,
}));
const getNodeByName = vi.fn().mockResolvedValue({ id: "node-123", name: "my-remote" });
const getNode = vi.fn().mockResolvedValue(undefined);
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode,
getNodeByName,
}));
await runTaskSetNode("FN-001", "my-remote");
expect(updateTask).toHaveBeenCalledWith("FN-001", { nodeId: "node-123" });
});
it("runTaskSetNode accepts raw node id", async () => {
const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: "12345678-1234-1234-1234-123456789012" }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "todo" })),
updateTask,
}));
const getNode = vi.fn().mockResolvedValue({ id: "12345678-1234-1234-1234-123456789012", name: "raw" });
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode,
getNodeByName: vi.fn().mockResolvedValue(undefined),
}));
await runTaskSetNode("FN-001", "12345678-1234-1234-1234-123456789012");
expect(updateTask).toHaveBeenCalledWith("FN-001", { nodeId: "12345678-1234-1234-1234-123456789012" });
});
it("runTaskSetNode blocks in-progress tasks", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "in-progress" })),
}));
await expect(runTaskSetNode("FN-001", "my-remote")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Cannot change node override: task FN-001 is in progress");
});
it("runTaskSetNode errors when node is unknown", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "todo" })),
updateTask: vi.fn(),
}));
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode: vi.fn().mockResolvedValue(undefined),
getNodeByName: vi.fn().mockResolvedValue(undefined),
}));
await expect(runTaskSetNode("FN-001", "missing-node")).rejects.toThrow("process.exit:1");
});
it("runTaskClearNode clears override", async () => {
const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: undefined }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "todo", nodeId: "node-123" })),
updateTask,
}));
await runTaskClearNode("FN-001");
expect(updateTask).toHaveBeenCalledWith("FN-001", { nodeId: null });
});
it("runTaskClearNode blocks in-progress tasks", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ column: "in-progress" })),
}));
await expect(runTaskClearNode("FN-001")).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Cannot change node override: task FN-001 is in progress");
});
it("runTaskShow displays node routing info", async () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ nodeId: "node-123", column: "todo" })),
getSettings: vi.fn().mockResolvedValue({ unavailableNodePolicy: "block" }),
}));
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode: vi.fn().mockResolvedValue({ id: "node-123", name: "remote-a" }),
getNodeByName: vi.fn().mockResolvedValue(undefined),
}));
await runTaskShow("FN-001");
const output = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Node:");
expect(output).toContain("Unavailable Node Policy");
});
it("runTaskCreate with node resolves and applies override", async () => {
const updateTask = vi.fn().mockResolvedValue(makeTask({ nodeId: "node-123" }));
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: vi.fn().mockResolvedValue(makeTask({ id: "FN-900", column: "triage" })),
updateTask,
}));
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getNode: vi.fn().mockResolvedValue(undefined),
getNodeByName: vi.fn().mockResolvedValue({ id: "node-123", name: "remote-a" }),
}));
await runTaskCreate("new task", undefined, undefined, undefined, "remote-a");
expect(updateTask).toHaveBeenCalledWith("FN-900", { nodeId: "node-123" });
});
});
// Mock fs/promises for runTaskCreate attach tests
@@ -262,7 +442,7 @@ describe("project-aware task command behavior", () => {
await runTaskCreate("test task", undefined, undefined, "demo-project");
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(mockCreateTask).toHaveBeenCalledWith({ description: "test task", dependencies: undefined });
expect(mockCreateTask).toHaveBeenCalledWith({ description: "test task", dependencies: undefined, source: { sourceType: "cli" } });
expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Project: demo-project"))).toBe(true);
logSpy.mockRestore();
@@ -281,7 +461,7 @@ describe("project-aware task command behavior", () => {
await runTaskCreate("default task");
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(mockCreateTask).toHaveBeenCalledWith({ description: "default task", dependencies: undefined });
expect(mockCreateTask).toHaveBeenCalledWith({ description: "default task", dependencies: undefined, source: { sourceType: "cli" } });
});
it("runTaskCreate without project flag falls back to TaskStore(process.cwd()) when resolution fails", async () => {
@@ -305,7 +485,7 @@ describe("project-aware task command behavior", () => {
expect(resolveProject).toHaveBeenCalledWith(undefined);
expect(TaskStore).toHaveBeenCalledWith("/current/project");
expect(init).toHaveBeenCalledOnce();
expect(mockCreateTask).toHaveBeenCalledWith({ description: "local task", dependencies: undefined });
expect(mockCreateTask).toHaveBeenCalledWith({ description: "local task", dependencies: undefined, source: { sourceType: "cli" } });
cwdSpy.mockRestore();
});
@@ -350,6 +530,8 @@ describe("project-aware task command behavior", () => {
});
it("runTaskPrCreate falls back to current working directory without project flag", async () => {
const originalGitHubRepo = process.env.GITHUB_REPOSITORY;
delete process.env.GITHUB_REPOSITORY;
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/local/project");
const mockCreatePr = vi.fn().mockResolvedValue({ number: 123, url: "https://example.com/pr/123" });
vi.mocked(isGhAvailable).mockReturnValue(true);
@@ -369,6 +551,7 @@ describe("project-aware task command behavior", () => {
expect(getCurrentRepo).toHaveBeenCalledWith("/local/project");
expect(mockCreatePr).toHaveBeenCalledWith(expect.objectContaining({ head: "fusion/fn-001" }));
cwdSpy.mockRestore();
if (originalGitHubRepo !== undefined) process.env.GITHUB_REPOSITORY = originalGitHubRepo;
});
it("runTaskPlan uses resolved project path only when project name is provided", async () => {
@@ -820,6 +1003,7 @@ describe("runTaskCreate with --depends", () => {
expect(mockCreateTask).toHaveBeenCalledWith({
description: "test task",
dependencies: ["FN-124"],
source: { sourceType: "cli" },
});
});
@@ -829,6 +1013,7 @@ describe("runTaskCreate with --depends", () => {
expect(mockCreateTask).toHaveBeenCalledWith({
description: "test task",
dependencies: ["FN-124", "FN-100"],
source: { sourceType: "cli" },
});
const depsLine = logSpy.mock.calls.find(
@@ -845,6 +1030,7 @@ describe("runTaskCreate with --depends", () => {
expect(mockCreateTask).toHaveBeenCalledWith({
description: "test task",
dependencies: undefined,
source: { sourceType: "cli" },
});
});
});
@@ -922,12 +1108,28 @@ describe("runTaskImportGitHubInteractive", () => {
description: "Description 1\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: "owner/repo",
externalIssueId: "1",
issueNumber: 1,
url: "https://github.com/owner/repo/issues/1",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1", issueNumber: 1 } },
});
expect(mockCreateTask).toHaveBeenCalledWith({
title: "Third Issue",
description: "Description 3\n\nSource: https://github.com/owner/repo/issues/3",
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: "owner/repo",
externalIssueId: "3",
issueNumber: 3,
url: "https://github.com/owner/repo/issues/3",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/3", issueNumber: 3 } },
});
});
@@ -978,6 +1180,14 @@ describe("runTaskImportGitHubInteractive", () => {
description: "Description 2\n\nSource: https://github.com/owner/repo/issues/2",
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: "owner/repo",
externalIssueId: "2",
issueNumber: 2,
url: "https://github.com/owner/repo/issues/2",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/2", issueNumber: 2 } },
});
const skipLine = logSpy.mock.calls.find(
@@ -1224,6 +1434,14 @@ describe("runTaskImportFromGitHub", () => {
description: "Description 1\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: "owner/repo",
externalIssueId: "1",
issueNumber: 1,
url: "https://github.com/owner/repo/issues/1",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1", issueNumber: 1 } },
});
const successLine = logSpy.mock.calls.find(
@@ -1299,6 +1517,14 @@ describe("runTaskImportFromGitHub", () => {
description: "(no description)\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: "owner/repo",
externalIssueId: "1",
issueNumber: 1,
url: "https://github.com/owner/repo/issues/1",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1", issueNumber: 1 } },
});
});
@@ -1313,6 +1539,14 @@ describe("runTaskImportFromGitHub", () => {
description: expect.stringContaining("Body"),
column: "triage",
dependencies: [],
sourceIssue: {
provider: "github",
repository: "owner/repo",
externalIssueId: "1",
issueNumber: 1,
url: "https://github.com/owner/repo/issues/1",
},
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/1", issueNumber: 1 } },
});
});
});

View File

@@ -0,0 +1,160 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const { execAsyncMock, existsSyncMock, readFileSyncMock, getCachedUpdateStatusMock } = vi.hoisted(() => ({
execAsyncMock: vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr: string }>>(),
existsSyncMock: vi.fn<(path: string) => boolean>(),
readFileSyncMock: vi.fn<(path: string, encoding: BufferEncoding) => string>(),
getCachedUpdateStatusMock: vi.fn<(currentVersion?: string) => {
updateAvailable: boolean;
latestVersion: string;
currentVersion: string;
} | null>(),
}));
vi.mock("node:child_process", async () => {
const { promisify } = await import("node:util");
const execFn: Record<PropertyKey, unknown> = vi.fn();
execFn[promisify.custom] = execAsyncMock;
return { exec: execFn };
});
vi.mock("node:fs", () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock,
}));
vi.mock("../../update-cache.js", () => ({
getCachedUpdateStatus: getCachedUpdateStatusMock,
}));
import { runUpdate } from "../update.js";
describe("runUpdate", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
process.exitCode = 0;
existsSyncMock.mockImplementation((path: string) => path.endsWith("package.json"));
readFileSyncMock.mockReturnValue(JSON.stringify({ name: "@runfusion/fusion", version: "1.2.3" }));
getCachedUpdateStatusMock.mockReturnValue(null);
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: number | string | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
});
afterEach(() => {
logSpy.mockRestore();
errorSpy.mockRestore();
exitSpy.mockRestore();
});
it("reports already up to date when current version matches latest", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3" } }) }));
await runUpdate();
expect(execAsyncMock).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith("Already up to date.");
});
it("installs when update is available", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
execAsyncMock.mockResolvedValue({ stdout: "ok", stderr: "" });
await runUpdate();
expect(execAsyncMock).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", expect.objectContaining({ timeout: 120_000 }));
expect(logSpy).toHaveBeenCalledWith("Update complete.");
});
it("check mode reports availability without installing and sets exit code", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
await runUpdate({ check: true });
expect(execAsyncMock).not.toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith("Update available.");
expect(process.exitCode).toBe(1);
});
it("json mode outputs expected payload", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.3" } }) }));
await runUpdate({ json: true });
const output = logSpy.mock.calls[0]?.[0] as string;
const parsed = JSON.parse(output) as {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
updated: boolean;
};
expect(parsed).toEqual({
currentVersion: "1.2.3",
latestVersion: "1.2.3",
updateAvailable: false,
updated: false,
});
});
it("returns helpful error on network failure without cache", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
await expect(runUpdate({ check: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error checking for updates: network down");
});
it("uses cached version when network fails in check mode", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
getCachedUpdateStatusMock.mockReturnValue({
updateAvailable: true,
currentVersion: "1.2.3",
latestVersion: "1.2.5",
});
await runUpdate({ check: true });
expect(logSpy).toHaveBeenCalledWith("Warning: npm registry unreachable, using cached update metadata.");
expect(logSpy).toHaveBeenCalledWith("Latest version: 1.2.5");
expect(process.exitCode).toBe(1);
});
it("returns helpful error when npm install fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
execAsyncMock.mockRejectedValue(new Error("permission denied"));
await expect(runUpdate()).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error installing update: permission denied");
});
it("handles semver comparisons for major, minor, and patch", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "2.0.0" } }) }));
execAsyncMock.mockResolvedValue({ stdout: "ok", stderr: "" });
readFileSyncMock.mockReturnValueOnce(JSON.stringify({ name: "@runfusion/fusion", version: "1.9.9" }));
await runUpdate({ check: true });
expect(process.exitCode).toBe(1);
process.exitCode = 0;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.3.0" } }) }));
readFileSyncMock.mockReturnValueOnce(JSON.stringify({ name: "@runfusion/fusion", version: "1.2.9" }));
await runUpdate({ check: true });
expect(process.exitCode).toBe(1);
process.exitCode = 0;
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ "dist-tags": { latest: "1.2.4" } }) }));
readFileSyncMock.mockReturnValueOnce(JSON.stringify({ name: "@runfusion/fusion", version: "1.2.3" }));
await runUpdate({ check: true });
expect(process.exitCode).toBe(1);
});
});

View File

@@ -14,6 +14,17 @@ export function getFusionAuthPath(home = process.env.HOME || process.env.USERPRO
return join(getFusionAgentDir(home), "auth.json");
}
export function getCodexCliAuthPath(home = process.env.HOME || process.env.USERPROFILE || homedir()): string {
return join(home, ".codex", "auth.json");
}
export function getClaudeCodeCredentialPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
return [
join(home, ".claude", ".credentials.json"),
join(home, ".config", "claude", ".credentials.json"),
];
}
export function getLegacyAuthPaths(home = process.env.HOME || process.env.USERPROFILE || homedir()): string[] {
return [
join(home, ".pi", "agent", "auth.json"),

View File

@@ -49,13 +49,15 @@ export type ClaudeCliExtensionResolution =
* module's location, and fall back to `require.resolve` for monorepo
* dev/test runs where this file executes from `src/` rather than `dist/`.
*/
export function resolveClaudeCliExtension(): ClaudeCliExtensionResolution {
export function resolveClaudeCliExtensionFromModuleUrl(
moduleUrl: string,
): ClaudeCliExtensionResolution {
let pkgJsonPath: string | undefined;
// Bundled lookup: when running from dist/, sibling dir dist/pi-claude-cli/
// holds the staged extension. Walk up a few levels to also catch nested
// layouts (e.g. dist/commands/foo.js) without hard-coding depth.
const here = dirname(fileURLToPath(import.meta.url));
const here = dirname(fileURLToPath(moduleUrl));
for (const rel of ["pi-claude-cli", "../pi-claude-cli", "../../pi-claude-cli"]) {
const candidate = resolve(here, rel, "package.json");
if (existsSync(candidate)) {
@@ -113,6 +115,10 @@ export function resolveClaudeCliExtension(): ClaudeCliExtensionResolution {
};
}
export function resolveClaudeCliExtension(): ClaudeCliExtensionResolution {
return resolveClaudeCliExtensionFromModuleUrl(import.meta.url);
}
/**
* Compute the paths to append to `discoverAndLoadExtensions`' configuredPaths
* based on the user's `useClaudeCli` setting.

View File

@@ -0,0 +1,96 @@
import type { CustomProvider } from "@fusion/core";
interface ModelRegistryLike {
registerProvider: (name: string, config: {
baseUrl: string;
api: string;
apiKey?: string;
models: Array<{
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
contextWindow: number;
maxTokens: number;
}>;
}) => void;
refresh: () => void;
}
export function resolveApiType(apiType: string): string {
if (apiType === "anthropic-compatible") {
return "anthropic";
}
return "openai-completions";
}
function toProviderConfig(provider: CustomProvider) {
return {
baseUrl: provider.baseUrl,
api: resolveApiType(provider.apiType),
apiKey: provider.apiKey,
models: (provider.models ?? []).map((model) => ({
id: model.id,
name: model.name,
reasoning: false,
input: ["text" as const],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 128000,
maxTokens: 16384,
})),
};
}
function providersDiffer(previous: CustomProvider, current: CustomProvider): boolean {
return JSON.stringify(toProviderConfig(previous)) !== JSON.stringify(toProviderConfig(current));
}
export function registerCustomProviders(
modelRegistry: ModelRegistryLike,
customProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
for (const provider of customProviders ?? []) {
try {
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
logFn(`Registered custom provider ${provider.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
}
}
modelRegistry.refresh();
}
export function reregisterCustomProviders(
modelRegistry: ModelRegistryLike,
previousProviders: CustomProvider[] | undefined,
currentProviders: CustomProvider[] | undefined,
logFn: (message: string) => void,
): void {
const previousById = new Map((previousProviders ?? []).map((provider) => [provider.id, provider]));
for (const provider of currentProviders ?? []) {
const previous = previousById.get(provider.id);
if (previous && !providersDiffer(previous, provider)) {
continue;
}
try {
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
logFn(`${previous ? "Updated" : "Registered"} custom provider ${provider.id}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
}
}
modelRegistry.refresh();
}

View File

@@ -12,7 +12,6 @@ import type { AddressInfo } from "node:net";
import { join } from "node:path";
import {
CentralCore,
PluginStore,
PluginLoader,
getTaskMergeBlocker,
INSIGHT_EXTRACTION_SCHEDULE_NAME,
@@ -49,10 +48,22 @@ import {
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import {
getCachedDroidCliResolution,
resolveDroidCliExtensionPaths,
setCachedDroidCliResolution,
} from "./droid-cli-extension.js";
import {
getCachedLlamaCppResolution,
resolveLlamaCppExtensionPaths,
setCachedLlamaCppResolution,
} from "./llama-cpp-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import { ensureBundledDependencyGraphPluginInstalled } from "../plugins/bundled-plugin-install.js";
import { syncStartupModels } from "./startup-model-sync.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let daemonStartTime = 0;
@@ -355,12 +366,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
// ── PluginStore: plugin installation management ─────────────────────
// Some mocked stores used in tests may not implement getRootDir(); fall
// back to the resolved runtime cwd in that case.
const storeRootDir = typeof (store as { getRootDir?: () => string }).getRootDir === "function"
? (store as { getRootDir: () => string }).getRootDir()
: cwd;
const pluginStore = new PluginStore(storeRootDir);
const pluginStore = store.getPluginStore();
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────
@@ -369,6 +375,39 @@ export async function runDaemon(opts: DaemonOptions = {}) {
taskStore: store,
});
try {
const installStatus = await ensureBundledDependencyGraphPluginInstalled(pluginStore, pluginLoader);
if (installStatus === "installed") {
console.log("[plugins] Installed bundled Dependency Graph plugin");
} else if (installStatus === "missing-bundle") {
console.warn("[plugins] Bundled Dependency Graph plugin was not found in this build");
}
} catch (err) {
console.warn(`[plugins] Failed to auto-install bundled Dependency Graph plugin: ${err instanceof Error ? err.message : err}`);
}
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
// can discover installed runtimes like Hermes and OpenClaw.
try {
const { loaded, errors } = await pluginLoader.loadAllPlugins();
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
if (schemaHooks.length > 0) {
try {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
} catch (err) {
console.error(
`[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`,
);
}
}
} catch (err) {
console.error(
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
);
}
// Get subsystems from the cwd engine for the HTTP layer
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
@@ -376,8 +415,12 @@ export async function runDaemon(opts: DaemonOptions = {}) {
const automationStore = cwdEngine.getAutomationStore();
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
...getLegacyAuthPaths(),
getCodexCliAuthPath(),
...getClaudeCodeCredentialPaths(),
]);
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
@@ -413,6 +456,42 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
})();
const droidCliPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveDroidCliExtensionPaths(globalSettings);
setCachedDroidCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] droid-cli: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useDroidCli setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedDroidCliResolution(null);
return [];
}
})();
const llamaCppPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveLlamaCppExtensionPaths(globalSettings);
setCachedLlamaCppResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] llama-cpp: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useLlamaCpp setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedLlamaCppResolution(null);
return [];
}
})();
// Always prefer Fusion's vendored `@fusion/pi-claude-cli` over any
// external `pi-claude-cli` install. Drops shadowing externals (e.g. a
// global `npm install -g pi-claude-cli`) so the upstream's once-and-lock
@@ -432,7 +511,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
);
const extensionsResult = await discoverAndLoadExtensions(
reconciledExtensionPaths,
[...reconciledExtensionPaths, ...droidCliPaths, ...llamaCppPaths],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
);
@@ -465,6 +544,13 @@ export async function runDaemon(opts: DaemonOptions = {}) {
modelRegistry.refresh();
}
void syncStartupModels({
getSettings: () => store.getSettings(),
authStorage: dashboardAuthStorage,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
// ── Skills adapter for skills discovery and execution toggling ─────────────
const skillsAdapter = packageManager
? createSkillsAdapter({
@@ -515,6 +601,28 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
return { status: r.status, reason: r.reason };
},
getDroidCliExtensionStatus: () => {
const r = getCachedDroidCliResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
getLlamaCppExtensionStatus: () => {
const r = getCachedLlamaCppResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => {
if (!next) return;
void (async () => {
@@ -531,6 +639,11 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
})();
},
onUseDroidCliToggled: (_prev, next) => {
if (next) {
console.log("[extensions] Droid CLI enabled — restart required for full effect");
}
},
headless: true,
daemon: { token: daemonToken },
skillsAdapter,

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "ink-testing-library";
import { DashboardApp } from "../app.js";
import { DashboardTUI } from "../controller.js";
import { createInitialState } from "../state.js";
import type { ProjectItem, TaskItem, AgentItem, AgentDetailItem, ModelItem, SettingsValues, TaskDetailData } from "../state.js";
function newController(): DashboardTUI {
@@ -42,7 +43,7 @@ function makeInteractiveData(opts: {
regeneratePersistentToken: () => Promise<{ maskedToken?: string; tokenType: "persistent"; expiresAt: null }>;
generateShortLivedToken: (ttlMs: number) => Promise<{ token?: string; tokenType: "short-lived"; expiresAt: string | null }>;
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>;
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => Promise<{ url: string; expiresAt: string | null; format: "text" | "image/svg" | "terminal"; data?: string }>;
}>;
} = {}) {
const projects = opts.projects ?? [];
@@ -145,17 +146,38 @@ function makeInteractiveData(opts: {
};
}
function setTerminalSize(instance: { stdout: unknown }, columns: number, rows: number) {
Object.defineProperty(instance.stdout as object, "columns", { value: columns, configurable: true });
Object.defineProperty(instance.stdout as object, "rows", { value: rows, configurable: true });
}
afterEach(() => {
vi.useRealTimers();
});
async function waitForFrameContains(lastFrame: () => string | undefined, text: string, timeoutMs = 1200) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if ((lastFrame() ?? "").includes(text)) return;
await new Promise((r) => setTimeout(r, 20));
}
throw new Error(`Timed out waiting for frame to include: ${text}`);
async function waitForFrameContains(lastFrame: () => string | undefined, text: string, timeoutMs = 3000) {
await vi.waitFor(() => {
expect(lastFrame() ?? "").toContain(text);
}, { timeout: timeoutMs });
}
async function flushFrames() {
await Promise.resolve();
await Promise.resolve();
}
async function focusSettingsDetailPane(stdin: { write: (chunk: string) => void }, lastFrame: () => string | undefined) {
stdin.write("\u001b[C");
await waitForFrameContains(lastFrame, "[C/V/X/P/L/U/K/R] remote actions");
}
function findTokenPosition(frame: string, token: string): { row: number; col: number } {
const lines = frame.split("\n");
const row = lines.findIndex((line) => line.includes(token));
expect(row).toBeGreaterThanOrEqual(0);
const col = lines[row].indexOf(token);
expect(col).toBeGreaterThanOrEqual(0);
return { row, col };
}
describe("DashboardApp smoke", () => {
@@ -165,7 +187,8 @@ describe("DashboardApp smoke", () => {
const frame = lastFrame() ?? "";
// Splash can render either the compact text mark or the expanded block-art logo.
expect(frame).toMatch(/FUSION|███████╗/);
expect(frame).toContain("AI coding agent dashboard");
expect(frame).toContain("multi node agent orchestrator");
expect(frame).toContain("runfusion.ai");
unmount();
});
@@ -203,7 +226,7 @@ describe("DashboardApp smoke", () => {
controller.setMode("interactive");
controller.setInteractiveView("board");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30));
await waitForFrameContains(lastFrame, "alpha");
const frame = lastFrame() ?? "";
// Board shows the currently selected project; first project "alpha" is selected by default
expect(frame).toContain("alpha");
@@ -211,6 +234,23 @@ describe("DashboardApp smoke", () => {
});
});
describe("Default active section", () => {
it("createInitialState defaults to system panel", () => {
const state = createInitialState();
expect(state.activeSection).toBe("system");
});
it("DashboardTUI controller defaults to system panel", () => {
const controller = newController();
expect(controller.getSnapshot().activeSection).toBe("system");
});
it("createInitialState defaults to mouseEnabled = false (selection-friendly; auto-toggled by panel focus)", () => {
const state = createInitialState();
expect(state.mouseEnabled).toBe(false);
});
});
describe("DashboardTUI snapshot stability", () => {
it("returns the same snapshot reference across reads when state has not changed", () => {
const controller = newController();
@@ -279,7 +319,7 @@ describe("Agents view", () => {
controller.setMode("interactive");
controller.setInteractiveView("agents");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30));
await waitForFrameContains(lastFrame, "worker-1");
const frame = lastFrame() ?? "";
expect(frame).toContain("worker-1");
expect(frame).toContain("worker-2");
@@ -294,7 +334,7 @@ describe("Agents view", () => {
controller.setMode("interactive");
controller.setInteractiveView("agents");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30));
await flushFrames();
expect(lastFrame() ?? "").toContain("Agent Detail");
unmount();
});
@@ -360,7 +400,7 @@ describe("Settings view", () => {
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30));
await waitForFrameContains(lastFrame, "Max Concurrent");
const frame = lastFrame() ?? "";
expect(frame).toContain("Settings");
expect(frame).toContain("Max Concurrent");
@@ -378,7 +418,7 @@ describe("Settings view", () => {
controller.setMode("interactive");
controller.setInteractiveView("settings");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30));
await waitForFrameContains(lastFrame, "Available Models");
const frame = lastFrame() ?? "";
expect(frame).toContain("Available Models");
expect(frame).toContain("Claude 3.5 Sonnet");
@@ -389,7 +429,10 @@ describe("Settings view", () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
let remoteState: "stopped" | "starting" | "running" | "error" = "running";
const activateProvider = vi.fn(async () => {});
let remoteProvider: "tailscale" | "cloudflare" | null = "tailscale";
const activateProvider = vi.fn(async (provider: "tailscale" | "cloudflare") => {
remoteProvider = provider;
});
const settings: SettingsValues = {
maxConcurrent: 1,
maxWorktrees: 2,
@@ -401,13 +444,13 @@ describe("Settings view", () => {
remoteActiveProvider: "cloudflare",
remoteShortLivedEnabled: true,
remoteShortLivedTtlMs: 600000,
remoteStatus: { provider: "cloudflare", state: "running", url: "https://remote.example.com", lastError: null },
remoteStatus: { provider: remoteProvider, state: "running", url: "https://remote.example.com", lastError: null },
};
controller.setInteractiveData(makeInteractiveData({
settings,
remote: {
activateProvider,
getStatus: async () => ({ provider: "cloudflare", state: remoteState, url: "https://remote.example.com", lastError: null }),
getStatus: async () => ({ provider: remoteProvider, state: remoteState, url: "https://remote.example.com", lastError: null }),
startTunnel: async () => {
remoteState = "starting";
},
@@ -420,14 +463,12 @@ describe("Settings view", () => {
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "Remote");
await waitForFrameContains(lastFrame, "Provider: cloudflare");
expect(lastFrame() ?? "").toContain("cloudflare");
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
await focusSettingsDetailPane(stdin, lastFrame);
stdin.write("C");
await new Promise((r) => setTimeout(r, 20));
expect(activateProvider).toHaveBeenCalledWith("cloudflare");
await vi.waitFor(() => expect(activateProvider).toHaveBeenCalledWith("cloudflare"));
stdin.write("V");
await waitForFrameContains(lastFrame, "Remote tunnel starting");
@@ -454,16 +495,16 @@ describe("Settings view", () => {
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
await waitForFrameContains(lastFrame, "──── Remote ────");
await focusSettingsDetailPane(stdin, lastFrame);
stdin.write("L");
await waitForFrameContains(lastFrame, "TTL ms:");
stdin.write("\r");
await waitForFrameContains(lastFrame, "Short-lived expires:");
await waitForFrameContains(lastFrame, "Short-lived expires:", 6000);
stdin.write("K");
await waitForFrameContains(lastFrame, "QR text payload: ASCII-QR-PAYLOAD");
await waitForFrameContains(lastFrame, "ASCII-QR-PAYLOAD", 6000);
unmount();
});
@@ -491,9 +532,8 @@ describe("Settings view", () => {
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
await waitForFrameContains(lastFrame, "──── Remote ────");
await focusSettingsDetailPane(stdin, lastFrame);
stdin.write("P");
await waitForFrameContains(lastFrame, "Persistent token: tok_****");
expect(regeneratePersistentToken).toHaveBeenCalledTimes(1);
@@ -513,18 +553,18 @@ describe("Settings view", () => {
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
await waitForFrameContains(lastFrame, "──── Remote ────");
await focusSettingsDetailPane(stdin, lastFrame);
stdin.write("L");
await waitForFrameContains(lastFrame, "TTL ms:");
stdin.write("a");
await new Promise((r) => setTimeout(r, 20));
await flushFrames();
expect(controller.getSnapshot().interactiveView).toBe("settings");
unmount();
});
it("renders SVG QR fallback instruction", async () => {
it("renders ASCII QR payload when terminal format is returned", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData({
@@ -532,8 +572,8 @@ describe("Settings view", () => {
getQrPayload: async () => ({
url: "https://remote.example.com?token=svg",
expiresAt: new Date().toISOString(),
format: "image/svg",
data: "<svg/>",
format: "terminal",
data: "▀▀▀ASCII-QR▀▀▀",
}),
},
}));
@@ -541,10 +581,10 @@ describe("Settings view", () => {
controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\t");
await new Promise((r) => setTimeout(r, 20));
await waitForFrameContains(lastFrame, "──── Remote ────");
await focusSettingsDetailPane(stdin, lastFrame);
stdin.write("K");
await waitForFrameContains(lastFrame, "QR SVG returned by server.");
await waitForFrameContains(lastFrame, "▀▀▀ASCII-QR▀▀▀", 6000);
unmount();
});
});
@@ -564,7 +604,7 @@ describe("Board view", () => {
controller.setMode("interactive");
controller.setInteractiveView("board");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30));
await waitForFrameContains(lastFrame, "TODO");
const frame = lastFrame() ?? "";
expect(frame).toContain("TODO");
expect(frame).toContain("IN PROGRESS");
@@ -583,7 +623,7 @@ describe("LogsPanel indicator", () => {
// Select index 1 (middle entry)
controller.setSelectedLogIndex(1);
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 10));
await flushFrames();
const frame = lastFrame() ?? "";
expect(frame).toContain("▶");
unmount();
@@ -596,10 +636,201 @@ describe("LogsPanel indicator", () => {
controller.log("only message", "test");
controller.setSelectedLogIndex(0);
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 10));
await flushFrames();
const frame = lastFrame() ?? "";
// The selected entry shows the arrow; it should appear at least once
expect(frame).toContain("▶");
unmount();
});
});
describe("StatusModeGrid layout stability", () => {
it("keeps System and Logs panel anchors stable as log content length changes", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setActiveSection("logs");
const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, 120, 28);
rendered.rerender(renderDashboardAppNode(controller));
controller.log("short msg", "app");
controller.log("small", "worker");
controller.log("ok", "db");
rendered.rerender(renderDashboardAppNode(controller));
await flushFrames();
const shortFrame = rendered.lastFrame() ?? "";
const shortSystem = findTokenPosition(shortFrame, "System");
const shortLogs = findTokenPosition(shortFrame, "Logs (3/1000)");
const shortStatus = findTokenPosition(shortFrame, "http://localhost:4040");
controller.log(
"this is a deliberately long log message that should force truncation or wrapping and previously nudged grid widths",
"very-long-prefix-value",
);
controller.log(
"another long log payload with changing text widths to emulate timer and event updates in real sessions",
"background-scheduler",
);
controller.log(
"final long line for width stability regression coverage across re-renders",
"super-verbose-component-prefix",
);
rendered.rerender(renderDashboardAppNode(controller));
await flushFrames();
const longFrame = rendered.lastFrame() ?? "";
const longSystem = findTokenPosition(longFrame, "System");
const longLogs = findTokenPosition(longFrame, "Logs (6/1000)");
const longStatus = findTokenPosition(longFrame, "http://localhost:4040");
expect(longSystem).toEqual(shortSystem);
expect(longLogs).toEqual(shortLogs);
expect(longStatus).toEqual(shortStatus);
rendered.unmount();
});
});
describe("StatsPanel memory row", () => {
it("renders memory percentage before absolute values", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setActiveSection("stats");
controller.setSystemStats({
rss: 0,
heapUsed: 0,
heapTotal: 0,
heapLimit: 1,
external: 0,
arrayBuffers: 0,
cpuPercent: 0,
loadAvg: [0, 0, 0],
cpuCount: 1,
systemTotalMem: 8 * 1024 * 1024 * 1024,
systemFreeMem: 2 * 1024 * 1024 * 1024,
pid: process.pid,
nodeVersion: process.version,
platform: `${process.platform}/${process.arch}`,
});
const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, 120, 24);
rendered.rerender(renderDashboardAppNode(controller));
await flushFrames();
const frame = rendered.lastFrame() ?? "";
const pctIndex = frame.indexOf("75.0%");
const usedIndex = frame.indexOf("6.00 GB");
expect(pctIndex).toBeGreaterThanOrEqual(0);
expect(usedIndex).toBeGreaterThanOrEqual(0);
expect(pctIndex).toBeLessThan(usedIndex);
rendered.unmount();
});
});
describe("Narrow status-mode mouse policy", () => {
it("enables mouse mode on Logs and disables it again on System", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.log("entry", "scope");
const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, 60, 24);
rendered.rerender(renderDashboardAppNode(controller));
await vi.waitFor(() => {
expect(controller.getSnapshot().mouseEnabled).toBe(false);
});
rendered.stdin.write("2");
await vi.waitFor(() => {
const snapshot = controller.getSnapshot();
expect(snapshot.activeSection).toBe("logs");
expect(snapshot.mouseEnabled).toBe(true);
});
rendered.stdin.write("1");
await vi.waitFor(() => {
const snapshot = controller.getSnapshot();
expect(snapshot.activeSection).toBe("system");
expect(snapshot.mouseEnabled).toBe(false);
});
rendered.unmount();
});
});
describe("LogsPanel narrow formatting", () => {
it("shows a compact index instead of full timestamp in narrow terminals", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setActiveSection("logs");
controller.log("narrow entry", "scope");
controller.setSelectedLogIndex(0);
const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, 60, 24);
rendered.rerender(renderDashboardAppNode(controller));
await flushFrames();
const frame = rendered.lastFrame() ?? "";
expect(frame).toContain("narrow entry");
expect(frame).toMatch(/\s1\s[✓⚠✗]/);
expect(frame).not.toMatch(/\d{2}:\d{2}:\d{2}\.\d{3}/);
rendered.unmount();
});
it("truncates long prefixes in narrow mode and keeps wide mode formatting", async () => {
const prefix = "very-long-scope-name";
const narrowController = newController();
narrowController.setSystemInfo(makeSystemInfo());
narrowController.setActiveSection("logs");
narrowController.log("narrow prefix", prefix);
narrowController.setSelectedLogIndex(0);
const narrowRender = render(renderDashboardAppNode(narrowController));
setTerminalSize(narrowRender, 60, 24);
narrowRender.rerender(renderDashboardAppNode(narrowController));
await flushFrames();
const narrowFrame = narrowRender.lastFrame() ?? "";
expect(narrowFrame).toContain("[very-…]");
narrowRender.unmount();
const wideController = newController();
wideController.setSystemInfo(makeSystemInfo());
wideController.setActiveSection("logs");
wideController.log("wide prefix", prefix);
wideController.setSelectedLogIndex(0);
const wideRender = render(renderDashboardAppNode(wideController));
setTerminalSize(wideRender, 120, 24);
wideRender.rerender(renderDashboardAppNode(wideController));
await flushFrames();
const wideFrame = wideRender.lastFrame() ?? "";
expect(wideFrame).toContain("[very-long-sco");
expect(wideFrame).not.toContain("[very-…]");
wideRender.unmount();
});
it("preserves full timestamp formatting in wide terminals", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setActiveSection("logs");
controller.log("wide timestamp", "scope");
controller.setSelectedLogIndex(0);
const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, 120, 24);
rendered.rerender(renderDashboardAppNode(controller));
await flushFrames();
const frame = rendered.lastFrame() ?? "";
expect(frame).toContain("wide timestamp");
expect(frame).toMatch(/\d{2}:\d{2}:\d{2}\.\d{3}/);
rendered.unmount();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,36 @@
import os from "node:os";
import v8 from "node:v8";
import { execSync } from "node:child_process";
import { execFile } from "node:child_process";
import { appendFileSync } from "node:fs";
// `os.freemem()` on macOS only counts truly-free pages and excludes the large
// "inactive"/cached pool that the OS will reclaim on demand — so total-free
// reads ~95%+ used on an otherwise-idle machine. `os.availableMemory()` (Node
// 22+) reports memory the OS considers available, matching Activity Monitor's
// notion of "used". Fall back to freemem on older runtimes.
function getAvailableMemory(): number {
const fn = (os as unknown as { availableMemory?: () => number }).availableMemory;
if (typeof fn === "function") {
try {
const v = fn.call(os);
if (Number.isFinite(v) && v >= 0) return v;
} catch {
// fall through
}
}
return os.freemem();
}
const TUI_DEBUG_LOG = process.env.FUSION_TUI_DEBUG_LOG;
function tuiDebug(tag: string, data: Record<string, unknown>): void {
if (!TUI_DEBUG_LOG) return;
try {
const line = `${new Date().toISOString()} [${tag}] ${JSON.stringify(data)}\n`;
appendFileSync(TUI_DEBUG_LOG, line);
} catch {
// best-effort
}
}
import { LogRingBuffer } from "./log-ring-buffer.js";
import type { LogEntry } from "./log-ring-buffer.js";
import type {
@@ -13,6 +43,8 @@ import type {
DashboardState,
InteractiveData,
InteractiveView,
RemoteStatus,
UpdateStatus,
} from "./state.js";
import { SECTION_ORDER } from "./state.js";
@@ -28,7 +60,7 @@ import { SECTION_ORDER } from "./state.js";
export class DashboardTUI {
// State fields mirror the original private layout so tests can access them.
activeSection: SectionId = "logs";
activeSection: SectionId = "system";
// Named `logBuffer` to match what captureConsole tests access via
// `(tui as unknown as { logBuffer: LogRingBuffer }).logBuffer`.
logBuffer: LogRingBuffer;
@@ -48,6 +80,10 @@ export class DashboardTUI {
logsExpandedMode = false;
selectedLogIndex = 0;
logsViewportStart = 0;
// True when the narrow single-pane view is split and the bottom log
// strip currently owns the keyboard. Cleared whenever the active section
// changes or the split is no longer rendered.
narrowLogSplitFocused = false;
loadingStatus = "Starting…";
mode: "status" | "interactive" = "status";
// When true, sampleSystemStats() kills any running vitest processes if
@@ -59,9 +95,12 @@ export class DashboardTUI {
// Throttle so we don't spam kills while the sampler keeps firing during
// sustained pressure (sampler runs every 2s).
private lastAutoKillAt = 0;
clipboardFlash: { ok: boolean; at: number } | null = null;
private clipboardFlashTimer: ReturnType<typeof setTimeout> | null = null;
interactiveData: InteractiveData | null = null;
interactiveView: InteractiveView = "board";
interactiveInputLocked = false;
updateStatus: UpdateStatus | null = null;
// Subscribers registered by the Ink App component.
private subscribers: Set<() => void> = new Set();
@@ -81,6 +120,12 @@ export class DashboardTUI {
} & Record<string, unknown> | null = null;
// Resize listener attached at start(), detached at stop().
private resizeListener: (() => void) | null = null;
// Debounce timer for resize handling — coalesces tmux/ssh resize bursts.
private resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Last observed terminal dims, used by the dim-poll fallback to detect
// resizes that didn't deliver a SIGWINCH (common under tmux/ssh).
private lastObservedCols: number = 0;
private lastObservedRows: number = 0;
// Uptime ticker to keep footer time live.
private uptimeTimer: ReturnType<typeof setInterval> | null = null;
@@ -89,6 +134,28 @@ export class DashboardTUI {
private lastCpuUsage: NodeJS.CpuUsage | null = null;
private lastCpuSampleAt = 0;
// Polled remote tunnel status; null until first successful fetch (or when
// no remote API is wired up).
private remoteStatus: RemoteStatus | null = null;
private remoteStatusTimer: ReturnType<typeof setInterval> | null = null;
// Mouse-wheel handling. We enable xterm SGR mouse mode in start() so the
// terminal sends button reports for wheel up/down (buttons 64/65). A
// parallel `data` listener parses those reports and dispatches to wheel
// handlers. Ink's own keypress parser ignores SGR mouse sequences so
// long as the full sequence (including the leading ESC) arrives in one
// chunk — which it does once raw mode is enabled before mouse mode is
// requested. (See ink#222 / @zenobius/ink-mouse for prior art.)
private wheelHandlers: Set<(direction: "up" | "down") => void> = new Set();
private mouseStdinListener: ((chunk: Buffer | string) => void) | null = null;
// Tracks the desired mouse-reporting state. Default OFF so click-drag
// text selection works out of the box — terminal owns the mouse. The
// dashboard auto-enables it via setMouseEnabled() when the user focuses
// a panel that uses wheel scrolling (Logs / Files / Git / Board task
// detail). [M] is also a manual override, but the next focus change
// will reapply the auto policy.
mouseEnabled: boolean = false;
constructor() {
this.logBuffer = new LogRingBuffer();
}
@@ -100,6 +167,16 @@ export class DashboardTUI {
return () => this.subscribers.delete(callback);
}
/**
* Subscribe to mouse-wheel events. Direction is "up" (scroll back/older
* content) or "down" (scroll forward/newer content). Only fires while the
* dashboard is running and the terminal supports xterm mouse reporting.
*/
onWheel(handler: (direction: "up" | "down") => void): () => void {
this.wheelHandlers.add(handler);
return () => this.wheelHandlers.delete(handler);
}
getSnapshot(): DashboardState {
if (this.cachedSnapshot) return this.cachedSnapshot;
this.cachedSnapshot = {
@@ -116,6 +193,7 @@ export class DashboardTUI {
logsExpandedMode: this.logsExpandedMode,
selectedLogIndex: this.selectedLogIndex,
logsViewportStart: this.logsViewportStart,
narrowLogSplitFocused: this.narrowLogSplitFocused,
loadingStatus: this.loadingStatus,
mode: this.mode,
interactiveData: this.interactiveData,
@@ -123,6 +201,10 @@ export class DashboardTUI {
interactiveInputLocked: this.interactiveInputLocked,
autoKillVitestOnPressure: this.autoKillVitestOnPressure,
vitestKillThreshold: this.vitestKillThreshold,
updateStatus: this.updateStatus,
clipboardFlash: this.clipboardFlash,
remoteStatus: this.remoteStatus,
mouseEnabled: this.mouseEnabled,
};
return this.cachedSnapshot;
}
@@ -203,7 +285,7 @@ export class DashboardTUI {
loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0],
cpuCount: os.cpus().length,
systemTotalMem: os.totalmem(),
systemFreeMem: os.freemem(),
systemFreeMem: getAvailableMemory(),
pid: process.pid,
nodeVersion: process.version,
platform: `${process.platform}/${process.arch}`,
@@ -211,20 +293,21 @@ export class DashboardTUI {
if (this.autoKillVitestOnPressure) {
const total = os.totalmem();
const free = os.freemem();
const free = getAvailableMemory();
if (total > 0) {
const usedRatio = (total - free) / total;
// 30s minimum gap between auto-kills — vitest restart and OS reclaim
// both take a few seconds; firing every 2s would flap.
if (usedRatio > this.vitestKillThreshold && now - this.lastAutoKillAt > 30_000) {
this.lastAutoKillAt = now;
const result = this.killVitestProcesses();
if (result.killed > 0) {
this.warn(
`Auto-killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"} (system memory at ${Math.round(usedRatio * 100)}%, threshold ${Math.round(this.vitestKillThreshold * 100)}%)`,
"memory-guard",
);
}
void this.killVitestProcesses().then((result) => {
if (result.killed > 0) {
this.warn(
`Auto-killed ${result.killed} vitest process${result.killed === 1 ? "" : "es"} (system memory at ${Math.round(usedRatio * 100)}%, threshold ${Math.round(this.vitestKillThreshold * 100)}%)`,
"memory-guard",
);
}
}).catch(() => {});
}
}
}
@@ -235,21 +318,25 @@ export class DashboardTUI {
* itself. Returns a count of pids signalled (best-effort — a pid may be
* gone by the time we send the signal).
*/
killVitestProcesses(): { killed: number; pids: number[] } {
const selfPid = process.pid;
let pids: number[] = [];
try {
// pgrep -f matches against the full command line. -a would include the
// command, but we only need pids. macOS and Linux both support -f.
const out = execSync("pgrep -f vitest", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
pids = out
.split("\n")
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid);
} catch {
// pgrep exits non-zero when no matches — treat as "nothing to kill".
async killVitestProcesses(): Promise<{ killed: number; pids: number[] }> {
// pgrep is POSIX-only; Windows path is a no-op above.
if (process.platform === "win32") {
return { killed: 0, pids: [] };
}
const selfPid = process.pid;
// execFile (not execSync) so the TUI render loop stays responsive while
// pgrep walks the process table — that walk can take 100ms+ on a busy
// machine and previously froze the UI on every memory-pressure check.
const stdout: string = await new Promise((resolve) => {
execFile("pgrep", ["-f", "vitest"], { encoding: "utf8" }, (err, out) => {
// pgrep exits non-zero when no matches — treat as empty result.
resolve(err ? "" : (typeof out === "string" ? out : ""));
});
});
const pids = stdout
.split("\n")
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => Number.isFinite(n) && n > 0 && n !== selfPid);
let killed = 0;
for (const pid of pids) {
@@ -319,6 +406,25 @@ export class DashboardTUI {
setInteractiveData(data: InteractiveData): void {
this.interactiveData = data;
this.notify();
this.startRemoteStatusPolling();
}
private startRemoteStatusPolling(): void {
if (this.remoteStatusTimer) return;
const tick = async () => {
const remote = this.interactiveData?.remote;
if (!remote) return;
try {
const status = await remote.getStatus();
const changed = JSON.stringify(this.remoteStatus) !== JSON.stringify(status);
this.remoteStatus = status;
if (changed) this.notify();
} catch {
// network/auth errors are non-fatal — leave the prior value alone
}
};
void tick();
this.remoteStatusTimer = setInterval(() => { void tick(); }, 3000);
}
setInteractiveView(view: InteractiveView): void {
@@ -332,15 +438,33 @@ export class DashboardTUI {
this.notify();
}
setUpdateStatus(status: UpdateStatus | null): void {
this.updateStatus = status;
this.notify();
}
addLog(entry: Omit<LogEntry, "timestamp">): void {
// If the cursor was sitting on the most recent entry (or there were no
// entries yet), keep it pinned to the new tail so live logs follow the
// latest event — same behavior as `tail -f` or k9s.
const beforeCount = this.getFilteredLogEntries().length;
const beforeEntries = this.getFilteredLogEntries();
const beforeCount = beforeEntries.length;
const wasAtTail = beforeCount === 0 || this.selectedLogIndex === beforeCount - 1;
// While the user is reading a single entry in expanded mode, pin the
// cursor on that entry so streaming logs don't yank the view away.
// Track by reference so ring-buffer eviction shifts the index correctly.
const pinnedEntry = this.logsExpandedMode ? beforeEntries[this.selectedLogIndex] : undefined;
this.logBuffer.push({ ...entry, timestamp: new Date() });
const after = this.getFilteredLogEntries();
if (wasAtTail) {
if (pinnedEntry) {
const newIdx = after.indexOf(pinnedEntry);
if (newIdx >= 0) {
this.selectedLogIndex = newIdx;
} else {
// Pinned entry was evicted from the ring buffer — fall back to oldest.
this.selectedLogIndex = 0;
}
} else if (wasAtTail) {
this.selectedLogIndex = Math.max(0, after.length - 1);
} else {
this.clampSelectedLogIndex(after);
@@ -360,6 +484,17 @@ export class DashboardTUI {
this.addLog({ level: "info", message, prefix });
}
flashClipboard(ok: boolean): void {
this.clipboardFlash = { ok, at: Date.now() };
if (this.clipboardFlashTimer) clearTimeout(this.clipboardFlashTimer);
this.clipboardFlashTimer = setTimeout(() => {
this.clipboardFlash = null;
this.clipboardFlashTimer = null;
this.notify();
}, 1800);
this.notify();
}
warn(message: string, prefix?: string): void {
this.addLog({ level: "warn", message, prefix });
}
@@ -373,6 +508,8 @@ export class DashboardTUI {
setActiveSection(section: SectionId): void {
this.activeSection = section;
this.showHelp = false;
// Section change supersedes any sub-focus on the narrow log split.
this.narrowLogSplitFocused = false;
this.notify();
}
@@ -381,6 +518,25 @@ export class DashboardTUI {
this.notify();
}
// Toggle xterm mouse reporting at runtime. When disabled, the terminal
// owns the mouse — click-drag does native text selection (the only path
// that works under tmux, where Shift-bypass is intercepted by tmux).
// Re-enable to restore wheel-driven log/list scrolling.
setMouseEnabled(enabled: boolean): void {
if (this.mouseEnabled === enabled) return;
this.mouseEnabled = enabled;
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
if (enabled) {
process.stdout.write("\x1b[?1000h\x1b[?1006h");
this.installMouseListener();
} else {
this.uninstallMouseListener();
process.stdout.write("\x1b[?1006l\x1b[?1000l");
}
}
this.notify();
}
setLogsWrapEnabled(enabled: boolean): void {
this.logsWrapEnabled = enabled;
this.notify();
@@ -391,6 +547,12 @@ export class DashboardTUI {
this.notify();
}
setNarrowLogSplitFocused(focused: boolean): void {
if (this.narrowLogSplitFocused === focused) return;
this.narrowLogSplitFocused = focused;
this.notify();
}
setSelectedLogIndex(index: number): void {
const entries = this.getFilteredLogEntries();
this.selectedLogIndex = this.clampIndex(index, entries.length);
@@ -451,7 +613,7 @@ export class DashboardTUI {
}
break;
case "k": {
const result = this.killVitestProcesses();
const result = await this.killVitestProcesses();
if (result.killed === 0) {
this.log("No vitest processes found.", "kill-vitest");
} else {
@@ -513,21 +675,65 @@ export class DashboardTUI {
createElement(DashboardApp, { controller: this }),
);
// Mouse mode must be enabled AFTER Ink mounts (which calls
// setRawMode(true) and resumes stdin). If we write the enable sequence
// before raw mode is on, the terminal can deliver the first wheel
// report's leading ESC byte alone, which Ink would parse as a bare
// Esc keypress (closing modals on every wheel tick).
if (process.stdin?.isTTY && this.mouseEnabled) {
// Enable xterm mouse reporting with SGR-encoded coordinates.
// ?1000h = button press/release reports (includes wheel as
// buttons 64/65)
// ?1006h = SGR encoding (handles wide terminals; the legacy form
// caps coords at 223 columns/rows)
// We deliberately do NOT enable ?1002h (button-event tracking with
// motion) or ?1003h (any-event tracking). Without motion reporting
// the terminal still owns drag gestures, so Shift+drag (and on most
// terminals plain click+drag) keeps doing native text selection.
// Holding Shift always works as a hard override even on terminals
// that grab the bare drag gesture.
//
// Gated by `this.mouseEnabled` so the default-off policy
// (selection-friendly on the System panel) holds at startup. The
// app component re-enables via setMouseEnabled() as soon as the
// user focuses a panel that uses wheel scrolling.
process.stdout.write("\x1b[?1000h\x1b[?1006h");
this.installMouseListener();
}
// Reset Ink's internal frame buffer (log-update line tracking) on every
// terminal resize. Without this Ink keeps treating the previous frame's
// line count as the clear region, leaving stale rows above/below the
// new render until another unrelated rerender happens.
//
// Debounced: tmux and mosh fire resize bursts during pane negotiation,
// and `process.stdout.rows` can briefly read stale/zero values mid-burst.
// Coalescing to a single trailing edge lets dimensions settle before we
// clear+rerender, then a follow-up notify() forces React to re-read
// stdout dims one more time so any timer-driven render that landed
// mid-burst with stale rows is corrected.
this.resizeListener = () => {
try {
this.inkInstance?.clear?.();
} catch {
// Ignore — clear is best-effort.
}
if (this.resizeDebounceTimer) clearTimeout(this.resizeDebounceTimer);
this.resizeDebounceTimer = setTimeout(() => {
this.resizeDebounceTimer = null;
const rows = process.stdout?.rows ?? 0;
const cols = process.stdout?.columns ?? 0;
if (rows <= 0 || cols <= 0) return;
this.recoverFrame(cols, rows);
}, 50);
};
if (process.stdout && typeof process.stdout.on === "function") {
process.stdout.on("resize", this.resizeListener);
}
// Prime the observed-dims baseline so the systemStats poll below can
// detect when stdout dims change without a SIGWINCH (tmux/ssh
// sometimes drop the signal — the dims still update on the stream
// object, but no resize event fires, so the user sees a stuck
// layout). Polling every 2s catches that case at minor cost.
this.lastObservedCols = process.stdout?.columns ?? 0;
this.lastObservedRows = process.stdout?.rows ?? 0;
this.uptimeTimer = setInterval(() => {
if (this.isRunning) this.notify();
}, 5000);
@@ -537,10 +743,81 @@ export class DashboardTUI {
this.lastCpuSampleAt = Date.now();
this.sampleSystemStats();
this.systemStatsTimer = setInterval(() => {
if (this.isRunning) this.sampleSystemStats();
if (!this.isRunning) return;
this.sampleSystemStats();
// Dim-poll fallback: tmux/ssh sometimes drop SIGWINCH entirely, and
// Node only refreshes process.stdout.columns/rows when SIGWINCH
// arrives — so reading those properties returns stale values that
// never recover on their own. Force-query the OS via getWindowSize
// (ioctl-backed) and compare against Node's cached dims; if they
// diverge, SIGWINCH was lost. Calling _refreshSize() pokes Node to
// re-read and emit 'resize', which routes through our existing
// resize listener and triggers the full recovery path.
const stdout = process.stdout as (typeof process.stdout) & {
getWindowSize?: () => [number, number];
_refreshSize?: () => void;
};
try {
const [trueCols, trueRows] = stdout.getWindowSize?.() ?? [0, 0];
if (trueCols <= 0 || trueRows <= 0) return;
const cachedCols = stdout.columns ?? 0;
const cachedRows = stdout.rows ?? 0;
if (trueCols !== cachedCols || trueRows !== cachedRows) {
// Node's cache is stale — poke it to re-read so React reads the
// new dims on the next render.
stdout._refreshSize?.();
}
if (trueCols !== this.lastObservedCols || trueRows !== this.lastObservedRows) {
// We haven't recovered to this size yet. Run the full recovery
// path directly rather than relying on a 'resize' event from
// _refreshSize, which doesn't always propagate under tmux+ssh
// (or when Node's cache already happens to match the OS but
// Ink's frame buffer is still pinned to the previous layout).
this.recoverFrame(trueCols, trueRows);
}
} catch {
// ioctl can fail in edge cases (detached pty, etc.) — ignore.
}
}, 2000);
}
// Reset Ink's internal frame buffer (log-update line tracking) and wipe
// the alt-screen so a fresh render lands on a known-empty surface. Used
// by both the resize listener (SIGWINCH path) and the dim-poll fallback
// (tmux+ssh path where SIGWINCH is dropped). Idempotent: a redundant
// call is at worst a 1-frame flicker.
//
// Why: Ink's clear() only resets log-update's tracked line count; if the
// previous frame painted more rows than the new terminal height (or
// content shrunk past a layout tier), those rows linger in the
// alt-screen buffer and the new frame paints on top, leaving garbage
// visible at the bottom. \x1b[2J\x1b[H wipes the buffer first.
// Order: wipe → reset Ink's tracking → record dims → notify so React
// reads fresh dims and rerenders cleanly.
private recoverFrame(cols: number, rows: number): void {
tuiDebug("recoverFrame", {
cols,
rows,
prevCols: this.lastObservedCols,
prevRows: this.lastObservedRows,
});
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
try {
process.stdout.write("\x1b[2J\x1b[H");
} catch {
// Ignore — wipe is best-effort.
}
}
try {
this.inkInstance?.clear?.();
} catch {
// Ignore — clear is best-effort.
}
this.lastObservedCols = cols;
this.lastObservedRows = rows;
this.notify();
}
async stop(): Promise<void> {
if (!this.isRunning) return;
this.isRunning = false;
@@ -555,10 +832,23 @@ export class DashboardTUI {
this.systemStatsTimer = null;
}
if (this.remoteStatusTimer) {
clearInterval(this.remoteStatusTimer);
this.remoteStatusTimer = null;
}
if (this.resizeListener && process.stdout && typeof process.stdout.off === "function") {
process.stdout.off("resize", this.resizeListener);
this.resizeListener = null;
}
if (this.resizeDebounceTimer) {
clearTimeout(this.resizeDebounceTimer);
this.resizeDebounceTimer = null;
}
if (this.clipboardFlashTimer) {
clearTimeout(this.clipboardFlashTimer);
this.clipboardFlashTimer = null;
}
if (this.inkInstance) {
this.inkInstance.unmount();
@@ -567,12 +857,58 @@ export class DashboardTUI {
// Leave the alt-screen buffer last so the user's shell scrollback
// is restored cleanly. \x1b[?1049l = leave alt-screen.
if (process.stdout?.isTTY && typeof process.stdout.write === "function") {
this.uninstallMouseListener();
// Disable mouse reporting before leaving the alt-screen so the
// user's shell isn't left with mouse mode active.
process.stdout.write("\x1b[?1006l\x1b[?1000l");
process.stdout.write("\x1b[?1049l");
}
}
// ── Private helpers ────────────────────────────────────────────────────────
// Attach a parallel `data` listener that decodes xterm SGR mouse
// sequences and dispatches wheel events. Ink's own listener is also
// attached; SGR sequences arrive as a single chunk that Ink's keypress
// parser silently ignores, so we don't need to (and shouldn't) strip
// them from the stream.
private installMouseListener(): void {
if (this.mouseStdinListener) return;
// eslint-disable-next-line no-control-regex -- ESC byte is intentional for SGR mouse parsing
const mouseRe = /\x1b\[<(\d+);\d+;\d+[Mm]/g;
const listener = (chunk: Buffer | string): void => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
if (text.indexOf("\x1b[<") === -1) return;
mouseRe.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = mouseRe.exec(text)) !== null) {
const btn = Number.parseInt(m[1] ?? "", 10);
// Buttons 64/65 are wheel up/down. Higher codes (66/67) are
// wheel left/right on some terminals — ignored here.
if (btn === 64) this.dispatchWheel("up");
else if (btn === 65) this.dispatchWheel("down");
}
};
this.mouseStdinListener = listener;
process.stdin.on("data", listener);
}
private uninstallMouseListener(): void {
if (!this.mouseStdinListener) return;
process.stdin.off("data", this.mouseStdinListener);
this.mouseStdinListener = null;
}
private dispatchWheel(direction: "up" | "down"): void {
for (const handler of this.wheelHandlers) {
try {
handler(direction);
} catch (err) {
tuiDebug("wheel-handler-error", { err: String(err) });
}
}
}
private clampSelectedLogIndex(entries: LogEntry[]): void {
if (entries.length === 0) {
this.selectedLogIndex = 0;

View File

@@ -2,6 +2,8 @@
// The caller picks a size based on terminal dimensions and applies an
// all-blue vertical gradient.
import { getCliPackageVersion } from "@fusion/dashboard";
// ANSI Shadow font — 47 cols × 6 rows.
export const FUSION_LOGO_LINES = [
"███████╗██╗ ██╗███████╗██╗ ██████╗ ███╗ ██╗",
@@ -30,4 +32,10 @@ export const FUSION_LOGO_LARGE_LINES = [
"╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝",
];
export const FUSION_TAGLINE = "AI coding agent dashboard";
export const FUSION_TAGLINE = "multi node agent orchestrator";
export const FUSION_URL = "runfusion.ai";
// Single source of truth: the dashboard's resolver also powers /api/health and
// /api/updates/check, so the splash/footer version and the Settings UI version
// (and the update-available banner) are guaranteed to agree.
export const FUSION_VERSION = getCliPackageVersion(import.meta.url);

View File

@@ -70,7 +70,7 @@ export interface RemoteTokenResult {
export interface RemoteQrPayload {
url: string;
expiresAt: string | null;
format: "text" | "image/svg";
format: "text" | "image/svg" | "terminal";
data?: string;
}
@@ -290,7 +290,7 @@ export interface InteractiveData {
regeneratePersistentToken: () => Promise<RemoteTokenResult>;
generateShortLivedToken: (ttlMs: number) => Promise<RemoteTokenResult>;
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<RemoteQrPayload>;
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => Promise<RemoteQrPayload>;
};
git: {
getStatus: (projectPath: string) => Promise<GitStatus>;
@@ -318,6 +318,14 @@ export interface InteractiveData {
};
}
// ── Update check status (surfaced in the TUI header/splash) ──────────────────
export interface UpdateStatus {
updateAvailable: boolean;
currentVersion: string;
latestVersion: string;
}
// ── Dashboard state (mutable, shared between controller and App) ───────────────
export interface DashboardState {
@@ -334,6 +342,10 @@ export interface DashboardState {
logsExpandedMode: boolean;
selectedLogIndex: number;
logsViewportStart: number;
// When the narrow single-pane main view is split horizontally to show a
// log strip at the bottom, this flag indicates whether the bottom log
// pane has key focus (vs. the top main panel).
narrowLogSplitFocused: boolean;
loadingStatus: string;
mode: AppMode;
interactiveData: InteractiveData | null;
@@ -341,13 +353,31 @@ export interface DashboardState {
interactiveInputLocked: boolean;
autoKillVitestOnPressure: boolean;
vitestKillThreshold: number;
updateStatus: UpdateStatus | null;
// Transient flash shown after the user copies a log entry. `at` is a
// monotonic timestamp so the view can render "Copied!" briefly before the
// controller clears it via setTimeout.
clipboardFlash: { ok: boolean; at: number } | null;
// Latest remote tunnel status, polled by the controller while
// `interactiveData.remote` is available. Used to surface tunnel state
// (state/url) globally in the TUI header.
remoteStatus: RemoteStatus | null;
// Whether xterm mouse reporting is currently enabled. When true, the
// controller decodes wheel events into log/list scrolling. When false,
// the terminal owns the mouse — needed for native click-drag selection
// under tmux, where Shift-bypass is intercepted by tmux itself.
mouseEnabled: boolean;
}
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
// Order matches the visual layout in StatusModeGrid: System (top), Logs
// (middle), then the bottom row left-to-right (Stats, Utilities, Settings).
// Both Tab/Shift+Tab (PANEL_ORDER in app.tsx) and ←/→ (cycleSection) use
// this same order so panel navigation matches what the user sees.
export const SECTION_ORDER: SectionId[] = ["system", "logs", "stats", "utilities", "settings"];
export function createInitialState(): DashboardState {
return {
activeSection: "logs",
activeSection: "system",
logEntries: [],
systemInfo: null,
taskStats: null,
@@ -360,6 +390,7 @@ export function createInitialState(): DashboardState {
logsExpandedMode: false,
selectedLogIndex: 0,
logsViewportStart: 0,
narrowLogSplitFocused: false,
loadingStatus: "Starting…",
mode: "status",
interactiveData: null,
@@ -367,5 +398,9 @@ export function createInitialState(): DashboardState {
interactiveInputLocked: false,
autoKillVitestOnPressure: true,
vitestKillThreshold: 0.9,
updateStatus: null,
clipboardFlash: null,
remoteStatus: null,
mouseEnabled: false,
};
}

View File

@@ -1,3 +1,36 @@
import { spawn } from "node:child_process";
export function isTTYAvailable(): boolean {
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
}
// Cross-platform clipboard write. Tries the native helper for the current
// platform; resolves false if no helper is available or the spawn fails so
// callers can surface a sensible error to the user.
export async function copyToClipboard(text: string): Promise<boolean> {
const candidates: Array<{ cmd: string; args: string[] }> =
process.platform === "darwin"
? [{ cmd: "pbcopy", args: [] }]
: process.platform === "win32"
? [{ cmd: "clip", args: [] }]
: [
{ cmd: "wl-copy", args: [] },
{ cmd: "xclip", args: ["-selection", "clipboard"] },
{ cmd: "xsel", args: ["--clipboard", "--input"] },
];
for (const { cmd, args } of candidates) {
const ok = await new Promise<boolean>((resolve) => {
try {
const child = spawn(cmd, args, { stdio: ["pipe", "ignore", "ignore"] });
child.once("error", () => resolve(false));
child.once("close", (code) => resolve(code === 0));
child.stdin.end(text);
} catch {
resolve(false);
}
});
if (ok) return true;
}
return false;
}

View File

@@ -8,7 +8,6 @@ import {
AutomationStore,
CentralCore,
AgentStore,
PluginStore,
PluginLoader,
getTaskMergeBlocker,
getEnabledPiExtensionPaths,
@@ -16,11 +15,13 @@ import {
DaemonTokenManager,
GlobalSettingsStore,
resolveGlobalDir,
DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS,
} from "@fusion/core";
import {
createServer,
GitHubClient,
createSkillsAdapter,
getCliPackageVersion,
getProjectSettingsPath,
loadTlsCredentialsFromEnv,
stopAllDevServers,
@@ -30,12 +31,13 @@ import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor,
import { AuthStorage, DefaultPackageManager, ModelRegistry, SettingsManager, discoverAndLoadExtensions, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
import {
getMergeStrategy,
getTaskBranchName,
processPullRequestMergeTask,
} from "./task-lifecycle.js";
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import {
ensureClaudeSkillsForAllProjectsOnStartup,
@@ -46,7 +48,21 @@ import {
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import {
getCachedDroidCliResolution,
resolveDroidCliExtensionPaths,
setCachedDroidCliResolution,
} from "./droid-cli-extension.js";
import {
getCachedLlamaCppResolution,
resolveLlamaCppExtensionPaths,
setCachedLlamaCppResolution,
} from "./llama-cpp-extension.js";
import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js";
import { resolveSelfExtension } from "./self-extension.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
// Re-export for backward compatibility with tests
@@ -91,6 +107,49 @@ function createDashboardRuntimeLogger(logSink: DashboardLogSink, scope: string):
};
}
type StartupUpdateStatus = {
updateAvailable: true;
latestVersion: string;
currentVersion: string;
};
async function resolveCachedStartupUpdateStatus(importMetaUrl: string): Promise<StartupUpdateStatus | null> {
try {
const updateCheckEnabled = await Promise.race<boolean>([
isUpdateCheckEnabled(),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 3_000);
}),
]);
if (!updateCheckEnabled) {
return null;
}
const currentVersion = getCliPackageVersion(importMetaUrl);
const cachedUpdate = getCachedUpdateStatus(currentVersion);
if (!cachedUpdate?.updateAvailable) {
return null;
}
return {
updateAvailable: true,
currentVersion: cachedUpdate.currentVersion,
latestVersion: cachedUpdate.latestVersion,
};
} catch {
return null;
}
}
function formatUpdateMessage(updateStatus: StartupUpdateStatus | null): string | null {
if (!updateStatus) {
return null;
}
return `⬆ Update available: v${updateStatus.latestVersion} (current: v${updateStatus.currentVersion})`;
}
export class StreamedLogBuffer {
private pending = "";
private flushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -665,6 +724,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const isTTY = isTTYAvailable();
let tui: DashboardTUI | undefined;
const dashboardStartedAt = Date.now();
const startupUpdateStatusPromise = resolveCachedStartupUpdateStatus(import.meta.url);
// Declare store and agentStore early so callbacks can safely reference them
// (they're assigned after initialization, but the variables exist from the start).
@@ -677,6 +737,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
if (isTTY) {
tui = new DashboardTUI();
void startupUpdateStatusPromise.then((updateStatus) => {
tui?.setUpdateStatus(updateStatus);
});
// Set up callbacks for utility actions
tui.setCallbacks({
onRefreshStats: async () => {
@@ -1003,11 +1066,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Enables the PluginManager UI to list, install, enable, disable, and
// configure plugins via the /api/plugins REST endpoints.
//
const pluginStoreRootDir =
typeof (store as { getRootDir?: () => string }).getRootDir === "function"
? store.getRootDir()
: store.getFusionDir();
const pluginStore = new PluginStore(pluginStoreRootDir);
const pluginStore = store.getPluginStore();
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────
@@ -1022,6 +1081,73 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
taskStore: store,
});
try {
const installStatus = await ensureBundledDependencyGraphPluginInstalled(pluginStore, pluginLoader);
if (installStatus === "installed") {
logSink.log("Installed bundled Dependency Graph plugin", "plugins");
} else if (installStatus === "missing-bundle") {
logSink.log("Bundled Dependency Graph plugin was not found in this build", "plugins");
}
} catch (err) {
logSink.log(
`Failed to auto-install bundled Dependency Graph plugin: ${err instanceof Error ? err.message : err}`,
"plugins",
);
}
// Lazy-install hook for bundled runtime plugins (Hermes/OpenClaw/Paperclip).
// Invoked by dashboard's PUT /api/plugins/:id/settings the first time the
// user clicks Save in Settings. Returns true if the plugin is now registered.
const ensureBundledPluginInstalledCallback = async (pluginId: string): Promise<boolean> => {
if (!isBundledPluginId(pluginId)) {
logSink.log(`ensureBundledPluginInstalled: unknown bundled plugin id "${pluginId}"`, "plugins");
return false;
}
try {
const status = await ensureBundledPluginInstalled(pluginStore, pluginLoader, pluginId);
if (status === "missing-bundle") {
logSink.log(`Bundled plugin "${pluginId}" was not found in this build`, "plugins");
return false;
}
if (status === "installed") {
logSink.log(`Installed bundled plugin "${pluginId}"`, "plugins");
} else if (status === "updated") {
logSink.log(`Updated bundled plugin "${pluginId}"`, "plugins");
}
return true;
} catch (err) {
logSink.log(
`Failed to auto-install bundled plugin "${pluginId}": ${err instanceof Error ? err.message : err}`,
"plugins",
);
throw err;
}
};
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
// can discover installed runtimes like Hermes and OpenClaw.
try {
const { loaded, errors } = await pluginLoader.loadAllPlugins();
logSink.log(`Loaded ${loaded} plugins (${errors} errors)`, "plugins");
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
if (schemaHooks.length > 0) {
try {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
} catch (err) {
logSink.log(
`Schema initialization failed: ${err instanceof Error ? err.message : err}`,
"plugins",
);
}
}
} catch (err) {
logSink.log(
`Failed to load plugins: ${err instanceof Error ? err.message : err}`,
"plugins"
);
}
// ── HeartbeatMonitor + HeartbeatTriggerScheduler ──────────────────────
//
// In non-dev mode: obtained from ProjectEngine after engine.start(), which
@@ -1054,6 +1180,21 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// (semaphore-gated via the engine's InProcessRuntime).
//
const onMergeImpl = async (taskId: string) => {
const settings = await store.getSettings();
if (getMergeStrategy(settings) === "pull-request") {
const githubClient = new GitHubClient();
const outcome = await processPullRequestMergeTask(store, cwd, taskId, githubClient, getTaskMergeBlocker);
const task = await store.getTask(taskId);
return {
task,
branch: getTaskBranchName(taskId),
merged: outcome === "merged",
worktreeRemoved: false,
branchDeleted: false,
error: outcome === "waiting" ? "pull request not ready" : undefined,
};
}
const streamedMergeLog = new StreamedLogBuffer(
(line) => logSink.log(line, "merge"),
STREAM_LOG_FLUSH_IDLE_MS,
@@ -1101,8 +1242,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Passing these to createServer enables the dashboard's Authentication
// tab (login/logout) and Model selector.
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
...getLegacyAuthPaths(),
getCodexCliAuthPath(),
...getClaudeCodeCredentialPaths(),
]);
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
@@ -1141,6 +1286,42 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
})();
const droidCliPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveDroidCliExtensionPaths(globalSettings);
setCachedDroidCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] droid-cli: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useDroidCli setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedDroidCliResolution(null);
return [];
}
})();
const llamaCppPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveLlamaCppExtensionPaths(globalSettings);
setCachedLlamaCppResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] llama-cpp: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useLlamaCpp setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedLlamaCppResolution(null);
return [];
}
})();
// Always inject the cli's own extension (`@runfusion/fusion`) so its
// `fn_*` tools register globally even when the user hasn't run
// `pi install npm:@runfusion/fusion`. Without this, agent chat with
@@ -1162,6 +1343,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
...getEnabledPiExtensionPaths(cwd),
...packageExtensionPaths,
...claudeCliPaths,
...droidCliPaths,
...llamaCppPaths,
],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
@@ -1183,48 +1366,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
// Eagerly sync OpenRouter models — the pi-openrouter-realtime extension
// only registers providers on session_start (TUI-only event), so kick off
// a fetch here so the dashboard model list is populated. Respects the
// openrouterModelSync setting (defaults to true).
(async () => {
try {
const settings = await store.getSettings();
if (settings.openrouterModelSync === false) return;
const hasOrAuth = await dashboardAuthStorage.getApiKey("openrouter");
const headers: Record<string, string> = {};
if (hasOrAuth) headers["Authorization"] = `Bearer ${hasOrAuth}`;
const res = await fetch("https://openrouter.ai/api/v1/models", { headers });
if (!res.ok) return;
const json = await res.json() as { data?: Array<{ id: string; name: string; context_length?: number; top_provider?: { max_completion_tokens?: number }; pricing?: Record<string, string>; architecture?: { modality?: string; input_modalities?: string[] } }> };
const orModels = (json.data || []).map((m) => {
const id = (m.id || "").toLowerCase();
const name = (m.name || "").toLowerCase();
const reasoning = id.includes(":thinking") || id.includes("-r1") || id.includes("/r1") || id.includes("o1-") || id.includes("o3-") || id.includes("o4-") || id.includes("reasoner") || name.includes("thinking") || name.includes("reasoner");
const hasVision = m.architecture?.input_modalities?.includes("image") ?? m.architecture?.modality?.includes("multimodal") ?? false;
function parseCost(v?: string) { const n = parseFloat(v || "0"); return isNaN(n) ? 0 : n * 1_000_000; }
return {
id: m.id,
name: m.name || m.id,
reasoning,
input: (hasVision ? ["text", "image"] : ["text"]) as ("text" | "image")[],
cost: { input: parseCost(m.pricing?.prompt), output: parseCost(m.pricing?.completion), cacheRead: parseCost(m.pricing?.input_cache_read), cacheWrite: parseCost(m.pricing?.input_cache_write) },
contextWindow: m.context_length || 128000,
maxTokens: m.top_provider?.max_completion_tokens || 16384,
};
});
modelRegistry.registerProvider("openrouter", {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "OPENROUTER_API_KEY",
api: "openai-completions",
models: orModels,
});
logSink.log(`Synced ${orModels.length} models from OpenRouter API`, "openrouter");
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logSink.log(`Failed to sync models: ${message}`, "openrouter");
}
})();
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
registerCustomProviders(
modelRegistry,
globalSettings.customProviders,
(message) => logSink.log(message, "custom-providers"),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logSink.warn(`Failed to load custom providers from global settings: ${message}`, "custom-providers");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logSink.log(`Failed to discover extensions: ${message}`, "extensions");
@@ -1232,6 +1385,28 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
modelRegistry.refresh();
}
void syncStartupModels({
getSettings: () => store.getSettings(),
authStorage: dashboardAuthStorage,
modelRegistry,
log: (scope, message) => logSink.log(message, scope),
});
registerHandler(store, "settings:updated", ({ settings, previous }) => {
const currentProviders = settings.customProviders;
const previousProviders = previous.customProviders;
if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) {
return;
}
reregisterCustomProviders(
modelRegistry,
previousProviders,
currentProviders,
(message) => logSink.log(message, "custom-providers"),
);
});
// ── Skills adapter for skills discovery and execution toggling ─────────────
//
// Create the skills adapter using the same DefaultPackageManager instance
@@ -1445,6 +1620,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
@@ -1460,6 +1636,28 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
return { status: r.status, reason: r.reason };
},
getDroidCliExtensionStatus: () => {
const r = getCachedDroidCliResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
getLlamaCppExtensionStatus: () => {
const r = getCachedLlamaCppResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => {
if (!next) return;
void (async () => {
@@ -1477,6 +1675,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
})();
},
onUseDroidCliToggled: (_prev, next) => {
if (next) {
logSink.log("Droid CLI enabled — restart required for full effect", "extensions");
}
},
skillsAdapter,
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
@@ -1589,11 +1792,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
agentStore,
taskStore: store,
rootDir: cwd,
onMissed: (agentId) => {
logSink.log(`Agent ${agentId} missed heartbeat`, "engine");
onMissed: (agentId, reason) => {
logSink.warn(`Agent ${agentId} missed heartbeat: ${reason}`, "engine");
},
onTerminated: (agentId) => {
logSink.log(`Agent ${agentId} terminated (unresponsive)`, "engine");
onTerminated: (agentId, reason) => {
logSink.warn(`Agent ${agentId} terminated (unresponsive): ${reason}`, "engine");
},
});
heartbeatMonitorImpl.start();
@@ -1624,22 +1827,63 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
triggerScheduler.start();
const agents = await agentStore.listAgents();
const missedCatchupTargets: { agentId: string; lastHeartbeatAt: string }[] = [];
for (const agent of agents) {
// State is the source of truth: arm timers only for non-ephemeral
// agents that are currently active/running. Transitions into
// State is the source of truth: arm timers only for non-ephemeral,
// heartbeat-enabled agents in tickable states. Transitions into
// tickable states while the scheduler is already running are
// handled by the scheduler's own agent:updated listener.
// handled by the scheduler's own lifecycle listeners.
if (isEphemeralAgent(agent)) continue;
if (agent.state !== "active" && agent.state !== "running") continue;
if (agent.runtimeConfig?.enabled === false) continue;
if (agent.state !== "active" && agent.state !== "running" && agent.state !== "idle") continue;
const rc = agent.runtimeConfig;
triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
});
const intervalMs = (rc?.heartbeatIntervalMs as number | undefined) ?? DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS;
triggerScheduler.registerAgent(
agent.id,
{
enabled: rc?.enabled as boolean | undefined,
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
maxConcurrentRuns: rc?.maxConcurrentRuns as number | undefined,
},
{ lastHeartbeatAt: agent.lastHeartbeatAt },
);
// Per-agent opt-in: if the server was down across a scheduled tick,
// fire one catch-up heartbeat. We require explicit lastHeartbeatAt to
// avoid firing on agents that have never run.
if (
rc?.runMissedHeartbeatOnStartup === true
&& rc?.enabled !== false
&& typeof agent.lastHeartbeatAt === "string"
&& agent.lastHeartbeatAt.length > 0
) {
const lastMs = Date.parse(agent.lastHeartbeatAt);
if (Number.isFinite(lastMs) && Date.now() - lastMs > intervalMs) {
missedCatchupTargets.push({ agentId: agent.id, lastHeartbeatAt: agent.lastHeartbeatAt });
}
}
}
if (agents.length > 0) {
logSink.log(`Registered ${triggerScheduler.getRegisteredAgents().length} agents for heartbeat triggers`, "engine");
}
for (const target of missedCatchupTargets) {
const monitor = heartbeatMonitorImpl;
if (!monitor) break;
logSink.log(
`Firing catch-up heartbeat for ${target.agentId} (lastHeartbeatAt=${target.lastHeartbeatAt})`,
"engine",
);
// Fire and forget; serialized per-agent inside executeHeartbeat.
void monitor.executeHeartbeat({
agentId: target.agentId,
source: "timer",
triggerDetail: "startup-missed-heartbeat-catchup",
}).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
logSink.warn(`Catch-up heartbeat for ${target.agentId} failed: ${message}`, "engine");
});
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logSink.log(`HeartbeatMonitor initialization failed (continuing without agent monitoring): ${message}`, "engine");
@@ -1675,6 +1919,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback,
onProjectRegistered: ({ path }) => {
maybeInstallClaudeSkillForNewProject(path);
},
@@ -1689,6 +1934,28 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
return { status: r.status, reason: r.reason };
},
getDroidCliExtensionStatus: () => {
const r = getCachedDroidCliResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
getLlamaCppExtensionStatus: () => {
const r = getCachedLlamaCppResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => {
if (!next) return;
void (async () => {
@@ -1706,6 +1973,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
})();
},
onUseDroidCliToggled: (_prev, next) => {
if (next) {
logSink.log("Droid CLI enabled — restart required for full effect", "extensions");
}
},
skillsAdapter,
https: loadTlsCredentialsFromEnv(),
daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined,
@@ -1862,6 +2134,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
? `${baseUrl}/?token=${encodeURIComponent(dashboardAuthToken)}`
: baseUrl;
const updateMessage = formatUpdateMessage(await startupUpdateStatusPromise);
// ── TTY Mode: Set system info on TUI ───────────────────────────────
//
// In TTY mode, we populate the TUI System panel instead of printing
@@ -2192,9 +2466,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
return await response.json();
},
getQrPayload: async (tokenType: "persistent" | "short-lived", ttlMs?: number) => {
getQrPayload: async (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => {
const params = new URLSearchParams({ tokenType });
if (typeof ttlMs === "number") params.set("ttlMs", String(ttlMs));
if (format) params.set("format", format);
const response = await fetch(`${baseUrl}/api/remote/qr?${params.toString()}`, { headers: buildAuthHeaders() });
if (!response.ok) {
const payload = await response.json().catch(() => null);
@@ -2340,6 +2615,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
tui.log("AI engine paused");
}
tui.log("File watcher active");
if (updateMessage) {
tui.log(updateMessage);
}
} else {
// ── Non-TTY Mode: Print plain-text banner ───────────────────────────
//
@@ -2370,6 +2648,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
console.log(` • cron: scheduled task execution`);
}
console.log(` File watcher: ✓ active`);
if (updateMessage) {
console.log(` ${updateMessage}`);
}
console.log(` Press Ctrl+C to stop`);
console.log();
}

View File

@@ -0,0 +1,188 @@
/**
* Resolver for the vendored `@fusion/droid-cli` pi extension.
*
* `@fusion/droid-cli` is a workspace package at `packages/droid-cli/`. It
* ships its extension entry as raw `.ts` source — pi's loader compiles TS on
* the fly via jiti, so we just need to point pi at the right file.
*
* We deliberately do NOT auto-add "npm:@fusion/droid-cli" to the user's
* ~/.fusion/agent/settings.json packages array. The package is resolved from
* this workspace at runtime and loaded explicitly only when
* GlobalSettings.useDroidCli is true — this avoids polluting user-owned
* config files and lets us gate the extension on a UI toggle without
* settings.json churn.
*/
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const require_ = createRequire(import.meta.url);
/**
* Outcome of resolving the bundled @fusion/droid-cli extension entry.
*
* - `"ok"`: the absolute path to the extension file was found — push it into
* the paths array passed to `discoverAndLoadExtensions`.
* - `"not-installed"`: the package isn't in node_modules (unusual — it's a
* hard dep, so this typically means a corrupted install).
* - `"missing-entry"`: the package is present but its package.json doesn't
* declare a pi.extensions entry, or the file it points to doesn't exist.
* Indicates a @fusion/droid-cli version mismatch or a broken release.
* - `"error"`: something unexpected — the reason is captured so the caller
* can surface it in the Droid CLI provider card.
*/
export type DroidCliExtensionResolution =
| { status: "ok"; path: string; packageVersion: string }
| { status: "not-installed" }
| { status: "missing-entry"; reason: string }
| { status: "error"; reason: string };
/**
* Resolve the absolute path to `@fusion/droid-cli`'s pi extension entry file.
*
* The package is bundled into the published @runfusion/fusion as
* `dist/droid-cli/` (see tsup.config.ts) so it is not a runtime npm
* dependency. We look for that bundled copy first by walking up from this
* module's location, and fall back to `require.resolve` for monorepo
* dev/test runs where this file executes from `src/` rather than `dist/`.
*/
export function resolveDroidCliExtensionFromModuleUrl(
moduleUrl: string,
): DroidCliExtensionResolution {
let pkgJsonPath: string | undefined;
// Bundled lookup: when running from dist/, sibling dir dist/droid-cli/
// holds the staged extension. Walk up a few levels to also catch nested
// layouts (e.g. dist/commands/foo.js) without hard-coding depth.
const here = dirname(fileURLToPath(moduleUrl));
for (const rel of ["droid-cli", "../droid-cli", "../../droid-cli"]) {
const candidate = resolve(here, rel, "package.json");
if (existsSync(candidate)) {
pkgJsonPath = candidate;
break;
}
}
if (!pkgJsonPath) {
try {
pkgJsonPath = require_.resolve("@fusion/droid-cli/package.json");
} catch {
return { status: "not-installed" };
}
}
let pkgJson: { pi?: { extensions?: unknown }; version?: string };
try {
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as typeof pkgJson;
} catch (err) {
return {
status: "error",
reason: `Failed to read @fusion/droid-cli package.json: ${err instanceof Error ? err.message : String(err)}`,
};
}
const extensions = pkgJson.pi?.extensions;
if (!Array.isArray(extensions) || extensions.length === 0) {
return {
status: "missing-entry",
reason: "@fusion/droid-cli package.json has no pi.extensions array",
};
}
const rawEntry = extensions[0];
if (typeof rawEntry !== "string" || rawEntry.length === 0) {
return {
status: "missing-entry",
reason: "@fusion/droid-cli pi.extensions[0] is not a valid path string",
};
}
const entryPath = resolve(dirname(pkgJsonPath), rawEntry);
if (!existsSync(entryPath)) {
return {
status: "missing-entry",
reason: `@fusion/droid-cli extension file not found at ${entryPath}`,
};
}
return {
status: "ok",
path: entryPath,
packageVersion: pkgJson.version ?? "unknown",
};
}
export function resolveDroidCliExtension(): DroidCliExtensionResolution {
return resolveDroidCliExtensionFromModuleUrl(import.meta.url);
}
/**
* Compute the paths to append to `discoverAndLoadExtensions`' configuredPaths
* based on the user's `useDroidCli` setting.
*
* When the setting is off we return no paths at all — the bundled
* `@fusion/droid-cli` sits idle in node_modules and contributes nothing
* to the running pi session. Flipping the toggle on requires a server
* restart to pick up the new extension (pi has no stable runtime-reload API
* for custom provider registrations). The dashboard toggle hook surfaces
* this in its status response.
*
* `warning` is populated when resolution fails (corrupted install, missing
* entry). Callers should log it but must not fail startup — the feature is
* optional.
*/
export function resolveDroidCliExtensionPaths(globalSettings: {
useDroidCli?: unknown;
}): { paths: string[]; warning?: string; resolution: DroidCliExtensionResolution | null } {
const enabled = globalSettings?.useDroidCli === true;
if (!enabled) {
return { paths: [], resolution: null };
}
const resolution = resolveDroidCliExtension();
switch (resolution.status) {
case "ok":
return { paths: [resolution.path], resolution };
case "not-installed":
return {
paths: [],
resolution,
warning:
"useDroidCli is on but @fusion/droid-cli is not installed in node_modules. Run `pnpm install`.",
};
case "missing-entry":
case "error":
return { paths: [], resolution, warning: resolution.reason };
}
}
/**
* Last-observed resolution cached per-process. Populated by the CLI bootstrap
* (serve/daemon/dashboard) immediately after calling
* `resolveDroidCliExtensionPaths`, so HTTP endpoints like
* GET /api/providers/droid-cli/status can report the same view of the world
* that the extension loader saw without re-probing node_modules on every
* request.
*/
let cachedResolution: DroidCliExtensionResolution | null = null;
export function setCachedDroidCliResolution(
resolution: DroidCliExtensionResolution | null,
): void {
cachedResolution = resolution;
}
export function getCachedDroidCliResolution(): DroidCliExtensionResolution | null {
return cachedResolution;
}
/**
* Test helper: allow tests to point the resolver at a fake package.
* Call with `undefined` to restore the real resolver. Never used in prod.
*/
// Exported for use by tests — see droid-cli-extension.test.ts
export const _testInternals = {
moduleUrl: (): string => fileURLToPath(import.meta.url),
};

View File

@@ -13,8 +13,9 @@ import { join, resolve, basename } from "node:path";
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core";
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable, isValidSqliteDatabaseFile } from "@fusion/core";
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
import { isGitRepo } from "./git.js";
import {
installBundledFusionSkill,
type SkillInstallResult,
@@ -26,6 +27,8 @@ export interface InitOptions {
name?: string;
/** Path to initialize (defaults to cwd) */
path?: string;
/** Initialize a git repository if one does not exist */
git?: boolean;
}
/**
@@ -38,9 +41,11 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
const cwd = options.path ? resolve(options.path) : process.cwd();
const fusionDir = join(cwd, ".fusion");
const dbPath = join(fusionDir, "fusion.db");
const hasDbPath = existsSync(dbPath);
const hasValidDb = hasDbPath && isValidSqliteDatabaseFile(dbPath);
// Check if already initialized
if (existsSync(fusionDir) && existsSync(dbPath)) {
if (existsSync(fusionDir) && hasDbPath && hasValidDb) {
// Check if registered in central DB
const central = new CentralCore();
await central.init();
@@ -66,6 +71,13 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
return;
}
if (existsSync(fusionDir) && hasDbPath && !hasValidDb) {
throw new Error(
`Existing database at ${dbPath} is not a valid SQLite database. ` +
"Restore it from .fusion/backups or move it aside before re-running fn init.",
);
}
// Get or generate project name
const projectName = options.name ?? await detectProjectName(cwd);
@@ -78,18 +90,22 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
console.log(` ✓ Created .fusion/ directory`);
}
const hasGitRepo = await isGitRepo(cwd);
if (!hasGitRepo && options.git) {
await initializeGitRepo(cwd);
console.log(` ✓ Initialized git repository`);
} else if (!hasGitRepo) {
console.log(` ⚠ Not a git repository. Run 'fn init --git' to auto-initialize one.`);
}
// Add local Fusion/Pi storage directories to .gitignore
await addLocalStorageToGitignore(cwd);
await warnIfQmdMissing();
// Create fusion.db (empty SQLite file)
if (!existsSync(dbPath)) {
// SQLite database header for an empty database
const sqliteHeader = Buffer.from([
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66,
0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00
]);
writeFileSync(dbPath, sqliteHeader);
// A zero-byte bootstrap file is a valid SQLite starting point.
writeFileSync(dbPath, "");
console.log(` ✓ Created fusion.db`);
}
@@ -214,6 +230,55 @@ async function addLocalStorageToGitignore(cwd: string): Promise<void> {
}
}
async function initializeGitRepo(cwd: string): Promise<void> {
await execAsync("git init", { cwd, timeout: 10_000 });
try {
const { stdout } = await execAsync("git symbolic-ref --quiet --short HEAD", {
cwd,
timeout: 10_000,
});
if (stdout.trim() !== "main") {
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
}
} catch {
// Older git versions or detached/unborn states may fail symbolic-ref.
// Best-effort: create/switch to main.
try {
await execAsync("git checkout -b main", { cwd, timeout: 10_000 });
} catch {
await execAsync("git checkout main", { cwd, timeout: 10_000 });
}
}
await ensureGitConfig(cwd, "user.name", "Fusion");
await ensureGitConfig(cwd, "user.email", "noreply@runfusion.ai");
const gitkeepPath = join(cwd, ".gitkeep");
if (!existsSync(gitkeepPath)) {
writeFileSync(gitkeepPath, "\n");
}
await execAsync("git add .gitkeep", { cwd, timeout: 10_000 });
await execAsync('git commit --allow-empty -m "chore: initial commit"', {
cwd,
timeout: 10_000,
});
}
async function ensureGitConfig(cwd: string, key: string, value: string): Promise<void> {
try {
const { stdout } = await execAsync(`git config --get ${key}`, { cwd, timeout: 10_000 });
if (stdout.trim().length > 0) {
return;
}
} catch {
// Missing config; set a local default.
}
await execAsync(`git config ${key} "${value}"`, { cwd, timeout: 10_000 });
}
async function warnIfQmdMissing(): Promise<void> {
if (await isQmdAvailable()) {
console.log(` ✓ qmd available for memory search`);

View File

@@ -0,0 +1,114 @@
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const require_ = createRequire(import.meta.url);
export type LlamaCppExtensionResolution =
| { status: "ok"; path: string; packageVersion: string }
| { status: "not-installed" }
| { status: "missing-entry"; reason: string }
| { status: "error"; reason: string };
export function resolveLlamaCppExtensionFromModuleUrl(
moduleUrl: string,
): LlamaCppExtensionResolution {
let pkgJsonPath: string | undefined;
const here = dirname(fileURLToPath(moduleUrl));
for (const rel of ["pi-llama-cpp", "../pi-llama-cpp", "../../pi-llama-cpp", "../../../pi-llama-cpp"]) {
const candidate = resolve(here, rel, "package.json");
if (existsSync(candidate)) {
pkgJsonPath = candidate;
break;
}
}
if (!pkgJsonPath) {
try {
pkgJsonPath = require_.resolve("@fusion/pi-llama-cpp/package.json");
} catch {
return { status: "not-installed" };
}
}
let pkgJson: { pi?: { extensions?: unknown }; version?: string };
try {
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as typeof pkgJson;
} catch (err) {
return {
status: "error",
reason: `Failed to read @fusion/pi-llama-cpp package.json: ${err instanceof Error ? err.message : String(err)}`,
};
}
const extensions = pkgJson.pi?.extensions;
if (!Array.isArray(extensions) || extensions.length === 0) {
return {
status: "missing-entry",
reason: "@fusion/pi-llama-cpp package.json has no pi.extensions array",
};
}
const rawEntry = extensions[0];
if (typeof rawEntry !== "string" || rawEntry.length === 0) {
return {
status: "missing-entry",
reason: "@fusion/pi-llama-cpp pi.extensions[0] is not a valid path string",
};
}
const entryPath = resolve(dirname(pkgJsonPath), rawEntry);
if (!existsSync(entryPath)) {
return {
status: "missing-entry",
reason: `@fusion/pi-llama-cpp extension file not found at ${entryPath}`,
};
}
return { status: "ok", path: entryPath, packageVersion: pkgJson.version ?? "unknown" };
}
export function resolveLlamaCppExtension(): LlamaCppExtensionResolution {
return resolveLlamaCppExtensionFromModuleUrl(import.meta.url);
}
export function resolveLlamaCppExtensionPaths(globalSettings: {
useLlamaCpp?: unknown;
}): { paths: string[]; warning?: string; resolution: LlamaCppExtensionResolution | null } {
const enabled = globalSettings?.useLlamaCpp === true;
if (!enabled) return { paths: [], resolution: null };
const resolution = resolveLlamaCppExtension();
switch (resolution.status) {
case "ok":
return { paths: [resolution.path], resolution };
case "not-installed":
return {
paths: [],
resolution,
warning:
"useLlamaCpp is on but @fusion/pi-llama-cpp is not installed in node_modules. Run `pnpm install`.",
};
case "missing-entry":
case "error":
return { paths: [], resolution, warning: resolution.reason };
}
}
let cachedResolution: LlamaCppExtensionResolution | null = null;
export function setCachedLlamaCppResolution(
resolution: LlamaCppExtensionResolution | null,
): void {
cachedResolution = resolution;
}
export function getCachedLlamaCppResolution(): LlamaCppExtensionResolution | null {
return cachedResolution;
}
export const _testInternals = {
moduleUrl: (): string => fileURLToPath(import.meta.url),
};

View File

@@ -0,0 +1,96 @@
import {
createMemoryBackupManager,
runMemoryBackupCommand,
TaskStore,
type ProjectSettings,
} from "@fusion/core";
import { resolveProject } from "../project-context.js";
type MemoryBackupScope = "project" | "agents" | "all";
async function resolveBackupStore(projectName?: string): Promise<TaskStore> {
try {
return (await resolveProject(projectName)).store;
} catch {
const store = new TaskStore(process.cwd());
await store.init();
return store;
}
}
async function getMemoryBackupContext(projectName?: string): Promise<{
store: TaskStore;
fusionDir: string;
settings: ProjectSettings;
}> {
const store = await resolveBackupStore(projectName);
const fusionDir = (store as unknown as { fusionDir: string }).fusionDir;
const settings = await store.getSettings();
return { store, fusionDir, settings };
}
export async function runMemoryBackupCreate(options?: { projectName?: string; scope?: MemoryBackupScope }): Promise<void> {
const { fusionDir, settings } = await getMemoryBackupContext(options?.projectName);
const effectiveSettings = options?.scope ? { ...settings, memoryBackupScope: options.scope } : settings;
console.log("Creating memory backup...");
const result = await runMemoryBackupCommand(fusionDir, effectiveSettings);
if (result.success) {
console.log(result.output);
process.exit(0);
}
console.error(result.output);
process.exit(1);
}
export async function runMemoryBackupList(projectName?: string): Promise<void> {
const { fusionDir, settings } = await getMemoryBackupContext(projectName);
const manager = createMemoryBackupManager(fusionDir, settings);
const backups = await manager.listBackups();
if (backups.length === 0) {
console.log("No memory backups found.");
return;
}
console.log(`Found ${backups.length} memory backup(s):\n`);
console.log("Date Scope Entries Size Filename");
console.log("-".repeat(80));
let totalSize = 0;
for (const backup of backups) {
totalSize += backup.size;
const date = new Date(backup.createdAt).toLocaleString();
const scope = backup.scope.padEnd(7);
const entries = String(backup.entryCount).padEnd(7);
const size = formatBytes(backup.size).padEnd(9);
console.log(`${date} ${scope} ${entries} ${size} ${backup.filename}`);
}
console.log("-".repeat(80));
console.log(`Total: ${formatBytes(totalSize)}`);
}
export async function runMemoryBackupRestore(filename: string, projectName?: string): Promise<void> {
const { fusionDir, settings } = await getMemoryBackupContext(projectName);
const manager = createMemoryBackupManager(fusionDir, settings);
console.log(`Restoring memory backup: ${filename}`);
console.log("This may overwrite project and/or agent memory files.\n");
try {
await manager.restoreBackup(filename, { overwrite: true });
console.log(`Successfully restored memory from ${filename}`);
} catch (err) {
console.error(`Memory restore failed: ${(err as Error).message}`);
process.exit(1);
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}

View File

@@ -1,6 +1,12 @@
import { CentralCore, type NodeConfig } from "@fusion/core";
import { createInterface } from "node:readline/promises";
const GREEN = "\x1b[32m";
const RED = "\x1b[31m";
const YELLOW = "\x1b[33m";
const GRAY = "\x1b[90m";
const RESET = "\x1b[0m";
// ── Options Interfaces ───────────────────────────────────────────────────────
/** Options for node list command. */
@@ -133,19 +139,51 @@ export function formatLastActivity(timestamp?: string | null): string {
*/
function getStatusIndicator(status: string): string {
switch (status) {
case "online": return "●";
case "offline": return "○";
case "error": return "✕";
case "connecting": return "◐";
default: return "○";
case "online":
return `${GREEN}${RESET}`;
case "offline":
return `${RED}${RESET}`;
case "error":
return `${RED}${RESET}`;
case "connecting":
return `${YELLOW}${RESET}`;
default:
return `${GRAY}${RESET}`;
}
}
function colorizeStatusText(status: string): string {
switch (status) {
case "online":
return `${GREEN}${status}${RESET}`;
case "offline":
case "error":
return `${RED}${status}${RESET}`;
case "connecting":
return `${YELLOW}${status}${RESET}`;
default:
return `${GRAY}${status}${RESET}`;
}
}
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_PATTERN = new RegExp("\\u001b\\[[0-9;]*m", "g");
function stripAnsi(text: string): string {
return text.replace(ANSI_ESCAPE_PATTERN, "");
}
function visualPadEnd(text: string, minWidth: number): string {
const visibleLength = stripAnsi(text).length;
if (visibleLength >= minWidth) return text;
return `${text}${" ".repeat(minWidth - visibleLength)}`;
}
/**
* Get color-coded status string.
*/
function formatStatus(status: string): string {
return `${getStatusIndicator(status)} ${status}`;
return `${getStatusIndicator(status)} ${colorizeStatusText(status)}`;
}
// ── Core Command Functions ──────────────────────────────────────────────────
@@ -201,7 +239,7 @@ export async function runNodeList(options: NodeListOptions = {}): Promise<void>
for (const node of sorted) {
const name = node.name.padEnd(16);
const type = node.type.padEnd(8);
const statusStr = formatStatus(node.status).padEnd(12);
const statusStr = visualPadEnd(formatStatus(node.status), 12);
const max = String(node.maxConcurrent).padStart(3);
if (hasMetrics && node.systemMetrics) {
@@ -527,7 +565,7 @@ export async function runMeshStatus(options: MeshStatusOptions = {}): Promise<vo
for (const node of sorted) {
const name = node.name.padEnd(16);
const type = node.type.padEnd(8);
const statusStr = formatStatus(node.status).padEnd(12);
const statusStr = visualPadEnd(formatStatus(node.status), 12);
const url = node.type === "remote" ? (node.url ?? "-") : "(local)";
console.log(` ${name} ${type} ${statusStr} ${url}`);
}

View File

@@ -10,12 +10,68 @@
*/
import { existsSync } from "node:fs";
import { join } from "node:path";
import { readFile } from "node:fs/promises";
import { dirname, extname, join, resolve } from "node:path";
import { readFile, stat } from "node:fs/promises";
import * as readline from "node:readline";
import { PluginStore, PluginLoader, validatePluginManifest } from "@fusion/core";
import { PluginStore, PluginLoader, validatePluginManifest, resolveGlobalDir } from "@fusion/core";
import { resolveProject } from "../project-context.js";
export interface BuiltinPluginCatalogEntry {
id: string;
name: string;
description: string;
category: "runtime" | "integration";
path?: string;
experimental?: boolean;
}
export const BUILTIN_PLUGINS: BuiltinPluginCatalogEntry[] = [
{
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime",
description: "Runtime provider for Hermes CLI-backed execution.",
category: "runtime",
path: "./plugins/fusion-plugin-hermes-runtime",
experimental: true,
},
{
id: "fusion-plugin-paperclip-runtime",
name: "Paperclip Runtime",
description: "Runtime provider for Paperclip agent connections.",
category: "runtime",
path: "./plugins/fusion-plugin-paperclip-runtime",
},
{
id: "fusion-plugin-openclaw-runtime",
name: "OpenClaw Runtime",
description: "Runtime provider for OpenClaw execution.",
category: "runtime",
path: "./plugins/fusion-plugin-openclaw-runtime",
experimental: true,
},
{
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime",
description: "Runtime provider for Droid CLI execution.",
category: "runtime",
path: "./plugins/fusion-plugin-droid-runtime",
experimental: true,
},
{
id: "fusion-plugin-dependency-graph",
name: "Dependency Graph",
description: "Dashboard plugin for task dependency graph visualization.",
category: "integration",
path: "./plugins/fusion-plugin-dependency-graph",
},
{
id: "fusion-plugin-agent-browser",
name: "Agent Browser",
description: "Built-in integration metadata. Package install support lands in FN-3101.",
category: "integration",
},
];
/**
* Get the project path for plugin operations.
*/
@@ -36,11 +92,23 @@ async function getProjectPath(projectName?: string): Promise<string> {
/**
* Create a PluginStore for the given project.
*/
async function createPluginStore(projectName?: string): Promise<PluginStore> {
const projectPath = await getProjectPath(projectName);
const pluginStore = new PluginStore(projectPath);
await pluginStore.init();
return pluginStore;
async function createPluginStore(
projectName?: string,
options?: { centralGlobalDir?: string },
): Promise<PluginStore> {
try {
const context = await resolveProject(projectName, process.cwd(), options?.centralGlobalDir);
const pluginStore = context.store.getPluginStore();
await pluginStore.init();
return pluginStore;
} catch {
const projectPath = await getProjectPath(projectName);
const pluginStore = new PluginStore(projectPath, {
centralGlobalDir: options?.centralGlobalDir ?? resolveGlobalDir(),
});
await pluginStore.init();
return pluginStore;
}
}
/**
@@ -53,6 +121,7 @@ async function createPluginLoader(
const projectPath = await getProjectPath(projectName);
// Create a mock TaskStore for the loader (plugins don't need full task store access)
const mockTaskStore = {
getRootDir: () => projectPath,
getFusionDir: () => projectPath + "/.fusion",
on: () => {},
off: () => {},
@@ -66,13 +135,111 @@ async function createPluginLoader(
return { store: pluginStore, loader };
}
const JS_ENTRY_EXTENSIONS = new Set([".js", ".mjs", ".cjs"]);
const TS_SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
function isJsEntryFile(path: string): boolean {
return JS_ENTRY_EXTENSIONS.has(extname(path).toLowerCase());
}
function isTypeScriptSource(path: string): boolean {
return TS_SOURCE_EXTENSIONS.has(extname(path).toLowerCase());
}
async function statPath(path: string): Promise<import("node:fs").Stats | undefined> {
try {
return await stat(path);
} catch {
return undefined;
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
/**
* Resolve plugin installation source to a compiled JavaScript entry file.
*/
export async function resolvePluginEntryFile(pluginDir: string): Promise<string> {
const absoluteInputPath = resolve(pluginDir);
const inputStats = await statPath(absoluteInputPath);
if (inputStats?.isFile()) {
if (isTypeScriptSource(absoluteInputPath)) {
throw new Error(
`Plugin entry must be compiled JavaScript, but got TypeScript source: ${absoluteInputPath}. Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
if (isJsEntryFile(absoluteInputPath)) {
return absoluteInputPath;
}
throw new Error(`Plugin entry file must end with .js, .mjs, or .cjs: ${absoluteInputPath}`);
}
const packageJsonPath = join(absoluteInputPath, "package.json");
let selectedCandidate: string | undefined;
if (existsSync(packageJsonPath)) {
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8")) as Record<string, unknown>;
const exportsRecord = asRecord(packageJson.exports);
const dotExport = exportsRecord?.["."];
const dotExportRecord = asRecord(dotExport);
if (typeof dotExportRecord?.import === "string") {
selectedCandidate = dotExportRecord.import;
} else if (typeof dotExportRecord?.default === "string") {
selectedCandidate = dotExportRecord.default;
} else if (typeof dotExport === "string") {
selectedCandidate = dotExport;
} else if (typeof packageJson.main === "string") {
selectedCandidate = packageJson.main;
}
}
if (selectedCandidate) {
const absoluteCandidate = resolve(absoluteInputPath, selectedCandidate);
if (isTypeScriptSource(absoluteCandidate)) {
throw new Error(
`Plugin entry resolves to TypeScript source (${absoluteCandidate}). Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
const candidateStats = await statPath(absoluteCandidate);
if (!candidateStats?.isFile()) {
throw new Error(
`Plugin entry file not found: ${absoluteCandidate}. Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
return absoluteCandidate;
}
const distIndexPath = resolve(absoluteInputPath, "dist/index.js");
const distStats = await statPath(distIndexPath);
if (distStats?.isFile()) {
return distIndexPath;
}
const indexPath = resolve(absoluteInputPath, "index.js");
const indexStats = await statPath(indexPath);
if (indexStats?.isFile()) {
return indexPath;
}
throw new Error(
`Could not resolve a plugin JavaScript entry file in ${absoluteInputPath}. Tried package.json exports/main, dist/index.js, and index.js. Build the plugin first (for example: pnpm build in the plugin directory).`,
);
}
/**
* Load plugin manifest from a local path.
*/
async function loadManifestFromPath(
pluginPath: string,
): Promise<{ manifest: import("@fusion/core").PluginManifest; path: string }> {
const manifestPath = join(pluginPath, "manifest.json");
const absoluteInputPath = resolve(pluginPath);
const inputStats = await statPath(absoluteInputPath);
const manifestDir = inputStats?.isFile() ? dirname(absoluteInputPath) : absoluteInputPath;
const manifestPath = join(manifestDir, "manifest.json");
if (!existsSync(manifestPath)) {
throw new Error(`Plugin manifest not found at: ${manifestPath}`);
@@ -86,7 +253,7 @@ async function loadManifestFromPath(
throw new Error(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
}
return { manifest, path: pluginPath };
return { manifest, path: manifestDir };
}
/**
@@ -121,7 +288,7 @@ export async function runPluginList(projectName?: string): Promise<void> {
}
console.log();
console.log(" ID Name Version State Enabled");
console.log(" ID Name Version State Project Enabled");
console.log(" ─────────────────────────────────────────────────────────────────────");
for (const plugin of plugins) {
@@ -141,7 +308,7 @@ export async function runPluginList(projectName?: string): Promise<void> {
*/
export async function runPluginInstall(
source: string,
options?: { projectName?: string },
options?: { projectName?: string; aiScan?: boolean },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
@@ -161,27 +328,29 @@ export async function runPluginInstall(
}
try {
const { manifest, path } = await loadManifestFromPath(source);
const entryPath = await resolvePluginEntryFile(source);
const { manifest } = await loadManifestFromPath(source);
console.log();
console.log(` Installing ${manifest.name} v${manifest.version}...`);
console.log(` Installing ${manifest.name} v${manifest.version} globally...`);
// Register the plugin
const plugin = await store.registerPlugin({
manifest,
path,
path: entryPath,
aiScanOnLoad: options?.aiScan ?? false,
});
// Try to load it
if (plugin.enabled) {
try {
await loader.loadPlugin(plugin.id);
console.log(`${manifest.name} installed and loaded`);
console.log(`${manifest.name} installed globally and enabled for this project`);
} catch (loadErr) {
console.log(`${manifest.name} installed but failed to load: ${loadErr instanceof Error ? loadErr.message : String(loadErr)}`);
}
} else {
console.log(`${manifest.name} installed (disabled)`);
console.log(`${manifest.name} installed globally (disabled for this project)`);
}
console.log();
} catch (err) {
@@ -214,8 +383,8 @@ export async function runPluginUninstall(
// Confirm unless force
if (!options?.force) {
console.log();
console.log(` Uninstall "${plugin.name}"?`);
console.log(` This will stop and remove the plugin.`);
console.log(` Uninstall "${plugin.name}" globally?`);
console.log(" This removes it for all projects.");
console.log();
const response = await new Promise<string>((resolve) => {
@@ -246,7 +415,7 @@ export async function runPluginUninstall(
await store.unregisterPlugin(id);
console.log();
console.log(`${plugin.name} uninstalled`);
console.log(`${plugin.name} uninstalled globally`);
console.log();
}
@@ -288,7 +457,7 @@ export async function runPluginEnable(
}
console.log();
console.log(`${plugin.name} enabled and started`);
console.log(`${plugin.name} enabled for this project and started`);
console.log();
}
@@ -323,6 +492,173 @@ export async function runPluginDisable(
await store.disablePlugin(id);
console.log();
console.log(`${plugin.name} disabled and stopped`);
console.log(`${plugin.name} disabled for this project and stopped`);
console.log();
}
export async function runPluginSetupStatus(
id: string,
options?: { projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
try {
await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
if (!loader.isPluginLoaded(id)) {
console.error(`Plugin "${id}" is not loaded. Enable the plugin first.`);
process.exit(1);
}
const loadedPlugin = loader.getPlugin(id);
if (!loadedPlugin?.setup) {
console.log("Plugin has no setup requirements");
return;
}
const result = await loader.checkPluginSetup(id);
console.log(`status: ${result.status}`);
if (result.version) console.log(`version: ${result.version}`);
if (result.binaryPath) console.log(`binaryPath: ${result.binaryPath}`);
if (result.error) console.log(`error: ${result.error}`);
}
export async function runPluginAvailable(): Promise<void> {
console.log();
console.log(" ID Name Category Installable");
console.log(" ──────────────────────────────────────────────────────────────────────────────");
for (const plugin of BUILTIN_PLUGINS) {
const id = plugin.id.padEnd(30);
const name = plugin.name.padEnd(20);
const category = plugin.category.padEnd(13);
const installable = plugin.path ? "yes" : "metadata-only";
console.log(` ${id} ${name} ${category} ${installable}`);
}
console.log();
}
export async function runPluginSettings(
id: string,
key?: string,
value?: string,
options?: { projectName?: string },
): Promise<void> {
const pluginStore = await createPluginStore(options?.projectName);
const plugin = await pluginStore.getPlugin(id);
if (!key) {
console.log(JSON.stringify(plugin.settings ?? {}, null, 2));
return;
}
if (value === undefined) {
const currentValue = (plugin.settings ?? {})[key];
console.log(currentValue === undefined ? "undefined" : JSON.stringify(currentValue, null, 2));
return;
}
const parsedValue = (() => {
try {
return JSON.parse(value);
} catch {
return value;
}
})();
await pluginStore.updatePluginSettings(id, { [key]: parsedValue });
console.log(`✓ Updated ${id}.${key}`);
}
export async function runPluginRescan(
id: string,
options?: { projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
let plugin;
try {
plugin = await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
try {
if (plugin.state === "started" && typeof loader.reloadPlugin === "function") {
await loader.reloadPlugin(id);
} else if (plugin.enabled) {
await loader.loadPlugin(id);
}
} catch (error) {
// keep going to show persisted scan verdict/state
console.error(`Rescan/load failed: ${error instanceof Error ? error.message : String(error)}`);
}
const refreshed = await store.getPlugin(id);
const scan = refreshed.lastSecurityScan;
const verdict = scan?.verdict ?? "unavailable";
const summary = scan?.summary ?? refreshed.error ?? "No scan result available";
const findingCount = scan?.findings?.length ?? 0;
console.log(`${refreshed.name}`);
console.log(`verdict: ${verdict}`);
console.log(`summary: ${summary}`);
console.log(`findings: ${findingCount}`);
if (verdict === "blocked" || verdict === "error" || verdict === "unavailable") {
process.exit(1);
}
}
export async function runPluginSetup(
id: string,
options?: { action?: "install" | "uninstall"; projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const action = options?.action ?? "install";
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
let plugin;
try {
plugin = await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
if (!loader.isPluginLoaded(id)) {
console.error(`Plugin "${id}" is not loaded. Enable the plugin first.`);
process.exit(1);
}
const loadedPlugin = loader.getPlugin(id);
if (!loadedPlugin?.setup) {
console.log("Plugin has no setup requirements");
return;
}
try {
if (action === "uninstall") {
await loader.uninstallPluginSetup(id);
console.log(`${plugin.name} setup uninstalled`);
return;
}
if (!loadedPlugin.setup.hooks.install) {
console.error("Plugin has no install hook");
process.exit(1);
}
await loader.installPluginSetup(id);
console.log(`${plugin.name} setup installed`);
} catch (error) {
console.error(`Failed to ${action} setup for "${id}": ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}

View File

@@ -1,12 +1,20 @@
import { existsSync, readFileSync } from "node:fs";
import type {
AuthStorage,
ModelRegistry,
AuthCredential,
} from "@mariozechner/pi-coding-agent";
import {
choosePreferredStoredCredential,
readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential,
type StoredAuthCredential,
} from "@fusion/core";
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
export type LoginCallbacks = Parameters<AuthStorage["login"]>[1];
export type LoginCallbacks = Parameters<AuthStorage["login"]>[1] & {
onManualCodeInput?: () => Promise<string>;
};
export interface DashboardAuthStorage {
reload(): void;
@@ -31,23 +39,18 @@ interface ReadFallbackAuthStorage {
list(): string[];
}
type StoredCredential = {
type?: string;
key?: string;
access?: string;
refresh?: string;
expires?: number;
[key: string]: unknown;
};
type StoredCredential = StoredAuthCredential;
const BUILT_IN_API_KEY_PROVIDERS: Array<{ id: string; name: string }> = [
{ id: "brave", name: "Brave Search" },
{ id: "kimi-coding", name: "Kimi" },
{ id: "minimax", name: "Minimax" },
{ id: "openrouter", name: "OpenRouter" },
{ id: "tavily", name: "Tavily" },
{ id: "zai", name: "Zai" },
];
const CLI_PROVIDER_IDS = new Set(["pi-claude-cli"]);
const CLI_PROVIDER_IDS = new Set(["pi-claude-cli", "droid-cli"]);
function getProviderDisplayName(providerId: string): string {
const knownProviderNames = new Map(
@@ -79,11 +82,16 @@ export function wrapAuthStorageWithApiKeyProviders(
.map((provider) => ({ id: provider.id, name: provider.name })),
hasAuth: (provider) => mergedAuthStorage.hasAuth(provider),
login: (providerId, callbacks) =>
mergedAuthStorage.login(providerId as Parameters<AuthStorage["login"]>[0], callbacks),
mergedAuthStorage.login(
providerId as Parameters<AuthStorage["login"]>[0],
callbacks as Parameters<AuthStorage["login"]>[1],
),
logout: (provider) => mergedAuthStorage.logout(provider),
getApiKeyProviders: () => {
const oauthProviderIds = new Set(
mergedAuthStorage.getOAuthProviders().map((provider) => provider.id),
mergedAuthStorage
.getOAuthProviders()
.map((provider) => provider.id),
);
const providers = new Map<string, string>();
@@ -130,21 +138,78 @@ export function mergeAuthStorageReads(
readFallbackAuthStorages: ReadFallbackAuthStorage[] = [],
): AuthStorage {
const readAuthStorages = [authStorage, ...readFallbackAuthStorages];
const getCredential = (providerId: string) => {
for (const storage of readAuthStorages) {
const credential = storage.get(providerId);
if (credential) return credential;
// Providers the user has explicitly logged out from. These should not be
// "resurrected" from supplemental credential files (e.g. ~/.claude/.credentials.json).
// Cleared when the user re-authenticates via set().
const loggedOutProviders = new Set<string>();
const selectCredential = (
providerId: string,
storages: Array<Pick<ReadFallbackAuthStorage, "get">>,
): StoredCredential | undefined => {
let best: StoredCredential | undefined;
for (const storage of storages) {
best = choosePreferredStoredCredential(best, storage.get(providerId));
}
return undefined;
return best;
};
const getCredential = (providerId: string) => {
if (loggedOutProviders.has(providerId)) {
return undefined;
}
return selectCredential(providerId, readAuthStorages);
};
const syncFallbackOauthCredentials = () => {
const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list()));
for (const providerId of providerIds) {
if (loggedOutProviders.has(providerId)) {
continue;
}
const current = authStorage.get(providerId) as StoredCredential | undefined;
const candidate = selectCredential(providerId, readFallbackAuthStorages);
if (!shouldHydrateStoredCredential(current, candidate)) {
continue;
}
if (candidate && (candidate.type === "oauth" || candidate.type === "api_key")) {
authStorage.set(providerId, candidate as AuthCredential);
}
}
};
syncFallbackOauthCredentials();
return new Proxy(authStorage, {
get(target, prop, receiver) {
if (prop === "logout") {
return (provider: string) => {
target.logout(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "remove") {
return (provider: string) => {
target.remove(provider);
loggedOutProviders.add(provider);
};
}
if (prop === "set") {
return (provider: string, credential: AuthCredential) => {
target.set(provider, credential);
loggedOutProviders.delete(provider);
};
}
if (prop === "reload") {
return () => {
for (const storage of readAuthStorages) {
storage.reload();
}
syncFallbackOauthCredentials();
};
}
@@ -153,29 +218,52 @@ export function mergeAuthStorageReads(
}
if (prop === "has") {
return (provider: string) => readAuthStorages.some((storage) => Boolean(storage.get(provider)));
return (provider: string) => {
if (loggedOutProviders.has(provider)) {
return false;
}
return readAuthStorages.some((storage) => Boolean(storage.get(provider)));
};
}
if (prop === "hasAuth") {
return (provider: string) => readAuthStorages.some((storage) => storage.hasAuth(provider));
return (provider: string) => {
if (loggedOutProviders.has(provider)) {
return false;
}
return readAuthStorages.some((storage) => storage.hasAuth(provider));
};
}
if (prop === "getAll") {
return () => ({
...readFallbackAuthStorages.reduce(
(merged, storage) => ({ ...merged, ...storage.getAll() }),
{} as Record<string, { type?: string; key?: string }>,
),
...target.getAll(),
});
return () => {
const providerIds = new Set(readAuthStorages.flatMap((storage) => storage.list()));
const merged: Record<string, StoredCredential> = {};
for (const providerId of providerIds) {
if (loggedOutProviders.has(providerId)) {
continue;
}
const credential = getCredential(providerId);
if (credential) {
merged[providerId] = credential;
}
}
return merged;
};
}
if (prop === "list") {
return () => Array.from(new Set(readAuthStorages.flatMap((storage) => storage.list())));
return () => {
const providers = readAuthStorages.flatMap((storage) => storage.list());
return Array.from(new Set(providers.filter((p) => !loggedOutProviders.has(p))));
};
}
if (prop === "getApiKey") {
return async (providerId: string) => {
if (loggedOutProviders.has(providerId)) {
return undefined;
}
for (const storage of readAuthStorages) {
const apiKey = await storage.getApiKey(providerId);
if (apiKey) return apiKey;
@@ -224,16 +312,9 @@ export function createReadOnlyAuthFileStorage(authPaths: string[]): ReadFallback
const reload = () => {
const nextCredentials: Record<string, StoredCredential> = {};
for (const authPath of authPaths) {
if (!existsSync(authPath)) {
continue;
}
try {
const parsed = JSON.parse(readFileSync(authPath, "utf-8")) as Record<string, StoredCredential>;
for (const [provider, credential] of Object.entries(parsed)) {
nextCredentials[provider] ??= credential;
}
} catch {
// Ignore unreadable legacy auth files and continue with other candidates.
const parsed = readStoredCredentialsFromAuthFile(authPath);
for (const [provider, credential] of Object.entries(parsed)) {
nextCredentials[provider] = choosePreferredStoredCredential(nextCredentials[provider], credential) ?? credential;
}
}
credentials = nextCredentials;

View File

@@ -0,0 +1,301 @@
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import {
RESEARCH_EXPORT_FORMATS,
RESEARCH_RUN_STATUSES,
ResearchRunStatus,
TaskStore,
resolveResearchSettings,
type ResearchExportFormat,
type ResearchRun,
} from "@fusion/core";
import { ResearchOrchestrator, ResearchProviderRegistry, ResearchStepRunner } from "@fusion/engine";
import { resolveProject } from "../project-context.js";
interface ResearchCommandOptions {
projectName?: string;
json?: boolean;
}
interface ResearchCreateOptions extends ResearchCommandOptions {
query: string;
waitForCompletion?: boolean;
maxWaitMs?: number;
}
interface ResearchListOptions extends ResearchCommandOptions {
status?: string;
limit?: number;
}
interface ResearchExportOptions extends ResearchCommandOptions {
runId: string;
format?: string;
output?: string;
}
async function getStore(projectName?: string): Promise<TaskStore> {
const project = projectName ? await resolveProject(projectName) : undefined;
const store = new TaskStore(project?.projectPath ?? process.cwd());
await store.init();
return store;
}
function hasProviderCredentials(settings: Awaited<ReturnType<TaskStore["getSettings"]>>, providerId: string | undefined): boolean {
if (!providerId || providerId === "builtin") return true;
if (providerId === "none") return false;
if (providerId === "searxng") return Boolean(settings.researchGlobalSearxngUrl);
if (providerId === "brave") return Boolean(settings.researchGlobalBraveApiKey);
if (providerId === "google") return Boolean(settings.researchGlobalGoogleSearchApiKey && settings.researchGlobalGoogleSearchCx);
if (providerId === "tavily") return Boolean(settings.researchGlobalTavilyApiKey);
return false;
}
async function getResearchRuntime(store: TaskStore) {
const settings = await store.getSettings();
const resolved = resolveResearchSettings(settings);
if (!resolved.enabled) {
throw new Error("feature-disabled: Research is disabled in settings.");
}
const configuredProvider = (resolved.searchProvider as string | undefined) ?? settings.researchGlobalWebSearchProvider ?? "builtin";
if (configuredProvider !== "builtin" && configuredProvider !== "none" && !hasProviderCredentials(settings, configuredProvider)) {
throw new Error(`missing-credentials: ${configuredProvider} credentials are missing. Configure Authentication and Research defaults in settings.`);
}
const registry = new ResearchProviderRegistry(settings, process.cwd());
const availableProviderTypes = registry.getAvailableProviders();
if (availableProviderTypes.length === 0) {
throw new Error("provider-unavailable: Research providers are not configured. Add provider credentials in settings.");
}
const stepRunner = new ResearchStepRunner({
providers: availableProviderTypes
.map((type) => registry.getProvider(type))
.filter((provider): provider is NonNullable<typeof provider> => Boolean(provider)),
});
const orchestrator = new ResearchOrchestrator({
store: store.getResearchStore(),
stepRunner,
maxConcurrentRuns: resolved.limits.maxConcurrentRuns,
});
return { orchestrator, settings, resolved, availableProviderTypes };
}
function printRun(run: ResearchRun): void {
console.log(`Run: ${run.id}`);
console.log(`Status: ${run.status}`);
console.log(`Query: ${run.query}`);
console.log(`Created: ${run.createdAt}`);
console.log(`Updated: ${run.updatedAt}`);
if (run.startedAt) console.log(`Started: ${run.startedAt}`);
if (run.completedAt) console.log(`Completed: ${run.completedAt}`);
if (run.cancelledAt) console.log(`Cancelled: ${run.cancelledAt}`);
if (run.results?.summary) console.log(`Summary: ${run.results.summary}`);
if (run.error) console.log(`Error: ${run.error}`);
}
function jsonOut(payload: unknown): void {
console.log(JSON.stringify(payload, null, 2));
}
function handleError(error: unknown): never {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: ${message}`);
process.exit(1);
}
export async function runResearchCreate(options: ResearchCreateOptions): Promise<void> {
try {
const store = await getStore(options.projectName);
const { orchestrator, settings, resolved, availableProviderTypes } = await getResearchRuntime(store);
const runId = orchestrator.createRun({
providers: availableProviderTypes
.filter((type) => type !== "llm-synthesis")
.map((type) => ({ type, config: { maxResults: resolved.limits.maxSourcesPerRun, timeoutMs: resolved.limits.requestTimeoutMs } })),
maxSources: resolved.limits.maxSourcesPerRun,
maxSynthesisRounds: Math.max(1, settings.researchMaxSynthesisRounds ?? settings.researchGlobalMaxSynthesisRounds ?? 2),
phaseTimeoutMs: resolved.limits.maxDurationMs,
stepTimeoutMs: resolved.limits.requestTimeoutMs,
});
const runPromise = orchestrator.startRun(runId, options.query);
if (!options.waitForCompletion) {
const run = store.getResearchStore().getRun(runId);
if (options.json) {
jsonOut(run);
} else {
console.log(`Created research run ${runId}.`);
if (run) printRun(run);
}
return;
}
const maxWaitMs = Math.max(1_000, Math.min(options.maxWaitMs ?? 90_000, resolved.limits.maxDurationMs));
const completed = await Promise.race([
runPromise,
new Promise<ResearchRun>((resolveRun) => setTimeout(() => {
const latest = store.getResearchStore().getRun(runId);
resolveRun(latest ?? ({
id: runId,
query: options.query,
status: "running",
sources: [],
events: [],
tags: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as ResearchRun));
}, maxWaitMs)),
]);
if (options.json) {
jsonOut(completed);
} else {
printRun(completed);
}
} catch (error) {
handleError(error);
}
}
export async function runResearchList(options: ResearchListOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
if (options.status && !RESEARCH_RUN_STATUSES.includes(options.status as ResearchRunStatus)) {
throw new Error(`Invalid status: ${options.status}`);
}
const runs = store.getResearchStore().listRuns({
status: options.status as ResearchRunStatus | undefined,
limit: options.limit ? Math.max(1, options.limit) : 20,
});
if (options.json) {
jsonOut({ runs });
return;
}
if (!runs.length) {
console.log("No research runs found.");
return;
}
for (const run of runs) {
console.log(`${run.id} [${run.status}] ${run.query}`);
}
} catch (error) {
handleError(error);
}
}
export async function runResearchShow(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
if (options.json) {
jsonOut(run);
return;
}
printRun(run);
} catch (error) {
handleError(error);
}
}
function renderMarkdown(run: ResearchRun): string {
const citations = run.results?.citations?.length
? `\n## Citations\n${run.results.citations.map((citation) => `- ${citation}`).join("\n")}`
: "";
return `# ${run.topic || run.query}\n\n## Summary\n${run.results?.summary ?? ""}${citations}\n`;
}
export async function runResearchExport(options: ResearchExportOptions): Promise<void> {
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(options.runId);
if (!run) throw new Error(`Research run not found: ${options.runId}`);
const format = (options.format ?? "markdown") as ResearchExportFormat;
if (!RESEARCH_EXPORT_FORMATS.includes(format)) {
throw new Error(`Unsupported export format: ${format}`);
}
const content = format === "json" ? JSON.stringify(run, null, 2) : renderMarkdown(run);
const ext = format === "json" ? "json" : "md";
const outputPath = options.output
? resolve(options.output)
: join(process.cwd(), `research-${run.id.toLowerCase()}.${ext}`);
await writeFile(outputPath, content, "utf8");
store.getResearchStore().createExport(run.id, format, content);
if (options.json) {
jsonOut({ runId: run.id, format, outputPath, bytes: Buffer.byteLength(content, "utf8") });
return;
}
console.log(`Exported ${run.id} (${format}) to ${outputPath}`);
} catch (error) {
handleError(error);
}
}
export async function runResearchCancel(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
const run = store.getResearchStore().getRun(runId);
if (!run) throw new Error(`Research run not found: ${runId}`);
if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) {
throw new Error(`invalid-transition: Run ${runId} cannot be cancelled from status ${run.status}.`);
}
const { orchestrator } = await getResearchRuntime(store);
const cancelled = orchestrator.cancelRun(runId);
if (options.json) {
jsonOut({ cancelled, run });
return;
}
console.log(cancelled ? `Cancellation requested for ${runId}.` : `Run ${runId} is not active.`);
printRun(run);
} catch (error) {
handleError(error);
}
}
export async function runResearchRetry(runId: string, options: ResearchCommandOptions = {}): Promise<void> {
try {
const store = await getStore(options.projectName);
const existing = store.getResearchStore().getRun(runId);
if (!existing) throw new Error(`Research run not found: ${runId}`);
if (existing.status === "retry_exhausted" || existing.lifecycle?.errorCode === "RETRY_EXHAUSTED") {
throw new Error(`retry-exhausted: Run ${runId} has exhausted retry attempts.`);
}
if (existing.lifecycle?.retryable === false) {
throw new Error(`non-retryable-provider-error: Run ${runId} is marked non-retryable.`);
}
const { orchestrator } = await getResearchRuntime(store);
const newRunId = orchestrator.retryRun(runId);
const run = store.getResearchStore().getRun(newRunId);
if (options.json) {
jsonOut({ retryOf: runId, run });
return;
}
console.log(`Created retry run ${newRunId} from ${runId}.`);
if (run) printRun(run);
} catch (error) {
handleError(error);
}
}

View File

@@ -13,7 +13,6 @@ import type { AddressInfo } from "node:net";
import { join } from "node:path";
import {
CentralCore,
PluginStore,
PluginLoader,
getTaskMergeBlocker,
INSIGHT_EXTRACTION_SCHEDULE_NAME,
@@ -41,7 +40,7 @@ import {
import { promptForPort } from "./port-prompt.js";
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js";
import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { getClaudeCodeCredentialPaths, getCodexCliAuthPath, getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js";
import { resolveProject } from "../project-context.js";
import {
ensureClaudeSkillsForAllProjectsOnStartup,
@@ -52,7 +51,20 @@ import {
resolveClaudeCliExtensionPaths,
setCachedClaudeCliResolution,
} from "./claude-cli-extension.js";
import {
getCachedDroidCliResolution,
resolveDroidCliExtensionPaths,
setCachedDroidCliResolution,
} from "./droid-cli-extension.js";
import {
getCachedLlamaCppResolution,
resolveLlamaCppExtensionPaths,
setCachedLlamaCppResolution,
} from "./llama-cpp-extension.js";
import { resolveSelfExtension } from "./self-extension.js";
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
import { syncStartupModels } from "./startup-model-sync.js";
import { ensureBundledDependencyGraphPluginInstalled, ensureBundledPluginInstalled, isBundledPluginId } from "../plugins/bundled-plugin-install.js";
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
@@ -216,7 +228,16 @@ export async function runServe(
serveStartTime = Date.now();
ensureProcessDiagnostics();
// Port resolution priority: CLI --port arg > process.env.PORT > default (4040)
// The env var fallback is critical for Docker containers where the mesh config
// injects PORT as an environment variable to control the container's listen port.
let selectedPort = port;
if (!opts.interactive && (port === 4040 || port === 0) && process.env.PORT) {
const envPort = Number(process.env.PORT);
if (Number.isFinite(envPort) && envPort > 0) {
selectedPort = envPort;
}
}
if (opts.interactive) {
try {
selectedPort = await promptForPort(port);
@@ -403,11 +424,7 @@ export async function runServe(
// internally for task-execution plugin hooks. These instances here serve the
// HTTP plugin-management API routes and are intentionally separate.
//
const pluginStoreRootDir =
typeof (store as { getRootDir?: () => string }).getRootDir === "function"
? store.getRootDir()
: store.getFusionDir();
const pluginStore = new PluginStore(pluginStoreRootDir);
const pluginStore = store.getPluginStore();
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ───────────────────────
@@ -422,6 +439,63 @@ export async function runServe(
taskStore: store,
});
try {
const installStatus = await ensureBundledDependencyGraphPluginInstalled(pluginStore, pluginLoader);
if (installStatus === "installed") {
console.log("[plugins] Installed bundled Dependency Graph plugin");
} else if (installStatus === "missing-bundle") {
console.warn("[plugins] Bundled Dependency Graph plugin was not found in this build");
}
} catch (err) {
console.warn(`[plugins] Failed to auto-install bundled Dependency Graph plugin: ${err instanceof Error ? err.message : err}`);
}
// Lazy-install hook for bundled runtime plugins (Hermes/OpenClaw/Paperclip).
const ensureBundledPluginInstalledCallback = async (pluginId: string): Promise<boolean> => {
if (!isBundledPluginId(pluginId)) {
console.warn(`[plugins] ensureBundledPluginInstalled: unknown bundled plugin id "${pluginId}"`);
return false;
}
try {
const status = await ensureBundledPluginInstalled(pluginStore, pluginLoader, pluginId);
if (status === "missing-bundle") {
console.warn(`[plugins] Bundled plugin "${pluginId}" was not found in this build`);
return false;
}
if (status === "installed") {
console.log(`[plugins] Installed bundled plugin "${pluginId}"`);
} else if (status === "updated") {
console.log(`[plugins] Updated bundled plugin "${pluginId}"`);
}
return true;
} catch (err) {
console.warn(`[plugins] Failed to auto-install bundled plugin "${pluginId}": ${err instanceof Error ? err.message : err}`);
throw err;
}
};
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
// can discover installed runtimes like Hermes and OpenClaw.
try {
const { loaded, errors } = await pluginLoader.loadAllPlugins();
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
if (schemaHooks.length > 0) {
try {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
} catch (err) {
console.error(
`[plugins] Schema initialization failed: ${err instanceof Error ? err.message : err}`,
);
}
}
} catch (err) {
console.error(
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
);
}
// Get subsystems from the cwd engine for the HTTP layer
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
@@ -429,8 +503,12 @@ export async function runServe(
const automationStore = cwdEngine.getAutomationStore();
const authStorage = AuthStorage.create(getFusionAuthPath());
const legacyAuthStorage = createReadOnlyAuthFileStorage(getLegacyAuthPaths());
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [legacyAuthStorage]);
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
...getLegacyAuthPaths(),
getCodexCliAuthPath(),
...getClaudeCodeCredentialPaths(),
]);
const mergedAuthStorage = mergeAuthStorageReads(authStorage, [supplementalAuthStorage]);
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
@@ -469,6 +547,42 @@ export async function runServe(
}
})();
const droidCliPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveDroidCliExtensionPaths(globalSettings);
setCachedDroidCliResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] droid-cli: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useDroidCli setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedDroidCliResolution(null);
return [];
}
})();
const llamaCppPaths = await (async () => {
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
const result = resolveLlamaCppExtensionPaths(globalSettings);
setCachedLlamaCppResolution(result.resolution);
if (result.warning) {
console.warn(`[extensions] llama-cpp: ${result.warning}`);
}
return result.paths;
} catch (err) {
console.warn(
`[extensions] Unable to evaluate useLlamaCpp setting: ${err instanceof Error ? err.message : String(err)}`,
);
setCachedLlamaCppResolution(null);
return [];
}
})();
// Inject the cli's own extension so fn_* tools register globally without
// requiring `pi install npm:@runfusion/fusion`.
const selfExtension = resolveSelfExtension();
@@ -484,6 +598,8 @@ export async function runServe(
...getEnabledPiExtensionPaths(cwd),
...packageExtensionPaths,
...claudeCliPaths,
...droidCliPaths,
...llamaCppPaths,
],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
@@ -511,83 +627,18 @@ export async function runServe(
extensionsResult.runtime.pendingProviderRegistrations = [];
modelRegistry.refresh();
(async () => {
try {
const settings = await store.getSettings();
if (settings.openrouterModelSync === false) return;
const hasOrAuth = await dashboardAuthStorage.getApiKey("openrouter");
const headers: Record<string, string> = {};
if (hasOrAuth) headers["Authorization"] = `Bearer ${hasOrAuth}`;
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers,
});
if (!res.ok) return;
const json = (await res.json()) as {
data?: Array<{
id: string;
name: string;
context_length?: number;
top_provider?: { max_completion_tokens?: number };
pricing?: Record<string, string>;
architecture?: {
modality?: string;
input_modalities?: string[];
};
}>;
};
const orModels = (json.data || []).map((m) => {
const id = (m.id || "").toLowerCase();
const name = (m.name || "").toLowerCase();
const reasoning =
id.includes(":thinking") ||
id.includes("-r1") ||
id.includes("/r1") ||
id.includes("o1-") ||
id.includes("o3-") ||
id.includes("o4-") ||
id.includes("reasoner") ||
name.includes("thinking") ||
name.includes("reasoner");
const hasVision =
m.architecture?.input_modalities?.includes("image") ??
m.architecture?.modality?.includes("multimodal") ??
false;
function parseCost(v?: string) {
const n = parseFloat(v || "0");
return isNaN(n) ? 0 : n * 1_000_000;
}
return {
id: m.id,
name: m.name || m.id,
reasoning,
input: (hasVision ? ["text", "image"] : ["text"]) as (
| "text"
| "image"
)[],
cost: {
input: parseCost(m.pricing?.prompt),
output: parseCost(m.pricing?.completion),
cacheRead: parseCost(m.pricing?.input_cache_read),
cacheWrite: parseCost(m.pricing?.input_cache_write),
},
contextWindow: m.context_length || 128000,
maxTokens: m.top_provider?.max_completion_tokens || 16384,
};
});
modelRegistry.registerProvider("openrouter", {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "OPENROUTER_API_KEY",
api: "openai-completions",
models: orModels,
});
console.log(
`[openrouter] Synced ${orModels.length} models from OpenRouter API`,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.log(`[openrouter] Failed to sync models: ${message}`);
}
})();
try {
const globalSettings = await store.getGlobalSettingsStore().getSettings();
registerCustomProviders(
modelRegistry,
globalSettings.customProviders,
(message) => console.log(`[custom-providers] ${message}`),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[custom-providers] Failed to load custom providers from global settings: ${message}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.log(`[extensions] Failed to discover extensions: ${message}`);
@@ -595,6 +646,28 @@ export async function runServe(
modelRegistry.refresh();
}
void syncStartupModels({
getSettings: () => store.getSettings(),
authStorage: dashboardAuthStorage,
modelRegistry,
log: (scope, message) => console.log(`[${scope}] ${message}`),
});
store.on("settings:updated", ({ settings, previous }) => {
const currentProviders = settings.customProviders;
const previousProviders = previous.customProviders;
if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) {
return;
}
reregisterCustomProviders(
modelRegistry,
previousProviders,
currentProviders,
(message) => console.log(`[custom-providers] ${message}`),
);
});
// ── Daemon token resolution ─────────────────────────────────────────────
//
// When --daemon flag is set, resolve the daemon token using the same
@@ -655,6 +728,7 @@ export async function runServe(
pluginStore,
pluginLoader,
pluginRunner: pluginLoader,
ensureBundledPluginInstalled: ensureBundledPluginInstalledCallback,
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
onProjectRegistered: ({ path }) => {
// Fire-and-forget: install the fusion Claude-skill when pi-claude-cli
@@ -672,6 +746,28 @@ export async function runServe(
}
return { status: r.status, reason: r.reason };
},
getDroidCliExtensionStatus: () => {
const r = getCachedDroidCliResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
getLlamaCppExtensionStatus: () => {
const r = getCachedLlamaCppResolution();
if (!r) return null;
if (r.status === "ok") {
return { status: "ok", path: r.path, packageVersion: r.packageVersion };
}
if (r.status === "not-installed") {
return { status: "not-installed" };
}
return { status: r.status, reason: r.reason };
},
onUseClaudeCliToggled: (_prev, next) => {
if (!next) return; // Toggle-off leaves existing skill symlinks alone.
void (async () => {
@@ -688,6 +784,11 @@ export async function runServe(
}
})();
},
onUseDroidCliToggled: (_prev, next) => {
if (next) {
console.log("[extensions] Droid CLI enabled — restart required for full effect");
}
},
headless: true,
skillsAdapter,
daemon: daemonToken ? { token: daemonToken } : undefined,

View File

@@ -15,6 +15,8 @@ export const VALID_SETTINGS = [
"defaultModel",
"runStepsInNewSessions",
"maxParallelSteps",
"defaultNodeId",
"unavailableNodePolicy",
] as const;
const GLOBAL_ONLY_SETTINGS = ["ntfyEnabled", "ntfyTopic", "defaultModel"] as const;
@@ -28,6 +30,8 @@ const PROJECT_ONLY_SETTINGS = [
"requirePlanApproval",
"runStepsInNewSessions",
"maxParallelSteps",
"defaultNodeId",
"unavailableNodePolicy",
] as const;
type ValidSettingKey = (typeof VALID_SETTINGS)[number];
@@ -45,9 +49,10 @@ const NUMBER_SETTINGS: readonly string[] = ["maxConcurrent", "maxWorktrees", "ma
const ENUM_SETTINGS: Record<string, readonly string[]> = {
worktreeNaming: ["random", "task-id", "task-title"],
unavailableNodePolicy: ["block", "fallback-local"],
};
const STRING_SETTINGS: readonly string[] = ["taskPrefix", "ntfyTopic", "defaultModel"];
const STRING_SETTINGS: readonly string[] = ["taskPrefix", "ntfyTopic", "defaultModel", "defaultNodeId"];
// Validation ranges for numeric settings
const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
@@ -203,6 +208,10 @@ export async function runSettingsShow(projectName?: string): Promise<void> {
title: "Tasks",
keys: ["taskPrefix", "requirePlanApproval", "includeTaskIdInCommit"],
},
{
title: "Node Routing",
keys: ["defaultNodeId", "unavailableNodePolicy"],
},
{
title: "Notifications",
keys: ["ntfyEnabled", "ntfyTopic"],

View File

@@ -187,6 +187,7 @@ export async function runSkillsInstall(
const child = spawn("npx", npxArgs, {
cwd: process.cwd(),
stdio: "inherit",
shell: true,
});
const exitCode = await new Promise<number>((resolve, reject) => {

View File

@@ -0,0 +1,233 @@
import { spawn } from "node:child_process";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
const OPENCODE_MODELS_TIMEOUT_MS = 15_000;
type ModelConfig = {
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
contextWindow: number;
maxTokens: number;
};
interface ModelRegistryLike {
registerProvider: (name: string, config: {
baseUrl: string;
api: string;
apiKey?: string;
models: ModelConfig[];
}) => void;
}
interface AuthStorageLike {
getApiKey: (provider: string) => Promise<string | undefined>;
}
interface SettingsLike {
openrouterModelSync?: boolean;
opencodeGoModelSync?: boolean;
}
interface StartupSyncOptions {
getSettings: () => Promise<SettingsLike>;
authStorage: AuthStorageLike;
modelRegistry: ModelRegistryLike;
log: (scope: string, message: string) => void;
}
function parseCost(value?: string): number {
const n = parseFloat(value || "0");
return Number.isNaN(n) ? 0 : n * 1_000_000;
}
function toOpenRouterModels(json: {
data?: Array<{
id: string;
name: string;
context_length?: number;
top_provider?: { max_completion_tokens?: number };
pricing?: Record<string, string>;
architecture?: { modality?: string; input_modalities?: string[] };
}>;
}): ModelConfig[] {
return (json.data || []).map((model) => {
const id = (model.id || "").toLowerCase();
const name = (model.name || "").toLowerCase();
const reasoning = id.includes(":thinking")
|| id.includes("-r1")
|| id.includes("/r1")
|| id.includes("o1-")
|| id.includes("o3-")
|| id.includes("o4-")
|| id.includes("reasoner")
|| name.includes("thinking")
|| name.includes("reasoner");
const hasVision = model.architecture?.input_modalities?.includes("image")
?? model.architecture?.modality?.includes("multimodal")
?? false;
return {
id: model.id,
name: model.name || model.id,
reasoning,
input: hasVision ? ["text", "image"] : ["text"],
cost: {
input: parseCost(model.pricing?.prompt),
output: parseCost(model.pricing?.completion),
cacheRead: parseCost(model.pricing?.input_cache_read),
cacheWrite: parseCost(model.pricing?.input_cache_write),
},
contextWindow: model.context_length || 128000,
maxTokens: model.top_provider?.max_completion_tokens || 16384,
};
});
}
async function syncOpenRouterModels(options: StartupSyncOptions): Promise<void> {
const { authStorage, modelRegistry, log } = options;
const apiKey = await authStorage.getApiKey("openrouter");
const headers: Record<string, string> = {};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const response = await fetch(OPENROUTER_MODELS_URL, { headers });
if (!response.ok) {
log("openrouter", `Failed to sync models: HTTP ${response.status}`);
return;
}
const json = await response.json() as {
data?: Array<{
id: string;
name: string;
context_length?: number;
top_provider?: { max_completion_tokens?: number };
pricing?: Record<string, string>;
architecture?: { modality?: string; input_modalities?: string[] };
}>;
};
const models = toOpenRouterModels(json);
modelRegistry.registerProvider("openrouter", {
baseUrl: "https://openrouter.ai/api/v1",
apiKey: "OPENROUTER_API_KEY",
api: "openai-completions",
models,
});
log("openrouter", `Synced ${models.length} models from OpenRouter API`);
}
function normalizeOpencodeGoModel(modelId: string): ModelConfig {
const trimmed = modelId.trim();
const normalizedId = trimmed.startsWith("opencode/")
? `opencode-go/${trimmed.slice("opencode/".length)}`
: trimmed.startsWith("opencode-go/")
? trimmed
: `opencode-go/${trimmed}`;
return {
id: normalizedId,
name: normalizedId,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
};
}
export function parseOpencodeModelsOutput(stdout: string): string[] {
const ids = new Set<string>();
const matches = stdout.matchAll(/\bopencode(?:-go)?\/[A-Za-z0-9._:-]+\b/g);
for (const match of matches) {
if (match[0]) {
ids.add(match[0]);
}
}
return [...ids];
}
async function discoverOpencodeGoModels(): Promise<string[]> {
return await new Promise<string[]>((resolve, reject) => {
const proc = spawn("opencode", ["models", "opencode", "--refresh"], {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error(`Timed out after ${OPENCODE_MODELS_TIMEOUT_MS}ms`));
}, OPENCODE_MODELS_TIMEOUT_MS);
proc.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
proc.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
proc.once("error", (error) => {
clearTimeout(timer);
reject(error);
});
proc.once("exit", (code) => {
clearTimeout(timer);
if (code !== 0) {
reject(new Error(stderr.trim() || `opencode exited with code ${code}`));
return;
}
resolve(parseOpencodeModelsOutput(stdout));
});
});
}
async function syncOpencodeGoModels(options: StartupSyncOptions): Promise<void> {
const { modelRegistry, log } = options;
const modelIds = await discoverOpencodeGoModels();
if (modelIds.length === 0) {
log("opencode-go", "No models discovered from opencode CLI refresh");
return;
}
const models = modelIds.map(normalizeOpencodeGoModel);
modelRegistry.registerProvider("opencode-go", {
baseUrl: "https://api.opencode.ai/v1",
apiKey: "OPENCODE_API_KEY",
api: "openai-completions",
models,
});
log("opencode-go", `Synced ${models.length} models from opencode CLI`);
}
export async function syncStartupModels(options: StartupSyncOptions): Promise<void> {
const settings = await options.getSettings();
if (settings.openrouterModelSync !== false) {
try {
await syncOpenRouterModels(options);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.log("openrouter", `Failed to sync models: ${message}`);
}
}
if (settings.opencodeGoModelSync !== false) {
try {
await syncOpencodeGoModels(options);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.log("opencode-go", `Failed to sync models: ${message}`);
}
}
}

View File

@@ -17,6 +17,7 @@ import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import type { TaskStore } from "@fusion/core";
import { resolveTaskMergeTarget } from "@fusion/core";
import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
/**
@@ -25,7 +26,7 @@ import type { Settings, TaskDetail, PrInfo } from "@fusion/core";
*/
interface GitHubOperations {
findPrForBranch(params: { head: string; state?: "open" | "closed" | "all" }): Promise<PrInfo | null>;
createPr(params: { title: string; body: string; head: string }): Promise<PrInfo>;
createPr(params: { title: string; body: string; head: string; base?: string }): Promise<PrInfo>;
getPrMergeStatus(base?: string, head?: string, number?: number): Promise<{
prInfo: PrInfo;
reviewDecision: string | null;
@@ -52,6 +53,67 @@ export function getTaskBranchName(taskId: string): string {
return `fusion/${taskId.toLowerCase()}`;
}
/**
* Push the per-task branch to origin so `gh pr create --head <branch>`
* can find it. Idempotent: creates the remote branch on first push and
* fast-forwards thereafter. Required because the GitHub PR-create flow
* does not implicitly publish the local branch.
*/
function commandExitCode(err: unknown): number | undefined {
if (typeof err === "object" && err !== null && "code" in err) {
const code = (err as { code?: unknown }).code;
return typeof code === "number" ? code : undefined;
}
return undefined;
}
async function gitCommandSucceeds(cwd: string, command: string, missingExitCode: number): Promise<boolean> {
try {
await execAsync(command, { cwd, timeout: 30_000 });
return true;
} catch (err: unknown) {
if (commandExitCode(err) === missingExitCode) return false;
throw err;
}
}
async function pushTaskBranchToOrigin(cwd: string, branch: string): Promise<void> {
const localRef = `refs/heads/${branch}`;
const localBranchExists = await gitCommandSucceeds(
cwd,
`git show-ref --verify --quiet "${localRef}"`,
1,
);
if (!localBranchExists) {
const remoteBranchExists = await gitCommandSucceeds(
cwd,
`git ls-remote --exit-code --heads origin "${branch}"`,
2,
);
if (remoteBranchExists) {
return;
}
throw new Error(
`Cannot create PR for missing task branch "${branch}": no local ref "${localRef}" and no origin branch "${branch}". Re-run the task or recreate the branch before retrying PR creation.`,
);
}
try {
await execAsync(`git push -u origin "${branch}"`, {
cwd,
timeout: 60_000,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to push branch "${branch}" to origin before PR creation: ${message}`,
);
}
}
/**
* Build the PR title for a task.
* Format: "{taskId}: {title}" or just "{taskId}" if no title.
@@ -113,11 +175,12 @@ async function finalizePullRequestMerge(
cwd: string,
task: TaskDetail,
prInfo: PrInfo,
message = "Pull request merged",
): Promise<void> {
await cleanupMergedTaskArtifacts(cwd, task);
await store.updateTask(task.id, { status: null, mergeRetries: 0 });
await store.moveTask(task.id, "done");
await store.logEntry(task.id, "Pull request merged", `PR #${prInfo.number}: ${prInfo.url}`);
await store.logEntry(task.id, message, `PR #${prInfo.number}: ${prInfo.url}`);
}
/**
@@ -167,17 +230,40 @@ export async function processPullRequestMergeTask(
}
const branch = getTaskBranchName(task.id);
const settings = await store.getSettings();
const projectDefaultBranch = typeof settings.baseBranch === "string" ? settings.baseBranch : undefined;
const mergeTarget = resolveTaskMergeTarget(task, {
projectDefaultBranch,
});
let prInfo: PrInfo | undefined = task.prInfo;
if (!prInfo) {
await store.updateTask(task.id, { status: "creating-pr" });
const existingPr = await github.findPrForBranch({ head: branch, state: "all" });
prInfo = existingPr ?? await github.createPr({
title: buildPullRequestTitle(task),
body: buildPullRequestBody(task),
head: branch,
});
if (!existingPr) {
// gh pr create / GitHub REST require the head branch to exist on
// origin. Nothing else in the merge path publishes the per-task
// branch, so we push it here right before creating the PR.
await pushTaskBranchToOrigin(cwd, branch);
}
try {
prInfo = existingPr ?? await github.createPr({
title: buildPullRequestTitle(task),
body: buildPullRequestBody(task),
head: branch,
base: mergeTarget.branch,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("No commits between")) {
const error = `No pull request created for ${branch}: the branch has no commits relative to the base branch.`;
await store.updateTask(task.id, { status: "failed", error });
await store.logEntry(task.id, error, message);
return "skipped";
}
throw err;
}
await store.updatePrInfo(task.id, prInfo);
await store.logEntry(
@@ -191,7 +277,7 @@ export async function processPullRequestMergeTask(
throw new Error(`Failed to create or resolve pull request for ${task.id}`);
}
const mergeStatus = await github.getPrMergeStatus(undefined, undefined, prInfo.number);
const mergeStatus = await github.getPrMergeStatus(mergeTarget.branch, branch, prInfo.number);
const refreshedPrInfo: PrInfo = {
...prInfo,
...mergeStatus.prInfo,
@@ -204,6 +290,17 @@ export async function processPullRequestMergeTask(
return "merged";
}
// Optional approval gate. GitHub's `required: true` flag for checks only
// flows from branch protection (Pro feature on private repos), so on free
// private repos every fresh PR is "merge ready" and would auto-squash
// immediately. `requirePrApproval` lets users keep PR mode as "open the
// PR, wait for me to approve and merge it" by holding the merge until
// reviewDecision === "APPROVED".
if (settings.requirePrApproval && mergeStatus.reviewDecision !== "APPROVED") {
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
return "waiting";
}
if (!mergeStatus.mergeReady) {
if (mergeStatus.prInfo.status === "open") {
await store.updateTask(task.id, { status: "awaiting-pr-checks" });
@@ -220,7 +317,36 @@ export async function processPullRequestMergeTask(
return "waiting";
}
await store.updateTask(task.id, { status: "merging-pr" });
const mergedPr = await github.mergePr({ number: prInfo.number, method: "squash" });
let mergedPr: PrInfo;
try {
mergedPr = await github.mergePr({ number: prInfo.number, method: "squash" });
} catch (err: unknown) {
let refreshedStatus: Awaited<ReturnType<GitHubOperations["getPrMergeStatus"]>>;
try {
refreshedStatus = await github.getPrMergeStatus(mergeTarget.branch, branch, prInfo.number);
} catch {
throw err;
}
const refreshedAfterFailure: PrInfo = {
...prInfo,
...refreshedStatus.prInfo,
lastCheckedAt: new Date().toISOString(),
};
await store.updatePrInfo(task.id, refreshedAfterFailure);
if (refreshedAfterFailure.status === "merged") {
await finalizePullRequestMerge(
store,
cwd,
task,
refreshedAfterFailure,
"Pull request already merged after merge command failed; reconciled task state from GitHub",
);
return "merged";
}
throw err;
}
await store.updatePrInfo(task.id, { ...mergedPr, lastCheckedAt: new Date().toISOString() });
await finalizePullRequestMerge(store, cwd, task, mergedPr);
return "merged";

View File

@@ -1,10 +1,10 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry } from "@fusion/core";
import { aiMergeTask } from "@fusion/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@fusion/dashboard/planning";
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { basename, join } from "node:path";
import { GitHubClient } from "@fusion/dashboard";
import {
getGhErrorMessage,
@@ -14,9 +14,77 @@ import {
runGhJsonAsync,
} from "@fusion/core/gh-cli";
import { resolveProject, type ProjectContext } from "../project-context.js";
import { findNodeByNameOrId } from "./node.js";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
function getGitHubIssueUrl(sourceMetadata: unknown): string | undefined {
if (!sourceMetadata || typeof sourceMetadata !== "object") return undefined;
const issueUrl = (sourceMetadata as { issueUrl?: unknown }).issueUrl;
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
}
function getResearchSourceContext(sourceMetadata: unknown): string | undefined {
if (!sourceMetadata || typeof sourceMetadata !== "object") return undefined;
const findingLabel = (sourceMetadata as { findingLabel?: unknown }).findingLabel;
if (typeof findingLabel === "string" && findingLabel.length > 0) {
return findingLabel;
}
const runId = (sourceMetadata as { runId?: unknown }).runId;
return typeof runId === "string" && runId.length > 0 ? runId : undefined;
}
function formatTaskSource(task: {
sourceType?: string;
sourceAgentId?: string;
sourceParentTaskId?: string;
sourceMetadata?: unknown;
}): string | null {
switch (task.sourceType) {
case "dashboard_ui":
return "Dashboard";
case "quick_chat":
return "Quick Chat";
case "chat_session":
return "Chat Session";
case "agent_heartbeat":
return task.sourceAgentId ? `Agent (${task.sourceAgentId})` : "Agent";
case "automation":
return "Automation";
case "cron":
return "Scheduled Task";
case "workflow_step":
return "Workflow Step";
case "github_import": {
const issueUrl = getGitHubIssueUrl(task.sourceMetadata);
return issueUrl ? `GitHub Import (${issueUrl})` : "GitHub Import";
}
case "research": {
const context = getResearchSourceContext(task.sourceMetadata);
return context ? `Research (${context})` : "Research";
}
case "task_refine":
return task.sourceParentTaskId
? `Refinement of ${task.sourceParentTaskId}`
: "Refinement";
case "task_duplicate":
return task.sourceParentTaskId
? `Duplicate of ${task.sourceParentTaskId}`
: "Duplicate";
case "cli":
return "CLI";
case "api":
return "API";
case "recovery":
return "Recovery";
case "unknown":
default:
return null;
}
}
interface CommandContext {
store: TaskStore;
projectPath: string;
@@ -29,7 +97,7 @@ function asLocalProjectContext(store: TaskStore): ProjectContext {
return {
projectId: cwd,
projectPath: cwd,
projectName: cwd.split("/").filter(Boolean).at(-1) ?? "current-project",
projectName: basename(cwd) || "current-project",
isRegistered: false,
store,
};
@@ -93,7 +161,31 @@ async function getProjectPath(projectName?: string): Promise<string> {
return (await getCommandContext(projectName)).projectPath;
}
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string) {
async function resolveNodeByNameOrId(nodeNameOrId: string): Promise<{ id: string; name?: string }> {
const central = new CentralCore();
await central.init();
try {
const looksLikeNodeId = nodeNameOrId.includes("-") && nodeNameOrId.length > 20;
let node = looksLikeNodeId
? await central.getNode(nodeNameOrId)
: await central.getNodeByName(nodeNameOrId);
if (!node) {
node = await findNodeByNameOrId(central, nodeNameOrId);
}
if (!node) {
throw new Error(`Node not found: ${nodeNameOrId}`);
}
return { id: node.id, name: node.name };
} finally {
await central.close();
}
}
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string, nodeName?: string) {
let description = descriptionArg;
const projectContext = await getProjectContext(projectName);
@@ -109,7 +201,22 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
}
const store = projectContext?.store ?? await getStore(projectName);
const task = await store.createTask({ description: description.trim(), dependencies: depends });
const task = await store.createTask({
description: description.trim(),
dependencies: depends,
source: { sourceType: "cli" },
});
let resolvedNode: { id: string; name?: string } | undefined;
if (nodeName) {
try {
resolvedNode = await resolveNodeByNameOrId(nodeName);
await store.updateTask(task.id, { nodeId: resolvedNode.id });
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
const label = task.description.length > 60
? task.description.slice(0, 60) + "…"
@@ -124,6 +231,9 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
if (resolvedNode) {
console.log(` Node: ${resolvedNode.name || resolvedNode.id}`);
}
console.log(` Path: .fusion/tasks/${task.id}/`);
if (attachFiles && attachFiles.length > 0) {
@@ -411,9 +521,60 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project
}
}
export async function runTaskSetNode(id: string, nodeNameOrId: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
if (task.column === "in-progress") {
console.error(`Cannot change node override: task ${id} is in progress`);
process.exit(1);
}
let resolvedNode: { id: string; name?: string };
try {
resolvedNode = await resolveNodeByNameOrId(nodeNameOrId);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return;
}
await store.updateTask(id, { nodeId: resolvedNode.id });
console.log(`✓ Set node override for ${id}: ${resolvedNode.name || resolvedNode.id}`);
}
export async function runTaskClearNode(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
if (task.column === "in-progress") {
console.error(`Cannot change node override: task ${id} is in progress`);
process.exit(1);
}
await store.updateTask(id, { nodeId: null });
console.log(`✓ Cleared node override for ${id}`);
}
export async function runTaskShow(id: string, projectName?: string) {
const store = await getStore(projectName);
const task = await store.getTask(id);
const settings: Partial<Settings> = "getSettings" in store ? await store.getSettings() : {};
let nodeSummary = "(default local)";
if (task.nodeId) {
let nodeName: string | undefined;
const central = new CentralCore();
await central.init();
try {
nodeName = (await central.getNode(task.nodeId))?.name;
} finally {
await central.close();
}
nodeSummary = nodeName ? `${nodeName} (${task.nodeId})` : task.nodeId;
} else if (settings.defaultNodeId) {
nodeSummary = `project default: ${settings.defaultNodeId}`;
}
console.log();
console.log(` ${task.id}: ${task.title || task.description}`);
@@ -421,6 +582,14 @@ export async function runTaskShow(id: string, projectName?: string) {
if (task.dependencies.length) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
console.log(` Node: ${nodeSummary}`);
if (settings.unavailableNodePolicy) {
console.log(` Unavailable Node Policy: ${settings.unavailableNodePolicy}`);
}
const sourceSummary = formatTaskSource(task);
if (sourceSummary) {
console.log(` Source: ${sourceSummary}`);
}
console.log();
// Steps
@@ -815,11 +984,17 @@ export async function runTaskImportGitHubInteractive(
const description = `${body}\n\nSource: ${issue.html_url}`;
// Create the task
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: source.sourceMetadata,
},
});
const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "");
@@ -887,6 +1062,19 @@ export interface TaskImportOptions {
labels?: string[];
}
function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }) {
return {
sourceIssue: {
provider: "github" as const,
repository: `${owner}/${repo}`,
externalIssueId: String(issue.number),
issueNumber: issue.number,
url: issue.html_url,
},
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
};
}
export async function runTaskImportFromGitHub(
ownerRepo: string,
options: TaskImportOptions = {},
@@ -951,11 +1139,17 @@ export async function runTaskImportFromGitHub(
const description = `${body}\n\nSource: ${issue.html_url}`;
// Create the task
const source = buildGitHubIssueSource(owner, repo, issue);
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
sourceIssue: source.sourceIssue,
source: {
sourceType: "github_import",
sourceMetadata: source.sourceMetadata,
},
});
const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "");
@@ -1424,7 +1618,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
clearThinking();
if (err instanceof RateLimitError) {
console.error("\n Rate limit exceeded. Maximum 5 planning sessions per hour.\n");
console.error("\n Rate limit exceeded. Maximum 1000 planning sessions per hour.\n");
process.exit(1);
}
@@ -1529,6 +1723,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
description: result.data.description,
column: "triage",
dependencies: result.data.suggestedDependencies,
source: { sourceType: "cli" },
});
console.log();

View File

@@ -0,0 +1,222 @@
import { exec } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { promisify } from "node:util";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { getCachedUpdateStatus } from "../update-cache.js";
const execAsync = promisify(exec);
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
export type RunUpdateOptions = {
check?: boolean;
global?: boolean;
json?: boolean;
};
type UpdateStatus = {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
updated: boolean;
};
function readOwnCliVersion(): string | undefined {
let currentDir: string;
try {
currentDir = dirname(fileURLToPath(import.meta.url));
} catch {
return undefined;
}
for (let i = 0; i < 8; i += 1) {
const pkgPath = resolve(currentDir, "package.json");
if (existsSync(pkgPath)) {
try {
const parsed = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name?: string; version?: string };
if (parsed.name === "@runfusion/fusion" && typeof parsed.version === "string") {
return parsed.version;
}
} catch {
// Ignore parse errors and keep walking.
}
}
const parentDir = resolve(currentDir, "..");
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
return undefined;
}
function parseVersion(version: string): number[] {
return version
.split(".")
.slice(0, 3)
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
}
function isRemoteNewer(remoteVersion: string, currentVersion: string): boolean {
const remote = parseVersion(remoteVersion);
const current = parseVersion(currentVersion);
const maxLength = Math.max(remote.length, current.length, 3);
for (let i = 0; i < maxLength; i += 1) {
const remotePart = remote[i] ?? 0;
const currentPart = current[i] ?? 0;
if (remotePart > currentPart) return true;
if (remotePart < currentPart) return false;
}
return false;
}
async function fetchLatestVersion(): Promise<string> {
const response = await fetch(REGISTRY_URL);
const payload = (await response.json()) as {
"dist-tags"?: {
latest?: string;
};
};
const latestVersion = payload?.["dist-tags"]?.latest;
if (typeof latestVersion !== "string" || latestVersion.length === 0) {
throw new Error("Could not determine latest version from npm registry response.");
}
return latestVersion;
}
async function installLatest(globalInstall: boolean): Promise<void> {
const command = globalInstall ? INSTALL_COMMAND : "npm install @runfusion/fusion@latest";
await execAsync(command, {
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
}
function printStatus(status: UpdateStatus, checkOnly: boolean): void {
console.log(`Current version: ${status.currentVersion}`);
console.log(`Latest version: ${status.latestVersion}`);
if (!status.updateAvailable) {
console.log("Already up to date.");
return;
}
if (checkOnly) {
console.log("Update available.");
return;
}
if (status.updated) {
console.log("Update complete.");
}
}
function printJson(status: UpdateStatus): void {
console.log(JSON.stringify(status));
}
function getLatestVersionFallback(currentVersion: string): string | null {
const cached = getCachedUpdateStatus(currentVersion);
if (!cached) return null;
return cached.latestVersion;
}
export async function runUpdate(options: RunUpdateOptions = {}): Promise<void> {
const checkOnly = options.check === true;
const globalInstall = options.global !== false;
const jsonOutput = options.json === true;
const currentVersion = readOwnCliVersion();
if (!currentVersion) {
console.error("Error: Could not determine current Fusion CLI version.");
process.exit(1);
return;
}
let latestVersion: string;
try {
latestVersion = await fetchLatestVersion();
} catch (error) {
const fallbackVersion = getLatestVersionFallback(currentVersion);
if (!fallbackVersion) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error checking for updates: ${message}`);
process.exit(1);
return;
}
latestVersion = fallbackVersion;
if (!jsonOutput) {
console.log("Warning: npm registry unreachable, using cached update metadata.");
}
}
const updateAvailable = isRemoteNewer(latestVersion, currentVersion);
if (checkOnly) {
const checkStatus: UpdateStatus = {
currentVersion,
latestVersion,
updateAvailable,
updated: false,
};
if (jsonOutput) {
printJson(checkStatus);
} else {
printStatus(checkStatus, true);
}
if (updateAvailable) {
process.exitCode = 1;
}
return;
}
if (!updateAvailable) {
const status: UpdateStatus = {
currentVersion,
latestVersion,
updateAvailable: false,
updated: false,
};
if (jsonOutput) {
printJson(status);
} else {
printStatus(status, false);
}
return;
}
try {
await installLatest(globalInstall);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error installing update: ${message}`);
process.exit(1);
return;
}
const updatedStatus: UpdateStatus = {
currentVersion,
latestVersion,
updateAvailable: true,
updated: true,
};
if (jsonOutput) {
printJson(updatedStatus);
return;
}
printStatus(updatedStatus, false);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,445 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// ── Mocks ────────────────────────────────────────────────────────────
// vi.mock factories are hoisted, so we use vi.hoisted() for mock references.
const { mockExistsSync, mockReadFile, mockValidatePluginManifest } = vi.hoisted(() => ({
mockExistsSync: vi.fn<(path: string) => boolean>(),
mockReadFile: vi.fn<(path: string, encoding: string) => Promise<string>>(),
mockValidatePluginManifest: vi.fn<(manifest: unknown) => { valid: boolean; errors: string[] }>(),
}));
vi.mock("node:fs", () => ({
existsSync: mockExistsSync,
}));
vi.mock("node:fs/promises", () => ({
readFile: mockReadFile,
}));
vi.mock("@fusion/core", () => ({
validatePluginManifest: mockValidatePluginManifest,
}));
// Import SUT after mocks are in place
import {
BUNDLED_PLUGIN_IDS,
ensureBundledDependencyGraphPluginInstalled,
ensureBundledCursorRuntimePluginInstalled,
ensureBundledPluginInstalled,
resolvePluginEntryPath,
} from "../bundled-plugin-install.js";
// ── Helpers ──────────────────────────────────────────────────────────
const BUNDLED_PLUGIN_ID = "fusion-plugin-dependency-graph";
const HERMES_PLUGIN_ID = "fusion-plugin-hermes-runtime";
const CURSOR_PLUGIN_ID = "fusion-plugin-cursor-runtime";
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
const CLI_PRINTING_PRESS_PLUGIN_ID = "fusion-plugin-cli-printing-press";
function makeManifest(overrides?: Partial<{ id: string; version: string; name: string }>) {
return {
id: BUNDLED_PLUGIN_ID,
name: "Dependency Graph",
version: "0.1.0",
description: "Top-level dependency graph dashboard view",
dashboardViews: [
{
viewId: "graph",
label: "Graph",
componentPath: "./dashboard-view",
icon: "Network",
placement: "more",
order: 40,
},
],
...overrides,
};
}
interface PluginLike {
id: string;
name: string;
version: string;
description?: string;
path: string;
enabled: boolean;
state: string;
settings: Record<string, unknown>;
dependencies?: string[];
createdAt: string;
updatedAt: string;
}
function makePlugin(overrides?: Partial<PluginLike>): PluginLike {
return {
id: BUNDLED_PLUGIN_ID,
name: "Dependency Graph",
version: "0.1.0",
description: "Top-level dependency graph dashboard view",
path: "", // callers should set this
enabled: true,
state: "installed",
settings: {},
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makePluginStore() {
const plugins = new Map<string, PluginLike>();
return {
getPlugin: vi.fn(async (id: string) => {
const plugin = plugins.get(id);
if (!plugin)
throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
return { ...plugin };
}),
registerPlugin: vi.fn(async (input: { manifest: unknown; path: string }) => {
const manifest = input.manifest as ReturnType<typeof makeManifest>;
const plugin = makePlugin({
id: manifest.id,
name: manifest.name,
version: manifest.version,
description: manifest.description,
path: input.path,
});
plugins.set(manifest.id, plugin);
return plugin;
}),
updatePlugin: vi.fn(async (id: string, updates: Record<string, unknown>) => {
const plugin = plugins.get(id);
if (!plugin) throw new Error(`Plugin "${id}" not found`);
const updated = { ...plugin, ...updates, updatedAt: new Date().toISOString() };
plugins.set(id, updated);
return updated;
}),
/** Directly inject a plugin record for test setup */
_inject(plugin: PluginLike) {
plugins.set(plugin.id, { ...plugin });
},
};
}
function makePluginLoader() {
return {
loadPlugin: vi.fn(async () => {}),
unloadPlugin: vi.fn(async () => {}),
getLoadedPlugins: vi.fn(() => new Map()),
isPluginLoaded: vi.fn(() => false),
};
}
/**
* Setup: bundled manifest exists at the first candidate path and is valid.
* The resolver's first candidate includes "dist/plugins/..." when running from source.
*/
function setupBundleExists(manifestOverrides?: Partial<{ id: string; version: string }>) {
const manifest = makeManifest(manifestOverrides);
mockExistsSync.mockImplementation((p: string) => {
if (typeof p !== "string") return false;
if (p.endsWith("manifest.json") && p.includes("dist")) return true;
if (p.includes("dist") && (p.endsWith("/bundled.js") || p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"))) {
return true;
}
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
return manifest;
}
/** Setup: no bundled manifest found on any candidate path. */
function setupBundleMissing() {
mockExistsSync.mockReturnValue(false);
}
/** Setup: bundled manifest found but invalid. */
function setupBundleInvalid() {
mockExistsSync.mockImplementation((p: string) => {
if (typeof p === "string" && p.endsWith("manifest.json") && p.includes("dist")) return true;
return false;
});
const badManifest = { id: "bad" };
mockReadFile.mockResolvedValue(JSON.stringify(badManifest));
mockValidatePluginManifest.mockReturnValue({
valid: false,
errors: ["Missing required field: name"],
});
}
/**
* Probe the resolver to determine the actual resolved bundled path.
* Registers the plugin and captures the path from the registerPlugin call.
*/
async function getResolvedBundledPath(): Promise<string> {
setupBundleExists();
const probeStore = makePluginStore();
const probeLoader = makePluginLoader();
await ensureBundledDependencyGraphPluginInstalled(
probeStore as unknown as import("@fusion/core").PluginStore,
probeLoader as unknown as import("@fusion/core").PluginLoader,
);
const call = probeStore.registerPlugin.mock.calls[0];
const path = (call?.[0] as { path: string })?.path ?? "";
expect(path.endsWith(".js") || path.endsWith(".ts")).toBe(true);
return path;
}
// ── Tests ────────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
});
describe("resolvePluginEntryPath", () => {
it("prefers bundled.js when both bundled and source entries exist", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/bundled.js"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
});
it("prefers bundled.js when source entry is unavailable", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/bundled.js"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/bundled.js");
});
it("prefers dist/index.js when bundled.js is unavailable", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts") || p.endsWith("/dist/index.js"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/dist/index.js");
});
it("falls back to src/index.ts for workspace-dev plugins without build outputs", () => {
mockExistsSync.mockImplementation((p: string) => p.endsWith("/src/index.ts"));
expect(resolvePluginEntryPath("/tmp/plugin")).toBe("/tmp/plugin/src/index.ts");
});
});
describe("ensureBundledDependencyGraphPluginInstalled", () => {
it("includes roadmap plugin in bundled plugin ids", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(ROADMAP_PLUGIN_ID);
});
it("includes CLI printing press plugin in bundled plugin ids", () => {
expect(BUNDLED_PLUGIN_IDS).toContain(CLI_PRINTING_PRESS_PLUGIN_ID);
});
it("fresh install: registers and loads the plugin when not in DB", async () => {
setupBundleExists();
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledOnce();
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: BUNDLED_PLUGIN_ID }),
}),
);
// Fresh install → enabled by default → should be loaded
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("already installed with matching path/version → returns already-installed without DB writes", async () => {
// First probe to get the actual resolved path
const bundledPath = await getResolvedBundledPath();
vi.clearAllMocks();
const manifest = setupBundleExists();
const store = makePluginStore();
const loader = makePluginLoader();
// Inject a plugin that matches the current bundle path and version
store._inject(makePlugin({ path: bundledPath, version: manifest.version }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("already-installed");
expect(store.updatePlugin).not.toHaveBeenCalled();
expect(store.registerPlugin).not.toHaveBeenCalled();
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("already installed with stale path → updates path to current bundled path", async () => {
const bundledPath = await getResolvedBundledPath();
const OLD_PATH = "/old/cli/dist/plugins/fusion-plugin-dependency-graph";
vi.clearAllMocks();
const manifest = setupBundleExists();
const store = makePluginStore();
const loader = makePluginLoader();
// Plugin registered with the OLD path, but current version
store._inject(makePlugin({ path: OLD_PATH, version: manifest.version }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalledWith(
BUNDLED_PLUGIN_ID,
expect.objectContaining({ path: bundledPath }),
);
// Plugin was enabled → should be loaded
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("already installed with stale version → updates version to current manifest version", async () => {
const bundledPath = await getResolvedBundledPath();
vi.clearAllMocks();
const manifest = setupBundleExists({ version: "0.2.0" });
const store = makePluginStore();
const loader = makePluginLoader();
// Plugin registered with old version but same path
store._inject(makePlugin({ path: bundledPath, version: "0.1.0" }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalledWith(
BUNDLED_PLUGIN_ID,
expect.objectContaining({ version: "0.2.0" }),
);
expect(loader.loadPlugin).toHaveBeenCalledWith(BUNDLED_PLUGIN_ID);
});
it("disabled plugin → path/version updated but plugin NOT loaded (user choice respected)", async () => {
setupBundleExists({ version: "0.2.0" });
const store = makePluginStore();
const loader = makePluginLoader();
// Plugin explicitly disabled by user with stale version
// Use a path that definitely won't match the resolved path
store._inject(makePlugin({ path: "/stale/path/plugin", version: "0.1.0", enabled: false }));
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("updated");
expect(store.updatePlugin).toHaveBeenCalled();
// User disabled the plugin → should NOT be loaded
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("missing bundle (no bundled manifest found) → returns missing-bundle without error", async () => {
setupBundleMissing();
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("missing-bundle");
expect(store.registerPlugin).not.toHaveBeenCalled();
expect(store.updatePlugin).not.toHaveBeenCalled();
expect(loader.loadPlugin).not.toHaveBeenCalled();
});
it("invalid bundled manifest → throws descriptive error", async () => {
setupBundleInvalid();
const store = makePluginStore();
const loader = makePluginLoader();
await expect(
ensureBundledDependencyGraphPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
),
).rejects.toThrow("Invalid plugin manifest");
});
it("registers Cursor runtime through the dedicated helper", async () => {
const manifest = makeManifest({ id: CURSOR_PLUGIN_ID, name: "Cursor Runtime" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(CURSOR_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(CURSOR_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledCursorRuntimePluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: CURSOR_PLUGIN_ID }) }),
);
});
it("registers roadmap plugin via generic bundled installer", async () => {
const manifest = makeManifest({ id: ROADMAP_PLUGIN_ID, name: "Roadmaps" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(ROADMAP_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(ROADMAP_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
ROADMAP_PLUGIN_ID,
);
expect(result).toBe("installed");
expect(store.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ manifest: expect.objectContaining({ id: ROADMAP_PLUGIN_ID }) }),
);
});
it("registers Hermes from bundled.js when bundled, src, and dist entries all exist", async () => {
const manifest = makeManifest({ id: HERMES_PLUGIN_ID, name: "Hermes Runtime" });
mockExistsSync.mockImplementation((p: string) => {
if (p.endsWith("manifest.json") && p.includes(HERMES_PLUGIN_ID)) return true;
if (p.endsWith("/bundled.js") && p.includes(HERMES_PLUGIN_ID)) return true;
if (p.endsWith("/src/index.ts") && p.includes(HERMES_PLUGIN_ID)) return true;
if (p.endsWith("/dist/index.js") && p.includes(HERMES_PLUGIN_ID)) return true;
return false;
});
mockReadFile.mockResolvedValue(JSON.stringify(manifest));
mockValidatePluginManifest.mockReturnValue({ valid: true, errors: [] });
const store = makePluginStore();
const loader = makePluginLoader();
const result = await ensureBundledPluginInstalled(
store as unknown as import("@fusion/core").PluginStore,
loader as unknown as import("@fusion/core").PluginLoader,
HERMES_PLUGIN_ID,
);
expect(result).toBe("installed");
const registerCall = store.registerPlugin.mock.calls[0]?.[0] as { path: string };
expect(registerCall.path).toContain(`${HERMES_PLUGIN_ID}/bundled.js`);
});
});

View File

@@ -0,0 +1,170 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { validatePluginManifest, type PluginInstallation, type PluginLoader, type PluginManifest, type PluginStore } from "@fusion/core";
const DEPENDENCY_GRAPH_PLUGIN_ID = "fusion-plugin-dependency-graph";
const CURSOR_RUNTIME_PLUGIN_ID = "fusion-plugin-cursor-runtime";
export const BUNDLED_PLUGIN_IDS = [
"fusion-plugin-dependency-graph",
"fusion-plugin-whatsapp-chat",
"fusion-plugin-roadmap",
"fusion-plugin-hermes-runtime",
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-cli-printing-press",
] as const;
export type BundledPluginId = (typeof BUNDLED_PLUGIN_IDS)[number];
export function isBundledPluginId(id: string): id is BundledPluginId {
return (BUNDLED_PLUGIN_IDS as readonly string[]).includes(id);
}
export type EnsureBundledResult =
| "installed"
| "updated"
| "already-installed"
| "missing-bundle";
function getCandidatePluginDirs(pluginId: string): string[] {
const moduleDir = dirname(fileURLToPath(import.meta.url));
const cliPackageRoot = resolve(moduleDir, "..", "..");
return [
join(cliPackageRoot, "dist", "plugins", pluginId),
join(cliPackageRoot, "plugins", pluginId),
join(cliPackageRoot, "..", "..", "plugins", pluginId),
];
}
async function loadManifest(pluginDir: string): Promise<PluginManifest> {
const manifestPath = join(pluginDir, "manifest.json");
const content = await readFile(manifestPath, "utf-8");
const manifest = JSON.parse(content);
const validation = validatePluginManifest(manifest);
if (!validation.valid) {
throw new Error(`Invalid plugin manifest: ${validation.errors.join(", ")}`);
}
return manifest;
}
function resolveBundledPluginDir(pluginId: string): string | null {
for (const path of getCandidatePluginDirs(pluginId)) {
if (existsSync(join(path, "manifest.json"))) {
return path;
}
}
return null;
}
/**
* Resolve the actual loadable entry FILE path for a plugin directory. Node ESM
* does not allow directory imports, so we must register the explicit file the
* loader will dynamic-import. Preference order:
* 1. ./bundled.js (esbuild-bundled, shipped in npm tarball)
* 2. ./dist/index.js (legacy prebuilt fallback)
* 3. ./src/index.ts (workspace/dev fallback when no bundle exists)
* 4. fall back to the directory itself
*/
export function resolvePluginEntryPath(pluginDir: string): string {
const candidates = [
join(pluginDir, "bundled.js"),
join(pluginDir, "dist", "index.js"),
join(pluginDir, "src", "index.ts"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return pluginDir;
}
export async function ensureBundledPluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
pluginId: string,
): Promise<EnsureBundledResult> {
let existingPlugin: PluginInstallation | null = null;
try {
existingPlugin = await pluginStore.getPlugin(pluginId);
} catch {
// Continue; plugin not installed yet.
}
const bundledDir = resolveBundledPluginDir(pluginId);
if (!bundledDir) {
return "missing-bundle";
}
const manifest = await loadManifest(bundledDir);
const entryPath = resolvePluginEntryPath(bundledDir);
if (existingPlugin) {
const pathChanged = existingPlugin.path !== entryPath;
const versionChanged = existingPlugin.version !== manifest.version;
if (!pathChanged && !versionChanged) {
if (existingPlugin.enabled) {
try {
await pluginLoader.loadPlugin(existingPlugin.id);
} catch {
// best-effort
}
}
return "already-installed";
}
await pluginStore.updatePlugin(pluginId, {
...(pathChanged ? { path: entryPath } : {}),
...(versionChanged ? { version: manifest.version } : {}),
});
if (existingPlugin.enabled) {
try {
await pluginLoader.loadPlugin(existingPlugin.id);
} catch {
// best-effort
}
}
return "updated";
}
const plugin = await pluginStore.registerPlugin({
manifest,
path: entryPath,
});
if (plugin.enabled) {
try {
await pluginLoader.loadPlugin(plugin.id);
} catch {
// best-effort
}
}
return "installed";
}
/**
* @deprecated Use {@link ensureBundledPluginInstalled} with the explicit plugin id.
* Kept for backwards compatibility with existing call sites.
*/
export async function ensureBundledDependencyGraphPluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, DEPENDENCY_GRAPH_PLUGIN_ID);
}
export async function ensureBundledCursorRuntimePluginInstalled(
pluginStore: PluginStore,
pluginLoader: PluginLoader,
): Promise<EnsureBundledResult> {
return ensureBundledPluginInstalled(pluginStore, pluginLoader, CURSOR_RUNTIME_PLUGIN_ID);
}

View File

@@ -5,9 +5,8 @@
* for operating on tasks across multiple registered projects.
*/
import { TaskStore, type RegisteredProject, CentralCore, GlobalSettingsStore } from "@fusion/core";
import { resolve, dirname } from "node:path";
import { existsSync } from "node:fs";
import { TaskStore, type RegisteredProject, CentralCore, GlobalSettingsStore, isValidSqliteDatabaseFile } from "@fusion/core";
import { resolve, dirname, basename } from "node:path";
/** Project context for CLI operations */
export interface ProjectContext {
@@ -181,26 +180,30 @@ export async function detectProjectFromCwd(
cwd: string,
central: CentralCore
): Promise<RegisteredProject | { id: string; name: string; path: string } | undefined> {
let currentDir = resolve(cwd);
const startDir = resolve(cwd);
let currentDir = startDir;
// Walk up the directory tree
while (true) {
// Check for fn database
const kbPath = resolve(currentDir, ".fusion", "fusion.db");
if (existsSync(kbPath)) {
if (isValidSqliteDatabaseFile(kbPath)) {
// Found a fn project - check if it's registered
const project = await central.getProjectByPath(currentDir);
if (project) {
return project;
}
// Not registered, but has .fusion/fusion.db - still use it as a valid project
// This preserves legacy single-project CLI behavior.
// Use empty string for id to indicate unregistered status.
return {
id: "",
name: currentDir.split("/").filter(Boolean).at(-1) ?? "current-project",
path: currentDir,
};
// For unregistered projects, only accept an exact CWD match.
// This preserves legacy single-project behavior without accidentally
// resolving unrelated parent directories higher in the filesystem.
if (currentDir === startDir) {
return {
id: "",
name: basename(currentDir) || "current-project",
path: currentDir,
};
}
}
// Move up to parent
@@ -309,4 +312,3 @@ export async function getStore(
const context = await resolveProject(projectName, cwd, globalDir);
return context.store;
}

View File

@@ -9,9 +9,9 @@
*/
import { existsSync, statSync } from "node:fs";
import { dirname, resolve, normalize } from "node:path";
import { basename, dirname, resolve, normalize } from "node:path";
import { createInterface } from "node:readline/promises";
import { CentralCore, type RegisteredProject, type TaskStore } from "@fusion/core";
import { CentralCore, isValidSqliteDatabaseFile, type RegisteredProject, type TaskStore } from "@fusion/core";
import { ProjectManager } from "@fusion/engine";
// Singleton instances for reuse across commands
@@ -106,8 +106,8 @@ export function findKbDir(startPath: string): string | null {
// Safety limit to prevent infinite loops
for (let i = 0; i < 100; i++) {
const kbPath = resolve(current, ".fusion");
if (existsSync(kbPath) && statSync(kbPath).isDirectory()) {
const dbPath = resolve(current, ".fusion", "fusion.db");
if (isValidSqliteDatabaseFile(dbPath)) {
return current;
}
@@ -250,7 +250,7 @@ export async function resolveProject(options: ResolveOptions = {}): Promise<Reso
if (shouldRegister) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const defaultName = fusionDir.split("/").pop() || "unnamed";
const defaultName = basename(fusionDir) || "unnamed";
const name = await rl.question(` Project name [${defaultName}]: `);
rl.close();
@@ -454,8 +454,7 @@ export async function isProjectNameTaken(
* Validate that a path contains an initialized fn project (.fusion/ directory exists).
*/
export function isKbProject(path: string): boolean {
const kbPath = resolve(path, ".fusion");
return existsSync(kbPath) && statSync(kbPath).isDirectory();
return isValidSqliteDatabaseFile(resolve(path, ".fusion", "fusion.db"));
}
/**

View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import {
createCliCoreMock,
createCliEngineMock,
resetCliCoreEngineMockState,
} from "./mockCoreEngine";
describe("cli test mock helpers", () => {
it("creates stable fallback functions for missing callable exports", async () => {
const module = await createCliCoreMock(async () => ({ known: vi.fn() }));
const first = module.missingThing as ReturnType<typeof vi.fn>;
const second = module.missingThing as ReturnType<typeof vi.fn>;
expect(first).toBe(second);
first("x");
expect(first).toHaveBeenCalledWith("x");
resetCliCoreEngineMockState();
expect(first).not.toHaveBeenCalled();
});
it("keeps real non-function exports while allowing callable overrides", async () => {
const module = await createCliEngineMock(
async () => ({ VERSION: "1.0.0", factory: () => "real" }),
{},
{ factory: vi.fn().mockReturnValue("mocked") },
);
expect(module.VERSION).toBe("1.0.0");
expect((module.factory as () => string)()).toBe("mocked");
});
});

View File

@@ -0,0 +1,59 @@
/**
* Canonical @fusion/core and @fusion/engine mock helpers for CLI command tests.
*
* When a new commonly-mocked export is added, update defaults here instead of
* copying large inline export lists into command suites.
*/
import { vi, type Mock } from "vitest";
type AnyModule = Record<string, unknown>;
type AnyMock = Mock;
const fallbackFns = new Map<string, AnyMock>();
function getFallback(name: string): AnyMock {
if (!fallbackFns.has(name)) {
fallbackFns.set(name, vi.fn());
}
return fallbackFns.get(name)!;
}
function withFallbackFunctions(actual: AnyModule, mocked: AnyModule): AnyModule {
return new Proxy(mocked, {
get(target, prop, receiver) {
if (typeof prop !== "string") return Reflect.get(target, prop, receiver);
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver);
if (["then", "catch", "finally"].includes(prop)) return undefined;
const actualValue = actual[prop];
if (typeof actualValue === "function" || actualValue === undefined) {
const fn = getFallback(prop);
target[prop] = fn;
return fn;
}
return actualValue;
},
});
}
export async function createCliCoreMock(
importActual: () => Promise<AnyModule>,
defaults: AnyModule = {},
overrides: AnyModule = {},
): Promise<AnyModule> {
const actual = await importActual();
return withFallbackFunctions(actual, { ...actual, ...defaults, ...overrides });
}
export async function createCliEngineMock(
importActual: () => Promise<AnyModule>,
defaults: AnyModule = {},
overrides: AnyModule = {},
): Promise<AnyModule> {
const actual = await importActual();
return withFallbackFunctions(actual, { ...actual, ...defaults, ...overrides });
}
export function resetCliCoreEngineMockState(): void {
for (const fn of fallbackFns.values()) fn.mockReset();
}

View File

@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { GlobalSettingsStore, resolveGlobalDir } from "@fusion/core";
type CachedUpdateStatus = {
updateAvailable: boolean;
latestVersion: string;
currentVersion: string;
};
type UpdateCachePayload = {
updateAvailable?: unknown;
latestVersion?: unknown;
currentVersion?: unknown;
};
export function getCachedUpdateStatus(currentVersion?: string): CachedUpdateStatus | null {
try {
const cachePath = join(resolveGlobalDir(), "update-check.json");
const raw = readFileSync(cachePath, "utf-8");
const parsed = JSON.parse(raw) as UpdateCachePayload;
if (
parsed.updateAvailable === true &&
typeof parsed.latestVersion === "string" &&
parsed.latestVersion.length > 0 &&
typeof parsed.currentVersion === "string" &&
parsed.currentVersion.length > 0
) {
if (
typeof currentVersion === "string" &&
currentVersion.length > 0 &&
parsed.currentVersion !== currentVersion
) {
return null;
}
return {
updateAvailable: true,
latestVersion: parsed.latestVersion,
currentVersion: parsed.currentVersion,
};
}
return null;
} catch {
return null;
}
}
export async function isUpdateCheckEnabled(): Promise<boolean> {
const store = new GlobalSettingsStore();
await store.init();
const settings = await store.getSettings();
return settings.updateCheckEnabled !== false;
}

View File

@@ -6,7 +6,8 @@
"types": ["node", "vitest/globals"],
"jsx": "react-jsx",
"paths": {
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"]
"@fusion/test-utils": ["../core/src/__test-utils__/workspace.ts"],
"node-pty": ["../dashboard/src/types/node-pty/index.d.ts"]
}
},
"include": ["src/**/*"],

View File

@@ -1,13 +1,45 @@
import { defineConfig } from "tsup";
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { build as esbuildBuild } from "esbuild";
// Runtime plugin ids that ship inside the published CLI tarball. Each plugin's
// entry is esbuild-bundled into dist/plugins/<id>/bundled.js with workspace
// deps (@fusion/plugin-sdk) inlined, since npm publish strips node_modules
// directories. See ensureBundledPluginInstalled for the loader-side counterpart.
const RUNTIME_PLUGIN_IDS = [
"fusion-plugin-hermes-runtime",
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
"fusion-plugin-droid-runtime",
] as const;
const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([
"fusion-plugin-openclaw-runtime",
"fusion-plugin-droid-runtime",
]);
const __dirname = dirname(fileURLToPath(import.meta.url));
const dashboardClientSrc = join(__dirname, "..", "dashboard", "dist", "client");
const dashboardClientDest = join(__dirname, "dist", "client");
const piClaudeCliSrc = join(__dirname, "..", "pi-claude-cli");
const piClaudeCliDest = join(__dirname, "dist", "pi-claude-cli");
const droidCliSrc = join(__dirname, "..", "droid-cli");
const droidCliDest = join(__dirname, "dist", "droid-cli");
const llamaCppSrc = join(__dirname, "..", "pi-llama-cpp");
const llamaCppDest = join(__dirname, "dist", "pi-llama-cpp");
const dependencyGraphPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-dependency-graph");
const dependencyGraphPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-dependency-graph");
const whatsappChatPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-whatsapp-chat");
const whatsappChatPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-whatsapp-chat");
const roadmapPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-roadmap");
const roadmapPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-roadmap");
const reportsPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-reports");
const reportsPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-reports");
const cliPrintingPressPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-cli-printing-press");
const cliPrintingPressPluginDest = join(__dirname, "dist", "plugins", "fusion-plugin-cli-printing-press");
const dashboardClientStub = `<!doctype html>
<html lang="en">
<head>
@@ -24,6 +56,76 @@ const dashboardClientStub = `<!doctype html>
</html>
`;
type BundlePluginEntryOptions = {
pluginId: string;
srcDir: string;
destDir: string;
withMcpAsset?: boolean;
};
async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = false }: BundlePluginEntryOptions) {
if (existsSync(destDir)) {
rmSync(destDir, { recursive: true, force: true });
}
if (!existsSync(srcDir)) {
console.warn(
`WARNING: Plugin source not found at ${srcDir}; ${pluginId} will be unavailable in the published package.`,
);
return;
}
mkdirSync(destDir, { recursive: true });
cpSync(join(srcDir, "manifest.json"), join(destDir, "manifest.json"));
const srcPkg = JSON.parse(readFileSync(join(srcDir, "package.json"), "utf-8"));
const destPkg = {
name: srcPkg.name,
version: srcPkg.version,
type: "module",
exports: { ".": { import: "./bundled.js" } },
private: true,
};
writeFileSync(join(destDir, "package.json"), JSON.stringify(destPkg, null, 2));
const srcEntry = join(srcDir, "src", "index.ts");
const builtEntry = join(srcDir, "dist", "index.js");
const entry = existsSync(srcEntry) ? srcEntry : builtEntry;
if (!existsSync(entry)) {
throw new Error(`No entry found for ${pluginId} (looked for src/index.ts and dist/index.js)`);
}
await esbuildBuild({
entryPoints: [entry],
bundle: true,
format: "esm",
platform: "node",
target: "node22",
outfile: join(destDir, "bundled.js"),
external: ["@fusion/core", "@fusion/engine"],
alias: {
"@fusion/plugin-sdk": join(__dirname, "..", "plugin-sdk", "src", "index.ts"),
},
logLevel: "warning",
});
if (withMcpAsset) {
const mcpServerAsset = join(srcDir, "src", "mcp-schema-server.cjs");
if (!existsSync(mcpServerAsset)) {
throw new Error(
`[tsup] Missing required bridge asset for ${pluginId} at ${mcpServerAsset}; expected committed source file mcp-schema-server.cjs.`,
);
}
cpSync(mcpServerAsset, join(destDir, "mcp-schema-server.cjs"));
}
const bundledOutput = join(destDir, "bundled.js");
if (!existsSync(bundledOutput)) {
throw new Error(`[tsup] Missing bundled output for ${pluginId}: expected ${bundledOutput}`);
}
console.log(`Bundled plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`);
}
export default defineConfig({
entry: ["src/bin.ts", "src/extension.ts"],
format: ["esm"],
@@ -32,7 +134,17 @@ export default defineConfig({
esbuildOptions(options) {
options.conditions = [...(options.conditions || []), "source"];
},
noExternal: [/^@fusion\//],
noExternal: [/^@fusion\//, /^@fusion-plugin-examples\//],
// Native module: leave node-pty (aliased to @homebridge fork) out of the
// bundle. esbuild can't statically resolve its conditional native require()s
// (build/Release/pty.node, build/Debug/conpty.node, ...).
external: [
"node-pty",
"@homebridge/node-pty-prebuilt-multiarch",
"dockerode",
"ssh2",
"cpu-features",
],
splitting: false,
clean: true,
removeNodeProtocol: false,
@@ -61,6 +173,110 @@ export default defineConfig({
);
}
// Stage the vendored @fusion/droid-cli pi extension into dist/, following
// the same pattern as pi-claude-cli above. The extension ships raw .ts
// source that pi loads via jiti at runtime, so it cannot be bundled by
// esbuild. This lets us drop @fusion/droid-cli from the published
// package's dependencies — the workspace package is private and would 404
// on `pnpm install` of @runfusion/fusion otherwise.
if (existsSync(droidCliDest)) {
rmSync(droidCliDest, { recursive: true, force: true });
}
if (existsSync(droidCliSrc)) {
mkdirSync(droidCliDest, { recursive: true });
cpSync(join(droidCliSrc, "index.ts"), join(droidCliDest, "index.ts"));
cpSync(join(droidCliSrc, "src"), join(droidCliDest, "src"), { recursive: true });
cpSync(join(droidCliSrc, "package.json"), join(droidCliDest, "package.json"));
console.log("Copied droid-cli extension to dist/droid-cli/");
} else {
console.warn(
`WARNING: droid-cli source not found at ${droidCliSrc}; useDroidCli will not work in the published package.`,
);
}
if (existsSync(llamaCppDest)) {
rmSync(llamaCppDest, { recursive: true, force: true });
}
if (existsSync(llamaCppSrc)) {
mkdirSync(llamaCppDest, { recursive: true });
cpSync(join(llamaCppSrc, "index.ts"), join(llamaCppDest, "index.ts"));
cpSync(join(llamaCppSrc, "src"), join(llamaCppDest, "src"), { recursive: true });
cpSync(join(llamaCppSrc, "package.json"), join(llamaCppDest, "package.json"));
console.log("Copied pi-llama-cpp extension to dist/pi-llama-cpp/");
} else {
console.warn(
`WARNING: pi-llama-cpp source not found at ${llamaCppSrc}; useLlamaCpp will not work in the published package.`,
);
}
await bundlePluginEntry({
pluginId: "fusion-plugin-dependency-graph",
srcDir: dependencyGraphPluginSrc,
destDir: dependencyGraphPluginDest,
});
if (existsSync(whatsappChatPluginDest)) {
rmSync(whatsappChatPluginDest, { recursive: true, force: true });
}
if (existsSync(whatsappChatPluginSrc)) {
mkdirSync(whatsappChatPluginDest, { recursive: true });
cpSync(join(whatsappChatPluginSrc, "manifest.json"), join(whatsappChatPluginDest, "manifest.json"));
cpSync(join(whatsappChatPluginSrc, "package.json"), join(whatsappChatPluginDest, "package.json"));
cpSync(join(whatsappChatPluginSrc, "src"), join(whatsappChatPluginDest, "src"), { recursive: true });
console.log("Copied WhatsApp chat plugin to dist/plugins/fusion-plugin-whatsapp-chat/");
} else {
console.warn(
`WARNING: WhatsApp chat plugin source not found at ${whatsappChatPluginSrc}; bundled auto-install will be unavailable.`,
);
}
await bundlePluginEntry({
pluginId: "fusion-plugin-roadmap",
srcDir: roadmapPluginSrc,
destDir: roadmapPluginDest,
});
if (existsSync(reportsPluginDest)) {
rmSync(reportsPluginDest, { recursive: true, force: true });
}
if (existsSync(reportsPluginSrc)) {
mkdirSync(reportsPluginDest, { recursive: true });
cpSync(join(reportsPluginSrc, "manifest.json"), join(reportsPluginDest, "manifest.json"));
cpSync(join(reportsPluginSrc, "package.json"), join(reportsPluginDest, "package.json"));
cpSync(join(reportsPluginSrc, "src"), join(reportsPluginDest, "src"), { recursive: true });
console.log("Copied reports plugin to dist/plugins/fusion-plugin-reports/");
} else {
console.warn(
`WARNING: Reports plugin source not found at ${reportsPluginSrc}; bundled auto-install will be unavailable.`,
);
}
if (existsSync(cliPrintingPressPluginDest)) {
rmSync(cliPrintingPressPluginDest, { recursive: true, force: true });
}
if (existsSync(cliPrintingPressPluginSrc)) {
mkdirSync(cliPrintingPressPluginDest, { recursive: true });
cpSync(join(cliPrintingPressPluginSrc, "manifest.json"), join(cliPrintingPressPluginDest, "manifest.json"));
cpSync(join(cliPrintingPressPluginSrc, "package.json"), join(cliPrintingPressPluginDest, "package.json"));
cpSync(join(cliPrintingPressPluginSrc, "src"), join(cliPrintingPressPluginDest, "src"), { recursive: true });
console.log("Copied cli-printing-press plugin to dist/plugins/fusion-plugin-cli-printing-press/");
} else {
console.warn(
`WARNING: cli-printing-press plugin source not found at ${cliPrintingPressPluginSrc}; bundled auto-install will be unavailable.`,
);
}
// Bundle each runtime plugin into a self-contained ESM file so npm/npx
// installs can load them without the workspace `@fusion/plugin-sdk`.
for (const pluginId of RUNTIME_PLUGIN_IDS) {
await bundlePluginEntry({
pluginId,
srcDir: join(__dirname, "..", "..", "plugins", pluginId),
destDir: join(__dirname, "dist", "plugins", pluginId),
withMcpAsset: RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER.has(pluginId),
});
}
if (existsSync(dashboardClientDest)) {
rmSync(dashboardClientDest, { recursive: true, force: true });
}

View File

@@ -1,13 +1,8 @@
import { defineConfig } from "vitest/config";
import { resolve } from "node:path";
import { cpus } from "node:os";
import { computeMaxWorkers } from "../core/src/__test-utils__/vitest-workers";
// Use all-but-one core by default. Override with VITEST_MAX_WORKERS for
// constrained environments (CI runners, laptops on battery, etc.).
const defaultMaxWorkers = Math.max(1, cpus().length - 1);
const requestedMaxWorkers = Number.parseInt(process.env.VITEST_MAX_WORKERS ?? String(defaultMaxWorkers), 10);
const maxWorkers = Math.max(1, Number.isFinite(requestedMaxWorkers) ? requestedMaxWorkers : defaultMaxWorkers);
process.env.VITEST_MAX_WORKERS = String(maxWorkers);
const maxWorkers = computeMaxWorkers();
export default defineConfig({
resolve: {
@@ -21,6 +16,27 @@ export default defineConfig({
{ find: /^@fusion\/dashboard\/planning$/, replacement: resolve(__dirname, "../dashboard/src/planning.ts") },
{ find: /^@fusion\/dashboard$/, replacement: resolve(__dirname, "../dashboard/src/index.ts") },
{ find: /^@fusion\/engine$/, replacement: resolve(__dirname, "../engine/src/index.ts") },
{ find: /^@fusion\/plugin-sdk$/, replacement: resolve(__dirname, "../plugin-sdk/src/index.ts") },
{
find: /^@fusion-plugin-examples\/droid-runtime\/probe$/,
replacement: resolve(__dirname, "../../plugins/fusion-plugin-droid-runtime/src/probe.ts"),
},
{
find: /^@fusion-plugin-examples\/droid-runtime$/,
replacement: resolve(__dirname, "../../plugins/fusion-plugin-droid-runtime/src/index.ts"),
},
{
find: /^@fusion-plugin-examples\/hermes-runtime$/,
replacement: resolve(__dirname, "../../plugins/fusion-plugin-hermes-runtime/src/index.ts"),
},
{
find: /^@fusion-plugin-examples\/openclaw-runtime$/,
replacement: resolve(__dirname, "../../plugins/fusion-plugin-openclaw-runtime/src/index.ts"),
},
{
find: /^@fusion-plugin-examples\/paperclip-runtime$/,
replacement: resolve(__dirname, "../../plugins/fusion-plugin-paperclip-runtime/src/index.ts"),
},
{ find: /^@fusion\/test-utils$/, replacement: resolve(__dirname, "../core/src/__test-utils__/workspace.ts") },
],
},
@@ -31,12 +47,12 @@ export default defineConfig({
// run with file parallelism enabled.
exclude: ["**/node_modules/**", "**/dist/**", "src/__tests__/build-exe*.test.ts"],
setupFiles: [
"./src/__tests__/setup-test-isolation.ts",
resolve(__dirname, "../core/src/__test-utils__/vitest-setup.ts"),
],
globalSetup: [resolve(__dirname, "../core/src/__test-utils__/vitest-teardown.ts")],
pool: "forks",
maxWorkers,
poolOptions: { threads: { minThreads: 1, maxThreads: maxWorkers }, forks: { minForks: 1, maxForks: maxWorkers } },
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },
fileParallelism: true,
coverage: {
enabled: false,