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:
9
.changeset/merger-allowlist-staging.md
Normal file
9
.changeset/merger-allowlist-staging.md
Normal 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.
|
||||||
5
.changeset/settings-modal-mobile-full-height.md
Normal file
5
.changeset/settings-modal-mobile-full-height.md
Normal 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.
|
||||||
7
.changeset/test-cache-content-hash.md
Normal file
7
.changeset/test-cache-content-hash.md
Normal 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`).
|
||||||
5
.changeset/verification-cache-and-concurrency.md
Normal file
5
.changeset/verification-cache-and-concurrency.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Cache merge verification by tree hash and boost test concurrency for in-review verification.
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) ────────────────────────
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,6 +972,44 @@ 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-shell" data-testid="agent-org-chart-shell">
|
||||||
|
{isMobileViewport ? (
|
||||||
|
<div className="agent-org-chart-controls" data-testid="agent-org-chart-controls">
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
) : null}
|
||||||
|
<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)}`}>
|
||||||
<div className="agent-org-chart" data-testid="agent-org-chart">
|
<div className="agent-org-chart" data-testid="agent-org-chart">
|
||||||
{isOrgTreeLoading ? (
|
{isOrgTreeLoading ? (
|
||||||
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
||||||
@@ -988,6 +1032,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : agentView === "board" ? (
|
) : agentView === "board" ? (
|
||||||
<div className="agent-board">
|
<div className="agent-board">
|
||||||
{displayAgents.length === 0 ? (
|
{displayAgents.length === 0 ? (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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} />);
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"] });
|
||||||
|
|||||||
586
packages/engine/src/__tests__/merger-staging-allowlist.test.ts
Normal file
586
packages/engine/src/__tests__/merger-staging-allowlist.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
552
scripts/__tests__/test-changed.test.mjs
Normal file
552
scripts/__tests__/test-changed.test.mjs
Normal 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}`);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user