Merge branch 'main' into codex/xaml-support-3202

This commit is contained in:
azizur100389 2026-09-23 15:08:31 +01:00 • committed by GitHub
commit 5fb3e01b44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 5308 additions and 610 deletions

1
.gitignore vendored
View file

@ -65,6 +65,7 @@ repomix-output*
# Playwright artifacts
gitnexus-web/playwright-report/
gitnexus-web/test-results/
gitnexus-web/e2e/screenshots/
# Python test artifacts
eval/.coverage

28
.vercelignore Normal file
View file

@ -0,0 +1,28 @@
# Keep Vercel uploads under the 100 MB file limit — SPA only needs web + shared sources.
.git
.gitnexus
.gitnexus/**
node_modules
**/node_modules
gitnexus/**
!gitnexus/package.json
eval
eval/**
.claude
.cursor
.github
docs
Documentation
.devcontainer
gitnexus-claude-plugin
gitnexus-cursor-integration
pr-swarm-review
ci-personas
*.sqlite*
*.db
dist
**/dist
coverage
**/coverage
playwright-report
test-results

View file

@ -1,2 +1,3 @@
.vercel
.env*.local
.cursor/

View file

@ -0,0 +1,301 @@
import { test, expect } from '@playwright/test';
import fs from 'node:fs';
import {
MISSING_GITHUB,
TOKEN_LEAK,
assertNoLeaks,
bindBackend,
capture,
fetchOps,
livePrereqSkipReason,
openAnalyzeForm,
postAnalyze,
startLiveBackend,
stopLiveBackend,
waitForAnalyzeSlotFree,
waitForJob,
writeTinyRepo,
type LiveBackend,
} from './helpers/public-contract';
/**
* Live e2e for the public analyze flow. Spawns a real `gitnexus serve` and
* drives the UI — no `page.route` mocks.
*/
test.describe.configure({ mode: 'serial' });
let backend: LiveBackend | undefined;
test.beforeAll(async () => {
test.setTimeout(300_000);
const skip = await livePrereqSkipReason();
if (skip) {
test.skip(true, skip);
return;
}
backend = await startLiveBackend();
});
test.beforeEach(async ({ page }) => {
if (!backend) return;
await bindBackend(page, backend.url);
});
test.afterAll(async () => {
await stopLiveBackend(backend);
});
function requireBackend(): LiveBackend {
if (!backend) throw new Error('live backend was not started');
return backend;
}
test.describe('Analyze — happy path', () => {
test('local folder path → real analyze → done by basename, no path on screen', async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const { url, fixtures } = requireBackend();
const repoDir = writeTinyRepo(fixtures, 'courses');
const repoQueries: string[] = [];
page.on('request', (req) => {
const u = new URL(req.url());
if (u.pathname === '/api/repo' || u.pathname === '/api/graph') {
const repo = u.searchParams.get('repo');
if (repo) repoQueries.push(repo);
}
});
await openAnalyzeForm(page);
await capture(page, testInfo, '01-empty-form');
await page.getByRole('tab', { name: 'Local Folder' }).click();
await page.getByTestId('local-path-input').fill(repoDir);
await capture(page, testInfo, '02-filled-local-path');
await page.getByRole('button', { name: /Analyze Repository/ }).click();
await expect(page.locator('[data-testid="analyze-progress"]')).toBeVisible({ timeout: 20_000 });
await capture(page, testInfo, '03-progress');
const done = page.locator('[data-testid="analyze-done"]');
const retry = page.getByRole('button', { name: /Try again/ });
await expect(done.or(retry)).toBeVisible({ timeout: 120_000 });
if (await retry.isVisible()) {
throw new Error(`live analyze failed:\n${await page.locator('body').innerText()}`);
}
await expect(done).toBeVisible();
await expect(done.getByText('Analysis complete')).toBeVisible();
await expect(done.getByText('courses', { exact: true })).toBeVisible();
await expect(done).not.toContainText(repoDir);
await expect(done).not.toContainText(fixtures);
await capture(page, testInfo, '04-done-basename');
const snap = JSON.stringify(await fetchOps(url));
expect(snap).toContain('courses');
expect(snap).not.toContain(repoDir);
expect(snap).not.toContain('"repoPath"');
// Reconnect resolves the SSE repoId against /api/repos and loads the exact
// registered path, never a same-named sibling. The path stays off screen.
await expect
.poll(() => repoQueries.length > 0 && repoQueries.every((q) => q === repoDir), {
timeout: 20_000,
})
.toBe(true);
await capture(page, testInfo, '05-after-complete');
await assertNoLeaks(page, [fixtures]);
});
});
test.describe('Analyze — failure, retry, cancel', () => {
test('missing local path fails and Try again restores the form', async ({ page }, testInfo) => {
test.setTimeout(120_000);
const { fixtures } = requireBackend();
const notARepo = `${fixtures}/not-a-repo.txt`;
fs.writeFileSync(notARepo, 'this is a file, not a repository\n');
await openAnalyzeForm(page);
await page.getByRole('tab', { name: 'Local Folder' }).click();
await page.getByTestId('local-path-input').fill(notARepo);
await page.getByRole('button', { name: /Analyze Repository/ }).click();
await expect(page.getByRole('button', { name: /Try again/ })).toBeVisible({ timeout: 60_000 });
await expect(page.locator('body')).not.toContainText(TOKEN_LEAK);
await expect(page.locator('body')).not.toContainText(notARepo);
await capture(page, testInfo, '07-failed');
await assertNoLeaks(page, [fixtures, notARepo]);
await page.getByRole('button', { name: /Try again/ }).click();
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible();
await expect(page.getByRole('button', { name: /Analyze Repository/ })).toBeVisible();
await capture(page, testInfo, '08-try-again-form');
});
test('cancel during analyze DELETEs the live job and returns the form', async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const { url, fixtures } = requireBackend();
// The previous test's failed local-path job keeps the slot until its worker exits.
await waitForAnalyzeSlotFree(url);
const repoDir = writeTinyRepo(fixtures, 'cancel-me');
const deletes: string[] = [];
page.on('request', (req) => {
if (req.method() === 'DELETE' && req.url().includes('/api/analyze/')) {
deletes.push(req.url());
}
});
await openAnalyzeForm(page);
await page.getByRole('tab', { name: 'Local Folder' }).click();
await page.getByTestId('local-path-input').fill(repoDir);
await page.getByRole('button', { name: /Analyze Repository/ }).click();
const progress = page.locator('[data-testid="analyze-progress"]');
await expect(progress).toBeVisible({ timeout: 20_000 });
await capture(page, testInfo, '09-progress-before-cancel');
await page.getByRole('button', { name: /^Cancel$/ }).click();
await expect.poll(() => deletes.length, { timeout: 15_000 }).toBeGreaterThan(0);
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 15_000 });
await expect(progress).toBeHidden();
await capture(page, testInfo, '10-form-after-cancel');
});
test('second analyze while one is running surfaces the live 409', async ({ page }, testInfo) => {
test.setTimeout(180_000);
const { url, fixtures } = requireBackend();
const first = writeTinyRepo(fixtures, 'lock-a');
const second = writeTinyRepo(fixtures, 'lock-b');
await waitForAnalyzeSlotFree(url);
// A real local analyze holds the slot for the whole UI round trip; a
// missing-repo clone fails in under a second and would free it early.
const held = await postAnalyze(url, { path: first });
expect(held.http).toBe(202);
expect(held.jobId).toBeTruthy();
await openAnalyzeForm(page);
await page.getByRole('tab', { name: 'Local Folder' }).click();
await page.getByTestId('local-path-input').fill(second);
await page.getByRole('button', { name: /Analyze Repository/ }).click();
await expect(page.getByText(/already (active|in progress)/i)).toBeVisible({ timeout: 20_000 });
await capture(page, testInfo, '11-lock-409');
if (held.jobId) await waitForJob(url, held.jobId);
});
});
test.describe('Analyze — other sources and token', () => {
test('optional GitHub token is posted but never painted after a failed clone', async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
await waitForAnalyzeSlotFree(requireBackend().url);
let posted: Record<string, unknown> = {};
page.on('request', (req) => {
if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) {
posted = (req.postDataJSON() as Record<string, unknown>) ?? {};
}
});
await openAnalyzeForm(page);
await page.locator('input[type="url"]').fill(MISSING_GITHUB);
await page.locator('input[type="password"]').fill(TOKEN_LEAK);
await capture(page, testInfo, '13-token-filled');
await page.getByRole('button', { name: /Analyze Repository/ }).click();
await expect(page.getByRole('button', { name: /Try again/ })).toBeVisible({ timeout: 120_000 });
expect(posted).toMatchObject({ url: MISSING_GITHUB, token: TOKEN_LEAK });
await assertNoLeaks(page);
await capture(page, testInfo, '14-failed-token-masked');
});
test('GitLab URL is posted to the live server and fails closed without leaking the URL host path', async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
await waitForAnalyzeSlotFree(requireBackend().url);
let posted: Record<string, unknown> = {};
page.on('request', (req) => {
if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) {
posted = (req.postDataJSON() as Record<string, unknown>) ?? {};
}
});
await openAnalyzeForm(page);
await page.getByRole('tab', { name: 'GitLab URL' }).click();
await page.locator('input[type="url"]').fill('https://gitlab.com/gitnexus-e2e-missing/project');
await capture(page, testInfo, '15-gitlab-filled');
await page.getByRole('button', { name: /Analyze Repository/ }).click();
await expect(page.getByRole('button', { name: /Try again/ })).toBeVisible({ timeout: 120_000 });
expect(posted).toMatchObject({ url: 'https://gitlab.com/gitnexus-e2e-missing/project' });
const failureText = page.locator('p.text-red-400');
await expect(failureText).toBeVisible();
await expect(failureText).not.toContainText('gitlab.com');
await expect(failureText).not.toContainText('gitnexus-e2e-missing');
await capture(page, testInfo, '16-gitlab-failed');
await assertNoLeaks(page);
});
test('folder upload analyzes on the live server and shows the folder name', async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const { url, fixtures } = requireBackend();
await waitForAnalyzeSlotFree(url);
const fixtureDir = writeTinyRepo(fixtures, 'myrepo');
await openAnalyzeForm(page);
await page.getByRole('tab', { name: 'Local Folder' }).click();
await capture(page, testInfo, '19-local-folder-tab');
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
const done = page.locator('[data-testid="analyze-done"]');
const retry = page.getByRole('button', { name: /Try again/ });
await expect(done.or(retry)).toBeVisible({ timeout: 120_000 });
if (await retry.isVisible()) {
throw new Error(
`live folder-upload analyze failed:\n${await page.locator('body').innerText()}`,
);
}
await expect(done.getByText('myrepo', { exact: true })).toBeVisible();
await expect(done).not.toContainText(fixtureDir);
await capture(page, testInfo, '20-folder-upload-done');
await assertNoLeaks(page, [fixtures]);
});
});
test.describe('Analyze — form validation and tabs', () => {
test('invalid GitHub URL keeps Analyze disabled; tabs each have their own form', async ({
page,
}, testInfo) => {
requireBackend();
await openAnalyzeForm(page);
const analyzeBtn = page.getByRole('button', { name: /Analyze Repository/ });
await expect(analyzeBtn).toBeDisabled();
await page.locator('input[type="url"]').fill('not-a-url');
await expect(analyzeBtn).toBeDisabled();
await capture(page, testInfo, '21-invalid-github');
await page.getByRole('tab', { name: 'GitLab URL' }).click();
await expect(page.getByPlaceholder('https://gitlab.com/owner/repo')).toBeVisible();
await capture(page, testInfo, '22-gitlab-tab');
await page.getByRole('tab', { name: 'Azure DevOps' }).click();
await expect(
page.getByPlaceholder('http://azuredevops.example.com/Collection/Project/_git/Repo'),
).toBeVisible();
await capture(page, testInfo, '23-azure-tab');
await page.getByRole('tab', { name: 'Local Folder' }).click();
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
await capture(page, testInfo, '24-local-tab');
await page.getByRole('tab', { name: 'GitHub URL' }).click();
await expect(page.locator('input[type="url"]')).toHaveValue('');
await expect(analyzeBtn).toBeDisabled();
});
});

View file

