merge main: resolve db.test.ts import conflict

Combined both branches: TaskStore import from main + rmSync import
from our branch (needed for fresh DB test cleanup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Timothy Laurent
2026-05-04 14:58:47 -07:00
38 changed files with 2625 additions and 188 deletions

View File

@@ -0,0 +1,9 @@
---
"@fusion/engine": patch
---
Restrict merger staging to squash + fix-agent files; refuse to commit unrelated working-tree changes
Replaces the blanket `git add -A` in `commitOrAmendMergeWithFixes` with an explicit allowlist: only files that were squash-staged or explicitly modified by the in-merge verification fix agent are staged. Any other dirty files in the working tree are left untouched and a warning is logged naming each excluded path. Fixes a production bug where ~13 unrelated user-edited files were bundled into a task's squash commit.
Hardened by code review: replaced all shell-interpolated `git add` calls in `commitOrAmendMergeWithFixes` and the conflict-resolution helpers (`resolveWithOurs`, `resolveWithTheirs`, `resolveTrivialWhitespace`) with `execFile` array form to eliminate path-injection surface; adopted `git -z` NUL-delimited output for all dirty-file path queries in both `snapshotDirtyFiles` and `commitOrAmendMergeWithFixes` so paths with embedded spaces round-trip correctly; truncated long allowlist debug log lines to at most 20 entries.

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Make the settings modal fill the viewport on mobile and align section headings with form-group gutters for consistent spacing across each settings page.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
Cache per-package test results by content hash to skip unchanged packages across sequential merges.
`scripts/test-changed.mjs` now maintains a per-project cache at `.fusion/test-cache.json`. For each package in a changed-mode run, a SHA-256 is computed from the git blob SHAs of every tracked file in the package directory plus `pnpm-lock.yaml` and `tsconfig.base.json`. If the hash matches a cache entry younger than 7 days the package is excluded from the `pnpm --filter` invocation and tests are skipped. After a successful run the passing hashes are written atomically. Cache lookups are bypassed when `FUSION_TEST_NO_CACHE=1` or `--no-cache` is passed, and never applied to full-suite runs. A new `FUSION_TEST_WORKSPACE_CONCURRENCY` env var controls `--workspace-concurrency` (default `2`).

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Cache merge verification by tree hash and boost test concurrency for in-review verification.

View File

@@ -169,13 +169,18 @@ Concrete references:
### Research Runs ### Research Runs
- `ResearchStore` (`research-store.ts`, `research-types.ts`, `research-settings.ts`) persists bounded research runs, sources/events, and exports - `ResearchStore` (`research-store.ts`, `research-types.ts`, `research-settings.ts`) persists bounded research runs, sources/events, exports, lifecycle metadata, and retry/cancel state transitions.
- Backed by `research_runs`, `research_exports`, and `research_run_events` - Backed by `research_runs`, `research_exports`, and `research_run_events`.
- Engine orchestration is implemented in `packages/engine/src/research-orchestrator.ts` + `research-step-runner.ts` - Engine orchestration is implemented in `packages/engine/src/research-orchestrator.ts` + `research-step-runner.ts`.
- Dashboard/API surface is implemented under `/api/research` (`packages/dashboard/src/research-routes.ts`) with `ResearchView.tsx` in the app - Dashboard/API surface is implemented under `/api/research` (`packages/dashboard/src/research-routes.ts`) with `ResearchView.tsx` in the app.
- CLI surface is implemented in `packages/cli/src/commands/research.ts` with six subcommands (create, list, show, export, cancel, retry) - CLI surface is implemented in `packages/cli/src/commands/research.ts` with six subcommands (create, list, show, export, cancel, retry).
- Agent tool surface is exposed via `packages/cli/src/extension.ts` (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`) - Agent tool surface is exposed via `packages/cli/src/extension.ts` (`fn_research_run`, `fn_research_list`, `fn_research_get`, `fn_research_cancel`, `fn_research_retry`).
- **Boundary note:** research and insights are parallel subsystems sharing host infrastructure, not one table/store family - **Boundary contract (FN-3292):**
- `ResearchStore` owns persistence and lifecycle writes (status transitions, lifecycle event log rows, sources/results snapshots).
- `ResearchStepRunner` owns provider I/O concerns only (provider selection, timeout/abort/provider-error classification, synthesis call execution); it does not read/write run state.
- `ResearchOrchestrator` owns sequencing and failure policy (phase progression, provider fallback, partial-step continuation, terminal status choice) and interacts with store only through public store methods.
- Provider substitution must remain data-driven: source metadata can carry provider identity, and fetching should resolve providers per source rather than relying on provider ordering.
- **Boundary note:** research and insights are parallel subsystems sharing host infrastructure, not one table/store family.
### Plugin System ### Plugin System

View File

@@ -147,7 +147,19 @@ Key endpoints:
4. **Export surface asymmetry** 4. **Export surface asymmetry**
- Route export endpoint advertises markdown/json/html behavior while core export type includes `pdf`; CLI command accepts `pdf` format but markdown renderer fallback behavior should remain explicitly documented/validated. - Route export endpoint advertises markdown/json/html behavior while core export type includes `pdf`; CLI command accepts `pdf` format but markdown renderer fallback behavior should remain explicitly documented/validated.
## 9) Validation references used for this baseline ## 9) FN-3292 boundary stress-test confirmations
- Provider ordering assumptions were tightened: content fetch selection can now use source-level provider metadata (`providerType`) and falls back only when that provider is unavailable.
- Orchestrator/provider seam was validated with behavior-first tests:
- fallback from failed primary provider to a later provider,
- partial fetch failure with successful completion when at least one source is fetched,
- real `ResearchStore` persistence verification at orchestrator level (sources/events/results/lifecycle events persisted end-to-end).
- Boundary guidance for future work:
- keep retryability/lifecycle ownership in `ResearchStore` transitions,
- keep error classification in `ResearchStepRunner`,
- keep sequencing and policy decisions in `ResearchOrchestrator`.
## 10) Validation references used for this baseline
- `packages/dashboard/src/__tests__/research-routes.test.ts` - `packages/dashboard/src/__tests__/research-routes.test.ts`
- `packages/core/src/__tests__/research-store.test.ts` - `packages/core/src/__tests__/research-store.test.ts`

View File

@@ -4,6 +4,15 @@ _Date: 2026-04-08_
## 1) Executive Summary ## 1) Executive Summary
### FN-3293 stabilization update (2026-05-04)
- Replaced ad-hoc frame sleeps in `packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx` with deterministic `vi.waitFor`-based frame assertions and microtask flush helpers.
- Updated settings remote-action handling so `C/V/X/P/L/U/K/R` shortcuts are exercised deterministically in tests without timing races on pane focus transitions.
- Removed the blanket `{ timeout: 90_000 }` suite override in `packages/droid-cli/src/__tests__/provider.test.ts`; lifecycle coverage now relies on fake timers and event-driven completion.
- Tightened dashboard GitHub route tests (`packages/dashboard/src/__tests__/routes-github.test.ts`) by reducing synthetic retry delays and removing explicit long per-test timeouts.
- Confirmed `packages/core/src/__tests__/memory-backend.test.ts` remains fast while still asserting `installQmd()`/`ensureQmdInstalled()` forward the intended `timeout: 120_000` to `execFileAsync`.
- Restored deterministic CLI bundle-output verification by resolving the droid-runtime probe export path to source entries in workspace tests, eliminating build-order flake from missing `dist/probe.js`.
**Overall test health: _Good (with targeted high-risk gaps)_** **Overall test health: _Good (with targeted high-risk gaps)_**
- Total executed tests across audited packages (`core`, `engine`, `cli`, `dashboard`): **8,188 passing** - Total executed tests across audited packages (`core`, `engine`, `cli`, `dashboard`): **8,188 passing**
@@ -153,10 +162,10 @@ Interpretation:
Notable timer/retry/race hotspots: Notable timer/retry/race hotspots:
- **Known flaky path (confirmed):** `packages/dashboard/src/routes.test.ts:3992-4016` (429 retry path with explicit 30s timeout).
- `packages/engine/src/stuck-task-detector.test.ts` (heavy timer simulation) - `packages/engine/src/stuck-task-detector.test.ts` (heavy timer simulation)
- `packages/engine/src/agent-heartbeat.test.ts` (timer-heavy heartbeat behavior) - `packages/engine/src/agent-heartbeat.test.ts` (timer-heavy heartbeat behavior)
- `packages/cli/src/commands/dashboard.test.ts` (many timeout-driven behavioral checks) - `packages/cli/src/commands/dashboard.test.ts` (many timeout-driven behavioral checks)
- **Resolved in FN-3293:** `packages/dashboard/src/__tests__/routes-github.test.ts` batch-import 429/diff-path coverage now runs with deterministic mocked throttling and no explicit per-test 10s/30s timeout overrides.
Observed during test runs: Observed during test runs:
- recurring `MaxListenersExceededWarning` in CLI dashboard test runs - recurring `MaxListenersExceededWarning` in CLI dashboard test runs
@@ -220,7 +229,7 @@ One prelisted item is no longer untested:
From `.fusion/memory/MEMORY.md` testing pitfalls: From `.fusion/memory/MEMORY.md` testing pitfalls:
- **Engine pool setting must remain threads** — config currently reflects this (`packages/engine/vitest.config.ts:10`), but there is no dedicated regression test guarding accidental config drift. - **Engine pool setting must remain threads** — config currently reflects this (`packages/engine/vitest.config.ts:10`), but there is no dedicated regression test guarding accidental config drift.
- **Dashboard 429 retry test requires 30s timeout** — still covered (`packages/dashboard/src/routes.test.ts:3992-4016`). - **FN-3293 update:** dashboard GitHub batch-import retry/diff-path tests no longer rely on explicit long timeout gates; deterministic mocked throttling + reduced delay parameters keep default-lane coverage fast.
- **Build-before-test/workspace hydration pitfalls** — operationally reproducible; not represented by explicit dedicated regression tests as standalone safeguards. - **Build-before-test/workspace hydration pitfalls** — operationally reproducible; not represented by explicit dedicated regression tests as standalone safeguards.
--- ---

View File

@@ -44,9 +44,9 @@ await import("@runfusion/fusion/dist/bin.js");
// stderr isn't a TTY, or when FUSION_NO_UPDATE_CHECK=1 is set. // stderr isn't a TTY, or when FUSION_NO_UPDATE_CHECK=1 is set.
function maybeAnnounceUpdateAndRefresh() { function maybeAnnounceUpdateAndRefresh() {
try { try {
if (process.env.FUSION_NO_UPDATE_CHECK === "1") return; if (globalThis.process.env.FUSION_NO_UPDATE_CHECK === "1") return;
if (process.env.CI) return; if (globalThis.process.env.CI) return;
if (!process.stderr.isTTY) return; if (!globalThis.process.stderr.isTTY) return;
const fusionDir = resolveFusionDir(); const fusionDir = resolveFusionDir();
const cachePath = join(fusionDir, "update-check.json"); const cachePath = join(fusionDir, "update-check.json");
@@ -65,7 +65,7 @@ function maybeAnnounceUpdateAndRefresh() {
) { ) {
const yellow = (s) => `\x1b[33m${s}\x1b[0m`; const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
const dim = (s) => `\x1b[2m${s}\x1b[0m`; const dim = (s) => `\x1b[2m${s}\x1b[0m`;
process.stderr.write( globalThis.process.stderr.write(
yellow( yellow(
`\nFusion ${cache.latestVersion} is available (you have ${currentVersion}).\n`, `\nFusion ${cache.latestVersion} is available (you have ${currentVersion}).\n`,
) + ) +
@@ -88,7 +88,7 @@ function maybeAnnounceUpdateAndRefresh() {
} }
function resolveFusionDir() { function resolveFusionDir() {
const home = process.env.HOME || process.env.USERPROFILE || homedir(); const home = globalThis.process.env.HOME || globalThis.process.env.USERPROFILE || homedir();
const preferred = join(home, ".fusion"); const preferred = join(home, ".fusion");
if (existsSync(preferred)) return preferred; if (existsSync(preferred)) return preferred;
const legacy = join(home, ".pi", "fusion"); const legacy = join(home, ".pi", "fusion");
@@ -108,10 +108,10 @@ function readBundledFusionVersion() {
} }
async function backgroundRefresh(fusionDir, cachePath, currentVersion) { async function backgroundRefresh(fusionDir, cachePath, currentVersion) {
const controller = new AbortController(); const controller = new globalThis.AbortController();
const timeout = setTimeout(() => controller.abort(), 1500); const timeout = globalThis.setTimeout(() => controller.abort(), 1500);
try { try {
const response = await fetch("https://registry.npmjs.org/@runfusion%2Ffusion", { const response = await globalThis.fetch("https://registry.npmjs.org/@runfusion%2Ffusion", {
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok) return; if (!response.ok) return;
@@ -131,7 +131,7 @@ async function backgroundRefresh(fusionDir, cachePath, currentVersion) {
writeFileSync(cachePath, JSON.stringify(result, null, 2), "utf-8"); writeFileSync(cachePath, JSON.stringify(result, null, 2), "utf-8");
} catch { /* best-effort */ } } catch { /* best-effort */ }
} finally { } finally {
clearTimeout(timeout); globalThis.clearTimeout(timeout);
} }
} }

View File

@@ -155,12 +155,19 @@ afterEach(() => {
}); });
async function waitForFrameContains(lastFrame: () => string | undefined, text: string, timeoutMs = 3000) { async function waitForFrameContains(lastFrame: () => string | undefined, text: string, timeoutMs = 3000) {
const start = Date.now(); await vi.waitFor(() => {
while (Date.now() - start < timeoutMs) { expect(lastFrame() ?? "").toContain(text);
if ((lastFrame() ?? "").includes(text)) return; }, { timeout: timeoutMs });
await new Promise((r) => setTimeout(r, 20)); }
}
throw new Error(`Timed out waiting for frame to include: ${text}`); 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 } { function findTokenPosition(frame: string, token: string): { row: number; col: number } {
@@ -218,7 +225,7 @@ describe("DashboardApp smoke", () => {
controller.setMode("interactive"); controller.setMode("interactive");
controller.setInteractiveView("board"); controller.setInteractiveView("board");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30)); await waitForFrameContains(lastFrame, "alpha");
const frame = lastFrame() ?? ""; const frame = lastFrame() ?? "";
// Board shows the currently selected project; first project "alpha" is selected by default // Board shows the currently selected project; first project "alpha" is selected by default
expect(frame).toContain("alpha"); expect(frame).toContain("alpha");
@@ -294,7 +301,7 @@ describe("Agents view", () => {
controller.setMode("interactive"); controller.setMode("interactive");
controller.setInteractiveView("agents"); controller.setInteractiveView("agents");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30)); await waitForFrameContains(lastFrame, "worker-1");
const frame = lastFrame() ?? ""; const frame = lastFrame() ?? "";
expect(frame).toContain("worker-1"); expect(frame).toContain("worker-1");
expect(frame).toContain("worker-2"); expect(frame).toContain("worker-2");
@@ -309,7 +316,7 @@ describe("Agents view", () => {
controller.setMode("interactive"); controller.setMode("interactive");
controller.setInteractiveView("agents"); controller.setInteractiveView("agents");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30)); await flushFrames();
expect(lastFrame() ?? "").toContain("Agent Detail"); expect(lastFrame() ?? "").toContain("Agent Detail");
unmount(); unmount();
}); });
@@ -375,7 +382,7 @@ describe("Settings view", () => {
controller.setMode("interactive"); controller.setMode("interactive");
controller.setInteractiveView("settings"); controller.setInteractiveView("settings");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30)); await waitForFrameContains(lastFrame, "Max Concurrent");
const frame = lastFrame() ?? ""; const frame = lastFrame() ?? "";
expect(frame).toContain("Settings"); expect(frame).toContain("Settings");
expect(frame).toContain("Max Concurrent"); expect(frame).toContain("Max Concurrent");
@@ -393,7 +400,7 @@ describe("Settings view", () => {
controller.setMode("interactive"); controller.setMode("interactive");
controller.setInteractiveView("settings"); controller.setInteractiveView("settings");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30)); await waitForFrameContains(lastFrame, "Available Models");
const frame = lastFrame() ?? ""; const frame = lastFrame() ?? "";
expect(frame).toContain("Available Models"); expect(frame).toContain("Available Models");
expect(frame).toContain("Claude 3.5 Sonnet"); expect(frame).toContain("Claude 3.5 Sonnet");
@@ -435,14 +442,12 @@ describe("Settings view", () => {
controller.setInteractiveView("settings"); controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "Remote"); await waitForFrameContains(lastFrame, "Provider: cloudflare");
expect(lastFrame() ?? "").toContain("cloudflare"); expect(lastFrame() ?? "").toContain("cloudflare");
stdin.write("\u001B[C"); await focusSettingsDetailPane(stdin, lastFrame);
await new Promise((r) => setTimeout(r, 20));
stdin.write("C"); stdin.write("C");
await new Promise((r) => setTimeout(r, 20)); await vi.waitFor(() => expect(activateProvider).toHaveBeenCalledWith("cloudflare"));
expect(activateProvider).toHaveBeenCalledWith("cloudflare");
stdin.write("V"); stdin.write("V");
await waitForFrameContains(lastFrame, "Remote tunnel starting"); await waitForFrameContains(lastFrame, "Remote tunnel starting");
@@ -471,8 +476,7 @@ describe("Settings view", () => {
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "──── Remote ────"); await waitForFrameContains(lastFrame, "──── Remote ────");
stdin.write("\u001B[C"); await focusSettingsDetailPane(stdin, lastFrame);
await new Promise((r) => setTimeout(r, 20));
stdin.write("L"); stdin.write("L");
await waitForFrameContains(lastFrame, "TTL ms:"); await waitForFrameContains(lastFrame, "TTL ms:");
stdin.write("\r"); stdin.write("\r");
@@ -507,8 +511,8 @@ describe("Settings view", () => {
controller.setInteractiveView("settings"); controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\u001B[C"); await waitForFrameContains(lastFrame, "──── Remote ────");
await new Promise((r) => setTimeout(r, 20)); await focusSettingsDetailPane(stdin, lastFrame);
stdin.write("P"); stdin.write("P");
await waitForFrameContains(lastFrame, "Persistent token: tok_****"); await waitForFrameContains(lastFrame, "Persistent token: tok_****");
expect(regeneratePersistentToken).toHaveBeenCalledTimes(1); expect(regeneratePersistentToken).toHaveBeenCalledTimes(1);
@@ -530,12 +534,11 @@ describe("Settings view", () => {
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
await waitForFrameContains(lastFrame, "──── Remote ────"); await waitForFrameContains(lastFrame, "──── Remote ────");
stdin.write("\u001B[C"); await focusSettingsDetailPane(stdin, lastFrame);
await new Promise((r) => setTimeout(r, 20));
stdin.write("L"); stdin.write("L");
await waitForFrameContains(lastFrame, "TTL ms:"); await waitForFrameContains(lastFrame, "TTL ms:");
stdin.write("a"); stdin.write("a");
await new Promise((r) => setTimeout(r, 20)); await flushFrames();
expect(controller.getSnapshot().interactiveView).toBe("settings"); expect(controller.getSnapshot().interactiveView).toBe("settings");
unmount(); unmount();
}); });
@@ -557,8 +560,8 @@ describe("Settings view", () => {
controller.setInteractiveView("settings"); controller.setInteractiveView("settings");
const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, stdin, unmount } = render(renderDashboardAppNode(controller));
stdin.write("\u001B[C"); await waitForFrameContains(lastFrame, "──── Remote ────");
await new Promise((r) => setTimeout(r, 20)); await focusSettingsDetailPane(stdin, lastFrame);
stdin.write("K"); stdin.write("K");
await waitForFrameContains(lastFrame, "▀▀▀ASCII-QR▀▀▀", 6000); await waitForFrameContains(lastFrame, "▀▀▀ASCII-QR▀▀▀", 6000);
unmount(); unmount();
@@ -580,7 +583,7 @@ describe("Board view", () => {
controller.setMode("interactive"); controller.setMode("interactive");
controller.setInteractiveView("board"); controller.setInteractiveView("board");
const { lastFrame, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 30)); await waitForFrameContains(lastFrame, "TODO");
const frame = lastFrame() ?? ""; const frame = lastFrame() ?? "";
expect(frame).toContain("TODO"); expect(frame).toContain("TODO");
expect(frame).toContain("IN PROGRESS"); expect(frame).toContain("IN PROGRESS");
@@ -599,7 +602,7 @@ describe("LogsPanel indicator", () => {
// Select index 1 (middle entry) // Select index 1 (middle entry)
controller.setSelectedLogIndex(1); controller.setSelectedLogIndex(1);
const { lastFrame, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 10)); await flushFrames();
const frame = lastFrame() ?? ""; const frame = lastFrame() ?? "";
expect(frame).toContain("▶"); expect(frame).toContain("▶");
unmount(); unmount();
@@ -612,7 +615,7 @@ describe("LogsPanel indicator", () => {
controller.log("only message", "test"); controller.log("only message", "test");
controller.setSelectedLogIndex(0); controller.setSelectedLogIndex(0);
const { lastFrame, unmount } = render(renderDashboardAppNode(controller)); const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 10)); await flushFrames();
const frame = lastFrame() ?? ""; const frame = lastFrame() ?? "";
// The selected entry shows the arrow; it should appear at least once // The selected entry shows the arrow; it should appear at least once
expect(frame).toContain("▶"); expect(frame).toContain("▶");
@@ -634,7 +637,7 @@ describe("StatusModeGrid layout stability", () => {
controller.log("small", "worker"); controller.log("small", "worker");
controller.log("ok", "db"); controller.log("ok", "db");
rendered.rerender(renderDashboardAppNode(controller)); rendered.rerender(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 10)); await flushFrames();
const shortFrame = rendered.lastFrame() ?? ""; const shortFrame = rendered.lastFrame() ?? "";
const shortSystem = findTokenPosition(shortFrame, "System"); const shortSystem = findTokenPosition(shortFrame, "System");
@@ -654,7 +657,7 @@ describe("StatusModeGrid layout stability", () => {
"super-verbose-component-prefix", "super-verbose-component-prefix",
); );
rendered.rerender(renderDashboardAppNode(controller)); rendered.rerender(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 10)); await flushFrames();
const longFrame = rendered.lastFrame() ?? ""; const longFrame = rendered.lastFrame() ?? "";
const longSystem = findTokenPosition(longFrame, "System"); const longSystem = findTokenPosition(longFrame, "System");
@@ -693,7 +696,7 @@ describe("StatsPanel memory row", () => {
const rendered = render(renderDashboardAppNode(controller)); const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, 120, 24); setTerminalSize(rendered, 120, 24);
rendered.rerender(renderDashboardAppNode(controller)); rendered.rerender(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 10)); await flushFrames();
const frame = rendered.lastFrame() ?? ""; const frame = rendered.lastFrame() ?? "";
const pctIndex = frame.indexOf("75.0%"); const pctIndex = frame.indexOf("75.0%");
@@ -718,7 +721,7 @@ describe("LogsPanel narrow formatting", () => {
const rendered = render(renderDashboardAppNode(controller)); const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, 60, 24); setTerminalSize(rendered, 60, 24);
rendered.rerender(renderDashboardAppNode(controller)); rendered.rerender(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 10)); await flushFrames();
const frame = rendered.lastFrame() ?? ""; const frame = rendered.lastFrame() ?? "";
expect(frame).toContain("narrow entry"); expect(frame).toContain("narrow entry");
@@ -739,7 +742,7 @@ describe("LogsPanel narrow formatting", () => {
const narrowRender = render(renderDashboardAppNode(narrowController)); const narrowRender = render(renderDashboardAppNode(narrowController));
setTerminalSize(narrowRender, 60, 24); setTerminalSize(narrowRender, 60, 24);
narrowRender.rerender(renderDashboardAppNode(narrowController)); narrowRender.rerender(renderDashboardAppNode(narrowController));
await new Promise((r) => setTimeout(r, 10)); await flushFrames();
const narrowFrame = narrowRender.lastFrame() ?? ""; const narrowFrame = narrowRender.lastFrame() ?? "";
expect(narrowFrame).toContain("[very-…]"); expect(narrowFrame).toContain("[very-…]");
narrowRender.unmount(); narrowRender.unmount();
@@ -753,7 +756,7 @@ describe("LogsPanel narrow formatting", () => {
const wideRender = render(renderDashboardAppNode(wideController)); const wideRender = render(renderDashboardAppNode(wideController));
setTerminalSize(wideRender, 120, 24); setTerminalSize(wideRender, 120, 24);
wideRender.rerender(renderDashboardAppNode(wideController)); wideRender.rerender(renderDashboardAppNode(wideController));
await new Promise((r) => setTimeout(r, 10)); await flushFrames();
const wideFrame = wideRender.lastFrame() ?? ""; const wideFrame = wideRender.lastFrame() ?? "";
expect(wideFrame).toContain("[very-long-sco"); expect(wideFrame).toContain("[very-long-sco");
expect(wideFrame).not.toContain("[very-…]"); expect(wideFrame).not.toContain("[very-…]");
@@ -770,7 +773,7 @@ describe("LogsPanel narrow formatting", () => {
const rendered = render(renderDashboardAppNode(controller)); const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, 120, 24); setTerminalSize(rendered, 120, 24);
rendered.rerender(renderDashboardAppNode(controller)); rendered.rerender(renderDashboardAppNode(controller));
await new Promise((r) => setTimeout(r, 10)); await flushFrames();
const frame = rendered.lastFrame() ?? ""; const frame = rendered.lastFrame() ?? "";
expect(frame).toContain("wide timestamp"); expect(frame).toContain("wide timestamp");

View File

@@ -2375,19 +2375,7 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
return; return;
} }
if (!detailFocused) { const inputUpper = input.toUpperCase();
if (key.upArrow || input === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
return;
}
if (key.downArrow || input === "j") {
setSelectedIndex((i) => Math.min(SETTING_DEFS.length - 1, i + 1));
return;
}
return;
}
if (!selectedDef || !localSettings) return;
if (ttlInputMode) { if (ttlInputMode) {
if (key.escape) { if (key.escape) {
@@ -2397,14 +2385,14 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
return; return;
} }
const inputUpper = input.toUpperCase();
if (inputUpper === "R") { if (inputUpper === "R") {
void refreshRemoteStatus(); void refreshRemoteStatus();
setStatusMsg("Remote status refreshed"); setStatusMsg("Remote status refreshed");
return; return;
} }
if (!localSettings) return;
if (data?.remote && inputUpper === "C") { if (data?.remote && inputUpper === "C") {
const provider = localSettings.remoteActiveProvider; const provider = localSettings.remoteActiveProvider;
if (!provider) { if (!provider) {
@@ -2461,6 +2449,20 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
return; return;
} }
if (!detailFocused) {
if (key.upArrow || input === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
return;
}
if (key.downArrow || input === "j") {
setSelectedIndex((i) => Math.min(SETTING_DEFS.length - 1, i + 1));
return;
}
return;
}
if (!selectedDef) return;
if (selectedDef.type === "boolean" && input === " ") { if (selectedDef.type === "boolean" && input === " ") {
const current = localSettings[selectedDef.key] as boolean; const current = localSettings[selectedDef.key] as boolean;
const updated = { ...localSettings, [selectedDef.key]: !current }; const updated = { ...localSettings, [selectedDef.key]: !current };

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "../db.js"; import { Database, createDatabase, toJson, toJsonNullable, fromJson, normalizeTaskComments } from "../db.js";
import { DEFAULT_PROJECT_SETTINGS } from "../types.js"; import { DEFAULT_PROJECT_SETTINGS } from "../types.js";
import { TaskStore } from "../store.js";
import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs"; import { mkdtempSync, existsSync, readFileSync, rmSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -109,6 +110,8 @@ describe("Database", () => {
expect(tableNames).toContain("roadmaps"); expect(tableNames).toContain("roadmaps");
expect(tableNames).toContain("roadmap_milestones"); expect(tableNames).toContain("roadmap_milestones");
expect(tableNames).toContain("roadmap_features"); expect(tableNames).toContain("roadmap_features");
// Verification cache (migration 61)
expect(tableNames).toContain("verification_cache");
}); });
it("creates all expected indexes", () => { it("creates all expected indexes", () => {
@@ -152,10 +155,12 @@ describe("Database", () => {
// Roadmap indexes // Roadmap indexes
expect(indexNames).toContain("idxRoadmapMilestonesRoadmapOrder"); expect(indexNames).toContain("idxRoadmapMilestonesRoadmapOrder");
expect(indexNames).toContain("idxRoadmapFeaturesMilestoneOrder"); expect(indexNames).toContain("idxRoadmapFeaturesMilestoneOrder");
// Verification cache index (migration 61)
expect(indexNames).toContain("idxVerificationCacheRecordedAt");
}); });
it("seeds schema version", () => { it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
it("seeds lastModified", () => { it("seeds lastModified", () => {
@@ -178,7 +183,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => { it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow(); expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
it("does not overwrite existing config on re-init", () => { it("does not overwrite existing config on re-init", () => {
@@ -952,7 +957,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29) // Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -977,11 +982,11 @@ describe("schema migrations", () => {
const db = new Database(fusionDir); const db = new Database(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
// Re-init should not fail // Re-init should not fail
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
db.close(); db.close();
}); });
@@ -1016,7 +1021,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("priority"); expect(cols.map((col) => col.name)).toContain("priority");
@@ -1057,7 +1062,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -1126,7 +1131,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
const colNames = cols.map((col) => col.name); const colNames = cols.map((col) => col.name);
@@ -1229,7 +1234,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
expect(cols.map((col) => col.name)).toContain("attachments"); expect(cols.map((col) => col.name)).toContain("attachments");
@@ -1303,7 +1308,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]); expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -1327,7 +1332,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]); expect(tables).toEqual([{ name: "mission_events" }]);
@@ -1431,7 +1436,7 @@ describe("schema migrations", () => {
db.init(); db.init();
// Verify version bumped to 29 // Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1900,7 +1905,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(fusionDir); const db = createDatabase(fusionDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
expect(db.getLastModified()).toBeGreaterThan(0); expect(db.getLastModified()).toBeGreaterThan(0);
db.close(); db.close();
@@ -1934,3 +1939,82 @@ describe("createDatabase factory", () => {
db2.close(); db2.close();
}); });
}); });
// ── TaskStore — verification cache methods ────────────────────────────────
describe("TaskStore — verification cache", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "kb-vc-test-"));
globalDir = mkdtempSync(join(tmpdir(), "kb-vc-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
it("returns null when no cache entry exists", () => {
const hit = store.getVerificationCacheHit("abc1234", "pnpm test", "pnpm build");
expect(hit).toBeNull();
});
it("records a pass and retrieves it as a cache hit", () => {
const treeSha = "deadbeef1234567890";
store.recordVerificationCachePass(treeSha, "pnpm test", "pnpm build", "FN-001");
const hit = store.getVerificationCacheHit(treeSha, "pnpm test", "pnpm build");
expect(hit).not.toBeNull();
expect(hit!.taskId).toBe("FN-001");
expect(new Date(hit!.recordedAt).toISOString()).toBe(hit!.recordedAt);
});
it("returns null for a different tree sha", () => {
store.recordVerificationCachePass("sha-a", "pnpm test", "", "FN-001");
const hit = store.getVerificationCacheHit("sha-b", "pnpm test", "");
expect(hit).toBeNull();
});
it("distinguishes entries by testCommand", () => {
const treeSha = "aabbccdd";
store.recordVerificationCachePass(treeSha, "pnpm test", "", "FN-001");
expect(store.getVerificationCacheHit(treeSha, "pnpm test", "")).not.toBeNull();
expect(store.getVerificationCacheHit(treeSha, "vitest run", "")).toBeNull();
});
it("distinguishes entries by buildCommand", () => {
const treeSha = "11223344";
store.recordVerificationCachePass(treeSha, "", "pnpm build", "FN-002");
expect(store.getVerificationCacheHit(treeSha, "", "pnpm build")).not.toBeNull();
expect(store.getVerificationCacheHit(treeSha, "", "tsc --noEmit")).toBeNull();
});
it("normalizes undefined to empty string for stable primary key", () => {
const treeSha = "normtest";
// Pass undefined-ish values (coerced via nullish fallback in impl)
store.recordVerificationCachePass(treeSha, "", "", "FN-003");
const hit = store.getVerificationCacheHit(treeSha, "", "");
expect(hit).not.toBeNull();
expect(hit!.taskId).toBe("FN-003");
});
it("overwrites an existing entry on re-record (INSERT OR REPLACE)", () => {
const treeSha = "upserttest";
store.recordVerificationCachePass(treeSha, "pnpm test", "", "FN-010");
store.recordVerificationCachePass(treeSha, "pnpm test", "", "FN-020");
const hit = store.getVerificationCacheHit(treeSha, "pnpm test", "");
expect(hit).not.toBeNull();
expect(hit!.taskId).toBe("FN-020");
});
});

View File

@@ -869,7 +869,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33) // Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir); const db1 = createDatabase(legacyDir);
db1.init(); db1.init();
expect(db1.getSchemaVersion()).toBe(60); expect(db1.getSchemaVersion()).toBe(61);
db1.close(); db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables // Step 2: Manually downgrade to version 32 and drop insight tables
@@ -904,7 +904,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs"); expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration // Now run init — this triggers the v32→v33 migration
db3.init(); db3.init();
expect(db3.getSchemaVersion()).toBe(60); expect(db3.getSchemaVersion()).toBe(61);
// Step 4: Verify insight tables exist after migration // Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare( const tablesAfter = db3.prepare(
@@ -935,12 +935,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try { try {
const db1 = createDatabase(testDir); const db1 = createDatabase(testDir);
db1.init(); db1.init();
expect(db1.getSchemaVersion()).toBe(60); expect(db1.getSchemaVersion()).toBe(61);
db1.close(); db1.close();
const db2 = createDatabase(testDir); const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow(); expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(60); expect(db2.getSchemaVersion()).toBe(61);
db2.close(); db2.close();
} finally { } finally {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });

View File

@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => { describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 40 after migration", () => { it("schema version is 40 after migration", () => {
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
it("mission_features table has loop state columns", () => { it("mission_features table has loop state columns", () => {

View File

@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => { describe("schema version", () => {
it("schema version is 40 after init", () => { it("schema version is 40 after init", () => {
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
}); });

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
}); });
it("schema version is bumped to 40", () => { it("schema version is bumped to 40", () => {
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
}); });
}); });
}); });

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(60); expect(db.getSchemaVersion()).toBe(61);
const index = db const index = db
.prepare( .prepare(

View File

@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
// ── Schema Definition ──────────────────────────────────────────────── // ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 60; const SCHEMA_VERSION = 61;
function normalizeTaskComments( function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined, steeringComments: SteeringComment[] | undefined,
@@ -2397,6 +2397,22 @@ export class Database {
}); });
} }
if (version < 61) {
this.applyMigration(61, () => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS verification_cache (
treeSha TEXT NOT NULL,
testCommand TEXT NOT NULL DEFAULT '',
buildCommand TEXT NOT NULL DEFAULT '',
recordedAt TEXT NOT NULL,
taskId TEXT,
PRIMARY KEY (treeSha, testCommand, buildCommand)
)
`);
this.db.exec(`CREATE INDEX IF NOT EXISTS idxVerificationCacheRecordedAt ON verification_cache(recordedAt)`);
});
}
} }
/** /**

View File

@@ -6534,6 +6534,60 @@ ${notificationsSection}`;
return this.todoStore; return this.todoStore;
} }
// ── Verification Cache ────────────────────────────────────────────────────
/**
* Look up a previously recorded verification cache pass for a given tree sha
* and command pair. Returns null when no cached pass exists.
*
* @param treeSha - The git tree SHA of the merged commit.
* @param testCommand - The test command string (normalized to empty string when absent).
* @param buildCommand - The build command string (normalized to empty string when absent).
*/
getVerificationCacheHit(
treeSha: string,
testCommand: string,
buildCommand: string,
): { recordedAt: string; taskId: string | null } | null {
const normalizedTest = testCommand ?? "";
const normalizedBuild = buildCommand ?? "";
const row = this.db
.prepare(
`SELECT recordedAt, taskId FROM verification_cache
WHERE treeSha = ? AND testCommand = ? AND buildCommand = ?`,
)
.get(treeSha, normalizedTest, normalizedBuild) as
| { recordedAt: string; taskId: string | null }
| undefined;
return row ?? null;
}
/**
* Record a successful verification pass for the given tree sha and commands.
* Uses INSERT OR REPLACE so a re-run of the same tree updates the timestamp.
*
* @param treeSha - The git tree SHA of the merged commit.
* @param testCommand - The test command string (normalized to empty string when absent).
* @param buildCommand - The build command string (normalized to empty string when absent).
* @param taskId - The task ID that triggered the pass (for telemetry).
*/
recordVerificationCachePass(
treeSha: string,
testCommand: string,
buildCommand: string,
taskId: string,
): void {
const normalizedTest = testCommand ?? "";
const normalizedBuild = buildCommand ?? "";
const recordedAt = new Date().toISOString();
this.db
.prepare(
`INSERT OR REPLACE INTO verification_cache (treeSha, testCommand, buildCommand, recordedAt, taskId)
VALUES (?, ?, ?, ?, ?)`,
)
.run(treeSha, normalizedTest, normalizedBuild, recordedAt, taskId);
}
// ── Backward Compatibility (Multi-Project Support) ──────────────────────── // ── Backward Compatibility (Multi-Project Support) ────────────────────────
} }

View File

@@ -96,6 +96,14 @@ describe("Agent CSS classes", () => {
expect(hasClass(".agent-system-filter")).toBe(true); expect(hasClass(".agent-system-filter")).toBe(true);
expect(hasClass(".agent-controls-actions")).toBe(true); expect(hasClass(".agent-controls-actions")).toBe(true);
expect(hasClass(".agent-global-controls")).toBe(true); expect(hasClass(".agent-global-controls")).toBe(true);
expect(hasClass(".agent-org-chart-shell")).toBe(true);
expect(hasClass(".agent-org-chart-controls")).toBe(true);
expect(hasClass(".agent-org-chart-viewport")).toBe(true);
expect(hasClass(".agent-org-chart-canvas")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-75")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-100")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-125")).toBe(true);
expect(hasClass(".agent-org-chart-canvas--zoom-150")).toBe(true);
expect(hasClass(".agent-board")).toBe(true); expect(hasClass(".agent-board")).toBe(true);
expect(hasClass(".agent-board-card")).toBe(true); expect(hasClass(".agent-board-card")).toBe(true);
expect(hasClass(".agent-board-card--idle")).toBe(true); expect(hasClass(".agent-board-card--idle")).toBe(true);
@@ -177,6 +185,8 @@ describe("Agent CSS classes", () => {
expect(orgChartSection).toContain("padding: var(--space-lg)"); expect(orgChartSection).toContain("padding: var(--space-lg)");
expect(orgChartSection).toContain("--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs))"); expect(orgChartSection).toContain("--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs))");
expect(orgChartSection).toContain("min-height: var(--org-chart-node-width)"); expect(orgChartSection).toContain("min-height: var(--org-chart-node-width)");
expect(orgChartSection).toContain("touch-action: pan-x pan-y");
expect(orgChartSection).toContain("transform-origin: top left");
expect(orgChartSection).toContain("border: 1px solid var(--border)"); expect(orgChartSection).toContain("border: 1px solid var(--border)");
expect(orgChartSection).toContain("color: var(--text)"); expect(orgChartSection).toContain("color: var(--text)");
expect(orgChartSection).toContain("color: var(--text-muted)"); expect(orgChartSection).toContain("color: var(--text-muted)");

View File

@@ -790,6 +790,57 @@
} }
/* === FN-1167: Agent Org Chart + Chain of Command === */ /* === FN-1167: Agent Org Chart + Chain of Command === */
.agent-org-chart-shell {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.agent-org-chart-controls {
display: none;
}
.agent-org-chart-controls__zoom-label {
min-width: calc(var(--space-2xl) * 2);
font-family: var(--font-mono);
text-align: center;
color: var(--text-muted);
}
.agent-org-chart-controls__fit-btn {
margin-left: auto;
}
.agent-org-chart-viewport {
overflow: auto;
max-width: 100%;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-x pan-y;
}
.agent-org-chart-canvas {
width: max-content;
transform-origin: top left;
transition: transform var(--transition-fast);
}
.agent-org-chart-canvas--zoom-75 {
transform: scale(0.75);
}
.agent-org-chart-canvas--zoom-100 {
transform: scale(1);
}
.agent-org-chart-canvas--zoom-125 {
transform: scale(1.25);
}
.agent-org-chart-canvas--zoom-150 {
transform: scale(1.5);
}
.agent-org-chart { .agent-org-chart {
--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs)); --org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs));
--org-chart-connector-gap: var(--space-xs); --org-chart-connector-gap: var(--space-xs);
@@ -798,8 +849,6 @@
justify-content: flex-start; justify-content: flex-start;
gap: var(--space-xl); gap: var(--space-xl);
padding: var(--space-lg); padding: var(--space-lg);
overflow-x: auto;
overflow-y: visible;
min-height: var(--org-chart-node-width); min-height: var(--org-chart-node-width);
} }
@@ -1007,6 +1056,25 @@
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5); font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
} }
.agent-org-chart-shell {
min-height: 0;
}
.agent-org-chart-controls {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.agent-org-chart-controls .btn-icon,
.agent-org-chart-controls .agent-org-chart-controls__fit-btn {
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
}
.agent-org-chart-viewport {
min-height: calc(var(--space-2xl) * 4);
}
.agent-org-chart { .agent-org-chart {
--org-chart-node-width: calc(var(--space-2xl) * 5); --org-chart-node-width: calc(var(--space-2xl) * 5);
padding: var(--space-sm); padding: var(--space-sm);

View File

@@ -1,6 +1,6 @@
import "./AgentsView.css"; import "./AgentsView.css";
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react"; import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react";
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, ChevronUp, Filter, Upload, Network, SlidersHorizontal, Copy, Check } from "lucide-react"; import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, ChevronUp, Filter, Upload, Network, SlidersHorizontal, Copy, Check, ZoomIn, ZoomOut, Minimize2 } from "lucide-react";
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api"; import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api"; import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
@@ -47,6 +47,7 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const; const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
const SKILL_PATH_LABEL_PATTERN = /(?:^|\/)skills\/([^/]+)\/SKILL\.md$/i; const SKILL_PATH_LABEL_PATTERN = /(?:^|\/)skills\/([^/]+)\/SKILL\.md$/i;
const ORG_CHART_ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
export function formatAgentSkillBadgeLabel(skillId: string): string { export function formatAgentSkillBadgeLabel(skillId: string): string {
const trimmedSkillId = skillId.trim(); const trimmedSkillId = skillId.trim();
@@ -267,6 +268,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false); const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false); const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
const [isOverviewOpen, setIsOverviewOpen] = useState(false); const [isOverviewOpen, setIsOverviewOpen] = useState(false);
const [orgChartZoomIndex, setOrgChartZoomIndex] = useState(1);
const controlsPanelRef = useRef<HTMLDivElement>(null); const controlsPanelRef = useRef<HTMLDivElement>(null);
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const controlsTriggerRef = useRef<HTMLButtonElement>(null); const controlsTriggerRef = useRef<HTMLButtonElement>(null);
@@ -706,6 +708,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const handleAgentViewChange = useCallback((nextView: "list" | "board" | "org") => { const handleAgentViewChange = useCallback((nextView: "list" | "board" | "org") => {
setAgentView(nextView); setAgentView(nextView);
if (nextView !== "org") {
setOrgChartZoomIndex(1);
}
if (isMobileViewport && selectedAgentId) { if (isMobileViewport && selectedAgentId) {
handleCloseDetail(); handleCloseDetail();
} }
@@ -714,6 +719,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role; const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆"; const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆";
const selectedAgent = selectedAgentId ? displayAgents.find((agent) => agent.id === selectedAgentId) ?? null : null; const selectedAgent = selectedAgentId ? displayAgents.find((agent) => agent.id === selectedAgentId) ?? null : null;
const orgChartZoom = ORG_CHART_ZOOM_LEVELS[orgChartZoomIndex];
/** Get skill badges from agent metadata */ /** Get skill badges from agent metadata */
const getSkillBadges = (agent: Agent): string[] => { const getSkillBadges = (agent: Agent): string[] => {
@@ -966,27 +972,68 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<span>Loading agents...</span> <span>Loading agents...</span>
</div> </div>
) : agentView === "org" ? ( ) : agentView === "org" ? (
<div className="agent-org-chart" data-testid="agent-org-chart"> <div className="agent-org-chart-shell" data-testid="agent-org-chart-shell">
{isOrgTreeLoading ? ( {isMobileViewport ? (
<div className="agent-org-chart__loading" role="status" aria-live="polite"> <div className="agent-org-chart-controls" data-testid="agent-org-chart-controls">
<RefreshCw size={18} className="spin" /> <button
<span>Loading org chart...</span> type="button"
className="btn-icon touch-target"
onClick={() => setOrgChartZoomIndex((value) => Math.max(0, value - 1))}
disabled={orgChartZoomIndex === 0}
aria-label="Zoom out org chart"
title="Zoom out"
>
<ZoomOut size={16} />
</button>
<span className="agent-org-chart-controls__zoom-label" aria-live="polite">{Math.round(orgChartZoom * 100)}%</span>
<button
type="button"
className="btn-icon touch-target"
onClick={() => setOrgChartZoomIndex((value) => Math.min(ORG_CHART_ZOOM_LEVELS.length - 1, value + 1))}
disabled={orgChartZoomIndex === ORG_CHART_ZOOM_LEVELS.length - 1}
aria-label="Zoom in org chart"
title="Zoom in"
>
<ZoomIn size={16} />
</button>
<button
type="button"
className="btn touch-target btn-sm agent-org-chart-controls__fit-btn"
onClick={() => setOrgChartZoomIndex(1)}
aria-label="Fit org chart"
title="Fit org chart"
>
<Minimize2 size={16} />
Fit
</button>
</div> </div>
) : displayOrgTree.length === 0 ? ( ) : null}
<AgentEmptyState onCtaClick={handleOpenNewAgent} /> <div className="agent-org-chart-viewport" data-testid="agent-org-chart-viewport">
) : ( <div className={`agent-org-chart-canvas agent-org-chart-canvas--zoom-${Math.round(orgChartZoom * 100)}`}>
displayOrgTree.map((node) => ( <div className="agent-org-chart" data-testid="agent-org-chart">
<OrgChartNode {isOrgTreeLoading ? (
key={node.agent.id} <div className="agent-org-chart__loading" role="status" aria-live="polite">
node={node} <RefreshCw size={18} className="spin" />
onSelect={openAgentDetail} <span>Loading org chart...</span>
getHealthStatus={getHealthStatus} </div>
getRoleIcon={getRoleIcon} ) : displayOrgTree.length === 0 ? (
getSkillBadges={getSkillBadges} <AgentEmptyState onCtaClick={handleOpenNewAgent} />
selectedAgentId={selectedAgentId} ) : (
/> displayOrgTree.map((node) => (
)) <OrgChartNode
)} key={node.agent.id}
node={node}
onSelect={openAgentDetail}
getHealthStatus={getHealthStatus}
getRoleIcon={getRoleIcon}
getSkillBadges={getSkillBadges}
selectedAgentId={selectedAgentId}
/>
))
)}
</div>
</div>
</div>
</div> </div>
) : agentView === "board" ? ( ) : agentView === "board" ? (
<div className="agent-board"> <div className="agent-board">

View File

@@ -92,7 +92,8 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.modal-overlay.settings-modal-overlay, .modal-overlay.settings-modal-overlay,
.modal-overlay:has(.settings-modal) { .modal-overlay:has(.settings-modal) {
padding-top: 0; padding: 0;
inset: 0;
align-items: stretch; align-items: stretch;
justify-content: stretch; justify-content: stretch;
} }
@@ -102,16 +103,18 @@
min-width: 0; min-width: 0;
max-width: 100vw; max-width: 100vw;
height: 100dvh; height: 100dvh;
min-height: 0; min-height: 100dvh;
max-height: 100dvh; max-height: 100dvh;
margin: 0; margin: 0;
border: none; border: none;
border-radius: 0; border-radius: 0;
resize: none; resize: none;
flex: 1 1 auto;
} }
.modal.settings-modal[style*="--keyboard-overlap"] { .modal.settings-modal[style*="--keyboard-overlap"] {
height: var(--vv-height, 100dvh); height: var(--vv-height, 100dvh);
min-height: var(--vv-height, 100dvh);
max-height: var(--vv-height, 100dvh); max-height: var(--vv-height, 100dvh);
transform: translateY(var(--vv-offset-top, 0px)); transform: translateY(var(--vv-offset-top, 0px));
will-change: transform; will-change: transform;
@@ -366,16 +369,22 @@
.settings-section-heading { .settings-section-heading {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
padding: var(--space-lg) 0 var(--space-md); padding: var(--space-lg) var(--space-xl) var(--space-md);
margin: 0; margin: 0 0 var(--space-md);
color: var(--text); color: var(--text);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
margin-bottom: var(--space-xs); }
/* First heading inside the section drops top padding to remove a redundant
gap stacked on top of the settings-content container's own top padding. */
.settings-content > .settings-section-heading:first-child,
.settings-modal-section > .settings-section-heading:first-child {
padding-top: var(--space-md);
} }
/* Spacing modifier for settings-section-heading that need extra top margin */ /* Spacing modifier for settings-section-heading that need extra top margin */
.settings-section-heading--spaced { .settings-section-heading--spaced {
margin-top: var(--space-xl); margin-top: var(--space-lg);
} }
.settings-section-description { .settings-section-description {
@@ -1485,8 +1494,8 @@
} }
.settings-section-heading { .settings-section-heading {
padding: var(--space-lg) 0 var(--space-md); padding: var(--space-lg) var(--space-lg) var(--space-md);
margin: 0; margin: 0 0 var(--space-md);
} }
.settings-plugins-subsection-toggle { .settings-plugins-subsection-toggle {

View File

@@ -1291,6 +1291,40 @@ describe("AgentsView", () => {
}); });
}); });
it("shows mobile zoom controls for org chart and keeps node selection working", async () => {
mockViewportMode.mockReturnValue("mobile");
mockFetchOrgTree.mockResolvedValue(orgTree);
const { container } = render(<AgentsView addToast={mockAddToast} />);
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
const controls = await screen.findByTestId("agent-org-chart-controls");
expect(controls).toBeTruthy();
expect(screen.getByText("100%")).toBeTruthy();
const viewport = screen.getByTestId("agent-org-chart-viewport");
expect(viewport).toBeTruthy();
const canvas = container.querySelector(".agent-org-chart-canvas");
expect(canvas?.className).toContain("agent-org-chart-canvas--zoom-100");
fireEvent.click(within(controls).getByTitle("Zoom in"));
await waitFor(() => {
expect(screen.getByText("125%")).toBeTruthy();
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-125");
});
fireEvent.click(within(controls).getByTitle("Fit org chart"));
await waitFor(() => {
expect(screen.getByText("100%")).toBeTruthy();
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-100");
});
fireEvent.click(screen.getByText("Director One"));
await waitFor(() => {
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1");
});
});
it("shows org chart empty state when API returns no nodes", async () => { it("shows org chart empty state when API returns no nodes", async () => {
mockFetchOrgTree.mockResolvedValue([]); mockFetchOrgTree.mockResolvedValue([]);
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);

View File

@@ -259,7 +259,10 @@ describe("agents-view mobile CSS", () => {
expect(block).toContain("flex-wrap: wrap"); expect(block).toContain("flex-wrap: wrap");
}); });
it("defines mobile org chart sizing rules", () => { it("defines mobile org chart sizing and pan/zoom controls rules", () => {
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("display: flex");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("gap: var(--space-sm)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-viewport")).toContain("min-height: calc(var(--space-2xl) * 4)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("gap: var(--space-sm)"); expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("gap: var(--space-sm)");
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)"); expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)");
expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node-card")).toContain("padding: var(--space-sm)"); expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node-card")).toContain("padding: var(--space-sm)");

View File

@@ -725,7 +725,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/github/issues/batch-import", "/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }), JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -776,7 +776,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/github/issues/batch-import", "/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }), JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -803,7 +803,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/github/issues/batch-import", "/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }), JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -872,7 +872,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/github/issues/batch-import", "/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }), JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -896,7 +896,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/github/issues/batch-import", "/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }), JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -915,7 +915,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/github/issues/batch-import", "/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }), JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -924,7 +924,7 @@ describe("POST /github/issues/batch-import", () => {
expect(res.body.results[0].success).toBe(true); expect(res.body.results[0].success).toBe(true);
expect(res.body.results[0].taskId).toBeDefined(); expect(res.body.results[0].taskId).toBeDefined();
expect(throttledSpy).toHaveBeenCalledTimes(1); expect(throttledSpy).toHaveBeenCalledTimes(1);
}, 10000); // Increase timeout for retry delay });
it("returns error after max retries exceeded on 429", async () => { it("returns error after max retries exceeded on 429", async () => {
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({ const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({
@@ -969,7 +969,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/github/issues/batch-import", "/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 50 }), JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 1 }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -1025,7 +1025,7 @@ describe("POST /github/issues/batch-import", () => {
buildApp(), buildApp(),
"POST", "POST",
"/api/github/issues/batch-import", "/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }), JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" } { "Content-Type": "application/json" }
); );
@@ -1954,7 +1954,7 @@ describe("GET /tasks/:id/diff", () => {
}); });
describe("done tasks with commit SHA", () => { describe("done tasks with commit SHA", () => {
it("attempts git diff when commitSha is present", { timeout: 30_000 }, async () => { it("attempts git diff when commitSha is present", async () => {
const gitRepo = getSharedGitTestRepo(); const gitRepo = getSharedGitTestRepo();
const localStore = createMockStore({ const localStore = createMockStore({
getRootDir: vi.fn().mockReturnValue(gitRepo.repoDir), getRootDir: vi.fn().mockReturnValue(gitRepo.repoDir),

View File

@@ -147,7 +147,7 @@ describe("provider registration (default export)", () => {
}); });
}); });
describe("streamViaCli", { timeout: 90_000 }, () => { describe("streamViaCli", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });

View File

@@ -0,0 +1,586 @@
/**
* Integration tests for the merger staging allowlist (real git repos).
*
* These tests do NOT mock child_process — they run real git commands against
* temporary repositories created in the OS temp directory. This verifies the
* exact behavior of `snapshotDirtyFiles` and `commitOrAmendMergeWithFixes`
* against a real git index without the indirection of exec mocks.
*
* Test inventory:
* 1. snapshotDirtyFiles captures tracked-unstaged, staged, and untracked files
* 2. Unrelated dirty file is excluded — not staged, warn emitted
* 3. Fix-modified file is included — staged and committed
* 4. File in squash + further edited by fix agent — staged once, no error
* 5. Untracked file created by fix agent — staged and committed
* 6. Untracked file pre-existing in working tree (user WIP) — NOT staged
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { snapshotDirtyFiles, commitOrAmendMergeWithFixes } from "../merger.js";
import { mergerLog } from "../logger.js";
import { DEFAULT_SETTINGS } from "@fusion/core";
// ---------------------------------------------------------------------------
// Git repo helpers
// ---------------------------------------------------------------------------
/**
* Initialise a bare minimum git repo at `dir` with a single initial commit.
* Returns the SHA of that commit (used as `preAttemptHeadSha`).
*/
function initRepo(dir: string): string {
const git = (cmd: string) =>
execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim();
git("git init");
git('git config user.email "test@example.com"');
git('git config user.name "Test"');
git('git config commit.gpgsign false');
// Create an initial commit so HEAD exists
writeFileSync(join(dir, "README.md"), "# repo\n");
git("git add README.md");
git('git commit -m "chore: initial commit"');
return git("git rev-parse HEAD");
}
/**
* Create a feature branch with one commit, then return to main and run
* `git merge --squash <branch>` so that a squash is staged but not committed.
* Returns the SHA of main's tip (which becomes `preAttemptHeadSha`).
*/
function squashBranch(dir: string, branchName: string, fileName: string, content: string): string {
const git = (cmd: string) =>
execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim();
git(`git checkout -b ${branchName}`);
writeFileSync(join(dir, fileName), content);
git(`git add ${fileName}`);
git(`git commit -m "feat: add ${fileName}"`);
git("git checkout main");
const preAttemptSha = git("git rev-parse HEAD");
git(`git merge --squash ${branchName}`);
return preAttemptSha;
}
// ---------------------------------------------------------------------------
// Minimal stub settings / args used by commitOrAmendMergeWithFixes
// ---------------------------------------------------------------------------
const STUB_SETTINGS = {
...DEFAULT_SETTINGS,
commitAuthorEnabled: false, // skip --author flag to avoid user config issues
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("snapshotDirtyFiles", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fn-snapshot-"));
initRepo(dir);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("returns empty set when working tree is clean", async () => {
const snapshot = await snapshotDirtyFiles(dir);
expect(snapshot.size).toBe(0);
});
it("captures tracked-unstaged modifications", async () => {
writeFileSync(join(dir, "README.md"), "modified\n");
const snapshot = await snapshotDirtyFiles(dir);
expect(snapshot.has("README.md")).toBe(true);
});
it("captures staged (cached) modifications", async () => {
writeFileSync(join(dir, "README.md"), "staged change\n");
execSync("git add README.md", { cwd: dir, stdio: "pipe" });
const snapshot = await snapshotDirtyFiles(dir);
expect(snapshot.has("README.md")).toBe(true);
});
it("captures untracked files", async () => {
writeFileSync(join(dir, "new-file.ts"), "export const x = 1;\n");
const snapshot = await snapshotDirtyFiles(dir);
expect(snapshot.has("new-file.ts")).toBe(true);
});
it("captures all three categories simultaneously", async () => {
// Tracked-unstaged
writeFileSync(join(dir, "README.md"), "dirty\n");
// Staged
writeFileSync(join(dir, "staged.ts"), "const s = 1;\n");
execSync("git add staged.ts", { cwd: dir, stdio: "pipe" });
// Untracked
writeFileSync(join(dir, "untracked.ts"), "const u = 2;\n");
const snapshot = await snapshotDirtyFiles(dir);
expect(snapshot.has("README.md")).toBe(true);
expect(snapshot.has("staged.ts")).toBe(true);
expect(snapshot.has("untracked.ts")).toBe(true);
});
it("returns empty set when rootDir is not a git repo (error swallowed)", async () => {
const nonRepo = mkdtempSync(join(tmpdir(), "fn-non-repo-"));
try {
const snapshot = await snapshotDirtyFiles(nonRepo);
expect(snapshot.size).toBe(0);
} finally {
rmSync(nonRepo, { recursive: true, force: true });
}
});
});
describe("commitOrAmendMergeWithFixes — staging allowlist", () => {
let dir: string;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fn-allowlist-"));
initRepo(dir);
warnSpy = vi.spyOn(mergerLog, "warn");
});
afterEach(() => {
warnSpy.mockRestore();
rmSync(dir, { recursive: true, force: true });
});
// ── Scenario 1: Unrelated dirty file is excluded ───────────────────────
it("does not stage an unrelated dirty file and emits a warn", async () => {
const preAttemptSha = squashBranch(dir, "feat/A", "feature-a.ts", "export const a = 1;\n");
// Simulate user's unrelated WIP: a modified tracked file
writeFileSync(join(dir, "README.md"), "user WIP — should not be committed\n");
// fixModifiedFiles is empty — no fix agent ran
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-TEST",
"feat/A",
"- feat: add feature-a.ts",
false,
preAttemptSha,
"", // no --author flag
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set<string>(), // empty fixModifiedFiles
);
expect(result).toBe(true);
// The unrelated file must NOT appear in the commit
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
cwd: dir,
stdio: "pipe",
}).toString().trim().split("\n");
expect(committedFiles).not.toContain("README.md");
expect(committedFiles).toContain("feature-a.ts");
// Warn must have been emitted for the excluded file
const warnMessages = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warnMessages.some((m) => m.includes("README.md") && m.includes("refusing to stage"))).toBe(true);
// README.md must still be dirty in the working tree
const status = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString().trim();
expect(status).toContain("README.md");
});
// ── Scenario 2: Fix-modified file is included ─────────────────────────
it("stages a file that the fix agent modified", async () => {
const preAttemptSha = squashBranch(dir, "feat/B", "feature-b.ts", "export const b = 1;\n");
// Fix agent modified an additional file (tracked, unstaged)
writeFileSync(join(dir, "README.md"), "fixed by agent\n");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-TEST",
"feat/B",
"- feat: add feature-b.ts",
false,
preAttemptSha,
"",
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set(["README.md"]), // fix agent touched this
);
expect(result).toBe(true);
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
cwd: dir,
stdio: "pipe",
}).toString().trim().split("\n");
expect(committedFiles).toContain("feature-b.ts");
expect(committedFiles).toContain("README.md");
// Working tree should be clean for README.md now
const status = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString().trim();
expect(status).not.toContain("README.md");
});
// ── Scenario 3: Squash file further edited by fix agent ───────────────
it("stages squash file with additional fix-agent edits only once", async () => {
const preAttemptSha = squashBranch(dir, "feat/C", "feature-c.ts", "export const c = 1;\n");
// Fix agent further edits the squash file (it's tracked-unstaged after squash staged it)
writeFileSync(join(dir, "feature-c.ts"), "export const c = 2; // fixed\n");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-TEST",
"feat/C",
"- feat: add feature-c.ts",
false,
preAttemptSha,
"",
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set(["feature-c.ts"]), // fix agent touched the same file the squash staged
);
expect(result).toBe(true);
// The committed file must contain the fix agent's content, not the squash's
const committedContent = execSync("git show HEAD:feature-c.ts", {
cwd: dir,
stdio: "pipe",
}).toString();
expect(committedContent).toContain("// fixed");
// No double-staging error should have occurred (result is true)
// Working tree should be clean
const status = execSync("git status --porcelain", { cwd: dir, stdio: "pipe" }).toString().trim();
expect(status).toBe("");
});
// ── Scenario 4: Untracked file created by fix agent ───────────────────
it("stages an untracked file created by the fix agent", async () => {
const preAttemptSha = squashBranch(dir, "feat/D", "feature-d.ts", "export const d = 1;\n");
// Fix agent created a brand-new file (untracked)
writeFileSync(join(dir, "new-fixture.ts"), "export const fixture = {};\n");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-TEST",
"feat/D",
"- feat: add feature-d.ts",
false,
preAttemptSha,
"",
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set(["new-fixture.ts"]), // fix agent created this file
);
expect(result).toBe(true);
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
cwd: dir,
stdio: "pipe",
}).toString().trim().split("\n");
expect(committedFiles).toContain("new-fixture.ts");
expect(committedFiles).toContain("feature-d.ts");
});
// ── Scenario 5: Pre-existing untracked user WIP file not staged ────────
it("does not stage a pre-existing untracked user WIP file", async () => {
// Create untracked user WIP before squash (simulates pre-existing state)
writeFileSync(join(dir, "user-wip.ts"), "// WIP — do not touch\n");
const preAttemptSha = squashBranch(dir, "feat/E", "feature-e.ts", "export const e = 1;\n");
// fixModifiedFiles does not include the user's WIP file
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-TEST",
"feat/E",
"- feat: add feature-e.ts",
false,
preAttemptSha,
"",
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set<string>(), // empty — the WIP file is not fix-agent-produced
);
expect(result).toBe(true);
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
cwd: dir,
stdio: "pipe",
}).toString().trim().split("\n");
expect(committedFiles).not.toContain("user-wip.ts");
expect(committedFiles).toContain("feature-e.ts");
// The WIP file must still be untracked in the working tree
const porcelain = execSync("git status --porcelain", {
cwd: dir,
stdio: "pipe",
}).toString();
expect(porcelain).toContain("user-wip.ts");
// Warn must have been emitted
const warnMessages = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warnMessages.some((m) => m.includes("user-wip.ts") && m.includes("refusing to stage"))).toBe(true);
});
// ── Scenario 6: Mixed — fix file included, unrelated file excluded ─────
it("stages fix-agent file but excludes a second unrelated file in the same pass", async () => {
// Commit unrelated.ts into main so it is a properly tracked file
writeFileSync(join(dir, "unrelated.ts"), "// original\n");
execSync("git add unrelated.ts", { cwd: dir, stdio: "pipe" });
execSync('git commit -m "chore: add unrelated.ts"', { cwd: dir, stdio: "pipe" });
const preAttemptSha = squashBranch(dir, "feat/F", "feature-f.ts", "export const f = 1;\n");
// Fix agent modified one file
writeFileSync(join(dir, "README.md"), "agent fix\n");
// User modified the tracked (but unrelated) file in the working tree
writeFileSync(join(dir, "unrelated.ts"), "// user WIP\n");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-TEST",
"feat/F",
"- feat: add feature-f.ts",
false,
preAttemptSha,
"",
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set(["README.md"]), // only the agent's file is in the allowlist
);
expect(result).toBe(true);
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
cwd: dir,
stdio: "pipe",
}).toString().trim().split("\n");
expect(committedFiles).toContain("feature-f.ts");
expect(committedFiles).toContain("README.md");
expect(committedFiles).not.toContain("unrelated.ts");
// unrelated.ts must remain dirty
const dirty = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString();
expect(dirty).toContain("unrelated.ts");
const warnMessages = warnSpy.mock.calls.map((c) => String(c[0]));
expect(warnMessages.some((m) => m.includes("unrelated.ts") && m.includes("refusing to stage"))).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Embedded-space path tests — verify NUL-delimited parsing handles spaces
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Embedded-space path tests — verify NUL-delimited (-z) parsing handles spaces
//
// Note on untracked files in new subdirectories: git reports untracked entries
// at the outermost untracked directory level (e.g. `?? dir with space/`),
// not at the individual file level, when the directory itself is new. This is
// standard git behaviour regardless of -z. For that reason the untracked tests
// below use root-level files or files inside already-tracked directories,
// which are the cases that actually round-trip through `snapshotDirtyFiles`.
// ---------------------------------------------------------------------------
describe("snapshotDirtyFiles — paths with embedded spaces", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fn-snapshot-spaces-"));
initRepo(dir);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("captures a root-level untracked file whose name contains spaces", async () => {
// Root-level untracked files with spaces are reported verbatim by git (no quoting in -z mode).
writeFileSync(join(dir, "my file with spaces.ts"), "export const x = 1;\n");
const snapshot = await snapshotDirtyFiles(dir);
expect(snapshot.has("my file with spaces.ts")).toBe(true);
});
it("captures a tracked-unstaged file in a subdirectory whose path contains spaces", async () => {
// First commit the file so it is tracked (git diff reports full path including spaces).
mkdirSync(join(dir, "src dir"), { recursive: true });
writeFileSync(join(dir, "src dir", "my component.ts"), "export const v = 0;\n");
execSync("git add .", { cwd: dir, stdio: "pipe" });
execSync('git commit -m "chore: add spaced file"', { cwd: dir, stdio: "pipe" });
// Now modify it without staging — git diff -z --name-only emits the full path NUL-terminated.
writeFileSync(join(dir, "src dir", "my component.ts"), "export const v = 1;\n");
const snapshot = await snapshotDirtyFiles(dir);
expect(snapshot.has("src dir/my component.ts")).toBe(true);
});
it("captures a staged (cached) file in a subdirectory whose path contains spaces", async () => {
// Create the parent so it is already tracked, then add a new file.
mkdirSync(join(dir, "path with spaces"), { recursive: true });
writeFileSync(join(dir, "path with spaces", "keeper.ts"), "export {};\n");
execSync("git add .", { cwd: dir, stdio: "pipe" });
execSync('git commit -m "chore: track dir"', { cwd: dir, stdio: "pipe" });
// Now create a new file in the tracked dir and stage it.
writeFileSync(join(dir, "path with spaces", "index.ts"), "export const i = 1;\n");
execSync("git add .", { cwd: dir, stdio: "pipe" });
const snapshot = await snapshotDirtyFiles(dir);
// git diff -z --cached --name-only reports staged files with their full path.
expect(snapshot.has("path with spaces/index.ts")).toBe(true);
});
});
describe("commitOrAmendMergeWithFixes — embedded-space paths round-trip", () => {
let dir: string;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fn-allowlist-spaces-"));
initRepo(dir);
warnSpy = vi.spyOn(mergerLog, "warn");
});
afterEach(() => {
warnSpy.mockRestore();
rmSync(dir, { recursive: true, force: true });
});
it("stages and commits a tracked file edited by the fix agent whose path contains spaces", async () => {
// Pre-commit the spaced file so it is a tracked path.
mkdirSync(join(dir, "src components"), { recursive: true });
writeFileSync(join(dir, "src components", "my widget.ts"), "export const w = 0;\n");
execSync("git add .", { cwd: dir, stdio: "pipe" });
execSync('git commit -m "chore: add spaced component"', { cwd: dir, stdio: "pipe" });
const preAttemptSha = squashBranch(dir, "feat/G", "feature-g.ts", "export const g = 1;\n");
// Fix agent modifies the tracked spaced file (tracked-unstaged after squash).
const spacedPath = "src components/my widget.ts";
writeFileSync(join(dir, "src components", "my widget.ts"), "export const w = 1; // fixed\n");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-TEST",
"feat/G",
"- feat: add feature-g.ts",
false,
preAttemptSha,
"",
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set([spacedPath]), // fix agent touched this tracked file
);
expect(result).toBe(true);
// Verify both the squash file and the spaced file were committed.
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
cwd: dir,
stdio: "pipe",
}).toString().trim().split("\n");
expect(committedFiles).toContain("feature-g.ts");
expect(committedFiles).toContain(spacedPath);
// Working tree must be clean for the spaced file.
const dirty = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString();
expect(dirty).not.toContain(spacedPath);
});
it("excludes an unrelated tracked file with spaces and emits a warn", async () => {
// Commit a tracked file with spaces so it appears in git diff (not git status -z untracked).
const spacedUnrelated = "user notes/scratch.ts";
mkdirSync(join(dir, "user notes"), { recursive: true });
writeFileSync(join(dir, "user notes", "scratch.ts"), "// original\n");
execSync("git add .", { cwd: dir, stdio: "pipe" });
execSync('git commit -m "chore: add user notes"', { cwd: dir, stdio: "pipe" });
const preAttemptSha = squashBranch(dir, "feat/H", "feature-h.ts", "export const h = 1;\n");
// User edits their tracked spaced file — not in the allowlist.
writeFileSync(join(dir, "user notes", "scratch.ts"), "// user WIP\n");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-TEST",
"feat/H",
"- feat: add feature-h.ts",
false,
preAttemptSha,
"",
undefined,
STUB_SETTINGS,
undefined,
null,
null,
new Set<string>(), // empty allowlist
);
expect(result).toBe(true);
const committedFiles = execSync("git diff --name-only HEAD~1 HEAD", {
cwd: dir,
stdio: "pipe",
}).toString().trim().split("\n");
expect(committedFiles).toContain("feature-h.ts");
expect(committedFiles).not.toContain(spacedUnrelated);
// The file must still be dirty in the working tree.
const dirty = execSync("git diff --name-only", { cwd: dir, stdio: "pipe" }).toString();
expect(dirty).toContain(spacedUnrelated);
const warnMessages = warnSpy.mock.calls.map((c) => String(c[0]));
expect(
warnMessages.some((m) => m.includes(spacedUnrelated) && m.includes("refusing to stage")),
).toBe(true);
});
});

View File

@@ -74,7 +74,38 @@ vi.mock("node:child_process", async () => {
} }
}); });
}); });
return { execSync: execSyncFn, exec: execFn, spawn: spawnFn };
// execFile(file, args, opts, cb) — reassemble a shell-equivalent command and
// delegate to execSyncFn so the same mock infrastructure handles both exec and execFile.
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
// Normalize overloads: (file, args, cb) or (file, args, opts, cb)
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? {} : opts;
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
try {
const out = execSyncFn(cmd, { stdio: ["pipe", "pipe", "pipe"], ...options });
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
new Promise((resolve, reject) => {
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
}); });
vi.mock("node:fs", () => ({ vi.mock("node:fs", () => ({
@@ -157,6 +188,8 @@ function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = [
emit: vi.fn(), emit: vi.fn(),
on: vi.fn(), on: vi.fn(),
clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]), clearStaleBaseBranchReferences: vi.fn().mockReturnValue([]),
getVerificationCacheHit: vi.fn().mockReturnValue(null),
recordVerificationCachePass: vi.fn(),
} as unknown as TaskStore; } as unknown as TaskStore;
} }
@@ -1203,8 +1236,8 @@ describe("push-after-merge", () => {
if (cmdStr.includes("git diff --name-only --diff-filter=U")) { if (cmdStr.includes("git diff --name-only --diff-filter=U")) {
return hasConflicts ? "pnpm-lock.yaml" as any : "" as any; return hasConflicts ? "pnpm-lock.yaml" as any : "" as any;
} }
if (cmdStr.startsWith('git checkout --ours "pnpm-lock.yaml"')) return Buffer.from(""); if (cmdStr.includes("checkout --ours") && cmdStr.includes("pnpm-lock.yaml")) return Buffer.from("");
if (cmdStr.startsWith('git add "pnpm-lock.yaml"')) { if (cmdStr.includes("git add") && cmdStr.includes("pnpm-lock.yaml")) {
hasConflicts = false; hasConflicts = false;
return Buffer.from(""); return Buffer.from("");
} }
@@ -1233,7 +1266,7 @@ describe("push-after-merge", () => {
expect(result.pushed).toBe(true); expect(result.pushed).toBe(true);
expect( expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith('git checkout --ours "pnpm-lock.yaml"')), mockedExecSync.mock.calls.some((call) => String(call[0]).includes("checkout --ours") && String(call[0]).includes("pnpm-lock.yaml")),
).toBe(true); ).toBe(true);
expect( expect(
mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("GIT_EDITOR=true git rebase --continue")), mockedExecSync.mock.calls.some((call) => String(call[0]).startsWith("GIT_EDITOR=true git rebase --continue")),
@@ -4249,6 +4282,178 @@ describe("aiMergeTask — deterministic merge verification", () => {
); );
expect(verificationCalls).toHaveLength(0); expect(verificationCalls).toHaveLength(0);
}); });
it("skips test and build commands when a cache hit is found for the current tree sha", async () => {
const treeSha = "cachedtreeshaabc1234567890";
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
// Return the fake tree sha when rev-parse HEAD^{tree} is called
if (cmdStr.includes("HEAD^{tree}")) return Buffer.from(treeSha + "\n");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// Simulate a cache hit for this tree sha
const cacheHit = { recordedAt: "2026-05-01T00:00:00.000Z", taskId: "FN-049" };
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(cacheHit);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
buildCommand: "pnpm build",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// No actual test/build commands should have run
const runCalls = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest run") || String(call[0]).includes("pnpm build"),
);
expect(runCalls).toHaveLength(0);
// The cache skip message should appear in the task log
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
const cacheMsg = logCalls.find((call: any[]) =>
typeof call[1] === "string" && call[1].includes("Skipping deterministic verification — cached pass"),
);
expect(cacheMsg).toBeTruthy();
expect(cacheMsg![1]).toContain(treeSha.slice(0, 7));
expect(cacheMsg![1]).toContain("FN-049");
// getVerificationCacheHit should have been called with the tree sha and commands
expect(store.getVerificationCacheHit).toHaveBeenCalledWith(treeSha, "vitest run", "pnpm build");
});
it("runs commands and records a cache pass when no cache hit exists", async () => {
const treeSha = "freshtreedead0000beef";
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
if (cmdStr.includes("HEAD^{tree}")) return Buffer.from(treeSha + "\n");
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
// No cache hit — returns null (default mock)
(store.getVerificationCacheHit as ReturnType<typeof vi.fn>).mockReturnValue(null);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// The test command should have been executed
const testRuns = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest run"),
);
expect(testRuns.length).toBeGreaterThan(0);
// recordVerificationCachePass should have been called with the tree sha
expect(store.recordVerificationCachePass).toHaveBeenCalledWith(
treeSha, "vitest run", "", "FN-050",
);
});
it("gracefully skips cache lookup when git rev-parse HEAD^{tree} fails", async () => {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr === "git rev-parse HEAD" || cmdStr.startsWith("git rev-parse HEAD ")) return "mergedcommit123";
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("merge-base")) return Buffer.from("abc123");
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("vitest run")) return Buffer.from("");
if (cmdStr.includes("diff --cached --quiet")) return "1" as any;
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("show --shortstat")) return "3 files changed, 10 insertions(+), 2 deletions(-)" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
// Simulate git failure for tree sha resolution
if (cmdStr.includes("HEAD^{tree}")) {
const err = new Error("not a git repository") as any;
err.status = 128;
throw err;
}
return Buffer.from("");
});
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
const store = createMockStore(
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...DEFAULT_SETTINGS,
testCommand: "vitest run",
});
// Should not throw — merge should complete normally
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
expect(result.merged).toBe(true);
// Cache methods should never have been called
expect(store.getVerificationCacheHit).not.toHaveBeenCalled();
expect(store.recordVerificationCachePass).not.toHaveBeenCalled();
// The test command should still have run
const testRuns = mockedExecSync.mock.calls.filter(
(call) => String(call[0]).includes("vitest run"),
);
expect(testRuns.length).toBeGreaterThan(0);
});
}); });
describe("shouldSyncDependenciesForMerge", () => { describe("shouldSyncDependenciesForMerge", () => {
@@ -6672,8 +6877,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
name: "VerificationError", name: "VerificationError",
}); });
// Verify that fix agent was spawned (2 calls: merger + fix) // Verify that fix agent was spawned (3 calls: summarizer + merger + fix)
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(3);
// Verify the fix agent was called with correct options // Verify the fix agent was called with correct options
const fixAgentCall = mockedCreateFnAgent.mock.calls[1]; const fixAgentCall = mockedCreateFnAgent.mock.calls[1];
@@ -6897,8 +7102,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
name: "VerificationError", name: "VerificationError",
}); });
// Verify fix agent was NOT spawned (only merger) // Verify fix agent was NOT spawned (summarizer + merger only)
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(1); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(2);
// Verify no fix attempt was logged // Verify no fix attempt was logged
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls; const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;
@@ -7123,8 +7328,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
name: "VerificationError", name: "VerificationError",
}); });
// Should have 3 fix attempts (capped at 3) + 1 merger = 4 calls // Should have 3 fix attempts (capped at 3) + summarizer + merger = 5 calls
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(5);
}); });
it("default verificationFixRetries (omitted) results in 3 fix attempts", async () => { it("default verificationFixRetries (omitted) results in 3 fix attempts", async () => {
@@ -7174,8 +7379,8 @@ describe("aiMergeTask — in-merge verification fix", () => {
name: "VerificationError", name: "VerificationError",
}); });
// Should have 3 fix attempts (default) + 1 merger = 4 calls // Should have 3 fix attempts (default) + summarizer + merger = 5 calls
expect(mockedCreateFnAgent).toHaveBeenCalledTimes(4); expect(mockedCreateFnAgent).toHaveBeenCalledTimes(5);
// Verify the log shows 3 fix attempts (2 log entries per attempt: start + failure) // Verify the log shows 3 fix attempts (2 log entries per attempt: start + failure)
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls; const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls;

View File

@@ -1,5 +1,8 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { ResearchRun, ResearchSource } from "@fusion/core"; import { createDatabase, type Database, ResearchStore, type ResearchRun, type ResearchSource } from "@fusion/core";
import { ResearchOrchestrator } from "../research-orchestrator.js"; import { ResearchOrchestrator } from "../research-orchestrator.js";
function createHarness() { function createHarness() {
@@ -165,6 +168,12 @@ describe("ResearchOrchestrator", () => {
const run = await orchestrator.startRun(runId, "fallback query"); const run = await orchestrator.startRun(runId, "fallback query");
expect(run.status).toBe("completed"); expect(run.status).toBe("completed");
expect(stepRunner.runContentFetch).toHaveBeenCalledWith(
"https://backup.com",
"backup",
undefined,
expect.anything(),
);
expect(store.addEvent).toHaveBeenCalledWith( expect(store.addEvent).toHaveBeenCalledWith(
runId, runId,
expect.objectContaining({ expect.objectContaining({
@@ -174,6 +183,43 @@ describe("ResearchOrchestrator", () => {
); );
}); });
it("continues with partial fetched sources when one fetch step fails", async () => {
const { store, stepRunner } = createHarness();
stepRunner.runSourceQuery.mockResolvedValueOnce({
ok: true,
data: [
{ type: "web", reference: "https://a.example", status: "pending" },
{ type: "web", reference: "https://b.example", status: "pending" },
],
} as never);
stepRunner.runContentFetch
.mockResolvedValueOnce({ ok: false, error: { code: "provider_error", message: "fetch failed", retryable: true } } as never)
.mockResolvedValueOnce({ ok: true, data: { content: "good", metadata: {} } } as never);
const orchestrator = new ResearchOrchestrator({
store: store as never,
stepRunner: stepRunner as never,
maxConcurrentRuns: 1,
});
const runId = orchestrator.createRun({
providers: [{ type: "web" }],
maxSources: 2,
maxSynthesisRounds: 1,
});
const run = await orchestrator.startRun(runId, "partial fetch");
expect(run.status).toBe("completed");
expect(store.addEvent).toHaveBeenCalledWith(
runId,
expect.objectContaining({
type: "error",
metadata: expect.objectContaining({ orchestrationEventType: "step-failed" }),
}),
);
expect(store.setResults).toHaveBeenCalled();
});
it("emits step-failed for timeout-classified step errors", async () => { it("emits step-failed for timeout-classified step errors", async () => {
const { store, stepRunner } = createHarness(); const { store, stepRunner } = createHarness();
stepRunner.runSourceQuery stepRunner.runSourceQuery
@@ -237,6 +283,55 @@ describe("ResearchOrchestrator", () => {
expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(2); expect(stepRunner.runSourceQuery).toHaveBeenCalledTimes(2);
}); });
it("persists provider-substitution lifecycle with real ResearchStore", async () => {
const fusionDir = mkdtempSync(join(tmpdir(), "fn-research-orch-"));
const db: Database = createDatabase(fusionDir, { inMemory: true });
db.init();
const store = new ResearchStore(db);
const stepRunner = {
runSourceQuery: vi
.fn()
.mockResolvedValueOnce({ ok: false, error: { code: "provider_error", message: "primary down", retryable: true } })
.mockResolvedValueOnce({
ok: true,
data: [{ type: "web", reference: "https://backup.example", status: "pending", metadata: { origin: "backup" } }],
}),
runContentFetch: vi.fn(async () => ({ ok: true, data: { content: "backup content", metadata: { fetchedBy: "backup" } } })),
runSynthesis: vi.fn(async () => ({ ok: true, data: { output: "summary", citations: ["src-1"], confidence: 0.7 } })),
};
const orchestrator = new ResearchOrchestrator({
store,
stepRunner,
maxConcurrentRuns: 1,
});
const runId = orchestrator.createRun({
providers: [{ type: "primary" }, { type: "backup" }],
maxSources: 2,
maxSynthesisRounds: 1,
});
const run = await orchestrator.startRun(runId, "provider substitution");
expect(run.status).toBe("completed");
const persisted = store.getRun(runId)!;
expect(persisted.sources).toHaveLength(1);
expect(persisted.sources[0].metadata?.providerType).toBe("backup");
expect(stepRunner.runContentFetch).toHaveBeenCalledWith(
"https://backup.example",
"backup",
undefined,
expect.anything(),
);
const runEvents = store.listRunEvents(runId);
expect(runEvents.some((event) => event.status === "completed")).toBe(true);
expect(persisted.events.some((event) => event.metadata?.orchestrationEventType === "step-failed")).toBe(true);
expect(persisted.results?.summary).toBe("summary");
});
it("retries failed run with inherited config", () => { it("retries failed run with inherited config", () => {
const { store } = createHarness(); const { store } = createHarness();
const orchestrator = new ResearchOrchestrator({ const orchestrator = new ResearchOrchestrator({

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ResearchStepRunner } from "../research-step-runner.js"; import { ResearchStepRunner } from "../research-step-runner.js";
describe("ResearchStepRunner", () => { describe("ResearchStepRunner", () => {
@@ -77,6 +77,38 @@ describe("ResearchStepRunner", () => {
expect(result.error?.code).toBe("provider_not_configured"); expect(result.error?.code).toBe("provider_not_configured");
}); });
it("prefers requested provider for content fetch and falls back when unavailable", async () => {
const fetchPrimary = vi.fn(async () => ({ content: "primary", metadata: { provider: "primary" } }));
const fetchFallback = vi.fn(async () => ({ content: "fallback", metadata: { provider: "fallback" } }));
const runner = new ResearchStepRunner({
providers: [
{
type: "primary",
isConfigured: () => true,
search: async () => [],
fetchContent: fetchPrimary,
},
{
type: "fallback",
isConfigured: () => true,
search: async () => [],
fetchContent: fetchFallback,
},
],
});
const requested = await runner.runContentFetch("https://example.com", "fallback");
expect(requested.ok).toBe(true);
expect(requested.data?.metadata.provider).toBe("fallback");
const missing = await runner.runContentFetch("https://example.com", "missing");
expect(missing.ok).toBe(true);
expect(missing.data?.metadata.provider).toBe("primary");
expect(fetchPrimary).toHaveBeenCalledTimes(1);
expect(fetchFallback).toHaveBeenCalledTimes(1);
});
it("returns provider_not_configured for synthesis when no runner configured", async () => { it("returns provider_not_configured for synthesis when no runner configured", async () => {
const runner = new ResearchStepRunner(); const runner = new ResearchStepRunner();
const result = await runner.runSynthesis({ query: "q", sources: [], round: 1 }); const result = await runner.runSynthesis({ query: "q", sources: [], round: 1 });

View File

@@ -79,6 +79,35 @@ vi.mock("node:child_process", () => {
}); });
}); });
// execFile(file, args, opts, cb) — assemble a command string and delegate to
// execSyncFn so the same mock infrastructure covers execFile-based git calls.
const execFileFn: any = vi.fn((file: any, args: any, opts: any, cb: any) => {
const callback = typeof opts === "function" ? opts : cb;
const options = typeof opts === "function" ? undefined : opts;
const cmd = [file, ...(Array.isArray(args) ? args : [])].join(" ");
try {
const out = execSyncFn(cmd, options);
const stdout = out === undefined ? "" : out.toString();
if (typeof callback === "function") callback(null, stdout, "");
} catch (err: any) {
if (typeof callback === "function") {
callback(err, err?.stdout?.toString?.() ?? "", err?.stderr?.toString?.() ?? "");
}
}
});
execFileFn[promisify.custom] = (file: any, args?: any, opts?: any) =>
new Promise((resolve, reject) => {
execFileFn(file, args, opts, (err: any, stdout: any, stderr: any) => {
if (err) {
err.stdout = stdout;
err.stderr = stderr;
reject(err);
} else {
resolve({ stdout, stderr });
}
});
});
// spawn() is used by the merger's verification runner. Route it through the // spawn() is used by the merger's verification runner. Route it through the
// same execSyncFn mock so a single mockedExecSync.mockImplementation controls // same execSyncFn mock so a single mockedExecSync.mockImplementation controls
// both git calls (execSync) and verification commands (spawn). Throwing from // both git calls (execSync) and verification commands (spawn). Throwing from
@@ -104,7 +133,7 @@ vi.mock("node:child_process", () => {
return child; return child;
}); });
return { execSync: execSyncFn, exec: execFn, spawn: spawnFn }; return { execSync: execSyncFn, exec: execFn, execFile: execFileFn, spawn: spawnFn };
}); });
vi.mock("node:fs", () => ({ vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),

View File

@@ -1,8 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { execSync, exec } from "node:child_process"; import { execSync, exec, execFile } from "node:child_process";
import { promisify } from "node:util"; import { promisify } from "node:util";
const execAsync = promisify(exec); const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
import { import {
runVerificationCommand as runVerificationCommandShared, runVerificationCommand as runVerificationCommandShared,
summarizeVerificationOutput, summarizeVerificationOutput,
@@ -355,12 +356,86 @@ export function throwIfAborted(signal: AbortSignal | undefined, taskId: string):
throw new MergeAbortedError(`Merge aborted for ${taskId}: engine shutdown requested`); throw new MergeAbortedError(`Merge aborted for ${taskId}: engine shutdown requested`);
} }
/**
* Return the union of all dirty paths in `rootDir`:
* - tracked files modified vs the index (`git diff --name-only`)
* - staged but not yet committed (`git diff --cached --name-only`)
* - untracked files (`git status --porcelain` lines starting with `??`)
*
* Errors are swallowed and an empty set is returned so callers are never
* blocked by a failing porcelain query.
*
* All three git queries use NUL-delimited output (`-z`) so paths with
* embedded spaces or special characters are parsed correctly without quoting.
*/
export async function snapshotDirtyFiles(rootDir: string): Promise<Set<string>> {
const paths = new Set<string>();
try {
const [unstagedOut, stagedOut, porcelainOut] = await Promise.all([
execFileAsync("git", ["diff", "-z", "--name-only"], { cwd: rootDir, encoding: "utf-8" }).then(
(r) => r.stdout,
() => "",
),
execFileAsync("git", ["diff", "-z", "--cached", "--name-only"], { cwd: rootDir, encoding: "utf-8" }).then(
(r) => r.stdout,
() => "",
),
execFileAsync("git", ["status", "-z", "--porcelain"], { cwd: rootDir, encoding: "utf-8" }).then(
(r) => r.stdout,
() => "",
),
]);
for (const entry of unstagedOut.split("\0")) {
const p = entry.trim();
if (p) paths.add(p);
}
for (const entry of stagedOut.split("\0")) {
const p = entry.trim();
if (p) paths.add(p);
}
// Untracked files: entries beginning with `?? ` (3-char prefix, no quoting in -z mode)
for (const entry of porcelainOut.split("\0")) {
if (!entry.startsWith("?? ")) continue;
const p = entry.slice(3);
if (p) paths.add(p);
}
} catch {
// Best-effort — an empty snapshot is safe: the allowlist logic will simply
// not add any fix-agent files, which is conservative.
}
return paths;
}
function rethrowIfMergeAborted(error: unknown): void { function rethrowIfMergeAborted(error: unknown): void {
if (error instanceof Error && error.name === "MergeAbortedError") { if (error instanceof Error && error.name === "MergeAbortedError") {
throw error; throw error;
} }
} }
/**
* Run execSync and always return a trimmed UTF-8 string.
* execSync may return a Buffer, string, or null depending on the encoding option;
* this helper normalises all three cases.
*/
function execSyncText(command: string, options: Parameters<typeof execSync>[1]): string {
const output = execSync(command, options);
if (output == null) return "";
if (typeof output === "string") return output.trim();
return (output as Buffer).toString("utf-8").trim();
}
/** Extra environment variables injected into verification child processes to boost concurrency. */
const VERIFICATION_EXTRA_ENV: NodeJS.ProcessEnv = Object.fromEntries(
(
[
["FUSION_TEST_TOTAL_WORKERS", "8"],
["FUSION_TEST_CONCURRENCY", "4"],
["FUSION_TEST_WORKSPACE_CONCURRENCY", "4"],
] as [string, string][]
).filter(([key]) => !(key in process.env)),
);
async function runDeterministicVerification( async function runDeterministicVerification(
store: TaskStore, store: TaskStore,
rootDir: string, rootDir: string,
@@ -384,6 +459,41 @@ async function runDeterministicVerification(
const hasTestCommand = !!normalizedTestCommand; const hasTestCommand = !!normalizedTestCommand;
const hasBuildCommand = !!normalizedBuildCommand; const hasBuildCommand = !!normalizedBuildCommand;
// ── Tree-hash verification cache (Layer 1) ─────────────────────────────
const effectiveTestCommand = normalizedTestCommand ?? "";
const effectiveBuildCommand = normalizedBuildCommand ?? "";
let treeSha: string | null = null;
try {
treeSha = execSync("git rev-parse HEAD^{tree}", { cwd: rootDir, stdio: "pipe" })
.toString()
.trim();
} catch (err) {
mergerLog.warn(`${taskId}: could not resolve tree sha — skipping verification cache: ${String(err)}`);
}
if (treeSha) {
const cacheHit = store.getVerificationCacheHit(treeSha, effectiveTestCommand, effectiveBuildCommand);
if (cacheHit) {
const sha7 = treeSha.slice(0, 7);
const msg = `Skipping deterministic verification — cached pass for tree ${sha7} (recorded at ${cacheHit.recordedAt}, by ${cacheHit.taskId ?? "unknown"})`;
mergerLog.log(`${taskId}: ${msg}`);
await store.logEntry(taskId, msg);
await store.appendAgentLog(taskId, msg, "text", undefined, "merger");
const syntheticResult: VerificationCommandResult = {
command: "",
exitCode: 0,
stdout: "",
stderr: "",
success: true,
cached: true,
};
if (hasTestCommand) result.testResult = { ...syntheticResult, command: effectiveTestCommand };
if (hasBuildCommand) result.buildResult = { ...syntheticResult, command: effectiveBuildCommand };
return result;
}
}
// ── End cache lookup ───────────────────────────────────────────────────
// Build source indicator for logging // Build source indicator for logging
const testSourceLabel = testSource === "inferred" ? " [inferred]" : ""; const testSourceLabel = testSource === "inferred" ? " [inferred]" : "";
const buildSourceLabel = buildSource === "inferred" ? " [inferred]" : ""; const buildSourceLabel = buildSource === "inferred" ? " [inferred]" : "";
@@ -461,6 +571,18 @@ async function runDeterministicVerification(
mergerLog.log(`${taskId}: deterministic verification passed`); mergerLog.log(`${taskId}: deterministic verification passed`);
await store.logEntry(taskId, "Deterministic merge verification passed"); await store.logEntry(taskId, "Deterministic merge verification passed");
await store.appendAgentLog(taskId, "Deterministic merge verification passed", "text", undefined, "merger"); await store.appendAgentLog(taskId, "Deterministic merge verification passed", "text", undefined, "merger");
// ── Record cache pass ──────────────────────────────────────────────────
if (treeSha) {
try {
store.recordVerificationCachePass(treeSha, effectiveTestCommand, effectiveBuildCommand, taskId);
mergerLog.log(`${taskId}: Recorded verification pass for tree ${treeSha.slice(0, 7)}`);
await store.logEntry(taskId, `Recorded verification pass for tree ${treeSha.slice(0, 7)}`);
} catch (err) {
mergerLog.warn(`${taskId}: could not record verification cache pass: ${String(err)}`);
}
}
return result; return result;
} }
@@ -473,13 +595,19 @@ async function runVerificationCommand(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<VerificationCommandResult> { ): Promise<VerificationCommandResult> {
throwIfAborted(signal, taskId); throwIfAborted(signal, taskId);
return runVerificationCommandShared(store, rootDir, taskId, command, type, signal, mergerLog, "merger"); return runVerificationCommandShared(store, rootDir, taskId, command, type, signal, mergerLog, "merger", VERIFICATION_EXTRA_ENV);
} }
/** /**
* Attempt an in-merge verification fix by spawning an AI agent on the main branch. * Attempt an in-merge verification fix by spawning an AI agent on the main branch.
* Returns true if verification passes after the fix, false otherwise. * Returns true if verification passes after the fix, false otherwise.
* Never throws — errors are caught and logged, and the function returns false. * Never throws — errors are caught and logged, and the function returns false.
*
* @param fixModifiedFiles - Mutable set that this function populates with every
* path that changed during the fix agent's run (post-snapshot minus
* pre-snapshot). The caller passes this set across all fix attempts so that
* `commitOrAmendMergeWithFixes` can build an allowlist that covers every file
* the fix agent touched, regardless of how many retries were needed.
*/ */
async function attemptInMergeVerificationFix( async function attemptInMergeVerificationFix(
store: TaskStore, store: TaskStore,
@@ -497,7 +625,11 @@ async function attemptInMergeVerificationFix(
fixAttemptNumber?: number, fixAttemptNumber?: number,
_testCommand?: string, _testCommand?: string,
_buildCommand?: string, _buildCommand?: string,
fixModifiedFiles?: Set<string>,
): Promise<boolean> { ): Promise<boolean> {
// Snapshot the working tree before doing anything so the diff reflects only
// what the fix agent touched, not pre-existing dirty state.
const preFixSnapshot = await snapshotDirtyFiles(rootDir);
try { try {
mergerLog.log(`${taskId}: spawning in-merge verification fix agent`); mergerLog.log(`${taskId}: spawning in-merge verification fix agent`);
@@ -631,6 +763,17 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
}); });
await accumulateSessionTokenUsage(store, taskId, session); await accumulateSessionTokenUsage(store, taskId, session);
// Compute which paths the fix agent introduced or modified, then
// accumulate them into the caller's mutable set.
const postFixSnapshot = await snapshotDirtyFiles(rootDir);
if (fixModifiedFiles) {
for (const p of postFixSnapshot) {
if (!preFixSnapshot.has(p)) {
fixModifiedFiles.add(p);
}
}
}
// Re-run deterministic verification command after the fix attempt. // Re-run deterministic verification command after the fix attempt.
await store.logEntry( await store.logEntry(
taskId, taskId,
@@ -660,6 +803,19 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
} }
} catch (err: unknown) { } catch (err: unknown) {
rethrowIfMergeAborted(err); rethrowIfMergeAborted(err);
// Even on failure, try to surface any paths the agent partially touched.
if (fixModifiedFiles) {
try {
const postFixSnapshot = await snapshotDirtyFiles(rootDir);
for (const p of postFixSnapshot) {
if (!preFixSnapshot.has(p)) {
fixModifiedFiles.add(p);
}
}
} catch {
// Best-effort only
}
}
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: in-merge fix agent error: ${errorMessage}`); mergerLog.warn(`${taskId}: in-merge fix agent error: ${errorMessage}`);
await store.logEntry(taskId, "In-merge verification fix agent encountered an error", errorMessage); await store.logEntry(taskId, "In-merge verification fix agent encountered an error", errorMessage);
@@ -821,10 +977,16 @@ async function buildDeterministicMergeMessage(params: {
* branch's actual step commits, so consumers of mergeDetails never see a * branch's actual step commits, so consumers of mergeDetails never see a
* hallucinated body that talks about files that aren't in the diff. * hallucinated body that talks about files that aren't in the diff.
* *
* Only files that are part of the squash or that the fix agent explicitly
* modified are staged. Any other dirty files in the working tree are left
* untouched and a warning is emitted for each one.
*
* Returns true on a successful commit/amend. Never throws — errors are logged * Returns true on a successful commit/amend. Never throws — errors are logged
* and the function returns false (callers decide whether to abort the merge). * and the function returns false (callers decide whether to abort the merge).
*
* @internal Exported for integration tests only — not part of the public API.
*/ */
async function commitOrAmendMergeWithFixes( export async function commitOrAmendMergeWithFixes(
rootDir: string, rootDir: string,
taskId: string, taskId: string,
branch: string, branch: string,
@@ -837,19 +999,80 @@ async function commitOrAmendMergeWithFixes(
signal?: AbortSignal, signal?: AbortSignal,
aiSummary?: string | null, aiSummary?: string | null,
aiSubject?: string | null, aiSubject?: string | null,
fixModifiedFiles: ReadonlySet<string> = new Set(),
): Promise<boolean> { ): Promise<boolean> {
try { try {
// Stage everything (squash state + verification fixes the agent left // Build an allowlist of paths we are permitted to stage.
// unstaged). FN-2152 still applies: filter out any submodule gitlinks // Allowlist = (already staged by squash) ∪ (unstaged ∩ fixModifiedFiles)
// before committing. // We also handle untracked files created by the fix agent.
const { stdout: unstagedFiles } = await execAsync("git diff --name-only", { //
// FN-2152 still applies: the submodule-gitlink filter below removes any
// gitlinks that slip through (nested worktrees, etc.).
// 1. Read currently-staged files (squash produced these) for diagnostic logging.
const { stdout: squashStagedOut } = await execAsync("git diff --cached --name-only", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}); });
if (unstagedFiles.trim().length > 0) { const squashStaged = new Set(squashStagedOut.split("\n").map((l) => l.trim()).filter(Boolean));
await execAsync("git add -A", { cwd: rootDir });
// 2. What is currently unstaged (tracked, modified-but-not-staged).
const { stdout: unstagedOut } = await execAsync("git diff --name-only", {
cwd: rootDir,
encoding: "utf-8",
});
const unstaged = new Set(unstagedOut.split("\n").map((l) => l.trim()).filter(Boolean));
// 3. Untracked files created by the fix agent (NUL-delimited, no quoting needed).
const { stdout: porcelainOut } = await execFileAsync("git", ["status", "-z", "--porcelain"], {
cwd: rootDir,
encoding: "utf-8",
});
const untracked = new Set<string>();
for (const entry of porcelainOut.split("\0")) {
if (!entry.startsWith("?? ")) continue;
const p = entry.slice(3);
if (p) untracked.add(p);
} }
// 4. Stage each unstaged path that the fix agent touched (batched, no shell).
const unstagedToStage: string[] = [];
for (const p of unstaged) {
if (fixModifiedFiles.has(p)) {
unstagedToStage.push(p);
} else {
mergerLog.warn(
`${taskId}: refusing to stage unrelated working-tree change: ${p} (not part of squash or in-merge fix)`,
);
}
}
if (unstagedToStage.length > 0) {
await execFileAsync("git", ["add", "--", ...unstagedToStage], { cwd: rootDir });
}
// 5. Stage untracked files created by the fix agent (batched, no shell).
const untrackedToStage: string[] = [];
for (const p of untracked) {
if (fixModifiedFiles.has(p)) {
untrackedToStage.push(p);
} else {
mergerLog.warn(
`${taskId}: refusing to stage unrelated working-tree change: ${p} (not part of squash or in-merge fix)`,
);
}
}
if (untrackedToStage.length > 0) {
await execFileAsync("git", ["add", "--", ...untrackedToStage], { cwd: rootDir });
}
// Fix 3: cap long path lists to avoid unreadable single-line logs.
const cap = (arr: string[], n = 20) =>
arr.length <= n ? arr.join(", ") : `${arr.slice(0, n).join(", ")} ... (+${arr.length - n} more)`;
mergerLog.log(
`${taskId}: staging allowlist — squash: [${cap([...squashStaged])}], fixModified: [${cap([...fixModifiedFiles])}]`,
);
const { stdout: staged } = await execAsync("git diff --cached --raw", { const { stdout: staged } = await execAsync("git diff --cached --raw", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
@@ -1286,8 +1509,8 @@ export async function classifyConflict(filePath: string, cwd: string): Promise<C
*/ */
export async function resolveWithOurs(filePath: string, cwd: string): Promise<void> { export async function resolveWithOurs(filePath: string, cwd: string): Promise<void> {
try { try {
await execAsync(`git checkout --ours "${filePath}"`, { cwd }); await execFileAsync("git", ["checkout", "--ours", "--", filePath], { cwd });
await execAsync(`git add "${filePath}"`, { cwd }); await execFileAsync("git", ["add", "--", filePath], { cwd });
mergerLog.log(`Auto-resolved ${filePath} using --ours`); mergerLog.log(`Auto-resolved ${filePath} using --ours`);
} catch (error) { } catch (error) {
throw new Error(`Failed to auto-resolve ${filePath} with ours: ${error}`); throw new Error(`Failed to auto-resolve ${filePath} with ours: ${error}`);
@@ -1300,8 +1523,8 @@ export async function resolveWithOurs(filePath: string, cwd: string): Promise<vo
*/ */
export async function resolveWithTheirs(filePath: string, cwd: string): Promise<void> { export async function resolveWithTheirs(filePath: string, cwd: string): Promise<void> {
try { try {
await execAsync(`git checkout --theirs "${filePath}"`, { cwd }); await execFileAsync("git", ["checkout", "--theirs", "--", filePath], { cwd });
await execAsync(`git add "${filePath}"`, { cwd }); await execFileAsync("git", ["add", "--", filePath], { cwd });
mergerLog.log(`Auto-resolved ${filePath} using --theirs`); mergerLog.log(`Auto-resolved ${filePath} using --theirs`);
} catch (error) { } catch (error) {
throw new Error(`Failed to auto-resolve ${filePath} with theirs: ${error}`); throw new Error(`Failed to auto-resolve ${filePath} with theirs: ${error}`);
@@ -1314,7 +1537,7 @@ export async function resolveWithTheirs(filePath: string, cwd: string): Promise<
*/ */
export async function resolveTrivialWhitespace(filePath: string, cwd: string): Promise<void> { export async function resolveTrivialWhitespace(filePath: string, cwd: string): Promise<void> {
try { try {
await execAsync(`git add "${filePath}"`, { cwd }); await execFileAsync("git", ["add", "--", filePath], { cwd });
mergerLog.log(`Auto-resolved ${filePath} (trivial whitespace)`); mergerLog.log(`Auto-resolved ${filePath} (trivial whitespace)`);
} catch (error) { } catch (error) {
throw new Error(`Failed to auto-resolve ${filePath} trivial conflict: ${error}`); throw new Error(`Failed to auto-resolve ${filePath} trivial conflict: ${error}`);
@@ -1887,7 +2110,7 @@ function parsePushRemoteTarget(rootDir: string, pushRemote?: string): { remote:
let branch = branchTokens.join(" ").trim(); let branch = branchTokens.join(" ").trim();
if (!branch) { if (!branch) {
branch = execSync("git symbolic-ref --short HEAD", { branch = execSyncText("git symbolic-ref --short HEAD", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
@@ -2331,7 +2554,7 @@ export async function aiMergeTask(
result.error = `Branch '${branch}' not found — moving to done without merge`; result.error = `Branch '${branch}' not found — moving to done without merge`;
// Best-effort: try to capture current HEAD commitSha even though branch is missing // Best-effort: try to capture current HEAD commitSha even though branch is missing
try { try {
const commitSha = execSync("git rev-parse HEAD", { const commitSha = execSyncText("git rev-parse HEAD", {
cwd: rootDir, cwd: rootDir,
stdio: "pipe", stdio: "pipe",
encoding: "utf-8", encoding: "utf-8",
@@ -2360,12 +2583,12 @@ export async function aiMergeTask(
// causing feature code to be committed to the wrong lineage. // causing feature code to be committed to the wrong lineage.
try { try {
throwIfAborted(options.signal, taskId); throwIfAborted(options.signal, taskId);
const currentBranch = execSync("git symbolic-ref --short HEAD", { const currentBranch = execSyncText("git symbolic-ref --short HEAD", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
}).trim(); }).trim();
const mainBranch = execSync("git rev-parse --abbrev-ref origin/HEAD", { const mainBranch = execSyncText("git rev-parse --abbrev-ref origin/HEAD", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
@@ -3063,6 +3286,9 @@ export async function aiMergeTask(
if (failedResult) { if (failedResult) {
let fixSuccess = false; let fixSuccess = false;
// Accumulate all paths the fix agent touches across retries so
// commitOrAmendMergeWithFixes can build a precise allowlist.
const verificationFixModifiedFiles = new Set<string>();
for (let fixAttempt = 1; fixAttempt <= maxFixRetries; fixAttempt++) { for (let fixAttempt = 1; fixAttempt <= maxFixRetries; fixAttempt++) {
const fixAttemptStartedAt = Date.now(); const fixAttemptStartedAt = Date.now();
mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`); mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
@@ -3090,6 +3316,7 @@ export async function aiMergeTask(
fixAttempt, fixAttempt,
effectiveTestCommand, effectiveTestCommand,
effectiveBuildCommand, effectiveBuildCommand,
verificationFixModifiedFiles,
); );
const fixAttemptDurationMs = Date.now() - fixAttemptStartedAt; const fixAttemptDurationMs = Date.now() - fixAttemptStartedAt;
@@ -3135,6 +3362,7 @@ export async function aiMergeTask(
options.signal, options.signal,
aiMergeSummary, aiMergeSummary,
aiMergeSubject, aiMergeSubject,
verificationFixModifiedFiles,
); );
if (!finalized) { if (!finalized) {
// Phantom-merge guard: refused to fabricate a commit. Reset // Phantom-merge guard: refused to fabricate a commit. Reset
@@ -3175,6 +3403,9 @@ export async function aiMergeTask(
const fixType = effectiveBuildCommand ? "build" as const : "test" as const; const fixType = effectiveBuildCommand ? "build" as const : "test" as const;
let fixSuccess = false; let fixSuccess = false;
// Accumulate all paths the fix agent touches across retries so
// commitOrAmendMergeWithFixes can build a precise allowlist.
const buildFixModifiedFiles = new Set<string>();
for (let fixAttempt = 1; fixAttempt <= maxFixRetries; fixAttempt++) { for (let fixAttempt = 1; fixAttempt <= maxFixRetries; fixAttempt++) {
const fixAttemptStartedAt = Date.now(); const fixAttemptStartedAt = Date.now();
mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`); mergerLog.log(`${taskId}: in-merge verification fix attempt ${fixAttempt}/${maxFixRetries}`);
@@ -3202,6 +3433,7 @@ export async function aiMergeTask(
fixAttempt, fixAttempt,
effectiveTestCommand, effectiveTestCommand,
effectiveBuildCommand, effectiveBuildCommand,
buildFixModifiedFiles,
); );
const fixAttemptDurationMs = Date.now() - fixAttemptStartedAt; const fixAttemptDurationMs = Date.now() - fixAttemptStartedAt;
@@ -3242,6 +3474,7 @@ export async function aiMergeTask(
options.signal, options.signal,
aiMergeSummary, aiMergeSummary,
aiMergeSubject, aiMergeSubject,
buildFixModifiedFiles,
); );
if (!finalized) { if (!finalized) {
// Phantom-merge guard: the verification fix passed but no // Phantom-merge guard: the verification fix passed but no
@@ -3371,7 +3604,7 @@ export async function aiMergeTask(
// 5b. Collect merge details and store on task // 5b. Collect merge details and store on task
try { try {
const commitSha = execSync("git rev-parse HEAD", { const commitSha = execSyncText("git rev-parse HEAD", {
cwd: rootDir, cwd: rootDir,
stdio: "pipe", stdio: "pipe",
encoding: "utf-8", encoding: "utf-8",
@@ -3644,7 +3877,7 @@ export async function aiMergeTask(
async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promise<void> { async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promise<void> {
let currentBranch: string; let currentBranch: string;
try { try {
currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { currentBranch = execSyncText("git rev-parse --abbrev-ref HEAD", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
@@ -3665,7 +3898,7 @@ async function tryFastForwardFromOrigin(rootDir: string, taskId: string): Promis
let behind = 0; let behind = 0;
let ahead = 0; let ahead = 0;
try { try {
const counts = execSync(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, { const counts = execSyncText(`git rev-list --left-right --count "origin/${currentBranch}...HEAD"`, {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
stdio: "pipe", stdio: "pipe",
@@ -3903,7 +4136,7 @@ async function executeMergeAttempt(
// If only auto-resolvable conflicts (or all were resolved), commit directly // If only auto-resolvable conflicts (or all were resolved), commit directly
if (complex.length === 0) { if (complex.length === 0) {
// All conflicts auto-resolved, commit with fallback message // All conflicts auto-resolved, commit with fallback message
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", { const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();
@@ -4025,7 +4258,7 @@ async function executeMergeAttempt(
} }
// Check for conflicts // Check for conflicts
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", { const conflictedOutput = execSyncText("git diff --name-only --diff-filter=U", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();
@@ -4206,7 +4439,7 @@ async function attemptWithSideStrategy(
}); });
// Check if there are still conflicts (some types can't be auto-resolved) // Check if there are still conflicts (some types can't be auto-resolved)
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", { const conflictedOutput = execSyncText("git diff --name-only --diff-filter=U", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();
@@ -4217,7 +4450,7 @@ async function attemptWithSideStrategy(
} }
// Check if there's anything staged // Check if there's anything staged
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", { const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();
@@ -4584,7 +4817,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
} }
// Verify commit happened // Verify commit happened
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", { const staged = execSyncText("git diff --cached --quiet 2>&1; echo $?", {
cwd: rootDir, cwd: rootDir,
encoding: "utf-8", encoding: "utf-8",
}).trim(); }).trim();

View File

@@ -260,7 +260,13 @@ export class ResearchOrchestrator {
} }
for (const source of result.data.slice(0, Math.max(0, config.maxSources - allSources.length))) { for (const source of result.data.slice(0, Math.max(0, config.maxSources - allSources.length))) {
const saved = this.store.addSource(runId, source); const saved = this.store.addSource(runId, {
...source,
metadata: {
...(source.metadata ?? {}),
providerType: provider.type,
},
});
allSources.push(saved); allSources.push(saved);
this.store.addEvent(runId, { this.store.addEvent(runId, {
type: "source_added", type: "source_added",
@@ -298,7 +304,9 @@ export class ResearchOrchestrator {
}); });
this.stepStarted(runId, step); this.stepStarted(runId, step);
const result = await this.stepRunner.runContentFetch(source.reference, provider?.config, signal); const sourceProvider = this.getSourceProviderType(source);
const providerConfig = sourceProvider ? config.providers.find((p) => p.type === sourceProvider)?.config : provider?.config;
const result = await this.stepRunner.runContentFetch(source.reference, sourceProvider, providerConfig, signal);
if (!result.ok || !result.data) { if (!result.ok || !result.data) {
this.stepFailed(runId, step.id, result.error?.message ?? "Failed to fetch source content", result.error); this.stepFailed(runId, step.id, result.error?.message ?? "Failed to fetch source content", result.error);
continue; continue;
@@ -547,6 +555,11 @@ export class ResearchOrchestrator {
} }
} }
private getSourceProviderType(source: ResearchSource): string | undefined {
const providerType = source.metadata?.providerType;
return typeof providerType === "string" && providerType.length > 0 ? providerType : undefined;
}
private canWriteRunData(runId: string): boolean { private canWriteRunData(runId: string): boolean {
const run = this.store.getRun(runId); const run = this.store.getRun(runId);
if (!run) return false; if (!run) return false;

View File

@@ -64,6 +64,7 @@ export interface ResearchStepRunnerApi {
): Promise<ResearchStepResult<ResearchSource[]>>; ): Promise<ResearchStepResult<ResearchSource[]>>;
runContentFetch( runContentFetch(
url: string, url: string,
providerType?: string,
config?: ResearchProviderConfig, config?: ResearchProviderConfig,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>>; ): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>>;
@@ -118,10 +119,11 @@ export class ResearchStepRunner implements ResearchStepRunnerApi {
async runContentFetch( async runContentFetch(
url: string, url: string,
providerType?: string,
config: ResearchProviderConfig = {}, config: ResearchProviderConfig = {},
signal?: AbortSignal, signal?: AbortSignal,
): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>> { ): Promise<ResearchStepResult<{ content: string; metadata: Record<string, unknown> }>> {
const provider = this.findFirstConfiguredProvider(); const provider = this.resolveContentProvider(providerType);
if (!provider) { if (!provider) {
return this.unconfigured("no configured provider available for content fetch"); return this.unconfigured("no configured provider available for content fetch");
} }
@@ -169,6 +171,14 @@ export class ResearchStepRunner implements ResearchStepRunnerApi {
return undefined; return undefined;
} }
private resolveContentProvider(providerType?: string): ResearchProvider | undefined {
if (providerType) {
const selected = this.providers.get(providerType);
if (selected?.isConfigured()) return selected;
}
return this.findFirstConfiguredProvider();
}
private classifyError<T>(step: string, error: unknown): ResearchStepResult<T> { private classifyError<T>(step: string, error: unknown): ResearchStepResult<T> {
if (error instanceof ResearchStepTimeoutError) { if (error instanceof ResearchStepTimeoutError) {
return { ok: false, error: { code: "timeout", message: error.message, retryable: true } }; return { ok: false, error: { code: "timeout", message: error.message, retryable: true } };

View File

@@ -20,6 +20,8 @@ export interface VerificationCommandResult {
stdout: string; stdout: string;
stderr: string; stderr: string;
success: boolean; success: boolean;
/** True when this result was satisfied from the verification cache rather than running the command. */
cached?: boolean;
} }
/** Result of running all verification commands */ /** Result of running all verification commands */
@@ -40,7 +42,7 @@ export interface VerificationResult {
*/ */
export async function execWithProcessGroup( export async function execWithProcessGroup(
command: string, command: string,
options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal }, options: { cwd: string; timeout: number; maxBuffer: number; signal?: AbortSignal; env?: NodeJS.ProcessEnv },
): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean; aborted?: boolean }> { ): Promise<{ stdout: string; stderr: string; bufferOverflow: boolean; aborted?: boolean }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (options.signal?.aborted) { if (options.signal?.aborted) {
@@ -58,6 +60,7 @@ export async function execWithProcessGroup(
shell: true, shell: true,
detached: useProcessGroup, detached: useProcessGroup,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
...(options.env !== undefined && { env: { ...process.env, ...options.env } }),
}); });
let stdout = ""; let stdout = "";
@@ -310,6 +313,8 @@ export async function runVerificationCommand(
log?: { log: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void; warn: (message: string, ...args: unknown[]) => void }, log?: { log: (message: string, ...args: unknown[]) => void; error: (message: string, ...args: unknown[]) => void; warn: (message: string, ...args: unknown[]) => void },
/** Optional agent label for store log entries (e.g. "merger", "executor") */ /** Optional agent label for store log entries (e.g. "merger", "executor") */
agentLabel?: string, agentLabel?: string,
/** Optional extra environment variables to inject into the child process (merged over process.env). */
extraEnv?: NodeJS.ProcessEnv,
): Promise<VerificationCommandResult> { ): Promise<VerificationCommandResult> {
const logger = log ?? { log: console.log, error: console.error, warn: console.warn }; const logger = log ?? { log: console.log, error: console.error, warn: console.warn };
const label = (agentLabel ?? "merger") as AgentRole; const label = (agentLabel ?? "merger") as AgentRole;
@@ -340,6 +345,7 @@ export async function runVerificationCommand(
timeout: VERIFICATION_COMMAND_TIMEOUT_MS, timeout: VERIFICATION_COMMAND_TIMEOUT_MS,
maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER, maxBuffer: VERIFICATION_COMMAND_MAX_BUFFER,
signal, signal,
...(extraEnv !== undefined && { env: extraEnv }),
}); });
if (signal?.aborted) { if (signal?.aborted) {

View File

@@ -11,11 +11,11 @@
"exports": { "exports": {
".": { ".": {
"types": "./src/index.ts", "types": "./src/index.ts",
"import": "./dist/index.js" "import": "./src/index.ts"
}, },
"./probe": { "./probe": {
"types": "./src/probe.ts", "types": "./src/probe.ts",
"import": "./dist/probe.js" "import": "./src/probe.ts"
} }
}, },
"private": true, "private": true,

View File

@@ -0,0 +1,552 @@
/**
* Unit tests for scripts/test-changed.mjs
*
* Runner: node --test scripts/__tests__/test-changed.test.mjs
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
shouldForceFullSuite,
resolveAffectedPackages,
decideExecutionPlan,
computePackageHash,
readCache,
writeCache,
applyCacheToPlan,
recordCachePass,
cacheFilePath,
} from "../test-changed.mjs";
import { mkdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a minimal Map<dir, pkgName> for testing. */
function pkgMap(entries) {
return new Map(entries);
}
/** Build a reverse Map<pkgName, dir> for testing. */
function dirByName(entries) {
return new Map(entries);
}
/**
* Create a temporary directory, run the callback with its path, then clean up.
*
* @param {(dir: string) => void} fn
*/
function withTmpDir(fn) {
const dir = mkdtempSync(path.join(tmpdir(), "tc-test-"));
try {
fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/**
* A deterministic fake gitFn that returns a fixed blob sha for any path.
*
* @param {string} blobSha
* @returns {(args: string[]) => string}
*/
function fakeGit(blobSha = "aabbccdd00112233aabbccdd00112233aabbccdd") {
return (args) => {
// ls-files -s output format: "<mode> <sha> <stage>\t<path>"
const pathArg = args[args.length - 1];
return `100644 ${blobSha} 0\t${pathArg}`;
};
}
/**
* Compute a hash using a deterministic git stub.
*/
function hashWithFakeGit(pkgDir, blobSha) {
return computePackageHash(pkgDir, fakeGit(blobSha));
}
// ---------------------------------------------------------------------------
// shouldForceFullSuite
// ---------------------------------------------------------------------------
test("shouldForceFullSuite: returns false for pure package changes", () => {
assert.equal(
shouldForceFullSuite(["packages/engine/src/foo.ts", "packages/core/src/bar.ts"]),
false,
);
});
test("shouldForceFullSuite: returns true when pnpm-lock.yaml changed", () => {
assert.equal(shouldForceFullSuite(["pnpm-lock.yaml"]), true);
});
test("shouldForceFullSuite: returns true when scripts/test-changed.mjs changed", () => {
assert.equal(shouldForceFullSuite(["scripts/test-changed.mjs"]), true);
});
test("shouldForceFullSuite: returns true when a GitHub workflow changed", () => {
assert.equal(shouldForceFullSuite([".github/workflows/ci.yml"]), true);
});
// ---------------------------------------------------------------------------
// resolveAffectedPackages
// ---------------------------------------------------------------------------
test("resolveAffectedPackages: maps changed files to package names", () => {
const map = pkgMap([["engine", "@fusion/engine"], ["core", "@fusion/core"]]);
const result = resolveAffectedPackages(
["packages/engine/src/index.ts", "packages/core/src/utils.ts"],
map,
);
assert.deepEqual(result?.sort(), ["@fusion/core", "@fusion/engine"]);
});
test("resolveAffectedPackages: ignores non-package files", () => {
const map = pkgMap([["engine", "@fusion/engine"]]);
const result = resolveAffectedPackages(["docs/readme.md"], map);
assert.deepEqual(result, []);
});
test("resolveAffectedPackages: returns null for unknown package dir", () => {
const map = pkgMap([["engine", "@fusion/engine"]]);
const result = resolveAffectedPackages(["packages/unknown-pkg/src/foo.ts"], map);
assert.equal(result, null);
});
// ---------------------------------------------------------------------------
// decideExecutionPlan
// ---------------------------------------------------------------------------
const basePackageMap = pkgMap([["engine", "@fusion/engine"], ["core", "@fusion/core"]]);
test("decideExecutionPlan: forced full suite", () => {
const plan = decideExecutionPlan({
forceFullSuite: true,
comparisonBase: "abc123",
changedFiles: ["packages/engine/src/index.ts"],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "forced");
});
test("decideExecutionPlan: missing comparison base → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: null,
changedFiles: null,
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "missing-comparison-base");
});
test("decideExecutionPlan: diff failed → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: null,
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "diff-failed");
});
test("decideExecutionPlan: no changes → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: [],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "no-changes");
});
test("decideExecutionPlan: shared infra changed → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: ["pnpm-lock.yaml"],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "shared-infra-changed");
});
test("decideExecutionPlan: only package files changed → changed mode", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: ["packages/engine/src/index.ts"],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "changed");
assert.deepEqual(plan.packages, ["@fusion/engine"]);
});
test("decideExecutionPlan: no affected package resolved → full", () => {
const plan = decideExecutionPlan({
forceFullSuite: false,
comparisonBase: "abc123",
changedFiles: ["packages/nonexistent/src/foo.ts"],
packageNameByDir: basePackageMap,
});
assert.equal(plan.mode, "full");
assert.equal(plan.reason, "no-affected-package");
});
// ---------------------------------------------------------------------------
// computePackageHash
// ---------------------------------------------------------------------------
test("computePackageHash: produces a 64-char hex string", () => {
const hash = hashWithFakeGit("packages/engine", "aabb1122");
assert.match(hash, /^[0-9a-f]{64}$/);
});
test("computePackageHash: same inputs produce same hash (determinism)", () => {
const h1 = hashWithFakeGit("packages/engine", "aabb1122");
const h2 = hashWithFakeGit("packages/engine", "aabb1122");
assert.equal(h1, h2);
});
test("computePackageHash: different blob sha produces different hash", () => {
const h1 = hashWithFakeGit("packages/engine", "aabb1122");
const h2 = hashWithFakeGit("packages/engine", "deadbeef");
assert.notEqual(h1, h2);
});
test("computePackageHash: hash includes pnpm-lock.yaml so lockfile change busts everything", () => {
// Two fakeGit functions that return different blob SHAs for pnpm-lock.yaml.
const gitWithLockA = (args) => {
const p = args[args.length - 1];
if (p === "pnpm-lock.yaml") return `100644 locksha-AAAA 0\tpnpm-lock.yaml`;
return `100644 pkgsha-same 0\t${p}`;
};
const gitWithLockB = (args) => {
const p = args[args.length - 1];
if (p === "pnpm-lock.yaml") return `100644 locksha-BBBB 0\tpnpm-lock.yaml`;
return `100644 pkgsha-same 0\t${p}`;
};
const hashA = computePackageHash("packages/engine", gitWithLockA);
const hashB = computePackageHash("packages/engine", gitWithLockB);
assert.notEqual(hashA, hashB);
});
test("computePackageHash: hash includes tsconfig.base.json so shared TS config change busts cache", () => {
const gitWithTsA = (args) => {
const p = args[args.length - 1];
if (p === "tsconfig.base.json") return `100644 tsconfig-SHA-AAA 0\ttsconfig.base.json`;
return `100644 same-blob 0\t${p}`;
};
const gitWithTsB = (args) => {
const p = args[args.length - 1];
if (p === "tsconfig.base.json") return `100644 tsconfig-SHA-BBB 0\ttsconfig.base.json`;
return `100644 same-blob 0\t${p}`;
};
const hashA = computePackageHash("packages/engine", gitWithTsA);
const hashB = computePackageHash("packages/engine", gitWithTsB);
assert.notEqual(hashA, hashB);
});
// ---------------------------------------------------------------------------
// readCache / writeCache
// ---------------------------------------------------------------------------
test("readCache: returns empty cache for missing file", () => {
withTmpDir((dir) => {
const result = readCache(path.join(dir, "nonexistent.json"));
assert.equal(result.version, 1);
assert.deepEqual(result.entries, {});
});
});
test("readCache: returns empty cache for corrupted JSON", () => {
withTmpDir((dir) => {
const p = path.join(dir, "cache.json");
writeFileSync(p, "{ this is not valid json }", "utf8");
const result = readCache(p);
assert.equal(result.version, 1);
assert.deepEqual(result.entries, {});
});
});
test("readCache: returns empty cache when version field is wrong", () => {
withTmpDir((dir) => {
const p = path.join(dir, "cache.json");
writeFileSync(p, JSON.stringify({ version: 99, entries: {} }), "utf8");
const result = readCache(p);
assert.deepEqual(result.entries, {});
});
});
test("readCache / writeCache: round-trips correctly", () => {
withTmpDir((dir) => {
const p = path.join(dir, "cache.json");
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash: "abc123", passedAt: "2026-01-01T00:00:00.000Z", command: "test" },
},
};
writeCache(p, cache);
const read = readCache(p);
assert.deepEqual(read, cache);
});
});
// ---------------------------------------------------------------------------
// applyCacheToPlan
// ---------------------------------------------------------------------------
test("applyCacheToPlan: cache HIT excludes package from activePackages", () => {
const hash = hashWithFakeGit("packages/engine", "fixed-sha");
const passedAt = new Date().toISOString(); // just now → fresh
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash, passedAt, command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
assert.deepEqual(result.cachedPackages, ["@fusion/engine"]);
assert.deepEqual(result.activePackages, []);
});
test("applyCacheToPlan: cache MISS includes package in activePackages", () => {
const cache = { version: 1, entries: {} }; // no entries → miss
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
test("applyCacheToPlan: stale entry (older than 7 days) causes a cache MISS", () => {
const hash = hashWithFakeGit("packages/engine", "fixed-sha");
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash, passedAt: eightDaysAgo, command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
test("applyCacheToPlan: hash mismatch causes a cache MISS", () => {
const cachedHash = hashWithFakeGit("packages/engine", "old-sha");
// Script will compute hash with "new-sha" blob
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash: cachedHash, passedAt: new Date().toISOString(), command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("new-sha"), // different blob → different hash
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
test("applyCacheToPlan: noCache=true bypasses lookup and always returns all packages as active", () => {
const hash = hashWithFakeGit("packages/engine", "fixed-sha");
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash, passedAt: new Date().toISOString(), command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine"] };
const result = applyCacheToPlan(plan, {
noCache: true,
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
// Cache would be a HIT if noCache were false, but it's bypassed.
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
test("applyCacheToPlan: FUSION_TEST_NO_CACHE=1 bypasses lookup (env integration check)", () => {
// This test checks that callers pass noCache=true when env is set.
// The actual env reading is in main(); we verify the flag propagates correctly.
const noCacheFromEnv = process.env.FUSION_TEST_NO_CACHE === "1";
// Set env temporarily for this check.
const originalVal = process.env.FUSION_TEST_NO_CACHE;
process.env.FUSION_TEST_NO_CACHE = "1";
const noCache = process.env.FUSION_TEST_NO_CACHE === "1";
assert.equal(noCache, true);
process.env.FUSION_TEST_NO_CACHE = originalVal ?? "";
if (!originalVal) delete process.env.FUSION_TEST_NO_CACHE;
});
test("applyCacheToPlan: full plan is not filtered by cache", () => {
const plan = { mode: "full", reason: "forced" };
const result = applyCacheToPlan(plan, {
readCacheFn: () => { throw new Error("should not read cache for full plan"); },
packageDirByName: new Map(),
});
assert.equal(result.cachedPackages.length, 0);
assert.deepEqual(result.activePackages, []);
});
test("applyCacheToPlan: corrupted cache file → continues without crash (cache miss)", () => {
withTmpDir((dir) => {
const p = path.join(dir, "cache.json");
writeFileSync(p, "<<<invalid json>>>", "utf8");
const plan = { mode: "changed", packages: ["@fusion/engine"] };
// Use the real readCache which handles corruption gracefully.
const result = applyCacheToPlan(plan, {
gitFn: fakeGit("fixed-sha"),
readCacheFn: () => readCache(p),
writeCacheFn: () => {},
packageDirByName: dirByName([["@fusion/engine", "packages/engine"]]),
});
// Should not throw and should treat all packages as active (miss).
assert.deepEqual(result.cachedPackages, []);
assert.deepEqual(result.activePackages, ["@fusion/engine"]);
});
});
test("applyCacheToPlan: mixed HIT and MISS across multiple packages", () => {
// Use the same gitFn for both pre-computing the cached hash and the runtime
// lookup so that root-file blob SHAs (pnpm-lock.yaml, tsconfig.base.json)
// are identical in both contexts.
const gitFnMulti = (args) => {
const p = args[args.length - 1];
if (p === "packages/engine") return `100644 sha-engine 0\tpackages/engine/src/index.ts`;
if (p === "packages/core") return `100644 sha-core 0\tpackages/core/src/index.ts`;
// Root files (pnpm-lock.yaml, tsconfig.base.json) get a stable blob sha.
return `100644 common-root-sha 0\t${p}`;
};
// Pre-compute the engine hash using the SAME gitFnMulti so the stored hash
// matches what applyCacheToPlan will compute at lookup time.
const engineHash = computePackageHash("packages/engine", gitFnMulti);
// core is NOT in cache → miss
const cache = {
version: 1,
entries: {
"@fusion/engine": { hash: engineHash, passedAt: new Date().toISOString(), command: "test" },
},
};
const plan = { mode: "changed", packages: ["@fusion/engine", "@fusion/core"] };
const result = applyCacheToPlan(plan, {
gitFn: gitFnMulti,
readCacheFn: () => cache,
writeCacheFn: () => {},
packageDirByName: dirByName([
["@fusion/engine", "packages/engine"],
["@fusion/core", "packages/core"],
]),
});
assert.deepEqual(result.cachedPackages, ["@fusion/engine"]);
assert.deepEqual(result.activePackages, ["@fusion/core"]);
});
// ---------------------------------------------------------------------------
// recordCachePass
// ---------------------------------------------------------------------------
test("recordCachePass: writes hash and passedAt for passing packages", () => {
let written = null;
const cache = { version: 1, entries: {} };
recordCachePass(["@fusion/engine"], dirByName([["@fusion/engine", "packages/engine"]]), {
gitFn: fakeGit("abc123"),
readCacheFn: () => cache,
writeCacheFn: (c) => { written = c; },
});
assert.ok(written, "cache was written");
const entry = written.entries["@fusion/engine"];
assert.ok(entry, "entry exists");
assert.match(entry.hash, /^[0-9a-f]{64}$/);
assert.equal(entry.command, "test");
assert.ok(new Date(entry.passedAt).getTime() > 0, "passedAt is a valid date");
});
test("recordCachePass: noCache=true skips write", () => {
let written = false;
recordCachePass(["@fusion/engine"], dirByName([["@fusion/engine", "packages/engine"]]), {
noCache: true,
gitFn: fakeGit("abc123"),
readCacheFn: () => ({ version: 1, entries: {} }),
writeCacheFn: () => { written = true; },
});
assert.equal(written, false);
});
test("recordCachePass: empty package list skips write", () => {
let written = false;
recordCachePass([], new Map(), {
gitFn: fakeGit("abc123"),
readCacheFn: () => ({ version: 1, entries: {} }),
writeCacheFn: () => { written = true; },
});
assert.equal(written, false);
});
// ---------------------------------------------------------------------------
// cacheFilePath
// ---------------------------------------------------------------------------
test("cacheFilePath: ends with .fusion/test-cache.json", () => {
const p = cacheFilePath();
assert.ok(p.endsWith(path.join(".fusion", "test-cache.json")), `got: ${p}`);
});

View File

@@ -1,11 +1,23 @@
#!/usr/bin/env node #!/usr/bin/env node
import { readFileSync, readdirSync } from "node:fs"; import { readFileSync, readdirSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
const rootDir = process.cwd(); const rootDir = process.env.FUSION_PROJECT_DIR
? path.resolve(process.env.FUSION_PROJECT_DIR)
: process.cwd();
/** @type {string} Cache format version — bump when the shape or hash inputs change. */
const CACHE_FORMAT_VERSION = 1;
/** @type {string} Constant mixed into every content hash so format rev busts all entries. */
const HASH_VERSION_PREFIX = "v1";
/** @type {number} Max age (ms) for a cache entry to count as a pass. */
const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function run(command, commandArgs, options = {}) { function run(command, commandArgs, options = {}) {
const result = spawnSync(command, commandArgs, { const result = spawnSync(command, commandArgs, {
@@ -142,6 +154,258 @@ export function resolveAffectedPackages(changedFiles, packageNameByDir) {
return [...affected]; return [...affected];
} }
// ---------------------------------------------------------------------------
// Content-hash cache
// ---------------------------------------------------------------------------
/**
* @typedef {{ hash: string; passedAt: string; command: string }} CacheEntry
* @typedef {{ version: number; entries: Record<string, CacheEntry> }} CacheFile
*/
/**
* Return the path to the per-project test-cache JSON file.
* Honours FUSION_PROJECT_DIR (already reflected in rootDir).
*
* @returns {string}
*/
export function cacheFilePath() {
return path.join(rootDir, ".fusion", "test-cache.json");
}
/**
* Read and parse the cache file. Returns an empty cache structure on any
* read/parse failure (corruption, missing file, etc.) and logs a warning.
*
* @param {string} filePath
* @returns {CacheFile}
*/
export function readCache(filePath) {
try {
const raw = readFileSync(filePath, "utf8");
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
parsed.version === CACHE_FORMAT_VERSION &&
parsed.entries &&
typeof parsed.entries === "object"
) {
return parsed;
}
console.warn("[test-changed] cache file has unexpected shape; treating as empty.");
return { version: CACHE_FORMAT_VERSION, entries: {} };
} catch (err) {
if (err.code !== "ENOENT") {
console.warn(`[test-changed] could not read cache (${err.message}); treating as empty.`);
}
return { version: CACHE_FORMAT_VERSION, entries: {} };
}
}
/**
* Atomically write the cache file (write to temp then rename).
*
* @param {string} filePath
* @param {CacheFile} cache
*/
export function writeCache(filePath, cache) {
const dir = path.dirname(filePath);
mkdirSync(dir, { recursive: true });
const tmp = `${filePath}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf8");
renameSync(tmp, filePath);
}
/**
* Compute a stable content hash for a package directory.
*
* The hash is SHA-256 over:
* - The constant version prefix HASH_VERSION_PREFIX
* - The blob SHA of pnpm-lock.yaml at HEAD
* - The blob SHA of tsconfig.base.json at HEAD
* - Every (relativePath, blobSha) pair from `git ls-files -s <pkgDir>`,
* sorted lexicographically by path for stability.
*
* Using git blob SHAs means we never read file contents ourselves — git
* already hashes them, so this is fast even for large packages.
*
* @param {string} packageDir Relative path to the package dir (e.g. "packages/engine")
* @param {(args: string[]) => string|null} gitFn Injectable git runner (for tests)
* @returns {string} 64-char hex SHA-256
*/
export function computePackageHash(packageDir, gitFn = gitOutput) {
const hash = createHash("sha256");
hash.update(HASH_VERSION_PREFIX);
hash.update("\0");
// Bust when lock file or shared TS config changes.
for (const rootFile of ["pnpm-lock.yaml", "tsconfig.base.json"]) {
// `git ls-files -s <path>` → "<mode> <blobSha> <stage>\t<path>"
const out = gitFn(["ls-files", "-s", rootFile]);
const blobSha = out ? out.split(/\s+/)[1] ?? "" : "";
hash.update(rootFile);
hash.update("=");
hash.update(blobSha);
hash.update("\0");
}
// All tracked files inside the package directory.
const lsOut = gitFn(["ls-files", "-s", packageDir]);
const entries = [];
if (lsOut) {
for (const line of lsOut.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
// Format: <mode> SP <object> SP <stage> TAB <file>
const tabIdx = trimmed.indexOf("\t");
if (tabIdx === -1) continue;
const fields = trimmed.slice(0, tabIdx).split(/\s+/);
const blobSha = fields[1] ?? "";
const filePath = trimmed.slice(tabIdx + 1);
entries.push({ filePath, blobSha });
}
}
// Sort for determinism (git output is usually sorted, but let's be explicit).
entries.sort((a, b) => a.filePath.localeCompare(b.filePath));
for (const { filePath, blobSha } of entries) {
hash.update(filePath);
hash.update("=");
hash.update(blobSha);
hash.update("\0");
}
return hash.digest("hex");
}
/**
* Return a human-readable relative time string like "3h ago" or "2d ago".
*
* @param {string} isoTimestamp
* @returns {string}
*/
function relativeTime(isoTimestamp) {
const diffMs = Date.now() - new Date(isoTimestamp).getTime();
const diffSecs = Math.floor(diffMs / 1000);
if (diffSecs < 60) return `${diffSecs}s ago`;
const diffMins = Math.floor(diffSecs / 60);
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
/**
* @typedef {Object} CacheOptions
* @property {boolean} [noCache] When true, bypass cache reads AND writes.
* @property {(args: string[]) => string|null} [gitFn] Injectable git runner.
* @property {() => CacheFile} [readCacheFn] Injectable cache reader.
* @property {(cache: CacheFile) => void} [writeCacheFn] Injectable cache writer.
* @property {Map<string, string>} [packageDirByName] pkg-name → relative dir.
*/
/**
* Apply the content-hash cache to an execution plan.
*
* This is a SEPARATE function from decideExecutionPlan so it can be tested
* independently (decideExecutionPlan remains pure / I/O-free).
*
* For "full" plans, cache lookups are always skipped (running full means full).
* For "changed" plans, any package whose hash matches a fresh cache entry is
* removed from the run set. If all packages are cached, returns a synthetic
* "all-cached" result so the caller can skip the pnpm invocation entirely.
*
* @param {{ mode: string; packages?: string[]; reason?: string }} plan
* @param {CacheOptions} [options]
* @returns {{ plan: typeof plan; cachedPackages: string[]; activePackages: string[] }}
*/
export function applyCacheToPlan(plan, options = {}) {
const {
noCache = false,
gitFn = gitOutput,
readCacheFn,
writeCacheFn,
packageDirByName = new Map(),
} = options;
// Full suite runs always bypass cache (full means full).
if (plan.mode !== "changed" || noCache) {
return { plan, cachedPackages: [], activePackages: plan.packages ?? [] };
}
const filePath = cacheFilePath();
const cache = readCacheFn ? readCacheFn() : readCache(filePath);
const now = Date.now();
const cachedPackages = [];
const activePackages = [];
for (const pkg of plan.packages ?? []) {
const pkgDir = packageDirByName.get(pkg) ?? `packages/${pkg.replace(/^@[^/]+\//, "")}`;
const computedHash = computePackageHash(pkgDir, gitFn);
const entry = cache.entries[pkg];
const isHit =
entry &&
entry.hash === computedHash &&
now - new Date(entry.passedAt).getTime() < CACHE_MAX_AGE_MS;
if (isHit) {
const sha7 = computedHash.slice(0, 7);
const when = relativeTime(entry.passedAt);
console.log(`[test-changed] cache HIT for ${pkg} (hash ${sha7}, passed ${when})`);
cachedPackages.push(pkg);
} else {
activePackages.push(pkg);
}
}
return { plan, cachedPackages, activePackages };
}
/**
* Persist passing results for the given packages into the cache.
*
* @param {string[]} packages
* @param {Map<string, string>} packageDirByName
* @param {CacheOptions} [options]
*/
export function recordCachePass(packages, packageDirByName, options = {}) {
const {
noCache = false,
gitFn = gitOutput,
readCacheFn,
writeCacheFn,
} = options;
if (noCache || packages.length === 0) return;
const filePath = cacheFilePath();
const cache = readCacheFn ? readCacheFn() : readCache(filePath);
const now = new Date().toISOString();
for (const pkg of packages) {
const pkgDir = packageDirByName.get(pkg) ?? `packages/${pkg.replace(/^@[^/]+\//, "")}`;
const hash = computePackageHash(pkgDir, gitFn);
cache.entries[pkg] = { hash, passedAt: now, command: "test" };
}
if (writeCacheFn) {
writeCacheFn(cache);
} else {
writeCache(filePath, cache);
}
}
// ---------------------------------------------------------------------------
// Execution plan
// ---------------------------------------------------------------------------
const workspaceConcurrency =
process.env.FUSION_TEST_WORKSPACE_CONCURRENCY || "2";
const fullSuiteEnv = { const fullSuiteEnv = {
...process.env, ...process.env,
FUSION_TEST_TOTAL_WORKERS: process.env.FUSION_TEST_TOTAL_WORKERS || "4", FUSION_TEST_TOTAL_WORKERS: process.env.FUSION_TEST_TOTAL_WORKERS || "4",
@@ -149,7 +413,7 @@ const fullSuiteEnv = {
}; };
function runFullSuite(forwardedArgs) { function runFullSuite(forwardedArgs) {
run("pnpm", ["-r", "--workspace-concurrency=2", "test", ...forwardedArgs], { env: fullSuiteEnv }); run("pnpm", [`-r`, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
} }
export function decideExecutionPlan({ export function decideExecutionPlan({
@@ -176,7 +440,11 @@ export function main(argv = process.argv.slice(2)) {
process.env.FUSION_TEST_FULL === "1" || process.env.FUSION_TEST_FULL === "1" ||
argv.includes("--full"); argv.includes("--full");
const forwardedArgs = argv.filter((arg) => arg !== "--full"); const noCache =
process.env.FUSION_TEST_NO_CACHE === "1" ||
argv.includes("--no-cache");
const forwardedArgs = argv.filter((arg) => arg !== "--full" && arg !== "--no-cache");
run("pnpm", ["sync:fusion-skill:check"]); run("pnpm", ["sync:fusion-skill:check"]);
@@ -185,6 +453,12 @@ export function main(argv = process.argv.slice(2)) {
const changedFiles = comparisonBase ? changedFilesSince(comparisonBase) : null; const changedFiles = comparisonBase ? changedFilesSince(comparisonBase) : null;
const packageNameByDir = listWorkspacePackages(); const packageNameByDir = listWorkspacePackages();
// Build reverse map: pkg-name → relative dir (e.g. "packages/engine")
const packageDirByName = new Map();
for (const [dir, name] of packageNameByDir) {
packageDirByName.set(name, `packages/${dir}`);
}
const plan = decideExecutionPlan({ const plan = decideExecutionPlan({
forceFullSuite, forceFullSuite,
comparisonBase, comparisonBase,
@@ -209,9 +483,29 @@ export function main(argv = process.argv.slice(2)) {
return; return;
} }
const filterArgs = plan.packages.flatMap((pkg) => ["--filter", pkg]); // Apply the content-hash cache to prune already-passing packages.
console.log(`[test-changed] running tests for changed packages: ${plan.packages.join(", ")}`); const { cachedPackages, activePackages } = applyCacheToPlan(plan, {
run("pnpm", [...filterArgs, "test", ...forwardedArgs], { env: fullSuiteEnv }); noCache: noCache || forceFullSuite,
packageDirByName,
});
if (activePackages.length === 0) {
console.log(
`[test-changed] all changed packages are cache-fresh (${cachedPackages.join(", ")}); nothing to run.`,
);
return;
}
const filterArgs = activePackages.flatMap((pkg) => ["--filter", pkg]);
console.log(`[test-changed] running tests for changed packages: ${activePackages.join(", ")}`);
if (cachedPackages.length > 0) {
console.log(`[test-changed] skipping cached packages: ${cachedPackages.join(", ")}`);
}
run("pnpm", [...filterArgs, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: fullSuiteEnv });
// Tests passed — record in cache (never cache failures; process.exit on failure above).
recordCachePass(activePackages, packageDirByName, { noCache });
} }
const currentFilePath = fileURLToPath(import.meta.url); const currentFilePath = fileURLToPath(import.meta.url);