@ -0,0 +1,396 @@
import { expect, type Page, type TestInfo } from '@playwright/test';
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
export const TOKEN_LEAK = 'ghs_secret_e2e_token';
export const MISSING_GITHUB = 'https://github.com/gitnexus-e2e-missing/no-such-repo';
const GALLERY_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'screenshots');
const GITNEXUS_DIR = path.resolve(process.cwd(), '..', 'gitnexus');
const TSX_BIN = path.join(GITNEXUS_DIR, 'node_modules', '.bin', 'tsx');
const CLI_TS = path.join(GITNEXUS_DIR, 'src', 'cli', 'index.ts');
const CLI_DIST = path.join(GITNEXUS_DIR, 'dist', 'cli', 'index.js');
/** Per-request bound so a stalled connection cannot outlive a helper's deadline. */
const REQUEST_TIMEOUT_MS = 30_000;
/** POST /api/analyze allows 10/min per IP; 409 polling must not burn that budget. */
const SLOT_POLL_MS = 2_000;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export interface LiveBackend {
url: string;
port: number;
home: string;
fixtures: string;
child: ChildProcess;
log: string;
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
server.close();
reject(new Error('could not bind an ephemeral port'));
return;
}
const { port } = addr;
server.close((err) => (err ? reject(err) : resolve(port)));
});
server.on('error', reject);
});
}
function serveArgs(port: number): { cmd: string; args: string[] } {
if (fs.existsSync(TSX_BIN) && fs.existsSync(CLI_TS)) {
return { cmd: TSX_BIN, args: [CLI_TS, 'serve', '--port', String(port), '--host', '127.0.0.1'] };
}
if (fs.existsSync(CLI_DIST)) {
return {
cmd: process.execPath,
args: [CLI_DIST, 'serve', '--port', String(port), '--host', '127.0.0.1'],
};
}
throw new Error(`neither ${TSX_BIN} nor ${CLI_DIST} is available`);
}
/** Spawn an isolated `gitnexus serve` on 127.0.0.1. */
export async function startLiveBackend(): Promise<LiveBackend> {
const port = await freePort();
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-e2e-home-'));
const fixtures = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gn-e2e-fx-')));
const { cmd, args } = serveArgs(port);
const child = spawn(cmd, args, {
cwd: GITNEXUS_DIR,
env: {
...process.env,
GITNEXUS_HOME: home,
// Real parse workers — same override the CLI integration suite uses on
// a loaded host. A 5s default ready budget dies when two live specs
// cold-start worker pools at once.
GITNEXUS_WORKER_READY_TIMEOUT_MS: process.env.GITNEXUS_WORKER_READY_TIMEOUT_MS ?? '60000',
// Still a real pool; size 1 matches a two-file fixture and keeps two
// parallel live servers from spawning cores-1 workers each.
GITNEXUS_WORKER_POOL_SIZE: process.env.GITNEXUS_WORKER_POOL_SIZE ?? '1',
// Serve forks analyze with min(8192, 0.75×RAM) MB. Two parallel specs
// both getting an 8GB child is what produced `Worker crashed (code null)`.
GITNEXUS_SERVER_ANALYZE_HEAP_MB: process.env.GITNEXUS_SERVER_ANALYZE_HEAP_MB ?? '512',
GITNEXUS_WORKER_HEAP_MB: process.env.GITNEXUS_WORKER_HEAP_MB ?? '256',
// Ladybug FTS CREATE_FTS_INDEX SIGSEGVs on this host (CLI exit 139).
// Graph analyze still runs for real; keyword indexes are the only skip.
GITNEXUS_SKIP_FTS: process.env.GITNEXUS_SKIP_FTS ?? '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
// Own process group so teardown also reaches the forked analyze worker.
detached: process.platform !== 'win32',
});
const backend: LiveBackend = {
url: `http://127.0.0.1:${port}`,
port,
home,
fixtures,
child,
log: '',
};
const captureLog = (chunk: Buffer) => {
backend.log = (backend.log + chunk.toString()).slice(-8_192);
};
child.stdout?.on('data', captureLog);
child.stderr?.on('data', captureLog);
let exited: number | null | undefined;
child.on('exit', (code) => {
exited = code;
});
const deadline = Date.now() + 45_000;
try {
for (;;) {
if (exited !== undefined) {
throw new Error(`live backend exited early (code ${exited}):\n${backend.log}`);
}
const ok = await fetch(`${backend.url}/api/health`, {
signal: AbortSignal.timeout(2_000),
})
.then((r) => r.ok)
.catch(() => false);
if (ok) return backend;
if (Date.now() > deadline) {
throw new Error(`live backend did not become ready on ${backend.url}:\n${backend.log}`);
}
await new Promise((r) => setTimeout(r, 250));
}
} catch (err) {
await stopLiveBackend(backend);
throw err;
}
}
export async function stopLiveBackend(backend: LiveBackend | undefined): Promise<void> {
if (!backend) return;
if (backend.child.exitCode === null && backend.child.signalCode === null) {
const exited = new Promise<void>((resolve) => backend.child.once('exit', () => resolve()));
const pid = backend.child.pid;
const signal = (sig: NodeJS.Signals) => {
if (pid !== undefined && process.platform !== 'win32') {
try {
process.kill(-pid, sig);
return;
} catch {
/* group gone or not a leader: fall back to the child */
}
}
backend.child.kill(sig);
};
signal('SIGTERM');
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const exitedInTime = await Promise.race([
exited.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), 5_000);
}),
]);
if (!exitedInTime) {
signal('SIGKILL');
await exited;
}
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
try {
fs.rmSync(backend.home, { recursive: true, force: true });
fs.rmSync(backend.fixtures, { recursive: true, force: true });
} catch {
/* best-effort */
}
}
export function writeTinyRepo(parent: string, name: string): string {
const dir = path.join(parent, name);
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(path.join(dir, 'README.md'), `# ${name}\n`);
fs.writeFileSync(path.join(dir, 'src', 'index.ts'), 'export const ready = true;\n');
const git = spawnSync('git', ['-C', dir, 'init', '-q', '-b', 'main'], { stdio: 'ignore' });
if (git.status === 0) {
spawnSync('git', ['-C', dir, 'config', 'user.email', 'e2e@gitnexus.test'], { stdio: 'ignore' });
spawnSync('git', ['-C', dir, 'config', 'user.name', 'e2e'], { stdio: 'ignore' });
spawnSync('git', ['-C', dir, 'add', '-A'], { stdio: 'ignore' });
spawnSync('git', ['-C', dir, 'commit', '-qm', 'init'], { stdio: 'ignore' });
}
return dir;
}
export interface AnalyzePostResult {
jobId: string;
status?: string;
error?: string;
http: number;
/** From the draft-7 `RateLimit` header; Infinity when absent. */
remaining: number;
resetMs: number;
}
/**
* POST /api/analyze; a 429 waits out the limiter window and retries, unless
* that wait would pass the caller's `deadline`.
*/
export async function postAnalyze(
backendUrl: string,
body: Record<string, unknown>,
deadline = Infinity,
): Promise<AnalyzePostResult> {
for (;;) {
const res = await fetch(`${backendUrl}/api/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const rateLimit = res.headers.get('ratelimit') ?? '';
const resetMs = Number(/reset=(\d+)/.exec(rateLimit)?.[1] ?? 60) * 1000;
if (res.status === 429) {
await res.body?.cancel();
if (Date.now() + resetMs > deadline) {
throw new Error('analyze rate limit outlasts the caller deadline (HTTP 429)');
}
await sleep(resetMs + 250);
continue;
}
const json = (await res.json().catch(() => ({}))) as {
jobId?: string;
status?: string;
error?: string;
};
const remaining = /remaining=(\d+)/.exec(rateLimit)?.[1];
return {
jobId: json.jobId ?? '',
status: json.status,
error: json.error,
http: res.status,
remaining: remaining === undefined ? Infinity : Number(remaining),
resetMs,
};
}
}
/**
* Wait until the server will accept a new analyze, with at least `budget`
* POST /api/analyze calls left in the rate-limit window so the caller's own
* posts are not answered with 429.
*
* Probes with a clone that fails in-server before any worker fork: a `failed`
* probe leaves no child holding the slot. A local-path probe would fork a
* worker that outlives its own `failed` status and re-occupy the slot.
*/
export async function waitForAnalyzeSlotFree(
backendUrl: string,
timeoutMs = 90_000,
budget = 3,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const probe = await postAnalyze(backendUrl, { url: MISSING_GITHUB }, deadline);
if (probe.http !== 409) {
if (probe.jobId) {
await waitForJob(backendUrl, probe.jobId, Math.max(0, deadline - Date.now()));
}
if (probe.remaining < budget) await sleep(probe.resetMs + 250);
return;
}
if (Date.now() > deadline) {
throw new Error(`analyze slot stayed busy: ${probe.error ?? 'HTTP 409'}`);
}
await sleep(SLOT_POLL_MS);
}
}
/** Single-slot: a failed job still occupies the slot until its child exits. */
export async function postAnalyzeWhenIdle(
backendUrl: string,
body: Record<string, unknown>,
timeoutMs = 60_000,
): Promise<AnalyzePostResult> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const result = await postAnalyze(backendUrl, body, deadline);
if (result.http !== 409) return result;
if (Date.now() > deadline) {
throw new Error(`analyze slot stayed busy: ${result.error ?? 'HTTP 409'}`);
}
await sleep(SLOT_POLL_MS);
}
}
export async function waitForJob(
backendUrl: string,
jobId: string,
timeoutMs = 120_000,
): Promise<{ status: string; error?: string; repoName?: string }> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const poll = await fetch(`${backendUrl}/api/analyze/${jobId}`, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const job = (await poll.json()) as { status: string; error?: string; repoName?: string };
if (job.status === 'complete' || job.status === 'failed') {
return job;
}
if (Date.now() > deadline) throw new Error(`job ${jobId} timed out at ${job.status}`);
await sleep(400);
}
}
export async function fetchOps(backendUrl: string): Promise<Record<string, unknown>> {
const res = await fetch(`${backendUrl}/api/ops`, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`GET /api/ops → HTTP ${res.status}`);
return (await res.json()) as Record<string, unknown>;
}
/** Point the app at this spec's live server before the first navigation. */
export async function bindBackend(page: Page, backendUrl: string): Promise<void> {
await page.addInitScript((url) => {
window.localStorage.setItem('gitnexus-backend-url', url);
}, backendUrl);
}
function frontendHref(base: string, pathAndQuery: string): string {
const normalized = base.endsWith('/') ? base : `${base}/`;
return new URL(pathAndQuery.replace(/^\//, ''), normalized).href;
}
const probeFrontend = (url: string) =>
fetch(url, { signal: AbortSignal.timeout(2_000) })
.then((r) => r.ok)
.catch(() => false);
/** Prefer an explicit FRONTEND_URL; otherwise the first listener CI or local Vite bound. */
async function resolveFrontendUrl(): Promise<string> {
if (process.env.FRONTEND_URL) return process.env.FRONTEND_URL;
for (const url of ['http://localhost:5173', 'http://127.0.0.1:5173']) {
if (await probeFrontend(url)) return url;
}
return FRONTEND_URL;
}
export async function openAnalyzeForm(page: Page): Promise<void> {
// Stay on the reachable frontend (localStorage already has the backend).
// `?server=` makes App auto-load the last graph and never show the form.
// DropZone on `/` shows onboarding (0 repos) or landing + analyze (N repos).
await page.goto(frontendHref(await resolveFrontendUrl(), '/'));
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 30_000 });
}
export async function openOps(page: Page, backendUrl: string): Promise<void> {
await page.goto(
frontendHref(await resolveFrontendUrl(), `/?view=ops&server=${encodeURIComponent(backendUrl)}`),
);
await expect(page.locator('[data-testid="ops-dashboard"]')).toBeVisible({ timeout: 20_000 });
}
/** Empty string means go; otherwise a skip reason (or throw under E2E=1). */
export async function livePrereqSkipReason(): Promise<string> {
const frontendUp =
(await probeFrontend(FRONTEND_URL)) ||
(await probeFrontend('http://localhost:5173')) ||
(await probeFrontend('http://127.0.0.1:5173'));
const cliReady = fs.existsSync(TSX_BIN) || fs.existsSync(CLI_DIST);
if (process.env.E2E) {
if (!cliReady) throw new Error(`backend CLI missing (${CLI_TS} / ${CLI_DIST})`);
if (!frontendUp) throw new Error(`Vite dev server not available at ${FRONTEND_URL}`);
return '';
}
if (!frontendUp) return 'Vite dev server not available';
if (!cliReady) return 'backend CLI not available';
return '';
}
export async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const out = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path: out, fullPage: true });
fs.mkdirSync(GALLERY_DIR, { recursive: true });
const slug = testInfo.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 72);
fs.copyFileSync(out, path.join(GALLERY_DIR, `${slug}--${name}.png`));
}
export async function assertNoLeaks(page: Page, extra: string[] = []): Promise<void> {
const body = await page.locator('body').innerText();
for (const leak of [TOKEN_LEAK, 'ghs_secret', 'x-access-token', ...extra]) {
expect(body, `page must not show ${leak}`).not.toContain(leak);
}
expect(body).not.toMatch(/[A-Za-z]:\\Users\\/);
}

View file

@ -0,0 +1,146 @@
import { test, expect } from '@playwright/test';
import {
MISSING_GITHUB,
assertNoLeaks,
bindBackend,
capture,
fetchOps,
livePrereqSkipReason,
openOps,
postAnalyze,
postAnalyzeWhenIdle,
startLiveBackend,
stopLiveBackend,
waitForJob,
writeTinyRepo,
type LiveBackend,
} from './helpers/public-contract';
/**
* Live e2e for `?view=ops`. Spawns a real `gitnexus serve` and reads the
* unauthenticated ops feed the server actually emits — no `page.route` mocks.
*/
test.describe.configure({ mode: 'serial' });
let backend: LiveBackend | undefined;
let completeName = '';
let completePath = '';
test.beforeAll(async () => {
test.setTimeout(300_000);
const skip = await livePrereqSkipReason();
if (skip) {
test.skip(true, skip);
return;
}
backend = await startLiveBackend();
});
test.beforeEach(async ({ page }) => {
if (!backend) return;
await bindBackend(page, backend.url);
});
test.afterAll(async () => {
await stopLiveBackend(backend);
});
function requireBackend(): LiveBackend {
if (!backend) throw new Error('live backend was not started');
return backend;
}
test.describe('Ops dashboard — empty and connection states', () => {
test('empty server shows vacant lanes and waiting table', async ({ page }, testInfo) => {
const { url } = requireBackend();
await openOps(page, url);
await expect(page.getByText('No jobs in this lane yet')).toHaveCount(2);
await expect(
page.getByText('Waiting for analyze / embed jobs on the connected server…'),
).toBeVisible();
await expect(page.getByText('0 active · 0 queued · 0 done · 0 failed')).toHaveCount(2);
await capture(page, testInfo, '03-empty');
await assertNoLeaks(page);
});
test('unreachable backend shows offline and a connect error', async ({ page }, testInfo) => {
await openOps(page, 'http://127.0.0.1:5999');
await expect(page.getByText(/offline/)).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Backend unreachable')).toBeVisible();
await capture(page, testInfo, '04-unreachable');
});
test('Connect to a dead server updates the URL and goes offline', async ({ page }, testInfo) => {
const { url } = requireBackend();
await openOps(page, url);
await expect(page.getByText(/live · (sse|poll)/)).toBeVisible({ timeout: 15_000 });
await capture(page, testInfo, '06-before-reconnect');
const serverInput = page.locator('input[placeholder="http://localhost:4747"]');
await serverInput.fill('http://127.0.0.1:5999');
await page.getByRole('button', { name: 'Connect' }).click();
await expect(page.getByText('Backend unreachable')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/offline/)).toBeVisible();
expect(decodeURIComponent(page.url())).toContain('127.0.0.1:5999');
expect(page.url()).toContain('view=ops');
await capture(page, testInfo, '07-after-dead-connect');
});
});
test.describe('Ops dashboard — live public jobs', () => {
test('renders a real failed + complete analyze without leaking the repo path', async ({
page,
}, testInfo) => {
test.setTimeout(180_000);
const { url, fixtures } = requireBackend();
completeName = 'private-repo';
completePath = writeTinyRepo(fixtures, completeName);
const fail = await postAnalyze(url, { url: MISSING_GITHUB });
if (fail.jobId) await waitForJob(url, fail.jobId);
const ok = await postAnalyzeWhenIdle(url, { path: completePath });
expect(ok.http).toBeLessThan(400);
const done = await waitForJob(url, ok.jobId);
expect(done.status, done.error).toMatch(/complete/);
const snap = await fetchOps(url);
const raw = JSON.stringify(snap);
expect(raw).not.toContain(completePath);
expect(raw).not.toContain(fixtures);
expect(raw).not.toContain('"repoPath"');
expect(raw).not.toContain('"repoUrl"');
expect(raw).not.toContain('"branch"');
await openOps(page, url);
await expect(page.getByRole('heading', { name: 'Execution Ops' })).toBeVisible();
await expect(page.getByText(/live · (sse|poll)/)).toBeVisible();
await capture(page, testInfo, '01-live-jobs');
await expect(page.getByText('Analyze lane')).toBeVisible();
await expect(page.getByText(/1 failed/)).toBeVisible();
await expect(page.getByText(/1 done/)).toBeVisible();
const jobs = page.locator('[data-testid="ops-job"]');
await expect(jobs).toHaveCount(2);
await expect(page.getByText(completeName).first()).toBeVisible();
await expect(page.getByText('failed', { exact: true }).first()).toBeVisible();
await expect(page.getByText('complete', { exact: true }).first()).toBeVisible();
await expect(page.getByRole('heading', { name: 'Recent activity' })).toBeVisible();
await expect(page.locator('table tbody tr')).toHaveCount(2);
await capture(page, testInfo, '01b-live-jobs-detail');
await assertNoLeaks(page, [fixtures, completePath]);
});
test('mobile viewport still shows public rows only', async ({ page }, testInfo) => {
const { url, fixtures } = requireBackend();
await page.setViewportSize({ width: 390, height: 844 });
await openOps(page, url);
await expect(page.locator('[data-testid="ops-job"]').first()).toBeVisible();
await capture(page, testInfo, '02-mobile');
await assertNoLeaks(page, [fixtures]);
});
});

View file

@ -3,6 +3,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { postAnalyzeWhenIdle } from './helpers/public-contract';
/**
* E2E tests for repo *path* identity with duplicate display names (#2419).
@ -50,9 +51,6 @@ const CLI_PATH = path.resolve(process.cwd(), '..', 'gitnexus', 'dist', 'cli', 'i
const DUPE_NAME = 'pr2419-dupe';
const READY_TIMEOUT_MS = 45_000;
interface AnalyzeJobResponse {
jobId: string;
}
interface AnalyzeJobStatus {
status: string;
error?: string;
@ -119,13 +117,13 @@ function markerFile(repoPath: string): string {
}
async function analyzeAndWait(repoPath: string): Promise<void> {
const res = await fetch(`${BACKEND_URL}/api/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: repoPath, force: true }),
});
if (!res.ok) throw new Error(`POST /api/analyze for ${repoPath} → HTTP ${res.status}`);
const { jobId } = (await res.json()) as AnalyzeJobResponse;
// The previous analyze's worker holds the single slot until it exits, even
// after its job reports complete — wait out that 409 (and any 429).
const res = await postAnalyzeWhenIdle(BACKEND_URL, { path: repoPath, force: true });
if (res.http !== 202 || !res.jobId) {
throw new Error(`POST /api/analyze for ${repoPath} → HTTP ${res.http}`);
}
const { jobId } = res;
const deadline = Date.now() + 120_000;
for (;;) {
const poll = await fetch(`${BACKEND_URL}/api/analyze/${jobId}`);

View file

@ -23,7 +23,7 @@ export default defineConfig({
timeout: 60_000,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: 'http://localhost:5173',
baseURL: process.env.FRONTEND_URL || 'http://localhost:5173',
trace: 'retain-on-failure',
screenshot: 'retain-on-failure',
video: 'retain-on-failure',

View file

@ -9,6 +9,7 @@ import { SettingsPanel } from './components/SettingsPanel';
import { StatusBar } from './components/StatusBar';
import { FileTreePanel } from './components/FileTreePanel';
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
import { ExecutionDashboard } from './components/ExecutionDashboard';
import { getActiveProviderConfig } from './core/llm/settings-service';
import { buildGraphFromConnectResult } from './lib/apply-connect-result';
import {
@ -45,6 +46,11 @@ const BOTTOM_BANNER_CLASS =
export const pickRestoreRepo = (params: URLSearchParams): string | undefined =>
params.get('repo') ?? params.get('project') ?? undefined;
const isOpsView = (): boolean => {
if (typeof window === 'undefined') return false;
return new URLSearchParams(window.location.search).get('view') === 'ops';
};
const AppContent = () => {
const { t } = useTranslation(['common', 'errors']);
const {
@ -465,6 +471,9 @@ const AppContent = () => {
};
function App() {
if (isOpsView()) {
return <ExecutionDashboard />;
}
return (
<AppStateProvider>
<AppContent />

View file

@ -29,7 +29,7 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) =>
const pct = Math.max(0, Math.min(100, progress.percent));
return (
<div className="space-y-4">
<div className="space-y-4" data-testid="analyze-progress">
{/* Phase label + elapsed */}
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-text-secondary">{label}</span>

View file

@ -17,6 +17,11 @@ import { useAppState } from '../hooks/useAppState';
import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared';
import { NODE_COLORS } from '../lib/constants';
import { BackendError, readFile, type ReadFileResult } from '../services/backend-client';
import {
selectedNodeDisplayLine,
selectedNodeFileRange,
selectedNodeLineHighlighted,
} from './code-panel-lines';
import { useTranslation } from 'react-i18next';
const getSyntaxLanguage = (filePath: string | undefined): string => {
@ -46,6 +51,41 @@ export interface CodeReferencesPanelProps {
onFocusNode: (nodeId: string) => void;
}
/** A fetched code excerpt for one AI citation card. Line numbers are 0-based file offsets. */
interface CitationSnippet {
content: string;
start: number;
end: number;
/** Highlight range relative to `start`. */
highlightStart: number;
highlightEnd: number;
totalLines: number;
}
const CITATION_CONTEXT_LINES = 5;
/** Max lines to fetch when a citation has no start/end range. */
const RANGELESS_CITATION_LINES = 80;
/** Cap simultaneous `/api/file` reads when a reply cites many files. */
const CITATION_SNIPPET_CONCURRENCY = 4;
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
if (items.length === 0) return [];
const results: R[] = new Array(items.length);
let nextIndex = 0;
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (nextIndex < items.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await worker(items[currentIndex]);
}
});
await Promise.all(runners);
return results;
}
export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) => {
const { t } = useTranslation(['common', 'graph']);
const {
@ -180,19 +220,103 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
};
}, [codeReferenceFocus, aiReferences]);
// Per-citation snippets, fetched from the server around each reference's
// (0-based) line range. Keyed by reference id; a failed read stays absent so
// the card falls back to the "code not available" notice.
const [citationSnippets, setCitationSnippets] = useState<Map<string, CitationSnippet>>(
() => new Map(),
);
// Ids already requested (loaded or failed) — a failed read is not retried.
const requestedSnippetIds = useRef<Set<string>>(new Set());
const snippetRepoKey = currentRepo || projectName || undefined;
const snippetRepoKeyRef = useRef<string | undefined>(undefined);
// Live citation ids at apply time — in-flight batches must not resurrect
// excerpts after clearAICodeReferences() mints a fresh list.
const liveCitationIdsRef = useRef<Set<string>>(new Set());
liveCitationIdsRef.current = new Set(aiReferences.map((ref) => ref.id));
useEffect(() => {
if (snippetRepoKeyRef.current !== snippetRepoKey) {
snippetRepoKeyRef.current = snippetRepoKey;
requestedSnippetIds.current.clear();
setCitationSnippets(new Map());
}
const liveIds = liveCitationIdsRef.current;
for (const id of [...requestedSnippetIds.current]) {
if (!liveIds.has(id)) requestedSnippetIds.current.delete(id);
}
setCitationSnippets((prev) => {
if (prev.size === 0) return prev;
let removed = false;
const next = new Map<string, CitationSnippet>();
for (const [id, snippet] of prev) {
if (liveIds.has(id)) next.set(id, snippet);
else removed = true;
}
return removed ? next : prev;
});
const pending = aiReferences.filter((ref) => !requestedSnippetIds.current.has(ref.id));
if (pending.length === 0) return;
for (const ref of pending) requestedSnippetIds.current.add(ref.id);
mapWithConcurrency(pending, CITATION_SNIPPET_CONCURRENCY, async (ref) => {
const hasRange = typeof ref.startLine === 'number';
// Range-less citations must not download/highlight the entire file.
const refStart = hasRange ? (ref.startLine as number) : 0;
const refEnd = hasRange
? (ref.endLine ?? refStart)
: Math.max(0, RANGELESS_CITATION_LINES - 1);
const options = hasRange
? selectedNodeFileRange(refStart, refEnd, CITATION_CONTEXT_LINES)
: { startLine: 0, endLine: refEnd };
try {
const result = await readFile(ref.filePath, { ...options, repo: snippetRepoKey });
const start = result.startLine ?? 0;
const lineCount = result.content.split('\n').length;
const snippet: CitationSnippet = {
content: result.content,
start,
end: result.endLine ?? start + lineCount - 1,
highlightStart: hasRange ? refStart - start : 0,
highlightEnd: hasRange ? refEnd - start : 0,
totalLines: result.totalLines,
};
return [ref.id, snippet] as const;
} catch {
return null;
}
}).then((entries) => {
// Repo switch already cleared the set and started a replacement batch.
if (snippetRepoKeyRef.current !== snippetRepoKey) return;
const loaded = entries.filter((e): e is readonly [string, CitationSnippet] => e !== null);
if (loaded.length === 0) return;
setCitationSnippets((prev) => {
const next = new Map(prev);
for (const [id, snippet] of loaded) {
if (liveCitationIdsRef.current.has(id)) next.set(id, snippet);
}
return next;
});
});
}, [aiReferences, snippetRepoKey]);
const refsWithSnippets = useMemo(() => {
return aiReferences.map((ref) => {
const snippet = citationSnippets.get(ref.id);
return {
ref,
content: null as string | null,
start: 0,
end: 0,
highlightStart: 0,
highlightEnd: 0,
totalLines: 0,
content: snippet?.content ?? null,
start: snippet?.start ?? 0,
end: snippet?.end ?? 0,
highlightStart: snippet?.highlightStart ?? 0,
highlightEnd: snippet?.highlightEnd ?? 0,
totalLines: snippet?.totalLines ?? 0,
};
});
}, [aiReferences]);
}, [aiReferences, citationSnippets]);
const selectedFilePath = selectedNode?.properties?.filePath;
const selectedIsFile = selectedNode?.label === 'File' && !!selectedFilePath;
@ -224,17 +348,13 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
setFileResult(null);
setSourceUnavailable(false);
// Determine read range: full file for File nodes, buffered for symbols
// Determine read range: full file for File nodes, buffered for symbols.
// Graph node lines are 0-based (#2377); /api/file ranges are 0-indexed.
const startLine = selectedNode?.properties?.startLine as number | undefined;
const endLine = selectedNode?.properties?.endLine as number | undefined;
const isWholeFile = selectedIsFile || startLine === undefined;
const options = isWholeFile
? {}
: {
startLine: Math.max(0, startLine - CONTEXT_LINES),
endLine: (endLine ?? startLine) + CONTEXT_LINES,
};
const options = isWholeFile ? {} : selectedNodeFileRange(startLine, endLine, CONTEXT_LINES);
// Prefer the repo path identity over the display name — duplicate display
// names would otherwise resolve to the wrong repository's file (#2420).
@ -267,9 +387,10 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
currentRepo,
]);
// Scroll to the selected node's startLine after content loads
// Scroll to the selected node's startLine after content loads.
// GraphNode startLine is 0-based; displayed gutters are 1-based.
useEffect(() => {
if (!selectedFileContent || !selectedNode?.properties?.startLine) return;
if (!selectedFileContent || typeof selectedNode?.properties?.startLine !== 'number') return;
const startLine = selectedNode.properties.startLine as number;
// Double rAF: wait for SyntaxHighlighter to fully render before scrolling
@ -279,15 +400,23 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
if (cancelled) return;
const container = selectedViewerRef.current;
if (!container) return;
// Index into the rendered lines: the viewer starts at `fileStartLine`
// (0-based), so the symbol's 0-based file line minus that offset.
const renderedIndex = Math.max(0, startLine - fileStartLine);
const lineEl =
(container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement) ??
(container.querySelectorAll('.linenumber')[startLine] as HTMLElement);
(container.querySelector(
`[data-line-number="${selectedNodeDisplayLine(startLine)}"]`,
) as HTMLElement) ??
(container.querySelectorAll('.linenumber')[renderedIndex] as HTMLElement);
if (lineEl) {
lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
// Fallback: estimate scroll position based on line height
const lineHeight = 20.8; // 13px font * 1.6 line-height
container.scrollTop = Math.max(0, startLine * lineHeight - container.clientHeight / 3);
container.scrollTop = Math.max(
0,
renderedIndex * lineHeight - container.clientHeight / 3,
);
}
});
rafIds.push(innerRaf);
@ -297,7 +426,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
cancelled = true;
rafIds.forEach((id) => cancelAnimationFrame(id));
};
}, [selectedFileContent, selectedNode?.properties?.startLine]);
}, [selectedFileContent, selectedNode?.properties?.startLine, fileStartLine]);
if (isCollapsed) {
return (
@ -410,12 +539,12 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
userSelect: 'none',
}}
lineProps={(lineNumber) => {
// `lineNumber` is 1-based (startingLineNumber); node lines are 0-based.
const symStart = selectedNode?.properties?.startLine;
const symEnd = selectedNode?.properties?.endLine ?? symStart;
const isHighlighted =
typeof symStart === 'number' &&
lineNumber >= symStart + 1 &&
lineNumber <= (symEnd ?? symStart) + 1;
selectedNodeLineHighlighted(lineNumber, symStart, symEnd);
return {
style: {
display: 'block',

View file

@ -0,0 +1,493 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
connectHeartbeat,
fetchOpsSnapshot,
getAuthToken,
getBackendUrl,
normalizeServerUrl,
probeBackendStatus,
setBackendUrl,
streamOpsSnapshot,
type OpsJobView,
type OpsLaneMetrics,
type OpsSnapshot,
} from '../services/backend-client';
import { DEFAULT_BACKEND_URL } from '../config/ui-constants';
/** GET /api/ops is 60/min; safety REST + immediate tick + 1s would 429. */
const OPS_POLL_INTERVAL_MS = 2_000;
const STATUS_COLORS: Record<OpsJobView['status'], string> = {
queued: 'text-text-muted',
cloning: 'text-sky-400',
analyzing: 'text-amber-400',
loading: 'text-violet-400',
complete: 'text-emerald-400',
failed: 'text-red-400',
};
const TONE_CLASS: Record<'default' | 'ok' | 'warn' | 'bad', string> = {
default: 'border-border-default text-text-primary',
ok: 'border-emerald-500/30 text-emerald-300',
warn: 'border-amber-500/30 text-amber-300',
bad: 'border-red-500/30 text-red-300',
};
const EMPTY_LANE_METRICS: OpsLaneMetrics = {
total: 0,
active: 0,
queued: 0,
complete: 0,
failed: 0,
byStatus: {
queued: 0,
cloning: 0,
analyzing: 0,
loading: 0,
complete: 0,
failed: 0,
},
avgDurationMs: null,
maxDurationMs: null,
activeProgressSum: 0,
};
const formatDuration = (ms: number): string => {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return `${m}m ${rem}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
};
const formatClock = (ts: number): string =>
new Date(ts).toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const MetricCard = ({
label,
value,
hint,
tone = 'default',
}: {
label: string;
value: string | number;
hint?: string;
tone?: 'default' | 'ok' | 'warn' | 'bad';
}) => {
const toneClass = TONE_CLASS[tone];
return (
<div className={`rounded-xl border bg-surface/80 px-4 py-3 ${toneClass}`}>
<div className="text-[11px] tracking-wide text-text-muted uppercase">{label}</div>
<div className="mt-1 font-mono text-2xl font-semibold tabular-nums">{value}</div>
{hint ? <div className="mt-1 text-xs text-text-secondary">{hint}</div> : null}
</div>
);
};
const JobRow = ({ job }: { job: OpsJobView }) => {
const pct = Math.max(0, Math.min(100, job.progress.percent));
return (
<div
className="rounded-lg border border-border-subtle bg-elevated/60 px-3 py-2.5"
data-testid="ops-job"
data-job-id={job.id}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-text-primary">
{job.repoName || job.id.slice(0, 8)}
</div>
<div className="mt-0.5 font-mono text-[11px] text-text-muted">
{job.lane} · {job.id.slice(0, 8)}
{job.branch ? ` · ${job.branch}` : ''}
{job.retryCount > 0 ? ` · retry ${job.retryCount}` : ''}
</div>
</div>
<div className="flex items-center gap-3 text-right">
<span
className={`font-mono text-xs font-semibold uppercase ${STATUS_COLORS[job.status]}`}
>
{job.status}
</span>
<span className="font-mono text-xs text-text-muted">
{formatDuration(job.durationMs)}
</span>
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-void">
<div
className="h-full rounded-full bg-accent transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between gap-2 text-[11px] text-text-secondary">
<span className="truncate">{job.progress.message || job.progress.phase}</span>
<span className="shrink-0 font-mono">{pct}%</span>
</div>
{job.error ? <div className="mt-1 truncate text-[11px] text-red-400">{job.error}</div> : null}
</div>
);
};
const LanePanel = ({
title,
jobs,
metrics,
}: {
title: string;
jobs: OpsJobView[];
metrics: OpsSnapshot['analyze']['metrics'];
}) => (
<section className="flex min-h-0 flex-1 flex-col rounded-2xl border border-border-default bg-deep/80">
<header className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
<div>
<h2 className="text-sm font-semibold text-text-primary">{title}</h2>
<p className="text-xs text-text-muted">
{metrics.active} active · {metrics.queued} queued · {metrics.complete} done ·{' '}
{metrics.failed} failed
</p>
</div>
<div className="text-right font-mono text-[11px] text-text-muted">
<div>avg {metrics.avgDurationMs != null ? formatDuration(metrics.avgDurationMs) : '—'}</div>
<div>max {metrics.maxDurationMs != null ? formatDuration(metrics.maxDurationMs) : '—'}</div>
</div>
</header>
<div className="flex-1 space-y-2 overflow-y-auto p-3">
{jobs.length === 0 ? (
<div className="rounded-lg border border-dashed border-border-subtle px-3 py-8 text-center text-sm text-text-muted">
No jobs in this lane yet
</div>
) : (
jobs.map((job) => <JobRow key={`${job.lane}-${job.id}`} job={job} />)
)}
</div>
</section>
);
export const ExecutionDashboard = () => {
const [backendInput, setBackendInput] = useState(() => {
const params = new URLSearchParams(window.location.search);
return params.get('server') || getBackendUrl() || DEFAULT_BACKEND_URL;
});
// Applied URL the connection effect binds to — distinct from the input so
// keystrokes do not restart SSE/heartbeat, and Connect always reconnects.
const [connectedServer, setConnectedServer] = useState<string | null>(null);
const [connectNonce, setConnectNonce] = useState(0);
const [snapshot, setSnapshot] = useState<OpsSnapshot | null>(null);
const [live, setLive] = useState(false);
const [streamMode, setStreamMode] = useState<'sse' | 'poll' | 'offline'>('offline');
const [error, setError] = useState<string | null>(null);
const [lastTick, setLastTick] = useState<number | null>(null);
const validationErrorRef = useRef(false);
const applyBackend = useCallback((raw: string) => {
try {
const url = normalizeServerUrl(raw.trim() || DEFAULT_BACKEND_URL);
setBackendUrl(url);
setBackendInput(url);
setConnectedServer(url);
setSnapshot(null);
setLastTick(null);
setConnectNonce((n) => n + 1);
const next = new URL(window.location.href);
next.searchParams.set('view', 'ops');
next.searchParams.set('server', url);
window.history.replaceState({}, '', next.toString());
validationErrorRef.current = false;
setError(null);
} catch (err) {
validationErrorRef.current = true;
setLive(false);
setStreamMode('offline');
setError(err instanceof Error ? err.message : 'Invalid backend URL');
}
}, []);
useEffect(() => {
// A `?server=` link must not carry the session's deploy token to another
// origin on its own: prefill it and wait for Connect when a token is held.
const linked = new URLSearchParams(window.location.search).get('server');
if (linked && getAuthToken()) {
let foreign: boolean;
try {
foreign = normalizeServerUrl(linked) !== getBackendUrl();
} catch {
// Invalid input: applyBackend below reports it without connecting.
foreign = false;
}
if (foreign) return;
}
applyBackend(backendInput);
// Mount-only: wire ?server= into the client once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!connectedServer) return;
let cancelled = false;
let pollTimer: ReturnType<typeof setInterval> | undefined;
let streamAbort: AbortController | undefined;
let stopHeartbeat: (() => void) | undefined;
const ingest = (next: OpsSnapshot) => {
if (cancelled) return;
setSnapshot(next);
setLastTick(Date.now());
// A failed Connect leaves this stream running; do not wipe its error.
if (!validationErrorRef.current) setError(null);
setLive(true);
};
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = undefined;
}
};
const startPolling = () => {
if (cancelled) return;
stopPolling();
setStreamMode('poll');
let polling = false;
const tick = async () => {
if (polling || cancelled) return;
polling = true;
try {
const status = await probeBackendStatus();
if (cancelled) return;
if (status !== 'ok') {
setLive(false);
setError(status === 'unauthorized' ? 'Backend requires auth' : 'Backend unreachable');
return;
}
ingest(await fetchOpsSnapshot());
} catch (err) {
if (cancelled) return;
setLive(false);
setError(err instanceof Error ? err.message : 'Failed to fetch ops snapshot');
} finally {
polling = false;
}
};
void tick();
pollTimer = setInterval(() => void tick(), OPS_POLL_INTERVAL_MS);
};
const start = async () => {
const status = await probeBackendStatus();
if (cancelled) return;
if (status !== 'ok') {
setLive(false);
setError(status === 'unauthorized' ? 'Backend requires auth' : 'Backend unreachable');
startPolling();
return;
}
stopHeartbeat = connectHeartbeat(
() => setLive(true),
() => setLive(false),
);
streamAbort = streamOpsSnapshot(
(next) => {
// Safety poll may already be running after a transient REST miss.
stopPolling();
setStreamMode('sse');
ingest(next);
},
() => {
// Fall back to polling if SSE cannot stay up.
streamAbort?.abort();
streamAbort = undefined;
startPolling();
},
);
// Safety poll in case the first SSE frame is delayed.
try {
ingest(await fetchOpsSnapshot());
if (cancelled) return;
setStreamMode((mode) => (mode === 'offline' ? 'poll' : mode));
} catch {
// REST snapshot failed — do not abort a live SSE handshake. Polling
// covers the gap until the stream opens or its own onError fires.
startPolling();
}
};
void start();
return () => {
cancelled = true;
stopPolling();
streamAbort?.abort();
stopHeartbeat?.();
};
}, [connectedServer, connectNonce]);
const allJobs = useMemo(() => {
if (!snapshot) return [] as OpsJobView[];
return [...snapshot.analyze.jobs, ...snapshot.embed.jobs].sort(
(a, b) => b.startedAt - a.startedAt,
);
}, [snapshot]);
return (
<div
className="flex min-h-screen flex-col bg-void text-text-primary"
data-testid="ops-dashboard"
>
<header className="border-b border-border-subtle bg-deep/90 px-4 py-3 backdrop-blur">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs tracking-[0.2em] text-accent uppercase">GitNexus</div>
<h1 className="text-lg font-semibold">Execution Ops</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${
live
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300'
: 'border-red-500/40 bg-red-500/10 text-red-300'
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${live ? 'bg-emerald-400' : 'bg-red-400'}`}
/>
{live ? 'live' : 'offline'} · {streamMode}
</span>
{snapshot ? (
<span className="font-mono text-[11px] text-text-muted">
up {formatDuration(snapshot.uptimeMs)} · tick{' '}
{lastTick ? formatClock(lastTick) : '—'}
</span>
) : null}
</div>
</div>
<form
className="mx-auto mt-3 flex max-w-7xl gap-2"
onSubmit={(e) => {
e.preventDefault();
applyBackend(backendInput);
}}
>
<input
value={backendInput}
onChange={(e) => setBackendInput(e.target.value)}
className="min-w-0 flex-1 rounded-lg border border-border-default bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
placeholder="http://localhost:4747"
spellCheck={false}
/>
<button
type="submit"
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-dim"
>
Connect
</button>
</form>
{error ? <p className="mx-auto mt-2 max-w-7xl text-sm text-red-400">{error}</p> : null}
</header>
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-4 p-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard
label="Active jobs"
value={snapshot?.totals.active ?? 0}
tone={snapshot && snapshot.totals.active > 0 ? 'warn' : 'default'}
/>
<MetricCard label="Completed" value={snapshot?.totals.complete ?? 0} tone="ok" />
<MetricCard
label="Failed"
value={snapshot?.totals.failed ?? 0}
tone={snapshot && snapshot.totals.failed > 0 ? 'bad' : 'default'}
/>
<MetricCard
label="Server"
value={snapshot?.server.version ?? '—'}
hint={
snapshot
? `${snapshot.server.launchContext} · ${snapshot.server.nodeVersion}`
: undefined
}
/>
</div>
<div className="grid min-h-[28rem] flex-1 gap-4 lg:grid-cols-2">
<LanePanel
title="Analyze lane"
jobs={snapshot?.analyze.jobs ?? []}
metrics={snapshot?.analyze.metrics ?? EMPTY_LANE_METRICS}
/>
<LanePanel
title="Embed lane"
jobs={snapshot?.embed.jobs ?? []}
metrics={snapshot?.embed.metrics ?? EMPTY_LANE_METRICS}
/>
</div>
<section className="rounded-2xl border border-border-default bg-deep/80">
<header className="border-b border-border-subtle px-4 py-3">
<h2 className="text-sm font-semibold">Recent activity</h2>
<p className="text-xs text-text-muted">Both lanes, newest first</p>
</header>
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] text-left text-sm">
<thead className="text-[11px] tracking-wide text-text-muted uppercase">
<tr className="border-b border-border-subtle">
<th className="px-4 py-2 font-medium">Lane</th>
<th className="px-4 py-2 font-medium">Repo</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Phase</th>
<th className="px-4 py-2 font-medium">%</th>
<th className="px-4 py-2 font-medium">Duration</th>
<th className="px-4 py-2 font-medium">Started</th>
</tr>
</thead>
<tbody>
{allJobs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text-muted">
Waiting for analyze / embed jobs on the connected server…
</td>
</tr>
) : (
allJobs.map((job) => (
<tr key={`${job.lane}-${job.id}`} className="border-b border-border-subtle/60">
<td className="px-4 py-2 font-mono text-xs text-text-secondary">
{job.lane}
</td>
<td className="max-w-[14rem] truncate px-4 py-2">{job.repoName || '—'}</td>
<td
className={`px-4 py-2 font-mono text-xs uppercase ${STATUS_COLORS[job.status]}`}
>
{job.status}
</td>
<td className="max-w-[16rem] truncate px-4 py-2 text-text-secondary">
{job.progress.phase}
</td>
<td className="px-4 py-2 font-mono text-xs">{job.progress.percent}%</td>
<td className="px-4 py-2 font-mono text-xs">
{formatDuration(job.durationMs)}
</td>
<td className="px-4 py-2 font-mono text-xs text-text-muted">
{formatClock(job.startedAt)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</main>
</div>
);
};

View file

@ -23,6 +23,7 @@ import {
import {
startAnalyze,
cancelAnalyze,
fetchRepos,
streamAnalyzeProgress,
uploadFolder,
type JobProgress,
@ -190,6 +191,7 @@ function DoneState({ repoName }: { repoName: string }) {
className="flex animate-fade-in flex-col items-center gap-3 py-4"
role="status"
aria-live="polite"
data-testid="analyze-done"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-emerald-500/30 bg-emerald-500/15 shadow-[0_0_20px_rgba(16,185,129,0.15)]">
<Check className="h-6 w-6 text-emerald-400" />
@ -210,9 +212,13 @@ type InternalPhase = 'input' | 'starting' | 'analyzing' | 'done' | 'error';
export interface RepoAnalyzerProps {
variant: 'onboarding' | 'sheet';
/**
* Receives the repo IDENTITY to reconnect with — the analyzed path when the
* server provides one (`repoPath` on the SSE complete event), otherwise the
* display name. Never rendered; the done screen shows the display name.
* Receives the repo identity used to reconnect. Prefers `repoPath` when an
* older server still sends it on the SSE complete event. Current servers omit
* it (an unauthenticated ops-listed job id must not leak a filesystem path)
* and send an opaque `repoId`, which is resolved to the matching
* `GET /api/repos` entry's `path`. Falls back to the display name (`repoName`,
* then the input basename, then the i18n default) when neither resolves.
* Never rendered; the done screen shows the display name.
*/
onComplete: (repoIdentity: string) => void;
onCancel?: () => void;
@ -255,13 +261,19 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
// arriving after a mode switch / cancel / unmount can never drive state.
const requestControllerRef = useRef<AbortController | null>(null);
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// The completion identity may still be resolving (`/api/repos`) when the
// dwell timer fires; clearing the timer cannot cancel that continuation.
const unmountedRef = useRef(false);
const folderInputRef = useRef<HTMLInputElement>(null);
// dragenter/dragleave fire for every child boundary crossed; count them so
// the highlight does not flicker while the cursor moves over the button.
const dragDepthRef = useRef(0);
useEffect(() => {
// Reset on (re)mount: StrictMode runs cleanup then setup again.
unmountedRef.current = false;
return () => {
unmountedRef.current = true;
sseControllerRef.current?.abort();
requestControllerRef.current?.abort();
if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
@ -409,24 +421,34 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
(p) => setProgress(p),
(data) => {
// Display vs identity split: the done screen renders the display name
// (never an absolute path), while onComplete receives the identity —
// the analyzed path when the server provides it, so the reconnect
// targets the exact repo even when basenames collide. Old servers omit
// repoPath and degrade to today's name behavior.
// (never an absolute path). Current servers omit repoPath on the
// unauthenticated SSE terminal frame and send an opaque repoId that
// selects the exact /api/repos entry (names are not unique).
// Older servers that still send repoPath keep collision-safe reconnect.
const displayName =
data.repoName ??
(fallbackNameSource
? fallbackNameSource.split(/[/\\]/).filter(Boolean).at(-1)
: undefined) ??
t('onboarding:repoAnalyzer.defaultRepoName');
const identity = data.repoPath ?? displayName;
const repoId = data.repoId;
const identity: Promise<string> = data.repoPath
? Promise.resolve(data.repoPath)
: repoId
? fetchRepos().then(
(repos) => repos.find((r) => r.id === repoId)?.path ?? displayName,
() => displayName,
)
: Promise.resolve(displayName);
setCompletedRepoName(displayName);
setGithubToken('');
setPhase('done');
sseControllerRef.current = null;
completeTimerRef.current = setTimeout(() => {
completeTimerRef.current = null;
onComplete(identity);
void identity.then((id) => {
if (!unmountedRef.current) onComplete(id);
});
}, 1200);
},
(errMsg) => {
@ -829,6 +851,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
}}
disabled={isLoading}
placeholder={isWindows ? 'C:\\Users\\you\\project' : '/home/you/project'}
data-testid="local-path-input"
autoComplete="off"
spellCheck={false}
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"

View file

@ -25,6 +25,7 @@ export const RightPanel = () => {
graph,
graphMode,
addCodeReference,
resolveFilePath,
// LLM / chat state
chatMessages,
isChatLoading,
@ -46,10 +47,6 @@ export const RightPanel = () => {
isChatLoading,
);
const resolveFilePathForUI = useCallback((_requestedPath: string): string | null => {
return null;
}, []);
const findFileNodeIdForUI = useCallback(
(filePath: string): string | undefined => {
if (!graph) return undefined;
@ -81,7 +78,11 @@ export const RightPanel = () => {
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
}
const resolvedPath = resolveFilePathForUI(rawPath);
// Malformed citations like "[[ :10]]" leave an empty path; refuse rather
// than letting the suffix matcher return the first indexed file.
if (!rawPath) return;
const resolvedPath = resolveFilePath(rawPath);
if (!resolvedPath) return;
const nodeId = findFileNodeIdForUI(resolvedPath);
@ -100,7 +101,7 @@ export const RightPanel = () => {
source: 'ai',
});
},
[addCodeReference, findFileNodeIdForUI, resolveFilePathForUI],
[addCodeReference, findFileNodeIdForUI, resolveFilePath],
);
// Handler for node grounding: [[Class:View]], [[Function:trigger]], etc.
@ -134,12 +135,14 @@ export const RightPanel = () => {
// 2. Add to Code Panel (if node has file/line info)
if (node.properties.filePath) {
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
const resolvedPath = resolveFilePath(node.properties.filePath);
if (resolvedPath) {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
startLine:
typeof node.properties.startLine === 'number' ? node.properties.startLine : undefined,
endLine:
typeof node.properties.endLine === 'number' ? node.properties.endLine : undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
@ -148,7 +151,7 @@ export const RightPanel = () => {
}
}
},
[graph, resolveFilePathForUI, addCodeReference],
[graph, resolveFilePath, addCodeReference],
);
const handleLinkClick = useCallback(

View file

@ -0,0 +1,30 @@
/**
* GraphNode startLine/endLine are 0-based (#2377 / line-base.ts).
* `/api/file` ranges are also 0-indexed. Convert to 1-based only for display.
*/
export function selectedNodeFileRange(
startLine: number,
endLine: number | undefined,
contextLines: number,
): { startLine: number; endLine: number } {
return {
startLine: Math.max(0, startLine - contextLines),
endLine: (endLine ?? startLine) + contextLines,
};
}
/** 1-based gutter / `data-line-number` for a 0-based GraphNode line. */
export function selectedNodeDisplayLine(storedStartLine: number): number {
return storedStartLine + 1;
}
export function selectedNodeLineHighlighted(
displayedLineNumber: number,
storedStart: number,
storedEnd: number | undefined,
): boolean {
const displayStart = selectedNodeDisplayLine(storedStart);
const displayEnd = selectedNodeDisplayLine(storedEnd ?? storedStart);
return displayedLineNumber >= displayStart && displayedLineNumber <= displayEnd;
}

View file

@ -932,18 +932,48 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
return `⚠️ AMBIGUOUS TARGET: Multiple files named "${target}" found:\n\n${allPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease specify which file you mean by using a more specific path, e.g.:\n- impact("${allPaths[0].split('/').slice(-3).join('/')}")\n- impact("${allPaths[1]?.split('/').slice(-3).join('/') || allPaths[0]}")`;
}
// If target contains a path, try to find matching file
// If target contains a path, pick the File node for that path. The
// lookup above matched on `filePath CONTAINS target`, so every symbol
// DEFINED in the file (functions, classes, ...) is also in the result
// set and shares the same filePath — a plain "first row" pick could
// silently analyze one arbitrary symbol while reporting a file impact.
let targetNode = targetResults[0];
if (target.includes('/') && targetResults.length > 1) {
const exactMatch = targetResults.find((r: any) => {
const path = Array.isArray(r) ? r[2] : r.filePath;
return path && path.toLowerCase().includes(target.toLowerCase());
});
if (exactMatch) {
targetNode = exactMatch;
const rowType = (r: any) => (Array.isArray(r) ? r[1] : r.nodeType);
const rowPath = (r: any): string | undefined => (Array.isArray(r) ? r[2] : r.filePath);
const targetLower = target.toLowerCase();
const fileRows = targetResults.filter((r: any) => rowType(r) === 'File');
// Exact path first; suffix match only when unique among File rows.
const exactFile = fileRows.find((r: any) => rowPath(r)?.toLowerCase() === targetLower);
const suffixFiles = exactFile
? []
: fileRows.filter((r: any) => rowPath(r)?.toLowerCase()?.endsWith(`/${targetLower}`));
const fileMatch = exactFile ?? (suffixFiles.length === 1 ? suffixFiles[0] : undefined);
if (suffixFiles.length > 1) {
const paths = suffixFiles.map((r: any) => rowPath(r)).filter(Boolean) as string[];
return `⚠️ AMBIGUOUS TARGET: Multiple files match "${target}":\n\n${paths.map((p, i) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
}
// LIMIT 10 is a cap. A single suffix-matching File in that page can
// still hide another File past the limit — only exact path is safe.
if (!exactFile && fileMatch && targetResults.length >= 10) {
const distinctPaths = [...new Set<string>(allPaths)];
return `⚠️ AMBIGUOUS TARGET: Could not uniquely match "${target}". Found:\n\n${distinctPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
}
if (fileMatch) {
targetNode = fileMatch;
} else {
// Still ambiguous even with path
return `⚠️ AMBIGUOUS TARGET: Could not uniquely match "${target}". Found:\n\n${allPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
const distinctPaths = [...new Set<string>(allPaths)];
const uniquePath = distinctPaths.length === 1 ? distinctPaths[0] : undefined;
const uniqueLower = uniquePath?.toLowerCase();
const uniqueIsBounded =
uniqueLower === targetLower || uniqueLower?.endsWith(`/${targetLower}`) === true;
// LIMIT 10 is a cap, not a complete result set. One CONTAINS hit
// that is only a filename substring (src/mylib/foo.ts vs lib/foo.ts)
// must not be rebound as a unique File.
if (!uniqueIsBounded || targetResults.length >= 10 || !uniquePath) {
return `⚠️ AMBIGUOUS TARGET: Could not uniquely match "${target}". Found:\n\n${distinctPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
}
targetNode = { id: `file:${uniquePath}`, nodeType: 'File', filePath: uniquePath };
}
}

View file

@ -45,7 +45,7 @@ import {
} from '../services/backend-client';
import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
import i18n from '../i18n';
import { normalizePath } from '../lib/path-resolution';
import { normalizePath, resolveUniqueIndexedPath } from '../lib/path-resolution';
import { FILE_REF_REGEX, NODE_REF_REGEX } from '../lib/grounding-patterns';
import { GraphStateProvider, useGraphState, type GraphMode } from './app-state/graph';
@ -69,6 +69,12 @@ export type ViewMode = 'onboarding' | 'loading' | 'exploring';
export type RightPanelTab = 'code' | 'chat';
export type EmbeddingStatus = 'idle' | 'loading' | 'embedding' | 'indexing' | 'ready' | 'error';
/**
* POST /api/embed 409 "Another job is already active for this repository"
* is the shared analyze/embed lock, not proof this repo is embedding.
*/
export const embeddingStatusForStartFailure = (_error: unknown): EmbeddingStatus => 'error';
export interface QueryResult {
rows: Record<string, any>[];
nodeIds: string[];
@ -231,6 +237,8 @@ interface AppState {
isCodePanelOpen: boolean;
setCodePanelOpen: (open: boolean) => void;
addCodeReference: (ref: Omit<CodeReference, 'id'>) => void;
/** Resolve a (possibly partial) file path cited by the agent to a real graph file path. */
resolveFilePath: (requestedPath: string) => string | null;
removeCodeReference: (id: string) => void;
clearAICodeReferences: () => void;
clearCodeReferences: () => void;
@ -418,16 +426,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
}, [graph]);
const resolveFilePath = useCallback(
(requestedPath: string): string | null => {
const normalized = normalizePath(requestedPath);
// Exact match
if (filePathIndex.has(normalized)) return filePathIndex.get(normalized)!;
// Suffix match (partial paths like "src/utils.ts")
for (const [key, value] of filePathIndex) {
if (key.endsWith(normalized)) return value;
}
return null;
},
(requestedPath: string): string | null =>
resolveUniqueIndexedPath(filePathIndex, requestedPath),
[filePathIndex],
);
@ -558,13 +558,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
},
);
});
} catch (error: any) {
if (error?.message?.includes('already in progress')) {
// Dedup — embeddings already running, just wait
setEmbeddingStatus('embedding');
return;
}
setEmbeddingStatus('error');
} catch (error: unknown) {
// Shared acquireRepoLock 409 is used for both analyze-held and embed-held
// locks. Never treat it as in-progress embedding — that hid an analyze
// occupant as a successful embed start.
setEmbeddingStatus(embeddingStatusForStartFailure(error));
throw error;
}
}, []);
@ -630,6 +628,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
useEffect(() => {
graphModeRef.current = graphMode;
}, [graphMode]);
// Same trick for the display name: initializeAgent has empty deps, so a plain
// `projectName` read would be trapped at the initial '' for callers that pass
// no override (settings-saved re-init, lazy init from sendChatMessage) and
// the system prompt would label the codebase the literal 'project'.
const projectNameRef = useRef(projectName);
useEffect(() => {
projectNameRef.current = projectName;
}, [projectName]);
const initializeAgent = useCallback(
async (
@ -649,7 +655,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setAgentError(null);
try {
const effectiveProjectName = overrideProjectName || projectName || 'project';
const effectiveProjectName = overrideProjectName || projectNameRef.current || 'project';
// Sync repoRef so all agent backend calls target the correct repo.
// initializeAgent can be called from App.tsx (handleServerConnect) which
@ -911,10 +917,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine
? node.properties.startLine - 1
: undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
startLine:
typeof node.properties.startLine === 'number'
? node.properties.startLine
: undefined,
endLine:
typeof node.properties.endLine === 'number'
? node.properties.endLine
: undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
@ -1126,6 +1136,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
clearAIToolHighlights,
graph,
embeddingStatus,
llmSettings.activeProvider,
],
);
@ -1588,6 +1599,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
isCodePanelOpen,
setCodePanelOpen,
addCodeReference,
resolveFilePath,
removeCodeReference,
clearAICodeReferences,
clearCodeReferences,

View file

@ -1458,7 +1458,11 @@ export const useSigma = (options: UseSigmaOptions = {}): UseSigmaReturn => {
layoutTimeoutRef.current = setTimeout(() => {
if (layoutRef.current) {
// stop() only flips the supervisor's running flag; kill() terminates
// the Web Worker and unbinds its graph listeners. Nulling the ref
// without kill() leaked one worker per completed layout.
layoutRef.current.stop();
layoutRef.current.kill();
layoutRef.current = null;
// Light noverlap cleanup

View file

@ -3,6 +3,31 @@ export const normalizePath = (p: string): string => {
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
};
/**
* Resolve a citation against a normalized→original graph path index.
* Exact match wins. A suffix match is accepted only when it is unique among
* keys equal to the request or ending in `/${normalized}` — `index.ts` must
* not resolve `src/myindex.ts`, nor silently pick the first filePathIndex entry.
*/
export const resolveUniqueIndexedPath = (
filePathIndex: ReadonlyMap<string, string>,
requestedPath: string,
): string | null => {
const normalized = normalizePath(requestedPath);
if (!normalized) return null;
const exact = filePathIndex.get(normalized);
if (exact !== undefined) return exact;
const boundedSuffix = `/${normalized}`;
let unique: string | undefined;
for (const [key, value] of filePathIndex) {
if (!key.endsWith(boundedSuffix)) continue;
if (unique !== undefined) return null;
unique = value;
}
return unique ?? null;
};
/**
* Resolve a user-supplied path (which may be partial) to an exact file path in the repo.
* Follows the same heuristics previously embedded in useAppState:

View file

@ -18,6 +18,8 @@ import { decideSkipGraph } from '../lib/graph-load-decision';
// ── Types ──────────────────────────────────────────────────────────────────
export interface BackendRepo {
/** Opaque per-server-process handle; matches `repoId` on analyze completion. */
id?: string;
name: string;
path: string;
repoPath?: string; // git HEAD returns "repoPath"; older versions return "path"
@ -110,6 +112,52 @@ export interface JobStatus {
completedAt?: number;
}
/** Snapshot from GET /api/ops — execution metrics for the ops dashboard. */
export interface OpsLaneMetrics {
total: number;
active: number;
queued: number;
complete: number;
failed: number;
byStatus: Record<JobStatus['status'], number>;
avgDurationMs: number | null;
maxDurationMs: number | null;
activeProgressSum: number;
}
export interface OpsJobView extends JobStatus {
lane: 'analyze' | 'embed';
branch?: string;
retryCount: number;
durationMs: number;
partial?: {
kind: 'embedding-partial';
pendingNodeCount: number;
nodesProcessed: number;
};
}
export interface OpsSnapshot {
generatedAt: number;
uptimeMs: number;
health: 'ok';
server: {
version: string;
launchContext: string;
nodeVersion: string;
latestVersion?: string;
updateAvailable?: boolean;
};
analyze: { jobs: OpsJobView[]; metrics: OpsLaneMetrics };
embed: { jobs: OpsJobView[]; metrics: OpsLaneMetrics };
totals: {
jobs: number;
active: number;
failed: number;
complete: number;
};
}
export class BackendError extends Error {
constructor(
message: string,
@ -190,6 +238,18 @@ export interface SSEOptions {
* the edge's token gate resolves itself once a token is entered.
*/
retryOnHttpError?: boolean;
/**
* When true (default), a successful HTTP open resets the retry counter so a
* long-lived stream can reconnect forever after transient drops. Set false
* for finite budgets (ops → poll fallback): otherwise a 200 that then closes
* would reset the counter on every reconnect and never reach `onError`.
*/
resetRetriesOnOpen?: boolean;
/**
* Abort the handshake if response headers do not arrive in this window.
* Fires `onError` so callers (ops → poll) are not stuck on a pending fetch.
*/
connectTimeoutMs?: number;
}
/**
@ -210,6 +270,7 @@ export function streamSSE<T = unknown>(
const maxRetries = options.maxRetries ?? 3;
const baseDelayMs = options.baseDelayMs ?? 1_000;
const capDelayMs = options.capDelayMs ?? Infinity;
const resetRetriesOnOpen = options.resetRetriesOnOpen ?? true;
let lastEventId = '';
@ -225,13 +286,26 @@ export function streamSSE<T = unknown>(
if (controller.signal.aborted) return;
(async () => {
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
try {
const headers = withAuthHeader(new Headers());
if (lastEventId) {
headers.set('Last-Event-ID', lastEventId);
}
if (options.connectTimeoutMs && options.connectTimeoutMs > 0) {
handshakeTimer = setTimeout(() => {
if (controller.signal.aborted) return;
handlers.onError?.('SSE handshake timed out');
controller.abort();
}, options.connectTimeoutMs);
}
const response = await fetch(url, { signal: controller.signal, headers });
if (handshakeTimer) {
clearTimeout(handshakeTimer);
handshakeTimer = undefined;
}
if (!response.ok) {
if (options.retryOnHttpError && scheduleRetry(retryCount)) return;
handlers.onError?.(`Server returned ${response.status}`);
@ -244,8 +318,10 @@ export function streamSSE<T = unknown>(
return;
}
// Reset retry count on successful connection
retryCount = 0;
// Long-lived streams reset; finite budgets (ops poll fallback) must not.
if (resetRetriesOnOpen) {
retryCount = 0;
}
handlers.onOpen?.();
const decoder = new TextDecoder();
@ -292,12 +368,20 @@ export function streamSSE<T = unknown>(
}
}
// Stream ended without terminal event — try to reconnect
scheduleRetry(retryCount);
// Stream ended without terminal event — try to reconnect; when the
// retry budget is spent, surface the same onError path the catch arm
// already uses so callers (e.g. ops dashboard → poll fallback) can run.
// scheduleRetry also returns false when aborted — mirror the catch arm
// and do not invoke onError after the caller cancelled the stream.
if (!controller.signal.aborted && !scheduleRetry(retryCount)) {
handlers.onError?.('Stream ended');
}
} catch (err: unknown) {
if (handshakeTimer) clearTimeout(handshakeTimer);
if (err instanceof DOMException && err.name === 'AbortError') return;
// Network error — attempt reconnect with backoff
if (!scheduleRetry(retryCount)) {
// Network error — attempt reconnect with backoff. Skip onError when the
// caller already aborted (scheduleRetry returns false for abort too).
if (!controller.signal.aborted && !scheduleRetry(retryCount)) {
handlers.onError?.(err instanceof Error ? err.message : 'Stream error');
}
}
@ -342,10 +426,27 @@ export const setBackendUrl = (url: string): void => {
export const getBackendUrl = (): string => _backendUrl;
/**
* Strip `user[:password]@` userinfo from an http(s) URL so credentials never
* land in `?server=`, history, or `_backendUrl` display/storage paths.
*/
function stripBackendUrlCredentials(url: string): string {
try {
const parsed = new URL(url);
if (!parsed.username && !parsed.password) return url;
parsed.username = '';
parsed.password = '';
// URL() may add a trailing slash for bare origins; keep normalize's contract.
return parsed.toString().replace(/\/+$/, '');
} catch {
return url.replace(/^(https?:\/\/)[^/]*@/i, '$1');
}
}
/**
* Normalize a user-entered server URL into a base URL suitable for setBackendUrl().
* Adds protocol if missing, strips trailing slashes, and strips a trailing /api suffix
* (since all API methods append their own /api/... paths to _backendUrl).
* Adds protocol if missing, strips trailing slashes / userinfo, and strips a
* trailing /api suffix (since all API methods append their own /api/... paths).
*/
export function normalizeServerUrl(input: string): string {
let url = input.trim().replace(/\/+$/, '');
@ -361,7 +462,7 @@ export function normalizeServerUrl(input: string): string {
// Strip /api suffix if present — _backendUrl stores the base, not the /api path
url = url.replace(/\/api$/, '');
return url;
return stripBackendUrlCredentials(url);
}
// ── Access token ───────────────────────────────────────────────────────────
@ -1070,6 +1171,40 @@ export const getAnalyzeStatus = async (jobId: string): Promise<JobStatus> => {
return response.json() as Promise<JobStatus>;
};
/** Fetch the ops / execution metrics snapshot. */
export const fetchOpsSnapshot = async (): Promise<OpsSnapshot> => {
const response = await fetchWithTimeout(`${_backendUrl}/api/ops`, {}, 5_000);
await assertOk(response);
return response.json() as Promise<OpsSnapshot>;
};
/**
* Stream ops snapshots via SSE (≈1 Hz). Falls back callers should use
* `fetchOpsSnapshot` polling when the stream cannot be established.
*/
export const streamOpsSnapshot = (
onSnapshot: (snapshot: OpsSnapshot) => void,
onError: (error: string) => void,
): AbortController => {
return streamSSE<OpsSnapshot>(
`${_backendUrl}/api/ops/stream`,
{
onMessage: onSnapshot,
onError,
},
// Finite retries so onError can fire and the dashboard falls back to poll.
// Do not reset the budget on a successful open — short-lived 200s must count.
{
maxRetries: 3,
baseDelayMs: 1_000,
capDelayMs: 5_000,
retryOnHttpError: true,
resetRetriesOnOpen: false,
connectTimeoutMs: 5_000,
},
);
};
/** Cancel a running analysis job. */
export const cancelAnalyze = async (jobId: string): Promise<void> => {
const response = await fetchWithTimeout(
@ -1083,7 +1218,7 @@ export const cancelAnalyze = async (jobId: string): Promise<void> => {
export const streamAnalyzeProgress = (
jobId: string,
onProgress: (progress: JobProgress) => void,
onComplete: (data: { repoName?: string; repoPath?: string }) => void,
onComplete: (data: { repoName?: string; repoPath?: string; repoId?: string }) => void,
onError: (error: string) => void,
): AbortController => {
return streamSSE<JobProgress>(

View file

@ -235,5 +235,34 @@ describe('backend-client access token', () => {
// Budget spent → the caller finally hears about it.
expect(onError).toHaveBeenCalledWith('Server returned 401');
});
it('fires onError when the handshake exceeds connectTimeoutMs', async () => {
vi.useFakeTimers();
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
vi.stubGlobal('fetch', fetchMock);
const onError = vi.fn();
streamSSE(`${BASE}/api/ops/stream`, { onError }, { maxRetries: 0, connectTimeoutMs: 50 });
await vi.advanceTimersByTimeAsync(50);
expect(onError).toHaveBeenCalledWith('SSE handshake timed out');
vi.useRealTimers();
});
it('exhausts a finite budget across successful-then-closed streams when resetRetriesOnOpen is false', async () => {
// Ops dashboard needs this: otherwise every short 200 resets the counter
// and onError (poll fallback) never fires.
const fetchMock = vi.fn(async () => sseResponse(['data: {"ok":true}\n\n']));
vi.stubGlobal('fetch', fetchMock);
const onError = vi.fn();
streamSSE(
`${BASE}/api/ops/stream`,
{ onError },
{ baseDelayMs: 0, maxRetries: 2, resetRetriesOnOpen: false },
);
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith('Stream ended'));
// Initial + 2 retries = 3 opens before the budget is spent.
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
});

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import {
selectedNodeDisplayLine,
selectedNodeFileRange,
selectedNodeLineHighlighted,
} from '../../src/components/code-panel-lines';
describe('selected-node GraphNode line math (0-based storage)', () => {
it('requests /api/file starting at storedStart - context (not storedStart - 1 - context)', () => {
// GraphNode startLine 99 is editor line 100. CONTEXT_LINES = 50.
expect(selectedNodeFileRange(99, 99, 50)).toEqual({ startLine: 49, endLine: 149 });
});
it('highlights the 1-based display line for a 0-based node span', () => {
expect(selectedNodeLineHighlighted(100, 99, 99)).toBe(true);
expect(selectedNodeLineHighlighted(99, 99, 99)).toBe(false);
expect(selectedNodeDisplayLine(99)).toBe(100);
});
it('still highlights a first-line symbol stored as startLine 0', () => {
expect(selectedNodeLineHighlighted(1, 0, 0)).toBe(true);
expect(selectedNodeDisplayLine(0)).toBe(1);
expect(selectedNodeFileRange(0, 0, 50)).toEqual({ startLine: 0, endLine: 50 });
});
});

View file

@ -56,9 +56,16 @@ vi.mock('react-i18next', () => ({
}),
}));
const citationReads = () =>
vi.mocked(readFile).mock.calls.filter(([, opts]) => opts && 'startLine' in (opts as object));
describe('CodeReferencesPanel repo identity (#2420)', () => {
beforeEach(() => {
vi.clearAllMocks();
appState.codeReferences = [];
appState.selectedNode = fileNode;
appState.projectName = 'reels';
appState.currentRepo = undefined;
vi.mocked(readFile).mockResolvedValue({ content: 'const a = 1;', totalLines: 1 });
});
@ -91,4 +98,142 @@ describe('CodeReferencesPanel repo identity (#2420)', () => {
expect(screen.getByText('graph:codePanel.sourceUnavailable')).toBeInTheDocument();
});
});
it('does not free replacement-batch citation ids when a cancelled repo-switch batch settles', async () => {
appState.codeReferences = [
{
id: 'cite-1',
filePath: 'src/foo.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
appState.currentRepo = '/ws/a/reels';
let releaseFirst!: (value: { content: string; startLine: number; totalLines: number }) => void;
const firstCitation = new Promise<{ content: string; startLine: number; totalLines: number }>(
(resolve) => {
releaseFirst = resolve;
},
);
vi.mocked(readFile).mockImplementation((_path, opts) => {
if (opts && 'startLine' in opts) return firstCitation;
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
const { rerender } = render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(citationReads()).toHaveLength(1));
vi.mocked(readFile).mockImplementation((_path, opts) => {
if (opts && 'startLine' in opts) {
return Promise.resolve({ content: 'const a = 1;', startLine: 0, totalLines: 1 });
}
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
appState.currentRepo = '/ws/b/reels';
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(citationReads()).toHaveLength(2));
expect(citationReads()[1]?.[1]).toEqual(expect.objectContaining({ repo: '/ws/b/reels' }));
releaseFirst({ content: 'stale', startLine: 0, totalLines: 1 });
await new Promise((r) => setTimeout(r, 30));
expect(citationReads()).toHaveLength(2);
});
it('does not re-issue in-flight citation reads when a new reference is appended', async () => {
appState.codeReferences = [
{
id: 'cite-1',
filePath: 'src/foo.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
appState.currentRepo = '/ws/a/reels';
let releaseFirst!: (value: { content: string; startLine: number; totalLines: number }) => void;
const firstCitation = new Promise<{ content: string; startLine: number; totalLines: number }>(
(resolve) => {
releaseFirst = resolve;
},
);
vi.mocked(readFile).mockImplementation((filePath, opts) => {
if (opts && 'startLine' in opts) {
if (filePath === 'src/foo.ts') return firstCitation;
return Promise.resolve({ content: 'const b = 2;', startLine: 0, totalLines: 1 });
}
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
const { rerender } = render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(citationReads()).toHaveLength(1));
appState.codeReferences = [
...appState.codeReferences,
{
id: 'cite-2',
filePath: 'src/bar.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(citationReads()).toHaveLength(2));
expect(citationReads()[1]?.[0]).toBe('src/bar.ts');
releaseFirst({ content: 'first', startLine: 0, totalLines: 1 });
await new Promise((r) => setTimeout(r, 30));
expect(citationReads()).toHaveLength(2);
});
it('prunes cached citation snippets when AI references are cleared', async () => {
appState.codeReferences = [
{
id: 'cite-1',
filePath: 'src/foo.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
appState.currentRepo = '/ws/a/reels';
vi.mocked(readFile).mockImplementation((_path, opts) => {
if (opts && 'startLine' in opts) {
return Promise.resolve({ content: 'FIRST_SNIPPET', startLine: 0, totalLines: 1 });
}
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
const { rerender } = render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(screen.getByText('FIRST_SNIPPET')).toBeInTheDocument());
appState.codeReferences = [];
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
vi.mocked(readFile).mockImplementation((_path, opts) => {
if (opts && 'startLine' in opts) {
return Promise.resolve({ content: 'SECOND_SNIPPET', startLine: 0, totalLines: 1 });
}
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
});
appState.codeReferences = [
{
id: 'cite-1',
filePath: 'src/foo.ts',
startLine: 0,
endLine: 0,
source: 'ai',
},
];
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
await waitFor(() => expect(screen.getByText('SECOND_SNIPPET')).toBeInTheDocument());
expect(screen.queryByText('FIRST_SNIPPET')).not.toBeInTheDocument();
expect(citationReads().length).toBeGreaterThanOrEqual(2);
});
});

View file

@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { embeddingStatusForStartFailure } from '../../src/hooks/useAppState';
import { BackendError } from '../../src/services/backend-client';
describe('embeddingStatusForStartFailure', () => {
it('maps the shared analyze/embed lock 409 to error, not embedding', () => {
const error = new BackendError(
'Another job is already active for this repository',
409,
'client',
);
expect(embeddingStatusForStartFailure(error)).toBe('error');
expect(embeddingStatusForStartFailure(error)).not.toBe('embedding');
});
it('maps other start failures to error', () => {
expect(embeddingStatusForStartFailure(new Error('boom'))).toBe('error');
});
});

View file

@ -0,0 +1,183 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ExecutionDashboard } from '../../src/components/ExecutionDashboard';
import {
connectHeartbeat,
fetchOpsSnapshot,
getAuthToken,
normalizeServerUrl,
probeBackendStatus,
setBackendUrl,
streamOpsSnapshot,
type OpsSnapshot,
} from '../../src/services/backend-client';
vi.mock('../../src/services/backend-client', () => ({
connectHeartbeat: vi.fn(() => () => {}),
fetchOpsSnapshot: vi.fn(),
getAuthToken: vi.fn(() => ''),
getBackendUrl: vi.fn(() => 'http://127.0.0.1:4747'),
normalizeServerUrl: vi.fn((url: string) => url),
probeBackendStatus: vi.fn(),
setBackendUrl: vi.fn(),
streamOpsSnapshot: vi.fn(),
}));
const emptyMetrics = {
total: 0,
active: 0,
queued: 0,
complete: 0,
failed: 0,
byStatus: {
queued: 0,
cloning: 0,
analyzing: 0,
loading: 0,
complete: 0,
failed: 0,
},
avgDurationMs: null,
maxDurationMs: null,
activeProgressSum: 0,
};
const emptySnap = (): OpsSnapshot => ({
generatedAt: 1,
uptimeMs: 1,
health: 'ok',
server: { version: '1', launchContext: 'local', nodeVersion: 'v22' },
analyze: { jobs: [], metrics: emptyMetrics },
embed: { jobs: [], metrics: emptyMetrics },
totals: { jobs: 0, active: 0, failed: 0, complete: 0 },
});
describe('ExecutionDashboard safety poll', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(probeBackendStatus).mockResolvedValue('ok');
vi.mocked(connectHeartbeat).mockReturnValue(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('stops the safety poll once the SSE stream delivers a frame', async () => {
let onFrame: ((snapshot: OpsSnapshot) => void) | undefined;
vi.mocked(streamOpsSnapshot).mockImplementation((onSnapshot) => {
onFrame = onSnapshot;
return { abort: vi.fn() } as unknown as AbortController;
});
vi.mocked(fetchOpsSnapshot).mockRejectedValue(new Error('rest down'));
const setIntervalSpy = vi.spyOn(window, 'setInterval');
const clearIntervalSpy = vi.spyOn(window, 'clearInterval');
const { getByText } = render(<ExecutionDashboard />);
await waitFor(() => expect(setIntervalSpy).toHaveBeenCalled());
const timerId = setIntervalSpy.mock.results[0]?.value;
expect(onFrame).toBeTypeOf('function');
onFrame!(emptySnap());
await waitFor(() => expect(clearIntervalSpy).toHaveBeenCalledWith(timerId));
expect(getByText(/· sse/)).toBeInTheDocument();
});
it('polls /api/ops at 2s so the fallback stays under the 60/min route limit', async () => {
vi.mocked(streamOpsSnapshot).mockImplementation(
() => ({ abort: vi.fn() }) as unknown as AbortController,
);
vi.mocked(fetchOpsSnapshot).mockRejectedValue(new Error('rest down'));
const setIntervalSpy = vi.spyOn(window, 'setInterval');
render(<ExecutionDashboard />);
await waitFor(() => expect(setIntervalSpy).toHaveBeenCalled());
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 2_000);
});
it('clears the prior snapshot when connecting to an unreachable server', async () => {
const first = emptySnap();
first.server = { ...first.server, version: 'old-server' };
vi.mocked(streamOpsSnapshot).mockImplementation((onSnapshot) => {
onSnapshot(first);
return { abort: vi.fn() } as unknown as AbortController;
});
vi.mocked(fetchOpsSnapshot).mockResolvedValue(first);
const { getByText, getByPlaceholderText, queryByText } = render(<ExecutionDashboard />);
await waitFor(() => expect(getByText('old-server')).toBeInTheDocument());
vi.mocked(probeBackendStatus).mockResolvedValue('unreachable');
vi.mocked(fetchOpsSnapshot).mockRejectedValue(new Error('down'));
vi.mocked(streamOpsSnapshot).mockImplementation(
() => ({ abort: vi.fn() }) as unknown as AbortController,
);
const input = getByPlaceholderText('http://localhost:4747');
fireEvent.change(input, { target: { value: 'http://127.0.0.1:9999' } });
fireEvent.submit(input.closest('form')!);
await waitFor(() => {
expect(queryByText('old-server')).not.toBeInTheDocument();
});
});
it('keeps an invalid-server error when the prior stream delivers a snapshot', async () => {
let onFrame: ((snapshot: OpsSnapshot) => void) | undefined;
vi.mocked(streamOpsSnapshot).mockImplementation((onSnapshot) => {
onFrame = onSnapshot;
return { abort: vi.fn() } as unknown as AbortController;
});
vi.mocked(fetchOpsSnapshot).mockResolvedValue(emptySnap());
vi.mocked(normalizeServerUrl).mockImplementation((url: string) => {
if (url.includes('bad')) throw new Error('Invalid backend URL');
return url;
});
const { getByText, getByPlaceholderText } = render(<ExecutionDashboard />);
await waitFor(() => expect(onFrame).toBeTypeOf('function'));
const input = getByPlaceholderText('http://localhost:4747');
fireEvent.change(input, { target: { value: 'http://bad' } });
fireEvent.submit(input.closest('form')!);
await waitFor(() => expect(getByText('Invalid backend URL')).toBeInTheDocument());
onFrame!(emptySnap());
await waitFor(() => expect(getByText('Invalid backend URL')).toBeInTheDocument());
});
});
describe('ExecutionDashboard ?server= link', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(probeBackendStatus).mockResolvedValue('ok');
vi.mocked(fetchOpsSnapshot).mockResolvedValue(emptySnap());
vi.mocked(streamOpsSnapshot).mockReturnValue({ abort: vi.fn() } as unknown as AbortController);
window.history.replaceState({}, '', '/?view=ops&server=https://other.example');
});
afterEach(() => {
window.history.replaceState({}, '', '/');
vi.mocked(getAuthToken).mockReturnValue('');
});
it('does not auto-connect a foreign server while a deploy token is held', async () => {
vi.mocked(getAuthToken).mockReturnValue('deploy-token');
const { getByDisplayValue } = render(<ExecutionDashboard />);
expect(getByDisplayValue('https://other.example')).toBeInTheDocument();
expect(setBackendUrl).not.toHaveBeenCalled();
expect(streamOpsSnapshot).not.toHaveBeenCalled();
});
it('auto-connects the linked server when no token is held', async () => {
render(<ExecutionDashboard />);
await waitFor(() => expect(setBackendUrl).toHaveBeenCalledWith('https://other.example'));
});
});

View file

@ -206,4 +206,50 @@ describe('Graph-RAG impact risk contract', () => {
expect(output).toContain('enrichment-truncated');
expect(output).not.toContain('enrichment-budget-exhausted');
});
it('does not treat a filename substring as a unique File suffix', async () => {
const executeQuery = vi.fn(async (query: string) => {
if (query.includes('filePath CONTAINS')) {
return [
{ id: 'file-mylib', nodeType: 'File', filePath: 'src/mylib/foo.ts' },
{ id: 'fn-mylib', nodeType: 'Function', filePath: 'src/mylib/foo.ts' },
];
}
return [];
});
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
target: 'lib/foo.ts',
direction: 'upstream',
maxDepth: 1,
});
expect(output).toContain('AMBIGUOUS TARGET');
expect(output).toContain('lib/foo.ts');
});
it('does not accept a unique File suffix from a truncated CONTAINS page', async () => {
const executeQuery = vi.fn(async (query: string) => {
if (query.includes('filePath CONTAINS')) {
return [
...Array.from({ length: 9 }, (_, index) => ({
id: `sym-${index}`,
nodeType: 'Function',
filePath: `src/other-${index}.ts`,
})),
{ id: 'file-visible', nodeType: 'File', filePath: 'apps/web/lib/foo.ts' },
];
}
return [];
});
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
target: 'lib/foo.ts',
direction: 'upstream',
maxDepth: 1,
});
expect(output).toContain('AMBIGUOUS TARGET');
expect(output).toContain('Could not uniquely match');
});
});

View file

@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { normalizePath, resolveFilePath } from '../../src/lib/path-resolution';
import {
normalizePath,
resolveFilePath,
resolveUniqueIndexedPath,
} from '../../src/lib/path-resolution';
describe('path-resolution utilities', () => {
const contents = new Map<string, string>([
@ -31,3 +35,42 @@ describe('path-resolution utilities', () => {
expect(resolveFilePath(contents, '')).toBeNull();
});
});
describe('resolveUniqueIndexedPath', () => {
const index = new Map<string, string>([
['src/components/Header.tsx', 'src/components/Header.tsx'],
['src/index.ts', 'src/index.ts'],
['lib/index.ts', 'lib/index.ts'],
['packages/core/utils.ts', 'packages/core/utils.ts'],
]);
it('prefers an exact indexed path', () => {
expect(resolveUniqueIndexedPath(index, 'src/index.ts')).toBe('src/index.ts');
});
it('resolves a unique suffix', () => {
expect(resolveUniqueIndexedPath(index, 'core/utils.ts')).toBe('packages/core/utils.ts');
});
it('resolves a unique filename only at a path-component boundary', () => {
expect(resolveUniqueIndexedPath(index, 'Header.tsx')).toBe('src/components/Header.tsx');
});
it('does not treat a filename substring as a unique suffix', () => {
const substringIndex = new Map<string, string>([['src/myindex.ts', 'src/myindex.ts']]);
expect(resolveUniqueIndexedPath(substringIndex, 'index.ts')).toBeNull();
});
it('returns null when more than one file shares the suffix', () => {
expect(resolveUniqueIndexedPath(index, 'index.ts')).toBeNull();
});
it('returns null for an empty request instead of matching every key', () => {
expect(resolveUniqueIndexedPath(index, '')).toBeNull();
expect(resolveUniqueIndexedPath(index, './')).toBeNull();
});
it('returns null when nothing matches', () => {
expect(resolveUniqueIndexedPath(index, 'missing.ts')).toBeNull();
});
});

View file

@ -4,8 +4,9 @@
* The SSE complete event may carry `repoPath` (the analyzed path). RepoAnalyzer
* must pass that IDENTITY to onComplete — so the post-analyze reconnect targets
* the exact repo even when basenames collide — while the done screen keeps
* rendering the display NAME and never shows an absolute path. Old servers
* omit repoPath; the name fallback must be preserved.
* rendering the display NAME and never shows an absolute path. Current servers
* omit repoPath and send an opaque repoId, resolved against /api/repos; with
* neither, the name fallback must be preserved.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
@ -13,6 +14,7 @@ import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
import { i18nReady } from '../../src/i18n';
import {
cancelAnalyze,
fetchRepos,
streamAnalyzeProgress,
uploadFolder,
} from '../../src/services/backend-client';
@ -31,13 +33,14 @@ vi.mock('../../src/services/backend-client', () => ({
},
startAnalyze: vi.fn(),
cancelAnalyze: vi.fn(),
fetchRepos: vi.fn(),
streamAnalyzeProgress: vi.fn(),
uploadFolder: vi.fn(),
}));
const JOB = { jobId: 'job-1', status: 'queued' };
type CompleteData = { repoName?: string; repoPath?: string };
type CompleteData = { repoName?: string; repoPath?: string; repoId?: string };
beforeEach(async () => {
await i18nReady;
@ -64,7 +67,7 @@ async function startTrackedJob() {
vi.useFakeTimers();
const onDone = vi.fn<(repoIdentity: string) => void>();
render(<RepoAnalyzer variant="onboarding" onComplete={onDone} />);
const { unmount } = render(<RepoAnalyzer variant="onboarding" onComplete={onDone} />);
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
fireEvent.change(screen.getByTestId('folder-upload-input'), {
target: { files: [new File(['x'], 'a.ts')] },
@ -73,7 +76,7 @@ async function startTrackedJob() {
await act(async () => {});
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
return { onDone, complete: (data: CompleteData) => sseComplete?.(data) };
return { onDone, unmount, complete: (data: CompleteData) => sseComplete?.(data) };
}
describe('analyze completion identity', () => {
@ -90,7 +93,7 @@ describe('analyze completion identity', () => {
// onComplete fires after the ~1200ms done-screen dwell, with the identity.
expect(onDone).not.toHaveBeenCalled();
act(() => {
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledTimes(1);
@ -105,10 +108,64 @@ describe('analyze completion identity', () => {
});
expect(screen.getByText('reels')).toBeInTheDocument();
act(() => {
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledTimes(1);
expect(onDone).toHaveBeenCalledWith('reels');
});
it('resolves repoId to the exact /api/repos entry when names collide', async () => {
vi.mocked(fetchRepos).mockResolvedValue([
{ id: 'id-a', name: 'reels', path: '/ws/a/reels', indexedAt: '' },
{ id: 'id-b', name: 'reels', path: '/ws/b/reels', indexedAt: '' },
]);
const { onDone, complete } = await startTrackedJob();
act(() => {
complete({ repoName: 'reels', repoId: 'id-b' });
});
expect(screen.getByText('reels')).toBeInTheDocument();
expect(screen.queryByText('/ws/b/reels')).toBeNull();
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledWith('/ws/b/reels');
});
it('falls back to the display name when repoId matches no entry', async () => {
vi.mocked(fetchRepos).mockResolvedValue([]);
const { onDone, complete } = await startTrackedJob();
act(() => {
complete({ repoName: 'reels', repoId: 'gone' });
});
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(onDone).toHaveBeenCalledWith('reels');
});
it('does not call onComplete when unmounted while repoId is still resolving', async () => {
let resolveRepos: ((repos: never[]) => void) | undefined;
vi.mocked(fetchRepos).mockReturnValue(
new Promise((resolve) => {
resolveRepos = resolve;
}),
);
const { onDone, complete, unmount } = await startTrackedJob();
act(() => {
complete({ repoName: 'reels', repoId: 'id-b' });
});
await act(async () => {
vi.advanceTimersByTime(1200);
});
unmount();
await act(async () => {
resolveRepos?.([]);
});
expect(onDone).not.toHaveBeenCalled();
});
});

View file

@ -58,6 +58,13 @@ describe('normalizeServerUrl', () => {
it('preserves existing https://', () => {
expect(normalizeServerUrl('https://gitnexus.example.com')).toBe('https://gitnexus.example.com');
});
it('strips userinfo so credentials never reach ?server= or _backendUrl', () => {
expect(normalizeServerUrl('https://user:secret@gitnexus.example.com:8443')).toBe(
'https://gitnexus.example.com:8443',
);
expect(normalizeServerUrl('http://token@localhost:4747/api')).toBe('http://localhost:4747');
});
});
afterEach(() => {

View file

@ -1,3 +1,20 @@
{
"installCommand": "npm ci --include=dev && cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js"
"installCommand": "npm ci --include=dev && cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js",
"rewrites": [
{
"source": "/((?!assets/|api/).*)",
"destination": "/index.html"
}
],
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
]
}

View file

@ -14,6 +14,7 @@ import {
unregisterRepo,
listRegisteredRepos,
getStoragePaths,
type RegistryEntry,
} from '../storage/repo-manager.js';
import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js';
import { formatStaleSlotLine } from './stale-branch-format.js';
@ -24,6 +25,7 @@ import {
listStaleBranchSlots,
removeBranchSlot,
staleListingBlock,
type StaleBranchSlot,
} from '../storage/stale-branch-slots.js';
import {
cleanParkedLbugSidecars,
@ -32,6 +34,119 @@ import {
} from '../core/lbug/sidecar-recovery.js';
import { t } from './i18n/index.js';
type OwnedCwdStorage = {
repo: NonNullable<Awaited<ReturnType<typeof findRepo>>>;
entry: RegistryEntry | undefined;
storagePath: string;
};
const resolveOwnedCwdStorage = async (refusePrefix: string): Promise<OwnedCwdStorage | null> => {
const repo = await findRepo(process.cwd());
if (!repo) {
console.log(t('clean.notFoundHere'));
return null;
}
try {
const [entries, storagePath] = await Promise.all([
listRegisteredRepos(),
requireDeletableStoragePath({
path: repo.repoPath,
storagePath: repo.storagePath,
}),
]);
return {
repo,
entry: findRegistryEntryByRepoPath(entries, repo.repoPath),
storagePath,
};
} catch (err) {
if (err instanceof StorageDeletionError) {
logger.error(`${refusePrefix}${err.message}`);
return null;
}
throw err;
}
};
const printStaleSlotLines = (message: string, slots: readonly StaleBranchSlot[]): void => {
console.log(message);
for (const slot of slots) {
console.log(` - ${formatStaleSlotLine(slot)}`);
}
};
const cleanStaleBranchSlots = async (force: boolean): Promise<void> => {
const owned = await resolveOwnedCwdStorage('Refusing to clean leftover branch indexes: ');
if (!owned) return;
const { repo, entry, storagePath } = owned;
const slots = await listStaleBranchSlots({
repoPath: repo.repoPath,
storagePath,
branches: entry?.branches,
includeSize: !force,
});
const listingBlock = staleListingBlock(slots);
if (listingBlock === 'heads-unavailable') {
printStaleSlotLines(
t('clean.stale.headsUnavailable'),
slots.filter((row) => row.reason === 'heads-unavailable'),
);
return;
}
if (listingBlock === 'listing-failed') {
console.log(t('clean.stale.listingFailed'));
return;
}
const candidates = slots.filter(isDeleteCandidate);
const probeFailed = slots.filter((slot) => slot.reason === 'probe-failed');
if (candidates.length === 0) {
if (probeFailed.length > 0) {
printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed);
return;
}
console.log(t('clean.stale.none'));
return;
}
if (!force) {
printStaleSlotLines(t('clean.stale.preview', { count: candidates.length }), candidates);
if (probeFailed.length > 0) {
printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed);
}
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
let deletedAny = false;
for (const slot of candidates) {
const heads = listLocalHeads(repo.repoPath);
if (heads === null) {
console.log(
deletedAny ? t('clean.stale.remainingSkipped') : t('clean.stale.headsUnavailable'),
);
return;
}
if (heads.includes(slot.branch)) {
console.log(t('clean.stale.skippedLive', { branch: slot.branch }));
continue;
}
const result = await removeBranchSlot({
repoPath: repo.repoPath,
storagePath,
branch: slot.branch,
dir: slot.dir,
});
if (!result.ok) {
console.log(t('clean.stale.failed', { branch: slot.branch }));
logger.error({ err: result.error }, 'Failed to delete leftover branch index:');
continue;
}
deletedAny = true;
console.log(t('clean.stale.deleted', { branch: slot.branch }));
}
if (probeFailed.length > 0) {
printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed);
}
};
export const cleanCommand = async (options?: {
force?: boolean;
all?: boolean;
@ -42,98 +157,7 @@ export const cleanCommand = async (options?: {
// --stale: reclaim leftover per-branch slots whose recorded branch is not
// a live local head (#3331). Exclusive arm before --branch.
if (options?.stale) {
const cwd = process.cwd();
const repo = await findRepo(cwd);
if (!repo) {
console.log(t('clean.notFoundHere'));
return;
}
const entries = await listRegisteredRepos();
const entry = findRegistryEntryByRepoPath(entries, repo.repoPath);
let storagePath: string;
try {
storagePath = await requireDeletableStoragePath({
path: repo.repoPath,
storagePath: repo.storagePath,
});
} catch (err) {
if (err instanceof StorageDeletionError) {
logger.error(`Refusing to clean leftover branch indexes: ${err.message}`);
return;
}
throw err;
}
const slots = await listStaleBranchSlots({
repoPath: repo.repoPath,
storagePath,
branches: entry?.branches,
includeSize: !options.force,
});
const listingBlock = staleListingBlock(slots);
if (listingBlock === 'heads-unavailable') {
console.log(t('clean.stale.headsUnavailable'));
for (const slot of slots.filter((row) => row.reason === 'heads-unavailable')) {
console.log(` - ${formatStaleSlotLine(slot)}`);
}
return;
}
if (listingBlock === 'listing-failed') {
console.log(t('clean.stale.listingFailed'));
return;
}
const candidates = slots.filter(isDeleteCandidate);
const probeFailed = slots.filter((slot) => slot.reason === 'probe-failed');
const printProbeFailed = (): void => {
console.log(t('clean.stale.probeFailed'));
for (const slot of probeFailed) {
console.log(` - ${formatStaleSlotLine(slot)}`);
}
};
if (candidates.length === 0) {
if (probeFailed.length > 0) {
printProbeFailed();
return;
}
console.log(t('clean.stale.none'));
return;
}
if (!options.force) {
console.log(t('clean.stale.preview', { count: candidates.length }));
for (const slot of candidates) {
console.log(` - ${formatStaleSlotLine(slot)}`);
}
if (probeFailed.length > 0) {
printProbeFailed();
}
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
for (const slot of candidates) {
const heads = listLocalHeads(repo.repoPath);
if (heads === null) {
console.log(t('clean.stale.headsUnavailable'));
return;
}
if (heads.includes(slot.branch)) {
console.log(t('clean.stale.skippedLive', { branch: slot.branch }));
continue;
}
const result = await removeBranchSlot({
repoPath: repo.repoPath,
storagePath,
branch: slot.branch,
dir: slot.dir,
});
if (!result.ok) {
console.log(t('clean.stale.failed', { branch: slot.branch }));
logger.error({ err: result.error }, 'Failed to delete leftover branch index:');
continue;
}
console.log(t('clean.stale.deleted', { branch: slot.branch }));
}
if (probeFailed.length > 0) {
printProbeFailed();
}
await cleanStaleBranchSlots(options.force === true);
return;
}
@ -141,32 +165,14 @@ export const cleanCommand = async (options?: {
// Resolve against the RECORDED branches[] summary (never by slugging the
// user's raw input, which can disagree with the index-time-sanitized label).
if (options?.branch) {
const cwd = process.cwd();
const repo = await findRepo(cwd);
if (!repo) {
console.log(t('clean.notFoundHere'));
return;
}
const entries = await listRegisteredRepos();
const entry = findRegistryEntryByRepoPath(entries, repo.repoPath);
const owned = await resolveOwnedCwdStorage('Refusing to clean branch index: ');
if (!owned) return;
const { repo, entry, storagePath } = owned;
const summary = entry?.branches?.find((b) => b.branch === options.branch);
if (!summary) {
console.log(t('clean.branchNotIndexed', { branch: options.branch }));
return;
}
let storagePath: string;
try {
storagePath = await requireDeletableStoragePath({
path: repo.repoPath,
storagePath: repo.storagePath,
});
} catch (err) {
if (err instanceof StorageDeletionError) {
logger.error(`Refusing to clean branch index: ${err.message}`);
return;
}
throw err;
}
const { lbugPath } = getStoragePaths(repo.repoPath, summary.branch, storagePath);
const branchDir = path.dirname(lbugPath);
if (!isContainedBranchDir(storagePath, branchDir)) {

View file

@ -208,7 +208,7 @@ export function nativeStatusLine(check: NativeCheckResult): string {
* When heads cannot be listed, do not title rows as orphaned or name
* `clean --stale` (#3337): that command refuses to delete in the same state.
*/
export function orphanedBranchSlotDoctorLines(slots: StaleBranchSlot[]): string[] {
export function leftoverBranchSlotDoctorLines(slots: StaleBranchSlot[]): string[] {
if (slots.length === 0) return [];
const listingBlock = staleListingBlock(slots);
if (listingBlock === 'heads-unavailable') {
@ -400,22 +400,20 @@ export const doctorCommand = async () => {
console.log(` ${padDisplayEnd('', 12)}${cudaRedirect.detail}`);
}
}
// Doctor stays runtime-global. Add only a cwd leftover-slot section when
// this process is inside an indexed repo. Look up that repo's registry row
// for recorded branch slugs; do not report leftovers for every registered
// repo, and never delete.
const [cwdRepo, entries] = await Promise.all([findRepo(process.cwd()), listRegisteredRepos()]);
// Cwd leftover-slot report only; never delete.
const cwdRepo = await findRepo(process.cwd());
if (!cwdRepo) return;
const entries = await listRegisteredRepos();
const entry = findRegistryEntryByRepoPath(entries, cwdRepo.repoPath);
const slots = await listStaleBranchSlots({
repoPath: cwdRepo.repoPath,
storagePath: cwdRepo.storagePath,
branches: entry?.branches,
});
const orphanLines = orphanedBranchSlotDoctorLines(slots);
if (orphanLines.length === 0) return;
const leftoverLines = leftoverBranchSlotDoctorLines(slots);
if (leftoverLines.length === 0) return;
console.log('');
for (const line of orphanLines) {
for (const line of leftoverLines) {
console.log(line);
}
};

View file

@ -68,6 +68,8 @@ export const en = {
'clean.stale.registryOnlyPath': '(registry only)',
'clean.stale.headsUnavailable':
'Could not list local heads; leftover branch indexes were not deleted. Re-run `gitnexus clean --stale` when git is available.',
'clean.stale.remainingSkipped':
'Could not list local heads; remaining leftover branch indexes were skipped. Re-run `gitnexus clean --stale` when git is available.',
'clean.stale.listingFailed':
'Could not read leftover branch index directories; leftover indexes were not deleted. Check permissions on the branches/ directory and re-run `gitnexus clean --stale`.',
'clean.stale.probeFailed':

View file

@ -67,6 +67,8 @@ export const zhCN = {
'clean.stale.registryOnlyPath': '(仅注册表)',
'clean.stale.headsUnavailable':
'无法列出本地分支,因此未删除残留分支索引。请在 git 可用后重新运行 `gitnexus clean --stale`。',
'clean.stale.remainingSkipped':
'无法列出本地分支,因此已跳过其余残留分支索引。请在 git 可用后重新运行 `gitnexus clean --stale`。',
'clean.stale.listingFailed':
'无法读取残留分支索引目录,因此未删除残留索引。请检查 branches/ 目录权限后重新运行 `gitnexus clean --stale`。',
'clean.stale.probeFailed': '无法检查残留分支索引路径,因此未删除这些槽位。',

View file

@ -88,12 +88,17 @@ export interface AnalyzeJob {
const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
const JOB_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
/** How long a cancelled worker gets to exit via IPC before a signal is sent. */
const CANCEL_GRACE_MS = 15_000;
export class JobManager {
private jobs = new Map<string, AnalyzeJob>();
private children = new Map<string, ChildProcess>();
private abortControllers = new Map<string, AbortController>();
private timeouts = new Map<string, ReturnType<typeof setTimeout>>();
private cancelGraceTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Cancel reason to apply when a still-running worker exits. */
private pendingCancelReasons = new Map<string, string>();
private emitter = new EventEmitter();
private cleanupTimer: ReturnType<typeof setInterval>;
@ -113,13 +118,20 @@ export class JobManager {
* reject the request outright, which is a truthful answer.
*/
createJob(params: { repoUrl?: string; repoPath?: string; branch?: string }): AnalyzeJob {
// Dedup: return existing active job for the same repo (by URL or path) and branch
// Dedup: return existing active job for the same repo (by URL or path) and branch.
// A cancelled job still occupies the slot while its worker is registered —
// flipping to `failed` before exit used to let a second POST start cloneOrPull
// against a LadybugDB file the first worker was still writing.
for (const job of this.jobs.values()) {
if (!this.isTerminal(job.status)) {
if (this.isSlotOccupied(job)) {
const isSameRepo =
(params.repoUrl && job.repoUrl === params.repoUrl) ||
(params.repoPath && job.repoPath === params.repoPath);
if (isSameRepo && job.branch === params.branch) {
// A dying job still occupies the slot (pending cancel, or already
// failed while the child/lock is held until exit). Do not 202-reuse
// it — fall through to the single-slot throw.
if (this.hasPendingCancel(job.id) || this.isTerminal(job.status)) continue;
return job;
}
}
@ -127,7 +139,7 @@ export class JobManager {
// Single-slot: reject if another job is active (different repo)
for (const job of this.jobs.values()) {
if (!this.isTerminal(job.status)) {
if (this.isSlotOccupied(job)) {
throw new Error(`Analysis already in progress (job ${job.id})`);
}
}
@ -207,15 +219,58 @@ export class JobManager {
}, JOB_TIMEOUT_MS);
this.timeouts.set(jobId, timer);
// Clean up tracking when child exits
child.on('exit', () => {
this.children.delete(jobId);
const t = this.timeouts.get(jobId);
if (t) {
clearTimeout(t);
this.timeouts.delete(jobId);
}
});
// Apply a pending cancel BEFORE other `exit` listeners (analyze-launch's
// crash-retry) see a still-non-terminal job and fork a replacement worker.
const onExit = (): void => {
this.releaseChild(jobId);
this.applyPendingCancel(jobId);
};
if (typeof child.prependListener === 'function') {
child.prependListener('exit', onExit);
} else {
child.on('exit', onExit);
}
}
/** True while cancel was requested and the worker has not exited yet. */
hasPendingCancel(jobId: string): boolean {
return this.pendingCancelReasons.has(jobId);
}
/**
* Drop a registered child without waiting for `exit`. Spawn failures emit
* `error` and never `exit`, which would otherwise leave `isSlotOccupied`
* true forever after the job is already failed.
*/
releaseChild(jobId: string): void {
this.children.delete(jobId);
const t = this.timeouts.get(jobId);
if (t) {
clearTimeout(t);
this.timeouts.delete(jobId);
}
const grace = this.cancelGraceTimers.get(jobId);
if (grace) {
clearTimeout(grace);
this.cancelGraceTimers.delete(jobId);
}
}
/**
* Apply a stored cancel reason if one is pending. Returns true when a reason
* was consumed. Used by the worker-exit handler and by analyze-launch when
* the child reports a generic cancel IPC — that message must not overwrite
* the caller's reason (timeout vs user cancel).
*/
applyPendingCancel(jobId: string): boolean {
const reason = this.pendingCancelReasons.get(jobId);
if (reason === undefined) return false;
this.pendingCancelReasons.delete(jobId);
const current = this.jobs.get(jobId);
if (current && !this.isTerminal(current.status)) {
this.updateJob(jobId, { status: 'failed', error: reason });
}
return true;
}
/** Register cancellable in-process work for a job. */
@ -228,21 +283,38 @@ export class JobManager {
this.abortControllers.set(jobId, controller);
}
/** Cancel a running job — sends SIGTERM to child process. */
/**
* Cancel a running job.
*
* The worker is asked to stop over IPC first (`{ type: 'cancel' }`), which
* lets it reach a JS-visible safe point and checkpoint before exiting —
* the same cross-platform control path `core/auto-sync` uses. A signal is
* sent only as a bounded fallback: on Windows `child.kill('SIGTERM')` is a
* forceful termination (Node ignores the signal name there), so leading
* with it could kill the worker mid LadybugDB write.
*/
cancelJob(jobId: string, reason?: string): boolean {
const job = this.jobs.get(jobId);
if (!job || this.isTerminal(job.status)) return false;
const child = this.children.get(jobId);
if (child) {
child.kill('SIGTERM');
this.requestChildShutdown(jobId, child);
}
this.abortControllers.get(jobId)?.abort();
this.abortControllers.delete(jobId);
const cancelReason = reason || 'Analysis cancelled';
if (child) {
// Keep the job non-terminal until the worker exits so createJob and
// the resolveRepo hold-queue still see the slot as occupied.
this.pendingCancelReasons.set(jobId, cancelReason);
return true;
}
this.updateJob(jobId, {
status: 'failed',
error: reason || 'Analysis cancelled',
error: cancelReason,
});
return true;
@ -255,12 +327,57 @@ export class JobManager {
return () => this.emitter.off(event, listener);
}
dispose() {
// Kill all active child processes
for (const child of this.children.values()) {
/**
* Ask a worker to shut down: IPC cancel now, signal after a grace period if
* it has not exited on its own. The grace timer is cleared by the child's
* `exit` handler registered in `registerChild`.
*/
private requestChildShutdown(jobId: string, child: ChildProcess): void {
let ipcSent = false;
if (child.connected) {
try {
child.send({ type: 'cancel' });
ipcSent = true;
} catch {
// Channel already closed — fall through to the signal path.
}
}
if (!ipcSent) {
child.kill('SIGTERM');
return;
}
if (this.cancelGraceTimers.has(jobId)) return;
const grace = setTimeout(() => {
this.cancelGraceTimers.delete(jobId);
if (child.exitCode === null && child.signalCode === null) {
// IPC already set the worker's cooperative cancel flag; a second
// SIGTERM is a no-op there. SIGKILL is the actual bounded fallback
// (on Windows `kill()` is already TerminateProcess).
child.kill('SIGKILL');
}
}, CANCEL_GRACE_MS);
grace.unref?.();
this.cancelGraceTimers.set(jobId, grace);
}
dispose() {
// IPC first so a worker that checks in can stop at a safe point.
// On Windows `child.kill('SIGTERM')` is TerminateProcess — skip that
// immediate kill and leave the 15s grace timer to SIGKILL. On Unix,
// SIGTERM after IPC is cooperative, so the grace timers can be dropped.
const windows = process.platform === 'win32';
for (const [jobId, child] of this.children) {
this.requestChildShutdown(jobId, child);
if (!windows) {
child.kill('SIGTERM');
}
}
this.children.clear();
if (!windows) {
for (const timer of this.cancelGraceTimers.values()) clearTimeout(timer);
this.cancelGraceTimers.clear();
}
this.pendingCancelReasons.clear();
for (const controller of this.abortControllers.values()) controller.abort();
this.abortControllers.clear();
@ -278,6 +395,10 @@ export class JobManager {
return isTerminalJobStatus(status);
}
private isSlotOccupied(job: AnalyzeJob): boolean {
return !this.isTerminal(job.status) || this.children.has(job.id);
}
private cleanup() {
const now = Date.now();
for (const [id, job] of this.jobs) {

View file

@ -116,15 +116,18 @@ const settleDirFor = (
*
* Never rejects. Returns `true` once the index is settled. Timing out logs
* a warning and returns `false` — the caller must fail the job without
* publishing. The `alreadyUpToDate` fast path never rewrites `lbug` (see
* `run-analyze.ts`) and is treated as settled without waiting so it does
* not hold the analyze slot for 60s of polling.
* publishing. `shouldAbort` short-circuits the poll (no timeout warning)
* so a pending cancel or already-terminal job does not hold the write lock
* for the remaining 60s. The `alreadyUpToDate` fast path never rewrites
* `lbug` (see `run-analyze.ts`) and is treated as settled without waiting
* so it does not hold the analyze slot for 60s of polling.
*/
const waitForSettledIndex = async (
storagePath: string,
jobStartMs: number,
branch?: string,
isPrimaryBranch?: boolean,
shouldAbort?: () => boolean,
): Promise<boolean> => {
const settled = (probePath: string): boolean => {
try {
@ -170,6 +173,7 @@ const waitForSettledIndex = async (
};
const deadline = Date.now() + FINALIZE_SETTLE_TIMEOUT_MS;
for (;;) {
if (shouldAbort?.()) return false;
if (settled(settleDirFor(storagePath, branch, isPrimaryBranch))) return true;
if (Date.now() > deadline) {
logger.warn(
@ -235,9 +239,20 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
const workerHeapMb =
Number.isInteger(envHeapMb) && envHeapMb > 0 ? envHeapMb : Math.min(8192, autoHeapCapMb());
const launchAborted = (jobId: string): boolean => {
const current = jobManager.getJob(jobId);
return !current || isTerminalJobStatus(current.status) || jobManager.hasPendingCancel(jobId);
};
const forkWorker = () => {
const currentJob = jobManager.getJob(job.id);
if (!currentJob || isTerminalJobStatus(currentJob.status)) return;
if (launchAborted(job.id)) {
// Cancelled (or timed out) between lock acquisition and the fork, or
// during a crash-retry delay. A pending-cancel job stays non-terminal
// until the worker exits — do not fork a replacement. Nothing else
// drops the lock here, so release or the repo stays "busy" until restart.
releaseLockOnce();
return;
}
const child = fork(workerPath, [], {
execArgv: [...tsxHookArgs, `--max-old-space-size=${workerHeapMb}`],
@ -253,6 +268,13 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
// below reads that clean exit as a crash and retries a SUCCESSFUL
// analysis, three times, before failing it (#3199 review).
let terminalIpcSeen = false;
// Cancel `error` IPC arrives before the worker's `finally` checkpoint.
// Hold the write lock until `exit` so embed cannot acquire under a
// still-open native handle. Exit must release when this is set —
// `terminalIpcSeen` is already true for that IPC, so a naive
// "don't release on cancel error" would leak the lock.
let holdLockUntilExit = false;
let childExited = false;
child.stderr?.on('data', (chunk: Buffer) => {
stderrChunks += chunk.toString();
if (stderrChunks.length > 4096) stderrChunks = stderrChunks.slice(-4096);
@ -274,6 +296,12 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
progress: { phase: msg.phase, percent: msg.percent, message: msg.message },
});
} else if (msg.type === 'complete') {
if (jobManager.applyPendingCancel(job.id)) {
// Same as cancel `error` IPC: the worker still runs
// `boundedCheckpointBeforeExit` after sending terminal IPC.
holdLockUntilExit = true;
return;
}
// Hold the write lock through settle AND the collapse/publish
// decision. Release in `finally` so timeout / collapse / init
// failure / complete each drop it exactly once. alreadyUpToDate
@ -303,9 +331,15 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
jobStartMs,
opts.branch,
msg.result.isPrimaryBranch,
() => launchAborted(job.id),
);
settle
.then((settled) => {
if (launchAborted(job.id)) {
jobManager.applyPendingCancel(job.id);
if (!childExited) holdLockUntilExit = true;
return false;
}
if (!settled) {
// Finalization never became visible. Do not evict the cached
// handle (a previously published index should keep being
@ -324,6 +358,11 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
})
.then((readyToPublish) => {
if (!readyToPublish) return;
if (launchAborted(job.id)) {
jobManager.applyPendingCancel(job.id);
if (!childExited) holdLockUntilExit = true;
return;
}
// PARITY WITH THE CLI, which is what the IPC projection was added
// for. `analyze-worker-ipc.ts` carries `graphWriteCollapsed`
// "so a server-side caller sees the same degraded outcome the CLI
@ -398,27 +437,59 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
});
})
.finally(() => {
releaseLockOnce();
if (!holdLockUntilExit) releaseLockOnce();
});
} else if (msg.type === 'error') {
releaseLockOnce();
// A failed (force) analyze may still have rewritten DB files first.
void closeDbHandle().catch(() => {});
jobManager.updateJob(job.id, { status: 'failed', error: msg.message });
// Cancel path: the worker sends this IPC first, then
// `boundedCheckpointBeforeExit` in `finally`. Hold the lock until
// `exit` so embed (same `acquireRepoLock`) cannot open mid-checkpoint.
if (jobManager.hasPendingCancel(job.id)) {
jobManager.applyPendingCancel(job.id);
holdLockUntilExit = true;
} else {
releaseLockOnce();
// A failed (force) analyze may still have rewritten DB files first.
void closeDbHandle().catch(() => {});
jobManager.updateJob(job.id, { status: 'failed', error: msg.message });
}
}
});
child.on('error', (err) => {
// Fake test children have no `pid`. Treat that as pre-spawn so the
// spawn-failure path still frees the slot without waiting for `exit`.
// A numeric pid is a live child — Node also emits `error` for
// post-spawn send/kill failures (e.g. write EPIPE); those still get
// `exit`, which drops the lock when `!terminalIpcSeen || holdLockUntilExit`.
const preSpawn = typeof child.pid !== 'number';
if (!jobManager.applyPendingCancel(job.id)) {
jobManager.updateJob(job.id, {
status: 'failed',
error: `Worker process error: ${err.message}`,
});
}
if (!preSpawn) return;
releaseLockOnce();
jobManager.updateJob(job.id, {
status: 'failed',
error: `Worker process error: ${err.message}`,
});
// `fork`/`error` without `exit` (spawn failure) would otherwise keep
// the child in JobManager and block every later createJob.
jobManager.releaseChild(job.id);
});
child.on('exit', (code) => {
childExited = true;
const j = jobManager.getJob(job.id);
if (!j || isTerminalJobStatus(j.status)) return;
if (!j || isTerminalJobStatus(j.status) || jobManager.hasPendingCancel(job.id)) {
// (a) complete/error IPC is in flight (`terminalIpcSeen`) — that
// chain owns the lock through settle/`backend.init()`/`finally`.
// Releasing here lets a second analyze acquire under a publish.
// (b) cancel is pending or already failed BEFORE any terminal IPC —
// this exit is the only remaining place that can drop the lock.
// (c) cancel `error` IPC set `holdLockUntilExit`: `terminalIpcSeen`
// is true, so without this extra clause the lock would leak
// until process restart.
if (!terminalIpcSeen || holdLockUntilExit) releaseLockOnce();
return;
}
// The worker already reported a terminal outcome; this exit is it
// winding down, not dying. The job is still non-terminal only because

View file

@ -105,6 +105,7 @@ import {
buildServerInfo,
createServeUpdateController,
} from './update-controller.js';
import { buildOpsSnapshot, isGitNexusVercelOrigin, serializeOpsJob } from './ops-snapshot.js';
export {
bindServeUpdateControllerLifecycle,
@ -125,7 +126,11 @@ export {
* 10.0.0.0/8 → 10.x.x.x
* 172.16.0.0/12 → 172.16.x.x – 172.31.x.x
* 192.168.0.0/16 → 192.168.x.x
* - https://gitnexus.vercel.app — the deployed GitNexus web UI
* - https://gitnexus.vercel.app and https://gitnexus-web.vercel.app —
* first-party GitNexus web UI production hosts (ops dashboard included).
* Preview hosts cannot set GITNEXUS_PUBLIC_ORIGIN until serve auth exists
* (`assertServeAuthForPublicOrigin` refuses to start). Reach them through a
* proxy that authenticates, or bind loopback.
* - the origin named by GITNEXUS_PUBLIC_ORIGIN, when set — matched on hostname
* always, and on scheme and port when the configured value carries them
*
@ -146,7 +151,7 @@ export const isAllowedOrigin = (origin: string | undefined): boolean => {
origin === 'http://127.0.0.1' ||
origin.startsWith('http://[::1]:') ||
origin === 'http://[::1]' ||
origin === 'https://gitnexus.vercel.app'
isGitNexusVercelOrigin(origin)
) {
return true;
}
@ -613,6 +618,17 @@ const requestedRepo = (req: express.Request): string | undefined => {
return undefined;
};
/**
* Hold-queue opt-out for process/cluster GETs. Default is wait (same as
* `/api/repo`). `?awaitAnalysis=false` skips the 300s resolveRepo hold so a
* caller can fail fast instead of parking a connection on an in-flight analyze.
*/
export const parseAwaitAnalysisQuery = (value: unknown): boolean => {
const raw = Array.isArray(value) ? value[0] : value;
if (typeof raw !== 'string') return true;
return raw !== 'false' && raw !== '0';
};
const repoParamBasename = (repoName: string): string =>
repoName.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? repoName;
@ -663,6 +679,19 @@ export const resolveRegisteredRepoEntry = (
);
};
/** HTTP omit-`?repo=` policy: MCP returns 400 when multiple repos are indexed. */
export const resolveOmittedRepoSelection = (
repos: RegistryEntry[],
): { ok: true; entry: RegistryEntry } | { ok: false; status: 400 | 404; error: string } => {
if (repos.length === 1) return { ok: true, entry: repos[0]! };
if (repos.length === 0) return { ok: false, status: 404, error: 'Repository not found' };
return {
ok: false,
status: 400,
error: `Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${repos.map((r) => r.name).join(', ')}`,
};
};
export interface SourceAvailability {
available: boolean;
reason?: 'content-retention' | 'checkout-missing';
@ -774,7 +803,19 @@ export const handleFileRequest = async (
return;
}
const raw = await fs.readFile(fullPath, 'utf-8');
// The lexical check above cannot see symlinks: a repo cloned from an
// untrusted remote can contain `evil -> /etc/passwd` (or `-> ../../..`)
// that passes `path.relative` and is then followed by readFile. Re-check
// containment on the resolved (realpath) form of both sides. A missing
// file throws ENOENT here and keeps its 404 below.
const [realRoot, realFull] = await Promise.all([fs.realpath(repoRoot), fs.realpath(fullPath)]);
const realRel = path.relative(realRoot, realFull);
if (realRel === '..' || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
res.status(403).json({ error: 'Path traversal denied' });
return;
}
const raw = await fs.readFile(realFull, 'utf-8');
// Optional line-range support: ?startLine=10&endLine=50
// Returns only the requested slice (0-indexed), plus metadata.
@ -831,6 +872,26 @@ function readOnlyFtsOptions(skipFts?: true): { readOnly: true; skipFts?: true }
return skipFts ? { readOnly: true, skipFts: true } : { readOnly: true };
}
/**
* The server's `resolveRepo` returns `{ __timedOut: true, repoName }` when it
* waited the full hold-queue window for an in-flight analysis. Every route
* that resolves a repo must handle that sentinel — treating it as a registry
* entry crashes on `entry.storagePath` ("path argument must be of type
* string", surfaced as a 500). Returns true when a 503 was sent and the
* caller should return.
*/
export const respondIfAnalysisPending = (
entry: unknown,
res: { status: (code: number) => { json: (body: unknown) => void } },
): boolean => {
const sentinel = entry as { __timedOut?: boolean; repoName?: string } | null | undefined;
if (!sentinel?.__timedOut) return false;
res.status(503).json({
error: `Repository analysis for "${sentinel.repoName}" is taking longer than expected. Please try again in a moment.`,
});
return true;
};
export const handleQueryRequest = async (
req: express.Request,
res: express.Response,
@ -855,6 +916,7 @@ export const handleQueryRequest = async (
res.status(404).json({ error: 'Repository not found' });
return;
}
if (respondIfAnalysisPending(entry, res)) return;
const lbugPath = path.join(entry.storagePath, 'lbug');
const { skipFts } = await loadFtsSession(entry.storagePath);
const result = await withLbugDb(
@ -921,6 +983,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
const app = express();
app.disable('x-powered-by');
const serverStartedAt = Date.now();
// Which upstream hops may set X-Forwarded-*. Process-wide: every route's
// req.ip, and so the per-IP rate limiter, resolves through this.
@ -954,6 +1017,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// global body parser so rejected requests do not consume the JSON budget.
installServeMcpAuth(app);
app.use(express.json({ limit: '10mb' }));
// Express 5 leaves `req.body` undefined when no parser matched (e.g. a POST
// without `Content-Type: application/json`). Route handlers read
// `req.body.<field>` directly, so normalize to an empty object and let their
// own "Missing X in request body" 400s fire instead of a TypeError 500.
app.use((req, _res, next) => {
if (req.body == null) req.body = {};
next();
});
// Origin guard for write routes: loopback, the server's own bound host, and
// any configured public origin — prevents CSRF from other devices.
@ -1024,7 +1095,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
repoName?: string,
isRetry = false,
req?: any,
options: { validateStorage?: boolean } = {},
options: { validateStorage?: boolean; awaitAnalysis?: boolean } = {},
): Promise<any> => {
const repos = await listRegisteredRepos({
validate: options.validateStorage !== false,
@ -1039,7 +1110,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// analyzing this repo. Hold the connection open (up to 5 minutes) until it completes.
// We only wait for in-progress jobs ('queued'|'cloning'|'analyzing') — a 'complete' job
// whose repo is still missing means the registry sync failed; the fallback below handles it.
if (!found && normalizedName) {
if (!found && normalizedName && options.awaitAnalysis !== false) {
const lower = normalizedName.toLowerCase();
// Track client disconnect to cancel the wait early
@ -1068,7 +1139,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
for (let wait = 0; wait < HOLD_QUEUE_TIMEOUT_SECS; wait++) {
if (clientGone) return null; // client disconnected — stop polling
const currentJob = jobManager.getJob(job.id);
if (!currentJob || currentJob.status === 'failed') break;
if (!currentJob || currentJob.status === 'failed') {
// The job is over and produced no registry entry. This is a
// plain "not found", not "still analyzing" — falling through to
// the timed-out sentinel here told callers to keep waiting for
// a job that had already failed.
return null;
}
if (currentJob.status === 'complete') {
await backend.init();
const freshRepos = await listRegisteredRepos({
@ -1178,12 +1255,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
return;
}
// Timed out waiting for an active analysis job
if (entry.__timedOut) {
res.status(503).json({
error: `Repository analysis for "${entry.repoName}" is taking longer than expected. Please try again in a moment.`,
});
return;
}
if (respondIfAnalysisPending(entry, res)) return;
const meta = await loadMeta(entry.storagePath);
const [staleness, availability] = await Promise.all([
checkStalenessAsync(entry.path, resolveLastCommit(entry, meta)),
@ -1217,6 +1289,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Repository not found' });
return;
}
if (respondIfAnalysisPending(entry, res)) return;
let storagePath: string;
try {
storagePath = await requireDeletableStoragePath(entry);
@ -1306,6 +1379,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Repository not found' });
return;
}
if (respondIfAnalysisPending(entry, res)) return;
const lbugPath = path.join(entry.storagePath, 'lbug');
const includeContent = req.query.includeContent === 'true';
const stream = req.query.stream === 'true';
@ -1398,6 +1472,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Repository not found' });
return;
}
if (respondIfAnalysisPending(entry, res)) return;
const lbugPath = path.join(entry.storagePath, 'lbug');
const parsedLimit = Number(req.body.limit ?? 10);
const { ftsDisabledReason, skipFts } = await loadFtsSession(entry.storagePath);
@ -1572,6 +1647,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Repository not found' });
return;
}
if (respondIfAnalysisPending(entry, res)) return;
await handleFileRequest(req, res, entry.path, await getSourceAvailability(entry));
} catch (err: any) {
if (sendStorageRequirementHttp(err, res)) return;
@ -1591,6 +1667,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Repository not found' });
return;
}
if (respondIfAnalysisPending(entry, res)) return;
const sourceAvailability = await getSourceAvailability(entry);
if (!sourceAvailability.available) {
sendSourceUnavailable(res, sourceAvailability);
@ -1634,18 +1711,58 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
}
});
// Process / cluster routes resolve `?repo=` through the HTTP resolver
// (`resolveRepo` → `resolveRegisteredRepoEntry`) and hand the backend the
// registered ABSOLUTE path. Passing the raw param straight to the MCP
// resolver bypassed the policy documented on `resolveRegisteredRepoEntry`:
// a bare-name miss ran a CWD-relative realpathSync probe on attacker input
// and a full registry refresh, and a partial name (`?repo=core`) could
// silently pick `my-core-lib`. Same 60 rpm/IP limiter as `/api/repo`.
const resolveBackendRepoPath = async (
req: express.Request,
res: express.Response,
): Promise<string | null> => {
// Pass `req` so resolveRepo can abort its hold-queue wait when the client
// disconnects (close listener is only registered when `req` is supplied).
const requested = requestedRepo(req);
let entry;
if (!requested) {
const omitted = resolveOmittedRepoSelection(await listRegisteredRepos({ validate: true }));
if (omitted.ok === false) {
res.status(omitted.status).json({ error: omitted.error });
return null;
}
// Keep this snapshot's sole entry. resolveRepo(undefined) would list
// again and map an omitted name to repos[0] of a newer registry.
entry = await validateResolvedRepoEntry(omitted.entry);
} else {
entry = await resolveRepo(requested, false, req, {
awaitAnalysis: parseAwaitAnalysisQuery(req.query.awaitAnalysis),
});
}
if (!entry) {
res.status(404).json({ error: 'Repository not found' });
return null;
}
if (respondIfAnalysisPending(entry, res)) return null;
return entry.path as string;
};
// List all processes
app.get('/api/processes', async (req, res) => {
app.get('/api/processes', createRouteLimiter(), async (req, res) => {
try {
const result = await backend.queryProcesses(requestedRepo(req));
const repoPath = await resolveBackendRepoPath(req, res);
if (repoPath === null) return;
const result = await backend.queryProcesses(repoPath);
res.json(result);
} catch (err: any) {
if (sendStorageRequirementHttp(err, res)) return;
res.status(statusFromError(err)).json(httpErrorBody(err, 'Failed to query processes'));
}
});
// Process detail
app.get('/api/process', async (req, res) => {
app.get('/api/process', createRouteLimiter(), async (req, res) => {
try {
const name = String(req.query.name ?? '').trim();
if (!name) {
@ -1653,29 +1770,35 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
return;
}
const result = await backend.queryProcessDetail(name, requestedRepo(req));
const repoPath = await resolveBackendRepoPath(req, res);
if (repoPath === null) return;
const result = await backend.queryProcessDetail(name, repoPath);
if (result?.error) {
res.status(404).json({ error: result.error });
return;
}
res.json(result);
} catch (err: any) {
if (sendStorageRequirementHttp(err, res)) return;
res.status(statusFromError(err)).json(httpErrorBody(err, 'Failed to query process detail'));
}
});
// List all clusters
app.get('/api/clusters', async (req, res) => {
app.get('/api/clusters', createRouteLimiter(), async (req, res) => {
try {
const result = await backend.queryClusters(requestedRepo(req));
const repoPath = await resolveBackendRepoPath(req, res);
if (repoPath === null) return;
const result = await backend.queryClusters(repoPath);
res.json(result);
} catch (err: any) {
if (sendStorageRequirementHttp(err, res)) return;
res.status(statusFromError(err)).json(httpErrorBody(err, 'Failed to query clusters'));
}
});
// Cluster detail
app.get('/api/cluster', async (req, res) => {
app.get('/api/cluster', createRouteLimiter(), async (req, res) => {
try {
const name = String(req.query.name ?? '').trim();
if (!name) {
@ -1683,13 +1806,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
return;
}
const result = await backend.queryClusterDetail(name, requestedRepo(req));
const repoPath = await resolveBackendRepoPath(req, res);
if (repoPath === null) return;
const result = await backend.queryClusterDetail(name, repoPath);
if (result?.error) {
res.status(404).json({ error: result.error });
return;
}
res.json(result);
} catch (err: any) {
if (sendStorageRequirementHttp(err, res)) return;
res.status(statusFromError(err)).json(httpErrorBody(err, 'Failed to query cluster detail'));
}
});
@ -1831,7 +1957,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// url+branch: same value as registryName (dir basename), not
// the extractWebRepoName stem used only as getCloneDir's first arg.
repoName: analyzeBranch ? path.basename(targetPath) : repoName,
progress: { phase: 'cloning', percent: 0, message: `Cloning ${repoUrl}...` },
// Never put repoUrl in progress — ops feed is unauthenticated
// and may still serialize message (credentials via userinfo).
progress: {
phase: 'cloning',
percent: 0,
message: `Cloning ${analyzeBranch ? path.basename(targetPath) : repoName}...`,
},
});
await cloneOrPull(
@ -1912,17 +2044,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Job not found' });
return;
}
res.json({
id: job.id,
status: job.status,
repoUrl: job.repoUrl,
repoPath: job.repoPath,
repoName: job.repoName,
progress: job.progress,
error: job.error,
startedAt: job.startedAt,
completedAt: job.completedAt,
});
// Same public serializer as `/api/ops` — job ids on the ops feed must not
// unlock raw repoUrl/repoPath/userinfo through this pre-existing poll.
res.json(serializeOpsJob(job, 'analyze'));
});
// GET /api/analyze/:jobId/progress — SSE stream (shared helper)
@ -1941,7 +2065,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
return;
}
jobManager.cancelJob(jobId, 'Cancelled by user');
res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' });
// Live JobManager view: a registered child stays non-terminal until exit.
// Hard-coding `failed` here made clients retry immediately and then 409.
res.json(serializeOpsJob(jobManager.getJob(jobId) ?? job, 'analyze'));
});
// ── Embedding endpoints ────────────────────────────────────────────
@ -1961,6 +2087,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
return;
}
if (respondIfAnalysisPending(entry, res)) return;
// Re-check the exact registered slot immediately before taking the lock.
// The query resolver already validates it, but this closes the gap between
// lookup and a long-running metadata-writing job.
@ -2255,18 +2383,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
res.status(404).json({ error: 'Job not found' });
return;
}
res.json({
id: job.id,
status: job.status,
repoName: job.repoName,
progress: job.progress,
error: job.error,
// Absent unless the run was a partial one — omitted by JSON.stringify, so
// the response shape is unchanged for every other outcome (#2790).
partial: job.partial,
startedAt: job.startedAt,
completedAt: job.completedAt,
});
res.json(serializeOpsJob(job, 'embed'));
});
// GET /api/embed/:jobId/progress — SSE stream (shared helper)
@ -2285,7 +2402,59 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
return;
}
embedJobManager.cancelJob(jobId, 'Cancelled by user');
res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' });
// Same live serialize as analyze DELETE / GET poll — do not invent `failed`.
res.json(serializeOpsJob(embedJobManager.getJob(jobId) ?? job, 'embed'));
});
const currentOpsSnapshot = () =>
buildOpsSnapshot({
analyzeJobs: jobManager.listJobs(),
embedJobs: embedJobManager.listJobs(),
serverStartedAt,
server: buildServerInfo(updateController.snapshot()),
});
// GET /api/ops — realtime execution snapshot for the ops dashboard.
// In-memory only (analyze + embed JobManagers); no git/fs work, safe to poll.
// Rate-limited: snapshot serialization is cheap per call but unbounded
// polling from many clients is not.
app.get('/api/ops', createRouteLimiter({ limit: 60 }), (_req, res) => {
res.json(currentOpsSnapshot());
});
// Cap concurrent ops SSE streams — each holds two intervals and serializes
// the full job list every second for as long as the client stays connected.
let opsStreamConnections = 0;
const MAX_OPS_STREAM_CONNECTIONS = 8;
// GET /api/ops/stream — SSE push of the same snapshot every second.
app.get('/api/ops/stream', createRouteLimiter({ limit: 30 }), (req, res) => {
if (opsStreamConnections >= MAX_OPS_STREAM_CONNECTIONS) {
res.status(429).json({ error: 'Too many ops stream connections' });
return;
}
opsStreamConnections += 1;
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
res.flushHeaders();
const push = () => {
res.write(`data: ${JSON.stringify(currentOpsSnapshot())}\n\n`);
};
push();
const interval = setInterval(push, 1_000);
const keepAlive = setInterval(() => res.write(':ping\n\n'), 15_000);
const release = () => {
clearInterval(interval);
clearInterval(keepAlive);
opsStreamConnections = Math.max(0, opsStreamConnections - 1);
};
req.on('close', release);
});
// ── Web UI (served at root) ───────────────────────────────────────
@ -2299,8 +2468,19 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
const staticDir = await resolveWebDistDir(webDistDir, devWebDistDir);
registerWebUI(app, staticDir);
// Global error handler — catch anything the route handlers miss
// Global error handler — catch anything the route handlers miss.
// body-parser rejections (malformed JSON → 400, > limit → 413, wrong
// charset → 415) arrive here as http-errors with a 4xx `status`/`statusCode`
// and `expose: true`; report them as the client errors they are instead of
// logging them at error level as a 500.
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const status = Number(err?.status ?? err?.statusCode);
if (Number.isInteger(status) && status >= 400 && status < 500) {
const message =
err?.expose && typeof err.message === 'string' && err.message ? err.message : 'Bad request';
res.status(status).json({ error: message });
return;
}
logger.error({ err }, 'Unhandled error:');
res.status(500).json({ error: 'Internal server error' });
});

View file

@ -0,0 +1,352 @@
/**
* Ops / execution dashboard snapshot helpers.
*
* Pure serialization + metrics over in-memory JobManager state so the
* HTTP route stays thin and unit tests do not boot Express.
*/
import {
isTerminalJobStatus,
type AnalyzeJob,
type AnalyzeJobProgress,
type AnalyzeJobStatus,
} from './analyze-job.js';
import { publicRepoId } from './public-repo-id.js';
import { escapeRegExp } from './validation.js';
export interface OpsJobView {
id: string;
lane: 'analyze' | 'embed';
status: AnalyzeJobStatus;
repoName?: string;
/** Opaque `GET /api/repos` entry id; set once the job is complete. */
repoId?: string;
branch?: string;
progress: AnalyzeJob['progress'];
error?: string;
partial?: AnalyzeJob['partial'];
startedAt: number;
completedAt?: number;
retryCount: number;
/** Elapsed wall time; uses completedAt when terminal, else `now`. */
durationMs: number;
}
export interface OpsLaneMetrics {
total: number;
active: number;
queued: number;
complete: number;
failed: number;
byStatus: Record<AnalyzeJobStatus, number>;
/** Mean duration of terminal jobs that have completedAt; null when none. */
avgDurationMs: number | null;
/** Max duration among terminal jobs; null when none. */
maxDurationMs: number | null;
/** Sum of progress.percent across non-terminal jobs (0–100 each). */
activeProgressSum: number;
}
export interface OpsSnapshot {
generatedAt: number;
uptimeMs: number;
health: 'ok';
server: {
version: string;
launchContext: string;
nodeVersion: string;
latestVersion?: string;
updateAvailable?: boolean;
};
analyze: {
jobs: OpsJobView[];
metrics: OpsLaneMetrics;
};
embed: {
jobs: OpsJobView[];
metrics: OpsLaneMetrics;
};
totals: {
jobs: number;
active: number;
failed: number;
complete: number;
};
}
const emptyByStatus = (): Record<AnalyzeJobStatus, number> => ({
queued: 0,
cloning: 0,
analyzing: 0,
loading: 0,
complete: 0,
failed: 0,
});
/** Basename for ops UI — strip query/fragment so tokens never leak into repoName. */
export const publicRepoNameFromUrl = (repoUrl: string | undefined): string | undefined => {
if (!repoUrl) return undefined;
try {
const parsed = new URL(repoUrl);
// new URL accepts Windows drive paths as `c:` URLs; split on `\` too so we
// never emit a full filesystem pathname on the unauthenticated ops feed.
const segments = parsed.pathname
.replace(/[\\/]+$/, '')
.split(/[\\/]/)
.filter(Boolean);
const last = segments.pop();
if (!last) return undefined;
return last.replace(/\.git$/i, '') || undefined;
} catch {
// Non-URL fallbacks (scp-like git@host:org/repo.git) — drop query/hash then basename.
const cleaned = repoUrl
.split(/[?#]/, 1)[0]!
.replace(/\/+$/, '')
.replace(/\.git$/i, '');
const last = cleaned.split(/[/\\]/).pop();
return last || undefined;
}
};
const publicRepoNameFromPath = (repoPath: string | undefined): string | undefined => {
if (!repoPath) return undefined;
return (
repoPath
.replace(/[/\\]+$/, '')
.split(/[/\\]/)
.pop() || undefined
);
};
const preserveTrailingPunct = (raw: string, token: string): string => {
const trailing = raw.match(/(\.{2,}|[),;]+)$/);
return trailing ? `${token}${trailing[0]}` : token;
};
/**
* Display name for public payloads. A branch-pinned URL clone registers under
* its clone-directory name (`<repo>__<branch slug>`), which would put the
* requested branch on the unauthenticated feed — use the URL's repo name.
* Reconnect goes through {@link publicJobRepoId}, not this label.
*/
export const publicJobRepoName = (job: AnalyzeJob): string | undefined =>
(job.branch ? publicRepoNameFromUrl(job.repoUrl) : undefined) ||
job.repoName ||
publicRepoNameFromUrl(job.repoUrl) ||
publicRepoNameFromPath(job.repoPath);
/** Opaque registry handle, only once the job's index is published. */
export const publicJobRepoId = (job: AnalyzeJob): string | undefined =>
job.status === 'complete' && job.repoPath ? publicRepoId(job.repoPath) : undefined;
export const knownJobLocations = (
job?: Pick<AnalyzeJob, 'repoPath' | 'repoUrl'> | null,
): Array<string | undefined> => [job?.repoPath, job?.repoUrl];
/** HTTP(S)/ssh URLs and scp-like remotes redact as `[repo]`; filesystem paths as `[path]`. */
const knownRedactionToken = (value: string): '[repo]' | '[path]' =>
/^(https?:\/\/|ssh:\/\/)/i.test(value) || /^[\w.-]+@[\w.-]+:/.test(value) ? '[repo]' : '[path]';
/**
* After a path separator, consume a single space only when another `/` or `\`
* still follows — so `/home/Jane Doe/.gitnexus/foo` is one path, but
* `/home/alice/src/private-repo has no remote.origin` keeps the sentence.
*/
const PATH_WITH_INTERNAL_SPACE = String.raw`[^\s"')]+(?: [^\s"')]*[\\/][^\s"')]*)*`;
const redactKnownLocations = (text: string, known?: Array<string | undefined>): string => {
const values = (known ?? [])
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.sort((a, b) => b.length - a.length);
const seen = new Set<string>();
let out = text;
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
// Consume a descendant tail too (`<repoPath>/src/secret.ts`): once the
// prefix became `[path]`, the absolute-path scrub below could no longer see
// the rest. The lookahead keeps `<repoPath>2/…` (a sibling) for that scrub.
out = out.replace(
new RegExp(
`${escapeRegExp(value)}(?:[/\\\\]${PATH_WITH_INTERNAL_SPACE}|[/\\\\]+)?(?![\\w-]|\\.\\w)`,
'g',
),
(raw) => preserveTrailingPunct(raw, knownRedactionToken(value)),
);
}
return out;
};
/**
* Mid-string scrub for unauthenticated ops/poll payloads. Clone progress and
* worker errors embed the URL after a prefix ("Cloning https://…") and also
* embed local clone paths ("Existing clone at /home/alice/…"). Replace the
* whole HTTP(S) URL — host, path, and query — not only userinfo, replace
* scp-like `user@host:path` and `ssh://` remotes, and replace absolute POSIX /
* Windows / UNC filesystem paths so a LAN or official-Vercel origin cannot
* recover home-directory layout or private org/repo names from /api/ops.
*
* Remote URL forms are replaced first. Known `repoPath` / `repoUrl` literals
* then run (longest first) so a clone dir with spaces cannot leak around
* `[^\s]+`, then the filesystem path regexes.
*/
const redactRemoteUrls = (text: string): string =>
text
.replace(/https?:\/\/[^\s]+/gi, (raw) => preserveTrailingPunct(raw, '[repo]'))
.replace(/ssh:\/\/[^\s]+/gi, (raw) => preserveTrailingPunct(raw, '[repo]'))
.replace(/file:\/\/[^\s"']+/gi, (raw) => preserveTrailingPunct(raw, '[path]'))
.replace(/[\w.-]+@[\w.-]+:[^\s"')]+/g, (raw) => preserveTrailingPunct(raw, '[repo]'));
const redactFilesystemPaths = (text: string): string =>
text
.replace(new RegExp(`[A-Za-z]:[\\\\/]${PATH_WITH_INTERNAL_SPACE}`, 'g'), (raw) =>
preserveTrailingPunct(raw, '[path]'),
)
.replace(new RegExp(`\\\\\\\\${PATH_WITH_INTERNAL_SPACE}`, 'g'), (raw) =>
preserveTrailingPunct(raw, '[path]'),
)
.replace(
new RegExp(`(^|[\\s"'=(])(/${PATH_WITH_INTERNAL_SPACE})`, 'g'),
(_m, prefix: string, absPath: string) =>
`${prefix}${preserveTrailingPunct(absPath, '[path]')}`,
);
/**
* Remote URL forms first so a known `repoUrl` that is a prefix of a longer
* URL cannot punch a hole (`[repo]/other?token=`) before the URL scrubber
* runs. Known filesystem literals still run before the path regexes so a
* clone dir with spaces cannot leak around `[^\s]+`.
*/
export const redactPublicText = (text: string, known?: Array<string | undefined>): string =>
redactFilesystemPaths(redactKnownLocations(redactRemoteUrls(text), known));
/**
* Ops feed is unauthenticated — never emit raw repo URLs (or userinfo) via
* progress.message even when the in-memory job still holds them for cloning.
*/
export const publicOpsProgress = (
progress: AnalyzeJobProgress,
known?: Array<string | undefined>,
): AnalyzeJobProgress => ({
phase: progress.phase,
percent: progress.percent,
message: redactPublicText(progress.message, known),
});
export const serializeOpsJob = (
job: AnalyzeJob,
lane: 'analyze' | 'embed',
now: number = Date.now(),
): OpsJobView => {
const end = job.completedAt ?? now;
const known = knownJobLocations(job);
// Never emit raw repoUrl/repoPath or the requested branch on the
// unauthenticated ops feed (a ref can name a private project the same way a
// path would).
return {
id: job.id,
lane,
status: job.status,
repoName: publicJobRepoName(job),
repoId: publicJobRepoId(job),
progress: publicOpsProgress(job.progress, known),
error: job.error ? redactPublicText(job.error, known) : undefined,
partial: job.partial,
startedAt: job.startedAt,
completedAt: job.completedAt,
retryCount: job.retryCount,
durationMs: Math.max(0, end - job.startedAt),
};
};
export const summarizeOpsLane = (jobs: OpsJobView[]): OpsLaneMetrics => {
const byStatus = emptyByStatus();
let active = 0;
let queued = 0;
let complete = 0;
let failed = 0;
let durationSum = 0;
let durationCount = 0;
let maxDurationMs: number | null = null;
let activeProgressSum = 0;
for (const job of jobs) {
byStatus[job.status] += 1;
if (job.status === 'queued') queued += 1;
if (job.status === 'complete') complete += 1;
if (job.status === 'failed') failed += 1;
if (!isTerminalJobStatus(job.status)) {
active += 1;
activeProgressSum += Math.max(0, Math.min(100, job.progress.percent));
} else if (job.completedAt !== undefined) {
durationSum += job.durationMs;
durationCount += 1;
maxDurationMs =
maxDurationMs === null ? job.durationMs : Math.max(maxDurationMs, job.durationMs);
}
}
return {
total: jobs.length,
active,
queued,
complete,
failed,
byStatus,
avgDurationMs: durationCount === 0 ? null : Math.round(durationSum / durationCount),
maxDurationMs,
activeProgressSum,
};
};
export const buildOpsSnapshot = (input: {
analyzeJobs: AnalyzeJob[];
embedJobs: AnalyzeJob[];
serverStartedAt: number;
server: OpsSnapshot['server'];
now?: number;
}): OpsSnapshot => {
const now = input.now ?? Date.now();
const analyzeJobs = input.analyzeJobs
.map((j) => serializeOpsJob(j, 'analyze', now))
.sort((a, b) => b.startedAt - a.startedAt);
const embedJobs = input.embedJobs
.map((j) => serializeOpsJob(j, 'embed', now))
.sort((a, b) => b.startedAt - a.startedAt);
const analyze = { jobs: analyzeJobs, metrics: summarizeOpsLane(analyzeJobs) };
const embed = { jobs: embedJobs, metrics: summarizeOpsLane(embedJobs) };
return {
generatedAt: now,
uptimeMs: Math.max(0, now - input.serverStartedAt),
health: 'ok',
server: input.server,
analyze,
embed,
totals: {
jobs: analyze.metrics.total + embed.metrics.total,
active: analyze.metrics.active + embed.metrics.active,
failed: analyze.metrics.failed + embed.metrics.failed,
complete: analyze.metrics.complete + embed.metrics.complete,
},
};
};
/** True when Origin is an exact first-party GitNexus Vercel production host. */
export const isGitNexusVercelOrigin = (origin: string): boolean => {
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
return false;
}
if (parsed.protocol !== 'https:') return false;
// Browsers omit the default port; non-default ports such as :8443 are not
// documented production Origin strings. Explicit :443 is the same origin as the bare host.
if (parsed.port) return false;
const host = parsed.hostname.toLowerCase();
// Exact hosts only — a prefix like `gitnexus-web-` would also match any
// attacker-controlled Vercel project named `gitnexus-web-*`. Preview
// deployments should set GITNEXUS_PUBLIC_ORIGIN instead.
return host === 'gitnexus.vercel.app' || host === 'gitnexus-web.vercel.app';
};

View file

@ -0,0 +1,23 @@
/**
* Opaque repo handle for unauthenticated payloads.
*
* Public job views and SSE terminal frames must not carry the analyzed path,
* and a registry name is not unique (see `repo-manager.ts`). An HMAC of the
* canonical registry path under a per-process random key is unique per entry,
* reveals nothing about the path, and matches the `id` on `GET /api/repos`
* entries, so a client can select the exact entry a job just analyzed.
*
* Stable only for the lifetime of this server process: resolve it right after
* the job finishes, never persist it.
*/
import { createHmac, randomBytes } from 'node:crypto';
import { canonicalizePath, registryPathEquals } from '../storage/repo-manager.js';
const KEY = randomBytes(32);
export const publicRepoId = (repoPath: string): string => {
const canonical = canonicalizePath(repoPath);
// Same equality the registry uses: case-insensitive on Windows only.
const key = registryPathEquals('A', 'a') ? canonical.toLowerCase() : canonical;
return createHmac('sha256', KEY).update(key).digest('base64url').slice(0, 22);
};

View file

@ -16,6 +16,7 @@ import {
} from '../core/staleness-status.js';
import type { ContentRetention, RepoMeta } from '../storage/repo-meta.js';
import type { RegistryEntry } from '../storage/repo-manager.js';
import { publicRepoId } from './public-repo-id.js';
/** Retention + checkout facts computed by the route (see getSourceAvailability). */
export interface RepoProjectionSource {
@ -54,6 +55,8 @@ export const projectRepoListEntry = (
staleness: StalenessInfo,
source: RepoProjectionSource,
) => ({
// Matches `repoId` on analyze job views / SSE terminal frames.
id: publicRepoId(entry.path),
name: entry.name,
path: entry.path,
repoPath: entry.path,

View file

@ -14,6 +14,13 @@
import type express from 'express';
import { assertString, BadRequestError } from './validation.js';
import { isTerminalJobStatus, type AnalyzeJob, type JobManager } from './analyze-job.js';
import {
knownJobLocations,
publicJobRepoId,
publicJobRepoName,
publicOpsProgress,
redactPublicText,
} from './ops-snapshot.js';
/**
* The wire payload of a terminal (`event: complete` / `event: failed`) frame.
@ -22,11 +29,18 @@ import { isTerminalJobStatus, type AnalyzeJob, type JobManager } from './analyze
* always carries whatever the terminal `updateJob` call just committed.
* `undefined` fields are dropped by `JSON.stringify`, which keeps a clean run's
* payload byte-identical to the pre-`partial` shape.
*
* `repoPath` is omitted: `/api/ops` enumerates job ids, so this unauthenticated
* stream must not replay the analyzed filesystem path. `repoName` is a display
* label and is not unique; reconnect by matching `repoId` against the `id` on
* `GET /api/repos` entries. Progress text and `error` use the same
* public redaction as `/api/ops` / poll — raw clone URLs and home-directory
* paths stay off the wire.
*/
const terminalPayload = (job: AnalyzeJob | undefined) => ({
repoName: job?.repoName,
repoPath: job?.repoPath,
error: job?.error,
repoName: job ? publicJobRepoName(job) : undefined,
repoId: job ? publicJobRepoId(job) : undefined,
error: job?.error ? redactPublicText(job.error, knownJobLocations(job)) : undefined,
// Lets a client tell a partial embedding run ("retry these N nodes") from a
// total failure ("nothing worked") without a new `status` member (#2790).
partial: job?.partial,
@ -36,9 +50,11 @@ const terminalPayload = (job: AnalyzeJob | undefined) => ({
* Mount an SSE progress endpoint for a JobManager.
* Handles: initial state, terminal events, heartbeat, event IDs, client disconnect.
*
* Terminal payloads carry `repoPath` (the analyzed path) alongside the display
* `repoName` so clients can reconnect by path identity — with duplicate
* basenames, a name-only reconnect resolves to the first same-named sibling.
* Terminal payloads carry the display `repoName` and the opaque `repoId`,
* never a path. The analyzed filesystem
* path used to ride this event for duplicate-basename reconnect (#2420), but
* `/api/ops` lists job ids and this stream is unauthenticated — emitting
* `repoPath` leaked operator home directories. Clients reconnect by `repoId`.
* Exported for unit tests that lock the wire payload shape.
*
* ── The stream closes on the JOB'S STATUS, never on a phase string (#2790) ──
@ -84,7 +100,9 @@ export const mountSSEProgress = (app: express.Express, routePath: string, jm: Jo
// Send current state immediately
eventId++;
res.write(`id: ${eventId}\ndata: ${JSON.stringify(job.progress)}\n\n`);
res.write(
`id: ${eventId}\ndata: ${JSON.stringify(publicOpsProgress(job.progress, knownJobLocations(job)))}\n\n`,
);
// If already terminal, send event and close
if (isTerminalJobStatus(job.status)) {
@ -121,7 +139,9 @@ export const mountSSEProgress = (app: express.Express, routePath: string, jm: Jo
res.end();
unsubscribe();
} else {
res.write(`id: ${eventId}\ndata: ${JSON.stringify(progress)}\n\n`);
res.write(
`id: ${eventId}\ndata: ${JSON.stringify(publicOpsProgress(progress, knownJobLocations(eventJob)))}\n\n`,
);
}
} catch {
clearInterval(heartbeat);

View file

@ -674,10 +674,8 @@ export const listLocalHeads = (repoPath: string): string[] | null => {
try {
const result = spawnSync('git', ['for-each-ref', '--format=%(refname)', 'refs/heads'], {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
maxBuffer: GIT_PATH_LIST_MAX_BUFFER,
...gitPathListExec,
});
if (result.error || result.status !== 0) return null;
const output = (result.stdout ?? '').toString().trim();

View file

@ -0,0 +1,22 @@
/** Rolling worker pool: in-order results, fail-fast mapper errors. */
export const mapPool = async <T, R>(
items: readonly T[],
mapper: (item: T) => Promise<R>,
concurrency: number,
): Promise<R[]> => {
if (items.length === 0) return [];
const results = new Array<R>(items.length);
let next = 0;
const workerCount = Math.max(1, Math.min(concurrency, items.length));
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const index = next;
next += 1;
if (index >= items.length) return;
results[index] = await mapper(items[index] as T);
}
}),
);
return results;
};

View file

@ -27,6 +27,7 @@ import { stripWindowsLongPathPrefix } from '../lib/utils.js';
import { writeFileAtomic } from './fs-atomic.js';
import { getGlobalDir } from './global-dir.js';
import { logger } from '../core/logger.js';
import { mapPool } from './map-pool.js';
import {
acquireIndexLock,
IndexLockTimeoutError,
@ -1206,8 +1207,16 @@ const unregisterRepoUnlocked = async (repoPath: string): Promise<void> => {
// and vice versa. Matches the semantics of `registerRepo` and
// `resolveRegistryEntry` post-#1003 review.
const resolved = canonicalizePath(repoPath);
const entries = await readRegistry();
// Same rule as `registerRepoUnlocked` (#3094): a mutating write must not
// treat an unreadable/truncated registry as empty. The lenient reader
// returned `[]` on any read error (EBUSY/EPERM racing another gitnexus
// process's atomic rename on Windows, EIO, a half-written file) and this
// function then wrote `[]` back — deregistering every repo on the machine
// to remove one. A missing file means nothing to remove.
const entries = await readRegistryStrictIfPresent();
if (entries === undefined) return;
const filtered = entries.filter((e) => !registryPathEquals(canonicalizePath(e.path), resolved));
if (filtered.length === entries.length) return;
await writeRegistry(filtered);
};
@ -1689,28 +1698,6 @@ export const findRegistryEntryByName = (
* I/O storm cannot make the registry disappear; it remains unconfirmed until a
* later validating read succeeds.
*/
const mapPool = async <T, R>(
items: readonly T[],
mapper: (item: T) => Promise<R>,
concurrency: number,
): Promise<R[]> => {
if (items.length === 0) return [];
const results = new Array<R>(items.length);
let next = 0;
const workerCount = Math.max(1, Math.min(concurrency, items.length));
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const index = next;
next += 1;
if (index >= items.length) return;
results[index] = await mapper(items[index] as T);
}
}),
);
return results;
};
export const listRegisteredRepos = async (opts?: {
validate?: boolean;
}): Promise<RegistryEntry[]> => {

View file

@ -12,6 +12,7 @@ import path from 'path';
import { BRANCHES_DIR } from './branch-index.js';
import { listLocalHeads } from './git.js';
import { isMissingFilesystemError, loadMeta } from './repo-meta.js';
import { mapPool } from './map-pool.js';
import { getStoragePaths, removeBranchIndex } from './repo-manager.js';
export type StaleBranchReason =
@ -42,30 +43,9 @@ export interface ListStaleBranchSlotsInput {
const slotDirForBranch = (repoPath: string, storagePath: string, branch: string): string =>
path.dirname(getStoragePaths(repoPath, branch, storagePath).metaPath);
/** Same bound as `mapPool` in repo-manager: cap concurrent slot I/O. */
/** Same bound as `listRegisteredRepos`: cap concurrent slot I/O. */
const STALE_SLOT_IO_CONCURRENCY = 8;
const mapPool = async <T, R>(
items: readonly T[],
mapper: (item: T) => Promise<R>,
): Promise<R[]> => {
if (items.length === 0) return [];
const results = new Array<R>(items.length);
let next = 0;
const workerCount = Math.max(1, Math.min(STALE_SLOT_IO_CONCURRENCY, items.length));
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const index = next;
next += 1;
if (index >= items.length) return;
results[index] = await mapper(items[index] as T);
}
}),
);
return results;
};
/** Proven directory, proven absence, or a probe error that is not ENOENT/ENOTDIR. */
type DirectoryProbe = 'dir' | 'missing' | 'unreadable';
@ -77,123 +57,6 @@ const probeDirectory = async (dir: string): Promise<DirectoryProbe> => {
}
};
const directorySizeBytes = async (root: string): Promise<number> => {
let total = 0;
const stack = [root];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) break;
let entries;
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch {
continue;
}
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => path.join(current, entry.name));
for (const entry of entries) {
if (entry.isDirectory()) stack.push(path.join(current, entry.name));
}
for (const file of files) {
try {
total += (await fs.stat(file)).size;
} catch {
// Skip files that disappear or become unreadable mid-walk.
}
}
}
return total;
};
const metadataBranch = async (dir: string): Promise<string | null> => {
const meta = await loadMeta(dir);
return typeof meta?.branch === 'string' && meta.branch.length > 0 ? meta.branch : null;
};
export const listStaleBranchSlots = async (
input: ListStaleBranchSlotsInput,
): Promise<StaleBranchSlot[]> => {
const recorded = input.branches ?? [];
const branchesRoot = path.join(input.storagePath, BRANCHES_DIR);
const registryByDir = new Map<string, string>();
for (const row of recorded) {
registryByDir.set(
path.resolve(slotDirForBranch(input.repoPath, input.storagePath, row.branch)),
row.branch,
);
}
let diskDirs: string[] = [];
try {
const entries = await fs.readdir(branchesRoot, { withFileTypes: true });
diskDirs = entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(branchesRoot, entry.name));
} catch (err) {
if (!isMissingFilesystemError(err)) {
return [{ branch: '', dir: null, sizeBytes: 0, reason: 'listing-failed' }];
}
diskDirs = [];
}
if (recorded.length === 0 && diskDirs.length === 0) return [];
const heads = input.heads !== undefined ? input.heads : listLocalHeads(input.repoPath);
const live = heads === null ? null : new Set(heads);
const pending: Array<Omit<StaleBranchSlot, 'sizeBytes'>> = [];
const registryProbes = await mapPool([...registryByDir], async ([resolvedDir, branch]) => ({
resolvedDir,
branch,
probe: await probeDirectory(resolvedDir),
}));
for (const { resolvedDir, branch, probe } of registryProbes) {
if (probe === 'unreadable') {
pending.push({ branch, dir: resolvedDir, reason: 'probe-failed' });
continue;
}
const exists = probe === 'dir';
const dir = exists ? resolvedDir : null;
if (live === null) {
pending.push({ branch, dir, reason: 'heads-unavailable' });
continue;
}
if (!exists) {
pending.push({ branch, dir: null, reason: 'registry-only' });
continue;
}
if (!live.has(branch)) {
pending.push({ branch, dir, reason: 'ref-missing' });
}
}
const leftoverDirs = diskDirs.filter((dir) => !registryByDir.has(path.resolve(dir)));
const leftoverMeta = await mapPool(leftoverDirs, async (dir) => ({
dir,
branch: await metadataBranch(dir),
}));
for (const { dir, branch } of leftoverMeta) {
if (branch === null) continue;
const resolved = path.resolve(dir);
if (live === null) {
pending.push({ branch, dir: resolved, reason: 'heads-unavailable' });
continue;
}
if (!live.has(branch)) {
pending.push({ branch, dir: resolved, reason: 'disk-only' });
}
}
const includeSize = input.includeSize !== false;
return mapPool(pending, async (row) => ({
...row,
sizeBytes: includeSize && row.dir ? await directorySizeBytes(row.dir) : 0,
}));
};
/** Lexical / realpath containment: `child` is a proper descendant of `parent`. */
const isProperChildPath = (parent: string, child: string): boolean => {
const root = path.resolve(parent);
@ -210,9 +73,221 @@ const isProperChildPath = (parent: string, child: string): boolean => {
const isSameNormalizedPath = (left: string, right: string): boolean =>
path.relative(path.resolve(path.normalize(left)), path.resolve(path.normalize(right))) === '';
const expectedRealSlotPath = (realBranches: string, dir: string): string =>
path.join(realBranches, path.basename(path.resolve(dir)));
const directorySizeBytes = async (root: string): Promise<number> => {
let realRoot: string;
try {
const rootStat = await fs.lstat(root);
if (rootStat.isSymbolicLink()) return 0;
realRoot = await fs.realpath(root);
const realParent = await fs.realpath(path.dirname(path.resolve(root)));
if (!isSameNormalizedPath(realRoot, expectedRealSlotPath(realParent, root))) {
return 0;
}
} catch {
return 0;
}
const seen = new Set<string>([realRoot]);
let total = 0;
const stack = [root];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) break;
let entries: string[];
try {
entries = await fs.readdir(current);
} catch {
continue;
}
for (const name of entries) {
const child = path.join(current, name);
let stat: Awaited<ReturnType<typeof fs.lstat>>;
try {
stat = await fs.lstat(child);
} catch {
continue;
}
if (stat.isSymbolicLink()) continue;
let real: string;
try {
real = await fs.realpath(child);
} catch {
continue;
}
if (!isProperChildPath(realRoot, real) || seen.has(real)) continue;
seen.add(real);
if (stat.isDirectory()) {
stack.push(child);
continue;
}
if (stat.isFile()) {
try {
total += (await fs.stat(child)).size;
} catch {
// Skip files that disappear or become unreadable mid-walk.
}
}
}
}
return total;
};
const metadataBranch = async (dir: string): Promise<string | null> => {
const meta = await loadMeta(dir);
return typeof meta?.branch === 'string' && meta.branch.length > 0 ? meta.branch : null;
};
export const isContainedBranchDir = (storagePath: string, dir: string): boolean =>
isProperChildPath(path.resolve(storagePath, BRANCHES_DIR), dir);
/**
* One containment rule for preview and force: `branches/` must be a real
* directory whose realpath is `realpath(storagePath)/branches`.
*/
type BranchesRootProbe =
| { status: 'ok'; realBranches: string }
| { status: 'missing' }
| { status: 'escaped' }
| { status: 'unreadable' };
const probeContainedBranchesRoot = async (storagePath: string): Promise<BranchesRootProbe> => {
const branchesRoot = path.resolve(storagePath, BRANCHES_DIR);
let branchesStat: Awaited<ReturnType<typeof fs.lstat>>;
try {
branchesStat = await fs.lstat(branchesRoot);
} catch (err) {
return isMissingFilesystemError(err) ? { status: 'missing' } : { status: 'unreadable' };
}
if (branchesStat.isSymbolicLink() || !branchesStat.isDirectory()) {
return { status: 'escaped' };
}
try {
const realStorage = await fs.realpath(storagePath);
const realBranches = await fs.realpath(branchesRoot);
const expectedBranches = path.normalize(path.join(realStorage, BRANCHES_DIR));
if (!isSameNormalizedPath(realBranches, expectedBranches)) {
return { status: 'escaped' };
}
return { status: 'ok', realBranches };
} catch (err) {
return isMissingFilesystemError(err) ? { status: 'missing' } : { status: 'unreadable' };
}
};
const listingFailedRow = (): StaleBranchSlot => ({
branch: '',
dir: null,
sizeBytes: 0,
reason: 'listing-failed',
});
export const listStaleBranchSlots = async (
input: ListStaleBranchSlotsInput,
): Promise<StaleBranchSlot[]> => {
const recorded = input.branches ?? [];
const branchesRoot = path.join(input.storagePath, BRANCHES_DIR);
const registryByDir = new Map<string, string>();
for (const row of recorded) {
registryByDir.set(
path.resolve(slotDirForBranch(input.repoPath, input.storagePath, row.branch)),
row.branch,
);
}
const branchesProbe = await probeContainedBranchesRoot(input.storagePath);
if (branchesProbe.status === 'escaped' || branchesProbe.status === 'unreadable') {
return [listingFailedRow()];
}
let diskDirs: string[] = [];
if (branchesProbe.status === 'ok') {
try {
const entries = await fs.readdir(branchesRoot, { withFileTypes: true });
diskDirs = entries
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(branchesRoot, entry.name));
} catch (err) {
if (!isMissingFilesystemError(err)) {
return [listingFailedRow()];
}
diskDirs = [];
}
}
if (recorded.length === 0 && diskDirs.length === 0) return [];
const heads = input.heads !== undefined ? input.heads : listLocalHeads(input.repoPath);
const live = heads === null ? null : new Set(heads);
const pending: Array<Omit<StaleBranchSlot, 'sizeBytes'>> = [];
const leftoverDirs = diskDirs.filter((dir) => !registryByDir.has(path.resolve(dir)));
// Sequential phases so STALE_SLOT_IO_CONCURRENCY caps total slot I/O.
const registryProbes = await mapPool(
[...registryByDir],
async ([resolvedDir, branch]) => ({
resolvedDir,
branch,
probe: await probeDirectory(resolvedDir),
}),
STALE_SLOT_IO_CONCURRENCY,
);
const leftoverMeta = await mapPool(
leftoverDirs,
async (dir) => ({
dir,
branch: await metadataBranch(dir),
}),
STALE_SLOT_IO_CONCURRENCY,
);
for (const { resolvedDir, branch, probe } of registryProbes) {
if (probe === 'unreadable') {
pending.push({ branch, dir: resolvedDir, reason: 'probe-failed' });
continue;
}
const exists = probe === 'dir';
const dir = exists ? resolvedDir : null;
if (live === null) {
pending.push({ branch, dir, reason: 'heads-unavailable' });
continue;
}
if (!exists) {
if (!live.has(branch)) {
pending.push({ branch, dir: null, reason: 'registry-only' });
}
continue;
}
if (!live.has(branch)) {
pending.push({ branch, dir, reason: 'ref-missing' });
}
}
for (const { dir, branch } of leftoverMeta) {
if (branch === null) continue;
const resolved = path.resolve(dir);
if (live === null) {
pending.push({ branch, dir: resolved, reason: 'heads-unavailable' });
continue;
}
if (!live.has(branch)) {
pending.push({ branch, dir: resolved, reason: 'disk-only' });
}
}
const includeSize = input.includeSize !== false;
return mapPool(
pending,
async (row) => ({
...row,
sizeBytes: includeSize && row.dir ? await directorySizeBytes(row.dir) : 0,
}),
STALE_SLOT_IO_CONCURRENCY,
);
};
const toError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err)));
const keepRegistryFailure = (error: Error): RemoveBranchSlotResult => ({
@ -236,6 +311,111 @@ const slotPathExists = async (slotDir: string): Promise<boolean> => {
}
};
type ContainedPathDecision = { kind: 'unlink' } | { kind: 'keep'; real: string };
/** Symlink, Windows junction, or any realpath that leaves `containRoot`. */
const inspectContainedPath = async (
lexicalPath: string,
stat: Awaited<ReturnType<typeof fs.lstat>>,
containRoot: string,
): Promise<ContainedPathDecision> => {
if (stat.isSymbolicLink()) return { kind: 'unlink' };
try {
const real = await fs.realpath(lexicalPath);
return isProperChildPath(containRoot, real) ? { kind: 'keep', real } : { kind: 'unlink' };
} catch (err) {
if (isMissingFilesystemError(err)) return { kind: 'unlink' };
throw err;
}
};
type SlotRootClass =
| { kind: 'missing' }
| { kind: 'escape' }
| { kind: 'file' }
| { kind: 'dir'; realSlot: string };
type ContainedBranchesGate =
| { kind: 'gone' }
| { kind: 'refuse'; result: RemoveBranchSlotResult }
| { kind: 'ok'; realBranches: string };
const gateContainedBranches = async (
storagePath: string,
dir: string,
): Promise<ContainedBranchesGate> => {
const probe = await probeContainedBranchesRoot(storagePath);
if (probe.status === 'missing') return { kind: 'gone' };
if (probe.status !== 'ok') return { kind: 'refuse', result: refuseOutsideSlot(dir) };
return { kind: 'ok', realBranches: probe.realBranches };
};
const classifySlotRoot = async (dir: string, realBranches: string): Promise<SlotRootClass> => {
let stat: Awaited<ReturnType<typeof fs.lstat>>;
try {
stat = await fs.lstat(dir);
} catch (err) {
if (isMissingFilesystemError(err)) return { kind: 'missing' };
throw err;
}
if (stat.isSymbolicLink()) return { kind: 'escape' };
const realSlot = await fs.realpath(dir);
if (!isSameNormalizedPath(realSlot, expectedRealSlotPath(realBranches, dir))) {
return { kind: 'escape' };
}
return stat.isDirectory() ? { kind: 'dir', realSlot } : { kind: 'file' };
};
/**
* Close nested junctions/symlinks whose realpath leaves the leftover slot so a
* later `fs.rm` cannot walk a sibling index or an outside tree.
*/
const unlinkEscapingDescendants = async (
dir: string,
realSlot: string,
expectedReal: string = realSlot,
seen: Set<string> = new Set([realSlot]),
): Promise<void> => {
let entries: string[];
try {
// Revalidate right before readdir: the caller's lstat/realpath awaited, so
// this directory may since have been swapped for a symlink/junction.
// ponytail: narrows the window, not closes it — Node has no openat/fd walk.
const stat = await fs.lstat(dir);
if (stat.isSymbolicLink() || !isSameNormalizedPath(await fs.realpath(dir), expectedReal)) {
throw new Error(`Branch index directory changed during cleanup: ${dir}`);
}
entries = await fs.readdir(dir);
} catch (err) {
if (isMissingFilesystemError(err)) return;
throw err;
}
for (const name of entries) {
const child = path.join(dir, name);
let stat: Awaited<ReturnType<typeof fs.lstat>>;
try {
stat = await fs.lstat(child);
} catch (err) {
if (isMissingFilesystemError(err)) continue;
throw err;
}
const decision = await inspectContainedPath(child, stat, realSlot);
if (decision.kind === 'unlink' || seen.has(decision.real)) {
try {
await fs.unlink(child);
} catch (err) {
// Already gone between inspection and unlink: nothing left to close.
if (!isMissingFilesystemError(err)) throw err;
}
continue;
}
seen.add(decision.real);
if (stat.isDirectory()) {
await unlinkEscapingDescendants(child, realSlot, decision.real, seen);
}
}
};
/**
* Delete a lexically contained slot. Returns a failure result, or `null` when
* the slot path is gone and the registry row may drop.
@ -248,41 +428,12 @@ const removeValidatedSlotDir = async (
return refuseOutsideSlot(dir);
}
const branchesRoot = path.resolve(storagePath, BRANCHES_DIR);
const branchesGate = await gateContainedBranches(storagePath, dir);
if (branchesGate.kind === 'gone') return null;
if (branchesGate.kind === 'refuse') return branchesGate.result;
let branchesStat: Awaited<ReturnType<typeof fs.lstat>>;
try {
branchesStat = await fs.lstat(branchesRoot);
} catch (err) {
if (isMissingFilesystemError(err)) return null;
return keepRegistryFailure(toError(err));
}
// Never walk a branches/ symlink (rm of a child would delete the target).
if (branchesStat.isSymbolicLink()) {
return refuseOutsideSlot(dir);
}
let realStorage: string;
let realBranches: string;
try {
realStorage = await fs.realpath(storagePath);
realBranches = await fs.realpath(branchesRoot);
} catch (err) {
if (isMissingFilesystemError(err)) return null;
return keepRegistryFailure(toError(err));
}
// Junctions may not report as symlinks from lstat; realpath must still land
// on storagePath/branches, not an outside tree.
const expectedBranches = path.normalize(path.join(realStorage, BRANCHES_DIR));
if (!isSameNormalizedPath(realBranches, expectedBranches)) {
return refuseOutsideSlot(dir);
}
let slotStat: Awaited<ReturnType<typeof fs.lstat>>;
try {
slotStat = await fs.lstat(dir);
await fs.lstat(dir);
} catch (err) {
if (isMissingFilesystemError(err)) return null;
return keepRegistryFailure(toError(err));
@ -290,30 +441,27 @@ const removeValidatedSlotDir = async (
let deleteError: Error | undefined;
try {
// A symlink, or a Windows junction that lstat reports as a directory,
// must be unlinked at the lexical path. Never fs.rm through a target
// that realpath places outside branches/.
const realDir = slotStat.isSymbolicLink() ? null : await fs.realpath(dir);
const unlinkOnly =
slotStat.isSymbolicLink() || (realDir !== null && !isProperChildPath(realBranches, realDir));
// Revalidate immediately before the destructive op. Another process can
// replace branches/ or the slot after the earlier lstat/realpath awaits.
const lastBranches = await fs.lstat(branchesRoot);
if (lastBranches.isSymbolicLink()) {
return refuseOutsideSlot(dir);
}
const lastSlot = await fs.lstat(dir);
const lastUnlinkOnly = lastSlot.isSymbolicLink() || unlinkOnly;
if (lastUnlinkOnly) {
await fs.unlink(dir);
} else {
const lastReal = await fs.realpath(dir);
if (!isProperChildPath(realBranches, lastReal)) {
await fs.unlink(dir);
} else {
const lastGate = await gateContainedBranches(storagePath, dir);
if (lastGate.kind === 'gone') return null;
if (lastGate.kind === 'refuse') return lastGate.result;
const lastSlot = await classifySlotRoot(dir, lastGate.realBranches);
if (lastSlot.kind === 'missing') return null;
if (lastSlot.kind === 'dir') {
await unlinkEscapingDescendants(dir, lastSlot.realSlot);
const preRmGate = await gateContainedBranches(storagePath, dir);
if (preRmGate.kind === 'gone') return null;
if (preRmGate.kind === 'refuse') return preRmGate.result;
const preRmSlot = await classifySlotRoot(dir, preRmGate.realBranches);
if (preRmSlot.kind === 'missing') return null;
if (preRmSlot.kind === 'dir') {
await fs.rm(dir, { recursive: true, force: true });
} else {
await fs.unlink(dir);
}
} else {
await fs.unlink(dir);
}
} catch (err) {
deleteError = toError(err);

View file

@ -37,14 +37,19 @@ describe('clean --stale leftover branch slots (#3331)', () => {
await home.cleanup();
});
it('reclaims a slot after the git branch is deleted', async () => {
async function seedFeatureXSlot(opts?: {
tag?: boolean;
deleteBranch?: boolean;
}): Promise<{ repo: string; dir: string; storagePath: string }> {
const repo = fixture.dbPath;
initGitRepo(repo);
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
commitAll(repo, 'init');
execSync('git branch -M main', { cwd: repo, stdio: 'ignore', windowsHide: true });
execSync('git branch feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
if (opts?.tag) {
execSync('git tag feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
}
const storagePath = path.join(repo, '.gitnexus');
const meta = (branch: string): RepoMeta => ({
repoPath: repo,
@ -58,11 +63,17 @@ describe('clean --stale leftover branch slots (#3331)', () => {
await registerRepo(repo, meta('feature/x'), { branch: 'feature/x' });
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
await saveMeta(dir, meta('feature/x'));
await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}');
execSync('git branch -D feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
if (opts?.deleteBranch) {
execSync('git branch -D feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
}
vi.spyOn(process, 'cwd').mockReturnValue(repo);
vi.spyOn(console, 'log').mockImplementation(() => {});
return { dir, storagePath };
}
it('reclaims a slot after the git branch is deleted', async () => {
const { dir, storagePath } = await seedFeatureXSlot({ deleteBranch: true });
await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}');
await cleanCommand({ stale: true, force: true });
@ -76,30 +87,7 @@ describe('clean --stale leftover branch slots (#3331)', () => {
});
it('keeps a live slot when a tag shares the branch name', async () => {
const repo = fixture.dbPath;
initGitRepo(repo);
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
commitAll(repo, 'init');
execSync('git branch -M main', { cwd: repo, stdio: 'ignore', windowsHide: true });
execSync('git branch feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
execSync('git tag feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
const storagePath = path.join(repo, '.gitnexus');
const meta = (branch: string): RepoMeta => ({
repoPath: repo,
lastCommit: 'aaa',
indexedAt: '2026-09-20T00:00:00.000Z',
branch,
stats: { files: 1, nodes: 1 },
});
await saveMeta(storagePath, meta('main'));
await registerRepo(repo, meta('main'));
await registerRepo(repo, meta('feature/x'), { branch: 'feature/x' });
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
await saveMeta(dir, meta('feature/x'));
vi.spyOn(process, 'cwd').mockReturnValue(repo);
vi.spyOn(console, 'log').mockImplementation(() => {});
const { dir } = await seedFeatureXSlot({ tag: true });
await cleanCommand({ stale: true, force: true });
@ -109,31 +97,7 @@ describe('clean --stale leftover branch slots (#3331)', () => {
});
it('reclaims a slot after the branch is deleted even if a tag keeps the name', async () => {
const repo = fixture.dbPath;
initGitRepo(repo);
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
commitAll(repo, 'init');
execSync('git branch -M main', { cwd: repo, stdio: 'ignore', windowsHide: true });
execSync('git branch feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
execSync('git tag feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
const storagePath = path.join(repo, '.gitnexus');
const meta = (branch: string): RepoMeta => ({
repoPath: repo,
lastCommit: 'aaa',
indexedAt: '2026-09-20T00:00:00.000Z',
branch,
stats: { files: 1, nodes: 1 },
});
await saveMeta(storagePath, meta('main'));
await registerRepo(repo, meta('main'));
await registerRepo(repo, meta('feature/x'), { branch: 'feature/x' });
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
await saveMeta(dir, meta('feature/x'));
execSync('git branch -D feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
vi.spyOn(process, 'cwd').mockReturnValue(repo);
vi.spyOn(console, 'log').mockImplementation(() => {});
const { dir } = await seedFeatureXSlot({ tag: true, deleteBranch: true });
await cleanCommand({ stale: true, force: true });

View file

@ -9,6 +9,7 @@ import {
terminalFrameCount,
type SSEHarness,
} from '../helpers/sse-harness.js';
import { publicRepoId } from '../../src/server/public-repo-id.js';
import {
resolveEmbedRunOutcome,
withMeasuredEmbeddingCount,
@ -170,10 +171,10 @@ describe('mountSSEProgress terminality (#2790)', () => {
const body = await response.text();
expect(body).not.toContain('event: complete');
expect(body).not.toContain('/ws/embed-partial');
expect(terminalFrameCount(body)).toBe(1);
expect(terminalFrame(body, 'failed')).toMatchObject({
repoName: 'embed-partial',
repoPath: '/ws/embed-partial',
error: expect.stringContaining('finished partially') as unknown as string,
// The distinction a UI needs to offer "retry 2 nodes" instead of a bare
// red chip — carried without adding a `status` union member.
@ -207,9 +208,10 @@ describe('mountSSEProgress terminality (#2790)', () => {
// Exactly one — the status update carries a `progress` too, and #2264's
// single-emit rule is what keeps that from double-writing the terminal frame.
expect(terminalFrameCount(body)).toBe(1);
// Public frame: display name + opaque repoId, never the analyzed path.
expect(terminalFrame(body, 'complete')).toEqual({
repoName: 'embed-clean',
repoPath: '/ws/embed-clean',
repoId: publicRepoId('/ws/embed-clean'),
});
// The 'finalizing' frame was relayed as ordinary progress, not swallowed.
expect(body).toContain('"phase":"finalizing"');
@ -235,7 +237,10 @@ describe('mountSSEProgress terminality (#2790)', () => {
const body = await response.text();
expect(terminalFrameCount(body)).toBe(1);
expect(terminalFrame(body, 'complete')).toEqual({ repoName: 'reels', repoPath: '/ws/reels' });
expect(terminalFrame(body, 'complete')).toEqual({
repoName: 'reels',
repoId: publicRepoId('/ws/reels'),
});
});
it('a job that finished before the client connected replays its outcome', async () => {

View file

@ -0,0 +1,199 @@
/**
* DELETE /api/analyze/:jobId and DELETE /api/embed/:jobId body must match
* live JobManager state after cancelJob — not a hard-coded `failed`.
*
* A registered child keeps the in-memory job non-terminal until exit;
* clients that trusted a synthetic `failed` DELETE body retried and 409'd.
*
* Boots createServer the same way as api-fts-mode.test.ts (mocked listen,
* no LadybugDB/MCP). analyze-api.test.ts cannot import api.ts.
*/
import express from 'express';
import { EventEmitter } from 'node:events';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { isTerminalJobStatus, type JobManager } from '../../src/server/analyze-job.js';
const captured = vi.hoisted(() => ({
managers: [] as JobManager[],
}));
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/storage/repo-manager.js')>()),
loadMeta: vi.fn(async () => ({})),
listRegisteredRepos: vi.fn(async () => []),
}));
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/storage/storage-resolver.js')>()),
requireRegisteredStoragePath: vi.fn(async (entry: { storagePath: string }) => entry.storagePath),
}));
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
withLbugDb: vi.fn(),
executeQuery: vi.fn(async () => []),
executePrepared: vi.fn(async () => []),
executeWithReusedStatement: vi.fn(async () => []),
streamQuery: vi.fn(async () => 0),
flushWAL: vi.fn(),
closeLbug: vi.fn(),
isReadOnlyDbError: vi.fn(() => false),
}));
vi.mock('../../src/core/search/bm25-index.js', () => ({ searchFTSFromLbug: vi.fn() }));
vi.mock('../../src/mcp/local/local-backend.js', () => ({
LocalBackend: class {
async init() {
return true;
}
},
}));
vi.mock('../../src/server/mcp-http.js', () => ({
installServeMcpAuth: vi.fn(),
mountMCPEndpoints: vi.fn(async () => vi.fn()),
}));
vi.mock('../../src/server/upload-sweep.js', () => ({ sweepStaleUploads: vi.fn(async () => {}) }));
vi.mock('../../src/server/update-controller.js', () => ({
createServeUpdateController: vi.fn(() => ({ stop: vi.fn() })),
bindServeUpdateControllerLifecycle: vi.fn(),
buildServerInfo: vi.fn(),
}));
vi.mock('../../src/server/grep-scan.js', () => ({
runGrepScanInWorker: vi.fn(async () => ({ results: [], timedOut: false })),
}));
vi.mock('../../src/server/sse-progress.js', () => ({ mountSSEProgress: vi.fn() }));
vi.mock('../../src/server/analyze-job.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/server/analyze-job.js')>();
return {
...actual,
JobManager: class extends actual.JobManager {
constructor() {
super();
captured.managers.push(this);
}
},
};
});
import { createServer } from '../../src/server/api.js';
let app: express.Express;
const events = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'] as const;
const originalListeners = new Map(events.map((event) => [event, process.listeners(event)]));
beforeAll(async () => {
const listen = vi.spyOn(express.application, 'listen').mockImplementation(function (
this: express.Express,
...args: any[]
) {
app = this;
queueMicrotask(args.at(-1));
return new EventEmitter() as any;
});
try {
await createServer(0);
} finally {
listen.mockRestore();
}
});
afterAll(() => {
for (const manager of captured.managers) manager.dispose();
for (const event of events) {
for (const listener of process.listeners(event)) {
if (!originalListeners.get(event)!.includes(listener)) {
process.removeListener(event, listener);
}
}
}
});
afterEach(() => {
for (const manager of captured.managers) {
for (const job of manager.listJobs()) {
manager.releaseChild(job.id);
if (!isTerminalJobStatus(job.status)) {
manager.updateJob(job.id, { status: 'failed', error: 'test cleanup' });
}
}
}
});
type Lane = 'analyze' | 'embed';
const lanes: Array<{ lane: Lane; route: string; manager: () => JobManager }> = [
{ lane: 'analyze', route: '/api/analyze/:jobId', manager: () => captured.managers[0]! },
{ lane: 'embed', route: '/api/embed/:jobId', manager: () => captured.managers[1]! },
];
const fakeChild = () => {
const child = {
connected: true,
exitCode: null,
signalCode: null,
send: () => true,
kill: () => true,
on: () => child,
};
return child;
};
const invokeDelete = (route: string, jobId: string): { statusCode: number; body: any } => {
const layer = app.router.stack.find(
(item: any) => item.route?.path === route && item.route.methods?.delete,
);
expect(layer, `DELETE ${route}`).toBeDefined();
const handler = layer.route.stack.at(-1).handle;
const res = {
statusCode: 200,
body: undefined as any,
status(code: number) {
this.statusCode = code;
return this;
},
json(body: unknown) {
this.body = body;
return this;
},
};
handler({ params: { jobId } }, res);
return res;
};
describe('DELETE analyze/embed cancel body matches JobManager', () => {
it('boots two JobManagers (analyze then embed)', () => {
expect(captured.managers.length).toBe(2);
});
it.each(lanes)(
'DELETE $route with a registered child stays $lane analyzing, not failed',
({ lane, route, manager }) => {
const jobs = manager();
const job = jobs.createJob({ repoPath: `/tmp/cancel-child-${lane}` });
jobs.updateJob(job.id, { status: 'analyzing', repoName: `cancel-child-${lane}` });
jobs.registerChild(job.id, fakeChild() as any);
const res = invokeDelete(route, job.id);
const live = jobs.getJob(job.id);
expect(res.statusCode).toBe(200);
expect(res.body.status).toBe(live?.status);
expect(res.body.status).toBe('analyzing');
expect(res.body.lane).toBe(lane);
expect(res.body.id).toBe(job.id);
expect(isTerminalJobStatus(res.body.status)).toBe(false);
},
);
it.each(lanes)('DELETE $route without a child marks $lane failed', ({ lane, route, manager }) => {
const jobs = manager();
const job = jobs.createJob({ repoPath: `/tmp/cancel-no-child-${lane}` });
jobs.updateJob(job.id, { status: 'analyzing', repoName: `cancel-no-child-${lane}` });
const res = invokeDelete(route, job.id);
const live = jobs.getJob(job.id);
expect(res.statusCode).toBe(200);
expect(res.body.status).toBe(live?.status);
expect(res.body.status).toBe('failed');
expect(res.body.error).toBe('Cancelled by user');
expect(res.body.lane).toBe(lane);
expect(res.body.id).toBe(job.id);
});
});

View file

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
JobManager,
isTerminalJobStatus,
@ -14,6 +14,8 @@ describe('JobManager', () => {
afterEach(() => {
manager.dispose();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('creates a job with queued status', () => {
@ -193,6 +195,290 @@ describe('JobManager', () => {
expect(controller.signal.aborted).toBe(true);
});
// Cancellation must reach the worker over IPC first. On Windows
// `child.kill('SIGTERM')` is a forceful termination, so leading with the
// signal could kill the worker mid LadybugDB write; the signal is only a
// bounded fallback for a worker that ignores the cancel request.
it('cancelJob asks the worker to stop over IPC before sending any signal', () => {
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
const sent: unknown[] = [];
const signals: string[] = [];
let onExit: (() => void) | undefined;
const fakeChild = {
connected: true,
exitCode: null,
signalCode: null,
send: (msg: unknown) => {
sent.push(msg);
return true;
},
kill: (signal?: string) => {
signals.push(signal ?? 'SIGTERM');
return true;
},
on: (_event: string, listener: () => void) => {
onExit = listener;
return fakeChild;
},
};
manager.registerChild(job.id, fakeChild as any);
expect(manager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
expect(sent).toEqual([{ type: 'cancel' }]);
expect(signals).toEqual([]);
expect(manager.getJob(job.id)!.status).toBe('analyzing');
onExit?.();
expect(manager.getJob(job.id)!.status).toBe('failed');
expect(manager.getJob(job.id)!.error).toBe('Cancelled by user');
});
it('cancelJob keeps the slot occupied until the worker exits', () => {
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
let onExit: (() => void) | undefined;
const fakeChild = {
connected: true,
exitCode: null,
signalCode: null,
send: () => true,
kill: () => true,
on: (_event: string, listener: () => void) => {
onExit = listener;
return fakeChild;
},
};
manager.registerChild(job.id, fakeChild as any);
expect(manager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
expect(manager.getJob(job.id)!.status).toBe('analyzing');
expect(manager.hasPendingCancel(job.id)).toBe(true);
expect(() => manager.createJob({ repoPath: '/tmp/other' })).toThrow(
/Analysis already in progress/,
);
onExit?.();
expect(manager.getJob(job.id)!.status).toBe('failed');
expect(manager.hasPendingCancel(job.id)).toBe(false);
expect(manager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
});
it('createJob rejects same-repo reuse while a cancel is pending', () => {
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
let onExit: (() => void) | undefined;
const fakeChild = {
connected: true,
exitCode: null,
signalCode: null,
send: () => true,
kill: () => true,
on: (_event: string, listener: () => void) => {
onExit = listener;
return fakeChild;
},
};
manager.registerChild(job.id, fakeChild as any);
expect(manager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
expect(() => manager.createJob({ repoPath: '/tmp/repo' })).toThrow(
/Analysis already in progress/,
);
expect(() => manager.createJob({ repoPath: '/tmp/other' })).toThrow(
/Analysis already in progress/,
);
onExit?.();
expect(manager.createJob({ repoPath: '/tmp/repo' }).status).toBe('queued');
});
it('createJob rejects same-repo reuse after cancel IPC is consumed but the child remains', () => {
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
let onExit: (() => void) | undefined;
const fakeChild = {
connected: true,
exitCode: null,
signalCode: null,
send: () => true,
kill: () => true,
on: (_event: string, listener: () => void) => {
onExit = listener;
return fakeChild;
},
};
manager.registerChild(job.id, fakeChild as any);
expect(manager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
expect(manager.applyPendingCancel(job.id)).toBe(true);
expect(manager.getJob(job.id)?.status).toBe('failed');
expect(manager.hasPendingCancel(job.id)).toBe(false);
expect(() => manager.createJob({ repoPath: '/tmp/repo' })).toThrow(
/Analysis already in progress/,
);
onExit?.();
expect(manager.createJob({ repoPath: '/tmp/repo' }).status).toBe('queued');
});
it('applyPendingCancel writes the caller reason and ignores a later worker error', () => {
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
const fakeChild = {
connected: true,
exitCode: null,
signalCode: null,
send: () => true,
kill: () => true,
on: () => fakeChild,
};
manager.registerChild(job.id, fakeChild as any);
expect(manager.cancelJob(job.id, 'Analysis timed out (30 minute limit)')).toBe(true);
expect(manager.applyPendingCancel(job.id)).toBe(true);
expect(manager.getJob(job.id)!.status).toBe('failed');
expect(manager.getJob(job.id)!.error).toBe('Analysis timed out (30 minute limit)');
expect(manager.hasPendingCancel(job.id)).toBe(false);
manager.updateJob(job.id, {
status: 'failed',
error: 'Analysis cancelled (parent requested cancellation)',
});
expect(manager.getJob(job.id)!.error).toBe('Analysis timed out (30 minute limit)');
});
it('releaseChild frees the slot when a failed job never emits exit', () => {
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
const fakeChild = {
connected: true,
exitCode: null,
signalCode: null,
send: () => true,
kill: () => true,
on: () => fakeChild,
};
manager.registerChild(job.id, fakeChild as any);
manager.updateJob(job.id, { status: 'failed', error: 'Worker process error: spawn ENOENT' });
expect(() => manager.createJob({ repoPath: '/tmp/other' })).toThrow(/already in progress/);
manager.releaseChild(job.id);
expect(manager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
});
it('cancelJob falls back to a signal when the IPC channel is already closed', () => {
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
const signals: string[] = [];
// connected:false — requestChildShutdown skips send() and signal-kills.
const fakeChild = {
connected: false,
exitCode: null,
signalCode: null,
kill: (signal?: string) => {
signals.push(signal ?? 'SIGTERM');
return true;
},
on: () => fakeChild,
};
manager.registerChild(job.id, fakeChild as any);
manager.cancelJob(job.id);
expect(signals).toEqual(['SIGTERM']);
});
it('cancelJob SIGKILLs after the grace period when the worker ignores IPC', () => {
manager.dispose();
vi.useFakeTimers();
manager = new JobManager();
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
const signals: string[] = [];
const fakeChild = {
connected: true,
exitCode: null,
signalCode: null,
send: () => true,
kill: (signal?: string) => {
signals.push(signal ?? 'SIGTERM');
return true;
},
on: () => fakeChild,
};
manager.registerChild(job.id, fakeChild as any);
expect(manager.cancelJob(job.id)).toBe(true);
expect(signals).toEqual([]);
vi.advanceTimersByTime(15_000);
expect(signals).toEqual(['SIGKILL']);
});
it('dispose on Windows skips immediate SIGTERM and leaves the IPC grace timer', () => {
manager.dispose();
vi.useFakeTimers();
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
manager = new JobManager();
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
const signals: string[] = [];
const fakeChild = {
connected: true,
exitCode: null,
signalCode: null,
send: () => true,
kill: (signal?: string) => {
signals.push(signal ?? 'SIGTERM');
return true;
},
on: () => fakeChild,
};
manager.registerChild(job.id, fakeChild as any);
manager.dispose();
expect(signals).toEqual([]);
vi.advanceTimersByTime(15_000);
expect(signals).toEqual(['SIGKILL']);
vi.restoreAllMocks();
});
it('dispose on Unix SIGTERMs after IPC and clears the grace timer', () => {
manager.dispose();
vi.useFakeTimers();
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux');
manager = new JobManager();
const job = manager.createJob({ repoPath: '/tmp/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
const signals: string[] = [];
const fakeChild = {
connected: true,
exitCode: null,
signalCode: null,
send: () => true,
kill: (signal?: string) => {
signals.push(signal ?? 'SIGTERM');
return true;
},
on: () => fakeChild,
};
manager.registerChild(job.id, fakeChild as any);
manager.dispose();
expect(signals).toEqual(['SIGTERM']);
vi.advanceTimersByTime(15_000);
expect(signals).toEqual(['SIGTERM']);
vi.restoreAllMocks();
});
it('cancelJob returns false for terminal jobs', () => {
const job = manager.createJob({ repoUrl: 'https://github.com/user/repo' });
manager.updateJob(job.id, { status: 'complete' });

View file

@ -253,6 +253,58 @@ describe('finalization gate follows the placement the run chose', () => {
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
});
it('aborts settle on cancel without waiting for the 60s timeout', async () => {
vi.useFakeTimers();
H.settledDir = '';
const releaseRepoLock = vi.fn();
const job = jobManager.createJob({ repoPath: REPO_PATH });
await launcher({ releaseRepoLock })(job, REPO_PATH, {});
child.emit('message', completeMessage(true));
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
jobManager.cancelJob(job.id, 'Cancelled by user');
// One poll: shouldAbort sees pending cancel and returns without the
// timeout warning / "finalization not visible" failure.
await vi.advanceTimersByTimeAsync(200);
const done = jobManager.getJob(job.id);
expect(done?.status).toBe('failed');
expect(done?.error).toBe('Cancelled by user');
expect(done?.error).not.toMatch(/finalization not visible/);
expect(backendInit).not.toHaveBeenCalled();
expect(releaseRepoLock).not.toHaveBeenCalled();
child.emit('exit', 0);
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
});
it('does not release the lock or publish if cancel lands during settle', async () => {
vi.useFakeTimers();
H.settledDir = '';
const releaseRepoLock = vi.fn();
const job = jobManager.createJob({ repoPath: REPO_PATH });
await launcher({ releaseRepoLock })(job, REPO_PATH, {});
child.emit('message', completeMessage(true));
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
jobManager.cancelJob(job.id, 'Cancelled by user');
child.emit('exit', 0);
expect(jobManager.getJob(job.id)?.status).toBe('failed');
expect(releaseRepoLock).not.toHaveBeenCalled();
expect(backendInit).not.toHaveBeenCalled();
H.settledDir = H.STORAGE_PATH;
await vi.advanceTimersByTimeAsync(200);
expect(backendInit).not.toHaveBeenCalled();
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
expect(jobManager.getJob(job.id)?.status).toBe('failed');
});
it('holds the write lock until settle resolves, then releases once after publish', async () => {
vi.useFakeTimers();
H.settledDir = '';

View file

@ -102,6 +102,7 @@ interface FakeChild extends EventEmitter {
stderr: EventEmitter;
send: Mock<(msg: unknown) => boolean>;
kill: Mock<(signal?: NodeJS.Signals) => boolean>;
pid?: number;
}
const makeChild = (): FakeChild => {
@ -389,3 +390,152 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
expect(calls.indexOf('backend.init')).toBeLessThan(calls.indexOf('releaseRepoLock'));
});
});
describe('createLaunchAnalysisWorker — pending cancel', () => {
let jobManager: JobManager;
let child: FakeChild;
let backendInit: Mock<() => Promise<unknown>>;
let closeDbHandle: Mock<() => Promise<void>>;
const launchOne = async () => {
const launch = createLaunchAnalysisWorker({
jobManager,
backend: { init: backendInit },
acquireRepoLock: () => null,
releaseRepoLock: () => {},
closeDbHandle,
});
const job = jobManager.createJob({ repoPath: REPO_PATH });
await launch(job, REPO_PATH, {});
return job;
};
beforeEach(() => {
H.settleOk = true;
jobManager = new JobManager();
child = makeChild();
forkMock.mockImplementation(() => child);
backendInit = vi.fn(async () => true);
closeDbHandle = vi.fn(async () => {});
});
afterEach(() => {
vi.useRealTimers();
jobManager.dispose();
vi.restoreAllMocks();
forkMock.mockReset();
H.settleOk = true;
});
it('keeps the caller cancel reason when the worker reports a generic cancel error', async () => {
const job = await launchOne();
expect(jobManager.cancelJob(job.id, 'Analysis timed out (30 minute limit)')).toBe(true);
child.emit('message', {
type: 'error',
message: 'Analysis cancelled (parent requested cancellation)',
});
const done = jobManager.getJob(job.id);
expect(done?.status).toBe('failed');
expect(done?.error).toBe('Analysis timed out (30 minute limit)');
});
it('does not publish after complete IPC if cancel is already pending', async () => {
const job = await launchOne();
expect(jobManager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
child.emit('message', completeMessage());
await vi.waitFor(() => expect(jobManager.getJob(job.id)?.status).toBe('failed'));
expect(backendInit).not.toHaveBeenCalled();
expect(jobManager.getJob(job.id)?.error).toBe('Cancelled by user');
});
it('does not publish if cancel arrives while settle is in flight', async () => {
vi.useFakeTimers();
H.settleOk = false;
const job = await launchOne();
child.emit('message', completeMessage());
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
expect(jobManager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
H.settleOk = true;
await vi.advanceTimersByTimeAsync(200);
expect(backendInit).not.toHaveBeenCalled();
expect(jobManager.getJob(job.id)?.status).toBe('failed');
expect(jobManager.getJob(job.id)?.error).toBe('Cancelled by user');
});
it('releases the analyze slot when the worker emits error without exit', async () => {
const job = await launchOne();
child.emit('error', new Error('spawn ENOENT'));
expect(jobManager.getJob(job.id)?.status).toBe('failed');
expect(jobManager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
});
it('holds the repo lock until exit after complete IPC when cancel is already pending', async () => {
const releaseRepoLock = vi.fn();
const launch = createLaunchAnalysisWorker({
jobManager,
backend: { init: backendInit },
acquireRepoLock: () => null,
releaseRepoLock,
closeDbHandle,
});
const job = jobManager.createJob({ repoPath: REPO_PATH });
await launch(job, REPO_PATH, {});
expect(jobManager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
child.emit('message', completeMessage());
expect(jobManager.getJob(job.id)?.status).toBe('failed');
expect(jobManager.getJob(job.id)?.error).toBe('Cancelled by user');
expect(backendInit).not.toHaveBeenCalled();
expect(releaseRepoLock).not.toHaveBeenCalled();
expect(() => jobManager.createJob({ repoPath: '/tmp/other' })).toThrow(/already in progress/);
child.emit('exit', 0);
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
expect(jobManager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
});
it('holds the repo lock until exit after cancel error IPC', async () => {
const releaseRepoLock = vi.fn();
const launch = createLaunchAnalysisWorker({
jobManager,
backend: { init: backendInit },
acquireRepoLock: () => null,
releaseRepoLock,
closeDbHandle,
});
const job = jobManager.createJob({ repoPath: REPO_PATH });
await launch(job, REPO_PATH, {});
expect(jobManager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
child.emit('message', {
type: 'error',
message: 'Analysis cancelled (parent requested cancellation)',
});
expect(jobManager.getJob(job.id)?.status).toBe('failed');
expect(jobManager.getJob(job.id)?.error).toBe('Cancelled by user');
expect(releaseRepoLock).not.toHaveBeenCalled();
expect(() => jobManager.createJob({ repoPath: '/tmp/other' })).toThrow(/already in progress/);
child.emit('exit', 0);
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
expect(jobManager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
});
it('does not release the analyze slot on post-spawn child error until exit', async () => {
const job = await launchOne();
child.pid = 123;
child.emit('error', new Error('write EPIPE'));
expect(jobManager.getJob(job.id)?.status).toBe('failed');
expect(jobManager.getJob(job.id)?.error).toMatch(/write EPIPE/);
expect(() => jobManager.createJob({ repoPath: '/tmp/other' })).toThrow(/already in progress/);
child.emit('exit', 1);
expect(jobManager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
});
});

View file

@ -27,16 +27,32 @@ import os from 'node:os';
import { handleFileRequest, type SourceAvailability } from '../../src/server/api.js';
let tmpRoot: string;
/** Sibling of tmpRoot holding a secret a symlink inside the repo points at. */
let outsideDir: string;
/** False when the host cannot create symlinks (Windows without the privilege). */
let symlinksAvailable = false;
beforeAll(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-api-file-test-'));
await fs.writeFile(path.join(tmpRoot, 'hello.txt'), 'hello world\n', 'utf-8');
await fs.mkdir(path.join(tmpRoot, 'sub'), { recursive: true });
await fs.writeFile(path.join(tmpRoot, 'sub', 'nested.txt'), 'nested\n', 'utf-8');
outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-api-file-outside-'));
await fs.writeFile(path.join(outsideDir, 'secret.txt'), 'top secret\n', 'utf-8');
try {
await fs.symlink(path.join(outsideDir, 'secret.txt'), path.join(tmpRoot, 'escape.txt'), 'file');
await fs.symlink(outsideDir, path.join(tmpRoot, 'escape-dir'), 'dir');
await fs.symlink(path.join(tmpRoot, 'hello.txt'), path.join(tmpRoot, 'alias.txt'), 'file');
symlinksAvailable = true;
} catch {
symlinksAvailable = false;
}
});
afterAll(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
await fs.rm(outsideDir, { recursive: true, force: true });
});
// Minimal express-shaped mock that captures status() / json() calls in a
@ -131,6 +147,30 @@ describe('handleFileRequest — security wiring', () => {
expect(body.error).toBe('File not found');
});
// The lexical path.relative check cannot see symlinks. A repo cloned from an
// untrusted remote may contain `escape.txt -> /outside/secret.txt`; the
// realpath re-check must refuse it while still serving in-repo symlinks.
it('returns 403 for a symlink that resolves outside the repo root', async (ctx) => {
if (!symlinksAvailable) return ctx.skip();
const { status, body } = await invoke({ path: 'escape.txt' });
expect(status).toBe(403);
expect(body.error).toBe('Path traversal denied');
});
it('returns 403 for a file reached through a symlinked directory outside the root', async (ctx) => {
if (!symlinksAvailable) return ctx.skip();
const { status, body } = await invoke({ path: 'escape-dir/secret.txt' });
expect(status).toBe(403);
expect(body.error).toBe('Path traversal denied');
});
it('still serves a symlink whose target stays inside the repo root', async (ctx) => {
if (!symlinksAvailable) return ctx.skip();
const { status, body } = await invoke({ path: 'alias.txt' });
expect(status).toBe(200);
expect(body.content).toBe('hello world\n');
});
it('rejects a common-prefix sibling directory escape (path.relative idiom)', async () => {
// The classic pitfall of `startsWith(root + sep)` is that '/tmp/repo' does
// not catch '/tmp/repo-evil/x'. The path.relative idiom does.

View file

@ -194,6 +194,39 @@ describe('cleanCommand --stale (#3331)', () => {
await expect(fs.readFile(dir, 'utf8')).resolves.toBe('not-a-directory');
});
it('does not claim leftovers were not deleted after a mid-loop git failure', async () => {
initGitRepo(repo);
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
commitAll(repo, 'init');
await writeOwnedFlat(repo, storagePath);
await registerRepo(repo, metaFor('main', repo));
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
await registerRepo(repo, metaFor('feature/y', repo), { branch: 'feature/y' });
const dirX = path.join(storagePath, 'branches', branchSlug('feature/x'));
const dirY = path.join(storagePath, 'branches', branchSlug('feature/y'));
await saveMeta(dirX, metaFor('feature/x', repo));
await saveMeta(dirY, metaFor('feature/y', repo));
const realListLocalHeads = git.listLocalHeads;
let calls = 0;
vi.spyOn(git, 'listLocalHeads').mockImplementation((repoPath: string) => {
calls += 1;
if (calls <= 2) return realListLocalHeads(repoPath);
return null;
});
vi.spyOn(process, 'cwd').mockReturnValue(repo);
await cleanCommand({ stale: true, force: true });
const output = logs.join('\n');
const deletedX = output.includes(t('clean.stale.deleted', { branch: 'feature/x' }));
const deletedY = output.includes(t('clean.stale.deleted', { branch: 'feature/y' }));
expect(deletedX !== deletedY).toBe(true);
expect(output).toContain(t('clean.stale.remainingSkipped'));
expect(output).not.toContain(t('clean.stale.headsUnavailable'));
await expect(deletedX ? fs.access(dirX) : fs.access(dirY)).rejects.toThrow();
await expect(deletedX ? fs.access(dirY) : fs.access(dirX)).resolves.toBeUndefined();
});
it('does not delete when leftover directories cannot be listed', async () => {
initGitRepo(repo);
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');

View file

@ -11,6 +11,8 @@
* - RFC 1918 private network ranges → allowed
* 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
* - https://gitnexus.vercel.app → allowed
* - https://gitnexus-web.vercel.app → allowed
* - GITNEXUS_PUBLIC_ORIGIN (when set) → allowed
* - Everything else → rejected
*/
import { describe, it, expect, afterAll, afterEach, beforeAll } from 'vitest';
@ -67,6 +69,17 @@ describe('isAllowedOrigin: vercel.app', () => {
expect(isAllowedOrigin('https://gitnexus.vercel.app')).toBe(true);
});
it('allows gitnexus-web production host on vercel.app', () => {
expect(isAllowedOrigin('https://gitnexus-web.vercel.app')).toBe(true);
});
it('rejects arbitrary gitnexus-web-* vercel preview hosts', () => {
// Preview hosts must opt in via GITNEXUS_PUBLIC_ORIGIN — a prefix match
// would also allow attacker-controlled projects named gitnexus-web-*.
expect(isAllowedOrigin('https://gitnexus-web-mesquitafelipe571-5486.vercel.app')).toBe(false);
expect(isAllowedOrigin('https://gitnexus-web-evil.vercel.app')).toBe(false);
});
it('rejects other vercel.app subdomains', () => {
expect(isAllowedOrigin('https://evil.vercel.app')).toBe(false);
});

View file

@ -3,7 +3,7 @@ import {
displayWidth,
doctorCommand,
localEmbeddingDoctorStatus,
orphanedBranchSlotDoctorLines,
leftoverBranchSlotDoctorLines,
padDisplayEnd,
nativeStatusLine,
pageSizeDoctorLines,
@ -380,7 +380,7 @@ describe('doctor survives a malformed GITNEXUS_EMBEDDING_DIMS (#2385)', () => {
});
});
describe('orphanedBranchSlotDoctorLines (#3331)', () => {
describe('leftoverBranchSlotDoctorLines (#3331)', () => {
const slot = (overrides: Partial<StaleBranchSlot>): StaleBranchSlot => ({
branch: 'feature/x',
dir: '/tmp/branches/feature_x',
@ -390,11 +390,11 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
});
it('returns no lines when there are no leftover slots', () => {
expect(orphanedBranchSlotDoctorLines([])).toEqual([]);
expect(leftoverBranchSlotDoctorLines([])).toEqual([]);
});
it('prints branch, reason, size, total, and the clean --stale reclaim line', () => {
const lines = orphanedBranchSlotDoctorLines([slot({ sizeBytes: 4_800_000 })]);
const lines = leftoverBranchSlotDoctorLines([slot({ sizeBytes: 4_800_000 })]);
expect(lines[0]).toBe(t('doctor.orphanedBranches'));
expect(lines.join('\n')).toContain('feature/x');
expect(lines.join('\n')).toContain(t('clean.stale.reason.refMissing'));
@ -404,7 +404,7 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
});
it('prints retry-git copy instead of reclaim when heads cannot be listed (#3337)', () => {
const lines = orphanedBranchSlotDoctorLines([
const lines = leftoverBranchSlotDoctorLines([
slot({ reason: 'heads-unavailable', sizeBytes: 2048 }),
slot({ branch: 'other', reason: 'ref-missing', sizeBytes: 4096 }),
]);
@ -414,7 +414,7 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
});
it('prints only listingFailed when listing-failed is mixed with ref-missing', () => {
const lines = orphanedBranchSlotDoctorLines([
const lines = leftoverBranchSlotDoctorLines([
slot({ reason: 'listing-failed', branch: '', dir: null, sizeBytes: 0 }),
slot({ reason: 'ref-missing' }),
]);
@ -424,7 +424,7 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
});
it('prints heading and probe-failed row without reclaim', () => {
const lines = orphanedBranchSlotDoctorLines([slot({ reason: 'probe-failed' })]);
const lines = leftoverBranchSlotDoctorLines([slot({ reason: 'probe-failed' })]);
expect(lines[0]).toBe(t('doctor.orphanedBranches'));
expect(lines.join('\n')).toContain('feature/x');
expect(lines.join('\n')).toContain(t('clean.stale.reason.probeFailed'));
@ -432,7 +432,7 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
});
it('includes reclaim when probe-failed is mixed with ref-missing', () => {
const lines = orphanedBranchSlotDoctorLines([
const lines = leftoverBranchSlotDoctorLines([
slot({ reason: 'probe-failed' }),
slot({ branch: 'other', reason: 'ref-missing' }),
]);

View file

@ -0,0 +1,367 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { JobManager } from '../../src/server/analyze-job.js';
import {
buildOpsSnapshot,
isGitNexusVercelOrigin,
serializeOpsJob,
summarizeOpsLane,
} from '../../src/server/ops-snapshot.js';
import { publicRepoId } from '../../src/server/public-repo-id.js';
describe('serializeOpsJob / summarizeOpsLane', () => {
let manager: JobManager;
beforeEach(() => {
manager = new JobManager();
});
afterEach(() => {
manager.dispose();
});
it('computes duration from startedAt to now for active jobs', () => {
const job = manager.createJob({ repoUrl: 'https://github.com/user/repo' });
manager.updateJob(job.id, { status: 'analyzing' });
const now = job.startedAt + 5_000;
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze', now);
expect(view.durationMs).toBe(5_000);
expect(view.lane).toBe('analyze');
expect(view.repoName).toBe('repo');
expect(view.repoUrl).toBeUndefined();
expect(view.repoPath).toBeUndefined();
expect(view.branch).toBeUndefined();
});
it('omits the requested branch from the public ops view', () => {
const job = manager.createJob({
repoUrl: 'https://github.com/user/repo',
branch: 'customer/acme-release',
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.branch).toBeUndefined();
expect(JSON.stringify(view)).not.toContain('customer');
expect(JSON.stringify(view)).not.toContain('acme-release');
});
it('labels a branch-pinned clone by its repo name, not the branch-slug registry name', () => {
const job = manager.createJob({
repoUrl: 'https://github.com/user/repo',
branch: 'customer/acme-release',
});
manager.updateJob(job.id, { status: 'complete', repoName: 'repo__customer-acme-r-1a2b3c4d' });
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.repoName).toBe('repo');
expect(JSON.stringify(view)).not.toContain('acme');
});
it('exposes an opaque repoId only once the job is complete', () => {
const job = manager.createJob({ repoPath: '/home/alice/src/api' });
manager.updateJob(job.id, { status: 'analyzing' });
expect(serializeOpsJob(manager.getJob(job.id)!, 'analyze').repoId).toBeUndefined();
manager.updateJob(job.id, { status: 'complete' });
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.repoId).toBe(publicRepoId('/home/alice/src/api'));
expect(JSON.stringify(view)).not.toContain('alice');
});
it('strips query and fragment when deriving repoName from repoUrl', () => {
const job = manager.createJob({
repoUrl: 'https://github.com/user/repo.git?access_token=secret#frag',
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.repoName).toBe('repo');
expect(view.repoName).not.toContain('access_token');
expect(view.repoUrl).toBeUndefined();
});
it('redacts the full repository URL from job.error on the public ops view', () => {
const job = manager.createJob({
repoUrl: 'https://x-access-token:ghs_secret@github.com/user/repo.git',
});
manager.updateJob(job.id, {
status: 'failed',
error: 'fatal: unable to access https://x-access-token:ghs_secret@github.com/user/repo.git/',
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toBe('fatal: unable to access [repo]');
expect(JSON.stringify(view)).not.toContain('ghs_secret');
expect(JSON.stringify(view)).not.toContain('x-access-token');
expect(JSON.stringify(view)).not.toContain('github.com');
});
it('redacts the full repository URL from progress.message on the public ops view', () => {
const job = manager.createJob({
repoUrl: 'https://x-access-token:ghs_secret@github.com/user/repo.git',
});
manager.updateJob(job.id, {
status: 'cloning',
progress: {
phase: 'cloning',
percent: 0,
message: 'Cloning https://x-access-token:ghs_secret@github.com/user/repo.git...',
},
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.progress.message).toBe('Cloning [repo]...');
expect(JSON.stringify(view)).not.toContain('ghs_secret');
expect(JSON.stringify(view)).not.toContain('x-access-token');
expect(JSON.stringify(view)).not.toContain('github.com');
});
it('redacts a longer URL even when the known repoUrl is a prefix of it', () => {
const job = manager.createJob({
repoUrl: 'https://host/org/repo',
});
manager.updateJob(job.id, {
status: 'failed',
error: 'clone failed https://host/org/repo/other?token=secret',
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toBe('clone failed [repo]');
expect(JSON.stringify(view)).not.toContain('token=secret');
expect(JSON.stringify(view)).not.toContain('/other');
expect(JSON.stringify(view)).not.toContain('host/org');
});
it('redacts host, path, and query from a credential-stripped clone URL', () => {
const job = manager.createJob({
repoUrl: 'https://git.example/private/repo?token=x',
});
manager.updateJob(job.id, {
status: 'cloning',
progress: {
phase: 'cloning',
percent: 0,
message: 'Cloning https://git.example/private/repo?token=x',
},
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.progress.message).toBe('Cloning [repo]');
expect(JSON.stringify(view)).not.toContain('git.example');
expect(JSON.stringify(view)).not.toContain('private/repo');
expect(JSON.stringify(view)).not.toContain('token=x');
});
it('basenames Windows-like drive paths instead of emitting the full path', () => {
const job = manager.createJob({
repoUrl: String.raw`C:\Users\alice\private\repo`,
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.repoName).toBe('repo');
expect(JSON.stringify(view)).not.toContain('Users');
expect(JSON.stringify(view)).not.toContain('alice');
});
it('redacts a home-directory clone path from job.error on the public ops view', () => {
const job = manager.createJob({
repoPath: '/home/alice/src/private-repo',
});
manager.updateJob(job.id, {
status: 'failed',
error: 'Existing clone at /home/alice/src/private-repo has no remote.origin',
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toBe('Existing clone at [path] has no remote.origin');
expect(view.repoName).toBe('private-repo');
expect(JSON.stringify(view)).not.toContain('alice');
expect(JSON.stringify(view)).not.toContain('/home/');
});
it('redacts a descendant file under the known repoPath, not just the prefix', () => {
const job = manager.createJob({ repoPath: '/home/alice/repo' });
manager.updateJob(job.id, {
status: 'failed',
error: 'Parse failed in /home/alice/repo/src/secret.ts, aborting',
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toBe('Parse failed in [path], aborting');
});
it('does not treat a same-prefix sibling as the known repoPath', () => {
const job = manager.createJob({ repoPath: '/home/alice/repo' });
manager.updateJob(job.id, {
status: 'failed',
error: 'Lock held by /home/alice/repo2/x.lock',
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toBe('Lock held by [path]');
});
it('redacts a spaced POSIX clone path from job.error on the public ops view', () => {
const repoPath = '/home/Jane Doe/.gitnexus/repos/foo';
const job = manager.createJob({ repoPath });
manager.updateJob(job.id, {
status: 'failed',
error: `Existing clone at ${repoPath} has no remote.origin`,
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toContain('[path]');
expect(view.error).toBe('Existing clone at [path] has no remote.origin');
expect(JSON.stringify(view)).not.toContain('Jane');
expect(JSON.stringify(view)).not.toContain('Doe');
expect(JSON.stringify(view)).not.toContain('/home/');
});
it('redacts a spaced Windows clone path from job.error on the public ops view', () => {
const repoPath = String.raw`C:\Users\Jane Doe\My Projects\repo`;
const job = manager.createJob({ repoPath });
manager.updateJob(job.id, {
status: 'failed',
error: `Existing clone at ${repoPath} has no remote.origin`,
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toContain('[path]');
expect(view.error).toBe('Existing clone at [path] has no remote.origin');
expect(JSON.stringify(view)).not.toContain('Jane');
expect(JSON.stringify(view)).not.toContain('Projects');
});
it('redacts a quoted Node open() path and a file:// URL from job.error', () => {
const job = manager.createJob({
repoPath: '/home/alice/src/private-repo',
});
manager.updateJob(job.id, {
status: 'failed',
error:
"ENOENT: no such file or directory, open '/home/alice/src/private-repo' (file:///home/alice/src/private-repo)",
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toBe("ENOENT: no such file or directory, open '[path]' ([path])");
expect(JSON.stringify(view)).not.toContain('alice');
expect(JSON.stringify(view)).not.toContain('/home/');
});
it('redacts an scp-style remote from job.error on the public ops view', () => {
const job = manager.createJob({
repoUrl: 'git@github.com:private-org/secret.git',
});
manager.updateJob(job.id, {
status: 'failed',
error: 'fatal: could not read from remote git@github.com:private-org/secret.git',
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toBe('fatal: could not read from remote [repo]');
expect(JSON.stringify(view)).not.toContain('private-org');
expect(JSON.stringify(view)).not.toContain('github.com');
expect(JSON.stringify(view)).not.toContain('secret.git');
});
it('redacts an ssh:// remote from job.error on the public ops view', () => {
const job = manager.createJob({
repoUrl: 'ssh://git@github.com/private-org/secret.git',
});
manager.updateJob(job.id, {
status: 'failed',
error: 'fatal: could not read from remote ssh://git@github.com/private-org/secret.git',
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toBe('fatal: could not read from remote [repo]');
expect(JSON.stringify(view)).not.toContain('private-org');
expect(JSON.stringify(view)).not.toContain('github.com');
});
it('redacts a Windows drive path from job.error on the public ops view', () => {
const job = manager.createJob({
repoPath: String.raw`C:\Users\alice\private\repo`,
});
manager.updateJob(job.id, {
status: 'failed',
error: String.raw`Existing clone at C:\Users\alice\private\repo has no remote.origin`,
});
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
expect(view.error).toBe('Existing clone at [path] has no remote.origin');
expect(JSON.stringify(view)).not.toContain('alice');
expect(JSON.stringify(view)).not.toContain('Users');
});
it('summarizes lane metrics including avg duration of terminal jobs', () => {
const a = manager.createJob({ repoUrl: 'https://github.com/user/a' });
manager.updateJob(a.id, { status: 'analyzing' });
manager.updateJob(a.id, {
status: 'complete',
completedAt: a.startedAt + 2_000,
});
const b = manager.createJob({ repoUrl: 'https://github.com/user/b' });
manager.updateJob(b.id, { status: 'analyzing' });
manager.updateJob(b.id, {
status: 'failed',
error: 'boom',
completedAt: b.startedAt + 4_000,
});
const views = manager.listJobs().map((j) => serializeOpsJob(j, 'analyze', Date.now()));
const metrics = summarizeOpsLane(views);
expect(metrics.total).toBe(2);
expect(metrics.complete).toBe(1);
expect(metrics.failed).toBe(1);
expect(metrics.active).toBe(0);
expect(metrics.avgDurationMs).toBe(3_000);
expect(metrics.maxDurationMs).toBe(4_000);
});
});
describe('buildOpsSnapshot', () => {
let analyze: JobManager;
let embed: JobManager;
beforeEach(() => {
analyze = new JobManager();
embed = new JobManager();
});
afterEach(() => {
analyze.dispose();
embed.dispose();
});
it('aggregates both lanes and server uptime', () => {
const job = analyze.createJob({ repoUrl: 'https://github.com/user/repo' });
analyze.updateJob(job.id, { status: 'analyzing' });
const startedAt = 1_000;
const now = 6_000;
const snap = buildOpsSnapshot({
analyzeJobs: analyze.listJobs(),
embedJobs: embed.listJobs(),
serverStartedAt: startedAt,
server: { version: '1.0.0', launchContext: 'local', nodeVersion: 'v22.0.0' },
now,
});
expect(snap.health).toBe('ok');
expect(snap.uptimeMs).toBe(5_000);
expect(snap.totals.active).toBe(1);
expect(snap.analyze.jobs).toHaveLength(1);
expect(snap.embed.jobs).toHaveLength(0);
expect(snap.server.version).toBe('1.0.0');
});
});
describe('isGitNexusVercelOrigin', () => {
it('allows exact official vercel hosts only', () => {
expect(isGitNexusVercelOrigin('https://gitnexus.vercel.app')).toBe(true);
expect(isGitNexusVercelOrigin('https://gitnexus-web.vercel.app')).toBe(true);
});
it('rejects preview and unrelated vercel hosts', () => {
expect(isGitNexusVercelOrigin('https://gitnexus-web-mesquitafelipe571-5486.vercel.app')).toBe(
false,
);
expect(
isGitNexusVercelOrigin(
'https://gitnexus-web-git-local-bridge-v1-mesquitafelipe571-5486.vercel.app',
),
).toBe(false);
expect(isGitNexusVercelOrigin('https://gitnexus-web-evil.vercel.app')).toBe(false);
expect(isGitNexusVercelOrigin('https://evil.vercel.app')).toBe(false);
expect(isGitNexusVercelOrigin('https://gitnexus-web-attacker.com')).toBe(false);
expect(isGitNexusVercelOrigin('http://gitnexus-web.vercel.app')).toBe(false);
});
it('rejects non-default ports on the official hostnames', () => {
expect(isGitNexusVercelOrigin('https://gitnexus-web.vercel.app:8443')).toBe(false);
// :443 is the HTTPS default — URL.port is empty, same as the bare origin.
expect(isGitNexusVercelOrigin('https://gitnexus.vercel.app:443')).toBe(true);
expect(isGitNexusVercelOrigin('https://gitnexus.vercel.app')).toBe(true);
});
});

View file

@ -21,6 +21,7 @@ import {
import type { StalenessInfo } from '../../src/core/git-staleness.js';
import type { RegistryEntry } from '../../src/storage/repo-manager.js';
import type { RepoMeta } from '../../src/storage/repo-meta.js';
import { publicRepoId } from '../../src/server/public-repo-id.js';
const FULL_SOURCE = { contentRetention: 'full' as const, sourceAvailable: true };
@ -113,6 +114,14 @@ describe('projectRepoListEntry — GET /api/repos', () => {
expect([primary.branch, pinned.branch]).toEqual(['master', 'test']);
});
it('gives same-named entries distinct opaque ids that match the job repoId', () => {
const a = projectRepoListEntry(entry({ name: 'api', path: '/ws/a/api' }), FRESH, FULL_SOURCE);
const b = projectRepoListEntry(entry({ name: 'api', path: '/ws/b/api' }), FRESH, FULL_SOURCE);
expect(a.id).not.toBe(b.id);
expect(a.id).toBe(publicRepoId('/ws/a/api'));
expect(a.id).not.toContain('ws');
});
it('keeps every field the route returned before, unchanged', () => {
// Additive only: an existing client must not notice this change.
const e = entry();

View file

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { resolveRegisteredRepoEntry, storageRequirementToHttp } from '../../src/server/api.js';
import {
parseAwaitAnalysisQuery,
resolveOmittedRepoSelection,
resolveRegisteredRepoEntry,
storageRequirementToHttp,
} from '../../src/server/api.js';
import type { RegistryEntry } from '../../src/storage/repo-manager.js';
import {
STATUS_STORAGE_REQUIREMENTS,
@ -131,6 +136,25 @@ describe('resolveRegisteredRepoEntry', () => {
});
});
describe('resolveOmittedRepoSelection', () => {
it('400s when more than one repo is registered and ?repo= is omitted', () => {
const first = entry({ name: 'alpha', path: '/tmp/alpha', storagePath: '/tmp/alpha/.gitnexus' });
const second = entry({ name: 'beta', path: '/tmp/beta', storagePath: '/tmp/beta/.gitnexus' });
const result = resolveOmittedRepoSelection([first, second]);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(400);
expect(result.error).toMatch(/Multiple repositories indexed/);
expect(result.error).toContain('alpha');
expect(result.error).toContain('beta');
});
it('allows the sole registered repo when ?repo= is omitted', () => {
const only = entry({ name: 'solo' });
expect(resolveOmittedRepoSelection([only])).toEqual({ ok: true, entry: only });
});
});
describe('storageRequirementToHttp — GET /api/repo', () => {
const inspection = (state: StorageInspection['state']): StorageInspection => ({
repoPath: '/tmp/repo',
@ -171,3 +195,16 @@ describe('storageRequirementToHttp — GET /api/repo', () => {
expect(storageRequirementToHttp(err).body.code).toBe('index-unavailable');
});
});
describe('parseAwaitAnalysisQuery', () => {
it('defaults to waiting when the flag is omitted', () => {
expect(parseAwaitAnalysisQuery(undefined)).toBe(true);
expect(parseAwaitAnalysisQuery('true')).toBe(true);
});
it('opts out of the hold-queue for false/0', () => {
expect(parseAwaitAnalysisQuery('false')).toBe(false);
expect(parseAwaitAnalysisQuery('0')).toBe(false);
expect(parseAwaitAnalysisQuery(['false'])).toBe(false);
});
});

View file

@ -1,14 +1,15 @@
/**
* SSE terminal payload wire shape (mountSSEProgress).
*
* The `event: complete` payload must carry `repoPath` (the analyzed path)
* alongside the display `repoName` at BOTH terminal emit sites:
* The `event: complete` payload carries the display `repoName` at BOTH
* terminal emit sites:
* (a) the already-terminal replay (job finished before the client subscribed)
* (b) the live subscription (job finishes while the client is connected)
*
* Clients reconnect by this identity after "Analyze new" — with duplicate
* basenames, a name-only payload makes the web UI connect to the first
* same-named sibling instead of the repo just analyzed (PR #2420 review R2).
* The analyzed filesystem path is NOT on this unauthenticated stream: `/api/ops`
* enumerates job ids, so a LAN or official-Vercel origin must not recover
* operator home directories from a terminal frame. Clients reconnect by the
* opaque `repoId` (matches `id` on `GET /api/repos`). Older servers that still emit `repoPath` keep working in the UI.
*
* Imported from `src/server/sse-progress.ts`, NOT from `src/server/api.ts`:
* that module pulls Express, cors, the LadybugDB native adapter and the whole
@ -16,6 +17,7 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { JobManager } from '../../src/server/analyze-job.js';
import { publicRepoId } from '../../src/server/public-repo-id.js';
import { startSSEHarness, terminalFrame, type SSEHarness } from '../helpers/sse-harness.js';
const REPO_PATH = '/ws/b/reels';
@ -35,7 +37,7 @@ describe('mountSSEProgress terminal payload', () => {
afterEach(() => harness.close());
it('already-terminal replay includes repoName AND repoPath', async () => {
it('already-terminal replay includes repoName and omits repoPath', async () => {
const job = manager.createJob({ repoPath: REPO_PATH });
manager.updateJob(job.id, { status: 'complete', repoName: REPO_NAME });
@ -43,14 +45,15 @@ describe('mountSSEProgress terminal payload', () => {
const body = await response.text();
expect(body).toContain('event: complete');
expect(body).not.toContain(REPO_PATH);
// Exact match locks the wire shape (error is undefined → omitted by JSON).
expect(terminalFrame(body, 'complete')).toEqual({
repoName: REPO_NAME,
repoPath: REPO_PATH,
repoId: publicRepoId(REPO_PATH),
});
});
it('live subscription terminal event includes repoName AND repoPath', async () => {
it('live subscription terminal event includes repoName and omits repoPath', async () => {
const job = manager.createJob({ repoPath: REPO_PATH });
// fetch resolves once headers arrive — the handler has already subscribed
@ -65,9 +68,40 @@ describe('mountSSEProgress terminal payload', () => {
const body = await response.text();
expect(body).toContain('event: complete');
expect(body).not.toContain(REPO_PATH);
expect(terminalFrame(body, 'complete')).toEqual({
repoName: REPO_NAME,
repoPath: REPO_PATH,
repoId: publicRepoId(REPO_PATH),
});
});
it('redacts repository URLs in progress and error without leaking repoPath', async () => {
const job = manager.createJob({ repoPath: REPO_PATH });
manager.updateJob(job.id, {
repoName: REPO_NAME,
status: 'cloning',
progress: {
phase: 'cloning',
percent: 0,
message: 'Cloning https://x-access-token:ghs_secret@github.com/user/repo.git...',
},
});
const response = await fetch(`${baseUrl}/api/analyze/${job.id}/progress`);
manager.updateJob(job.id, {
status: 'failed',
repoName: REPO_NAME,
error: 'fatal: unable to access https://x-access-token:ghs_secret@github.com/user/repo.git/',
});
const body = await response.text();
expect(body).not.toContain('ghs_secret');
expect(body).not.toContain('x-access-token');
expect(body).not.toContain(REPO_PATH);
expect(body).toContain('Cloning [repo]...');
expect(terminalFrame(body, 'failed')).toEqual({
repoName: REPO_NAME,
error: 'fatal: unable to access [repo]',
});
});
});

View file

@ -25,6 +25,9 @@ async function writeSlotMeta(dir: string, branch: string): Promise<void> {
);
}
const linkDir = (target: string, dest: string): Promise<void> =>
fs.symlink(target, dest, process.platform === 'win32' ? 'junction' : 'dir');
describe('listStaleBranchSlots (#3331)', () => {
let fixture: TestDBHandle;
let repoPath: string;
@ -64,6 +67,74 @@ describe('listStaleBranchSlots (#3331)', () => {
expect(isDeleteCandidate(rows[0]!)).toBe(true);
});
it('does not include a sibling-slot junction in leftover size', async () => {
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
const sibling = path.join(storagePath, 'branches', branchSlug('main'));
await writeSlotMeta(leftover, 'feature/x');
await writeSlotMeta(sibling, 'main');
const secret = Buffer.alloc(64 * 1024, 7);
await fs.writeFile(path.join(sibling, 'payload.bin'), secret);
await fs.writeFile(path.join(leftover, 'tiny.bin'), 'x');
const inner = path.join(leftover, 'inner');
await fs.mkdir(inner, { recursive: true });
await linkDir(sibling, path.join(inner, 'escape'));
const rows = await listStaleBranchSlots({
repoPath,
storagePath,
branches: [{ branch: 'feature/x' }],
heads: ['main'],
});
expect(rows).toHaveLength(1);
expect(rows[0]?.sizeBytes).toBeGreaterThan(0);
expect(rows[0]?.sizeBytes).toBeLessThan(secret.length);
});
it('reports zero size when the leftover path is a junction to a sibling slot', async () => {
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
const sibling = path.join(storagePath, 'branches', branchSlug('main'));
await writeSlotMeta(sibling, 'main');
await fs.writeFile(path.join(sibling, 'payload.bin'), Buffer.alloc(64 * 1024, 7));
await fs.mkdir(path.dirname(leftover), { recursive: true });
await linkDir(sibling, leftover);
const rows = await listStaleBranchSlots({
repoPath,
storagePath,
branches: [{ branch: 'feature/x' }],
heads: ['main'],
});
expect(rows).toEqual([
expect.objectContaining({
branch: 'feature/x',
dir: leftover,
sizeBytes: 0,
reason: 'ref-missing',
}),
]);
});
it('does not hang sizing a leftover slot with a directory cycle', async () => {
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
await writeSlotMeta(leftover, 'feature/x');
await fs.writeFile(path.join(leftover, 'tiny.bin'), 'x');
const inner = path.join(leftover, 'inner');
await fs.mkdir(inner, { recursive: true });
await linkDir(inner, path.join(inner, 'loop'));
const rows = await listStaleBranchSlots({
repoPath,
storagePath,
branches: [{ branch: 'feature/x' }],
heads: ['main'],
});
expect(rows).toHaveLength(1);
expect(rows[0]?.sizeBytes).toBeGreaterThan(0);
});
it('does not classify a recorded branch that is still a local head', async () => {
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
await writeSlotMeta(dir, 'feature/x');
@ -155,6 +226,7 @@ describe('listStaleBranchSlots (#3331)', () => {
it('returns listing-failed when branches/ readdir fails with a non-missing error', async () => {
const branchesRoot = path.join(storagePath, 'branches');
await fs.mkdir(branchesRoot, { recursive: true });
const realReaddir = fs.readdir.bind(fs);
const readdirSpy = vi.spyOn(fs, 'readdir').mockImplementation((async (
target: unknown,
@ -194,6 +266,7 @@ describe('listStaleBranchSlots (#3331)', () => {
it('does not classify registry rows when branches/ listing fails', async () => {
const branchesRoot = path.join(storagePath, 'branches');
await fs.mkdir(branchesRoot, { recursive: true });
const realReaddir = fs.readdir.bind(fs);
const readdirSpy = vi.spyOn(fs, 'readdir').mockImplementation((async (
target: unknown,
@ -248,6 +321,42 @@ describe('listStaleBranchSlots (#3331)', () => {
]);
});
it('does not classify a live-head registry row whose directory is gone', async () => {
const rows = await listStaleBranchSlots({
repoPath,
storagePath,
branches: [{ branch: 'feature/x' }],
heads: ['feature/x'],
});
expect(rows).toEqual([]);
});
it('returns listing-failed when branches/ is a symlink or junction', async () => {
const outside = path.join(fixture.dbPath, 'outside-tree');
const slug = branchSlug('feature/x');
await writeSlotMeta(path.join(outside, slug), 'feature/x');
await fs.mkdir(storagePath, { recursive: true });
await linkDir(outside, path.join(storagePath, 'branches'));
const rows = await listStaleBranchSlots({
repoPath,
storagePath,
branches: [{ branch: 'feature/x' }],
heads: ['main'],
});
expect(rows).toEqual([
{
branch: '',
dir: null,
sizeBytes: 0,
reason: 'listing-failed',
},
]);
expect(isDeleteCandidate(rows[0]!)).toBe(false);
});
it('does not classify a leftover directory with unreadable metadata and no registry row', async () => {
const dir = path.join(storagePath, 'branches', 'mystery-deadbeef');
await fs.mkdir(dir, { recursive: true });
@ -469,7 +578,7 @@ describe('removeBranchSlot (#3331)', () => {
await fs.writeFile(path.join(outsideSlot, 'payload.bin'), 'secret');
await fs.mkdir(storagePath, { recursive: true });
const branchesRoot = path.join(storagePath, 'branches');
await fs.symlink(outside, branchesRoot, process.platform === 'win32' ? 'junction' : 'dir');
await linkDir(outside, branchesRoot);
const dir = path.join(branchesRoot, slug);
const result = await removeBranchSlot({
@ -499,7 +608,7 @@ describe('removeBranchSlot (#3331)', () => {
const branchesRoot = path.join(storagePath, 'branches');
await fs.mkdir(branchesRoot, { recursive: true });
const dir = path.join(branchesRoot, branchSlug('feature/x'));
await fs.symlink(outside, dir, process.platform === 'win32' ? 'junction' : 'dir');
await linkDir(outside, dir);
const result = await removeBranchSlot({
repoPath,
@ -516,6 +625,138 @@ describe('removeBranchSlot (#3331)', () => {
expect(entry.branches).toBeUndefined();
});
it('unlinks a nested junction inside a leftover slot and leaves the outside target', async () => {
await registerRepo(repoPath, metaFor('main'));
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
await writeSlotMeta(dir, 'feature/x');
await fs.writeFile(path.join(dir, 'payload.bin'), 'stale');
const inner = path.join(dir, 'inner');
await fs.mkdir(inner, { recursive: true });
const outside = path.join(fixture.dbPath, 'outside-nested');
await fs.mkdir(outside, { recursive: true });
await fs.writeFile(path.join(outside, 'secret.bin'), 'keep');
await linkDir(outside, path.join(inner, 'escape'));
const result = await removeBranchSlot({
repoPath,
storagePath,
branch: 'feature/x',
dir,
});
expect(result).toEqual({ ok: true, emptiedBranchesDir: true, keptRegistry: false });
await expect(fs.access(dir)).rejects.toThrow();
await expect(fs.access(outside)).resolves.toBeUndefined();
await expect(fs.readFile(path.join(outside, 'secret.bin'), 'utf8')).resolves.toBe('keep');
const [entry] = await listRegisteredRepos();
expect(entry.branches).toBeUndefined();
});
it('does not walk a nested directory swapped for a junction mid-cleanup', async () => {
await registerRepo(repoPath, metaFor('main'));
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
await writeSlotMeta(dir, 'feature/x');
const inner = path.join(dir, 'inner');
await fs.mkdir(inner, { recursive: true });
const outside = path.join(fixture.dbPath, 'outside-swapped');
await fs.mkdir(outside, { recursive: true });
const victim = path.join(outside, 'victim-link');
await linkDir(fixture.dbPath, victim);
// Swap `inner` for a junction to `outside` right after its containment
// realpath resolves — past the per-child checks, before the recursion.
const realRealpath = fs.realpath.bind(fs);
let swapped = false;
const realpathSpy = vi.spyOn(fs, 'realpath').mockImplementation((async (
target: Parameters<typeof fs.realpath>[0],
) => {
const resolved = await realRealpath(target);
if (!swapped && path.resolve(String(target)) === path.resolve(inner)) {
swapped = true;
await fs.rm(inner, { recursive: true });
await linkDir(outside, inner);
}
return resolved;
}) as typeof fs.realpath);
try {
await removeBranchSlot({ repoPath, storagePath, branch: 'feature/x', dir });
} finally {
realpathSpy.mockRestore();
}
expect(swapped).toBe(true);
await expect(fs.lstat(victim)).resolves.toBeDefined();
});
it('unlinks a nested junction aimed at a sibling slot', async () => {
await registerRepo(repoPath, metaFor('main'));
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
const sibling = path.join(storagePath, 'branches', branchSlug('main'));
await writeSlotMeta(leftover, 'feature/x');
await writeSlotMeta(sibling, 'main');
await fs.writeFile(path.join(sibling, 'live.bin'), 'keep');
const inner = path.join(leftover, 'inner');
await fs.mkdir(inner, { recursive: true });
await linkDir(sibling, path.join(inner, 'escape'));
const result = await removeBranchSlot({
repoPath,
storagePath,
branch: 'feature/x',
dir: leftover,
});
expect(result.ok).toBe(true);
await expect(fs.access(leftover)).rejects.toThrow();
await expect(fs.readFile(path.join(sibling, 'live.bin'), 'utf8')).resolves.toBe('keep');
});
it('unlinks a slot junction aimed at a sibling slot', async () => {
await registerRepo(repoPath, metaFor('main'));
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
const sibling = path.join(storagePath, 'branches', branchSlug('main'));
await writeSlotMeta(sibling, 'main');
await fs.writeFile(path.join(sibling, 'live.bin'), 'keep');
await fs.mkdir(path.dirname(leftover), { recursive: true });
await linkDir(sibling, leftover);
const result = await removeBranchSlot({
repoPath,
storagePath,
branch: 'feature/x',
dir: leftover,
});
expect(result).toEqual({ ok: true, emptiedBranchesDir: false, keptRegistry: false });
await expect(fs.lstat(leftover)).rejects.toThrow();
await expect(fs.readFile(path.join(sibling, 'live.bin'), 'utf8')).resolves.toBe('keep');
});
it('deletes a leftover slot that contains a directory cycle', async () => {
await registerRepo(repoPath, metaFor('main'));
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
await writeSlotMeta(leftover, 'feature/x');
const inner = path.join(leftover, 'inner');
await fs.mkdir(inner, { recursive: true });
await linkDir(inner, path.join(inner, 'loop'));
const result = await removeBranchSlot({
repoPath,
storagePath,
branch: 'feature/x',
dir: leftover,
});
expect(result.ok).toBe(true);
await expect(fs.access(leftover)).rejects.toThrow();
});
it('deletes a normal leftover slot directory', async () => {
await registerRepo(repoPath, metaFor('main'));
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });