diff --git a/.gitignore b/.gitignore index 1f350e059..873f4082f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 000000000..eab6bb113 --- /dev/null +++ b/.vercelignore @@ -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 diff --git a/gitnexus-web/.gitignore b/gitnexus-web/.gitignore index c8a733615..0fe739585 100644 --- a/gitnexus-web/.gitignore +++ b/gitnexus-web/.gitignore @@ -1,2 +1,3 @@ .vercel .env*.local +.cursor/ diff --git a/gitnexus-web/e2e/analyze-public-sse.spec.ts b/gitnexus-web/e2e/analyze-public-sse.spec.ts new file mode 100644 index 000000000..3e3a9800e --- /dev/null +++ b/gitnexus-web/e2e/analyze-public-sse.spec.ts @@ -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 = {}; + page.on('request', (req) => { + if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) { + posted = (req.postDataJSON() as Record) ?? {}; + } + }); + + 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 = {}; + page.on('request', (req) => { + if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) { + posted = (req.postDataJSON() as Record) ?? {}; + } + }); + + 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(); + }); +}); diff --git a/gitnexus-web/e2e/helpers/public-contract.ts b/gitnexus-web/e2e/helpers/public-contract.ts new file mode 100644 index 000000000..d54cfe6a1 --- /dev/null +++ b/gitnexus-web/e2e/helpers/public-contract.ts @@ -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 { + 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 { + 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 { + if (!backend) return; + if (backend.child.exitCode === null && backend.child.signalCode === null) { + const exited = new Promise((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 | undefined; + try { + const exitedInTime = await Promise.race([ + exited.then(() => true), + new Promise((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, + deadline = Infinity, +): Promise { + 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 { + 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, + timeoutMs = 60_000, +): Promise { + 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> { + 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; +} + +/** Point the app at this spec's live server before the first navigation. */ +export async function bindBackend(page: Page, backendUrl: string): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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\\/); +} diff --git a/gitnexus-web/e2e/ops-dashboard.spec.ts b/gitnexus-web/e2e/ops-dashboard.spec.ts new file mode 100644 index 000000000..e5add562a --- /dev/null +++ b/gitnexus-web/e2e/ops-dashboard.spec.ts @@ -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]); + }); +}); diff --git a/gitnexus-web/e2e/repo-path-identity.spec.ts b/gitnexus-web/e2e/repo-path-identity.spec.ts index a27928cb2..d093a581f 100644 --- a/gitnexus-web/e2e/repo-path-identity.spec.ts +++ b/gitnexus-web/e2e/repo-path-identity.spec.ts @@ -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 { - 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}`); diff --git a/gitnexus-web/playwright.config.ts b/gitnexus-web/playwright.config.ts index 291b1805f..560538b56 100644 --- a/gitnexus-web/playwright.config.ts +++ b/gitnexus-web/playwright.config.ts @@ -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', diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 6d847ff40..bfa35cb99 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -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 ; + } return ( diff --git a/gitnexus-web/src/components/AnalyzeProgress.tsx b/gitnexus-web/src/components/AnalyzeProgress.tsx index ded08d9dc..49529585d 100644 --- a/gitnexus-web/src/components/AnalyzeProgress.tsx +++ b/gitnexus-web/src/components/AnalyzeProgress.tsx @@ -29,7 +29,7 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) => const pct = Math.max(0, Math.min(100, progress.percent)); return ( -
+
{/* Phase label + elapsed */}
{label} diff --git a/gitnexus-web/src/components/CodeReferencesPanel.tsx b/gitnexus-web/src/components/CodeReferencesPanel.tsx index 3fb6e6461..533e66634 100644 --- a/gitnexus-web/src/components/CodeReferencesPanel.tsx +++ b/gitnexus-web/src/components/CodeReferencesPanel.tsx @@ -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( + items: T[], + concurrency: number, + worker: (item: T) => Promise, +): Promise { + 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>( + () => new Map(), + ); + // Ids already requested (loaded or failed) — a failed read is not retried. + const requestedSnippetIds = useRef>(new Set()); + + const snippetRepoKey = currentRepo || projectName || undefined; + const snippetRepoKeyRef = useRef(undefined); + // Live citation ids at apply time — in-flight batches must not resurrect + // excerpts after clearAICodeReferences() mints a fresh list. + const liveCitationIdsRef = useRef>(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(); + 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', diff --git a/gitnexus-web/src/components/ExecutionDashboard.tsx b/gitnexus-web/src/components/ExecutionDashboard.tsx new file mode 100644 index 000000000..51ceaf73b --- /dev/null +++ b/gitnexus-web/src/components/ExecutionDashboard.tsx @@ -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 = { + 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 ( +
+
{label}
+
{value}
+ {hint ?
{hint}
: null} +
+ ); +}; + +const JobRow = ({ job }: { job: OpsJobView }) => { + const pct = Math.max(0, Math.min(100, job.progress.percent)); + return ( +
+
+
+
+ {job.repoName || job.id.slice(0, 8)} +
+
+ {job.lane} · {job.id.slice(0, 8)} + {job.branch ? ` · ${job.branch}` : ''} + {job.retryCount > 0 ? ` · retry ${job.retryCount}` : ''} +
+
+
+ + {job.status} + + + {formatDuration(job.durationMs)} + +
+
+
+
+
+
+ {job.progress.message || job.progress.phase} + {pct}% +
+ {job.error ?
{job.error}
: null} +
+ ); +}; + +const LanePanel = ({ + title, + jobs, + metrics, +}: { + title: string; + jobs: OpsJobView[]; + metrics: OpsSnapshot['analyze']['metrics']; +}) => ( +
+
+
+

{title}

+

+ {metrics.active} active · {metrics.queued} queued · {metrics.complete} done ·{' '} + {metrics.failed} failed +

+
+
+
avg {metrics.avgDurationMs != null ? formatDuration(metrics.avgDurationMs) : '—'}
+
max {metrics.maxDurationMs != null ? formatDuration(metrics.maxDurationMs) : '—'}
+
+
+
+ {jobs.length === 0 ? ( +
+ No jobs in this lane yet +
+ ) : ( + jobs.map((job) => ) + )} +
+
+); + +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(null); + const [connectNonce, setConnectNonce] = useState(0); + const [snapshot, setSnapshot] = useState(null); + const [live, setLive] = useState(false); + const [streamMode, setStreamMode] = useState<'sse' | 'poll' | 'offline'>('offline'); + const [error, setError] = useState(null); + const [lastTick, setLastTick] = useState(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 | 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 ( +
+
+
+
+
GitNexus
+

Execution Ops

+
+
+ + + {live ? 'live' : 'offline'} · {streamMode} + + {snapshot ? ( + + up {formatDuration(snapshot.uptimeMs)} · tick{' '} + {lastTick ? formatClock(lastTick) : '—'} + + ) : null} +
+
+
{ + e.preventDefault(); + applyBackend(backendInput); + }} + > + 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} + /> + +
+ {error ?

{error}

: null} +
+ +
+
+ 0 ? 'warn' : 'default'} + /> + + 0 ? 'bad' : 'default'} + /> + +
+ +
+ + +
+ +
+
+

Recent activity

+

Both lanes, newest first

+
+
+ + + + + + + + + + + + + + {allJobs.length === 0 ? ( + + + + ) : ( + allJobs.map((job) => ( + + + + + + + + + + )) + )} + +
LaneRepoStatusPhase%DurationStarted
+ Waiting for analyze / embed jobs on the connected server… +
+ {job.lane} + {job.repoName || '—'} + {job.status} + + {job.progress.phase} + {job.progress.percent}% + {formatDuration(job.durationMs)} + + {formatClock(job.startedAt)} +
+
+
+
+
+ ); +}; diff --git a/gitnexus-web/src/components/RepoAnalyzer.tsx b/gitnexus-web/src/components/RepoAnalyzer.tsx index 560dbcf99..f4bf1d609 100644 --- a/gitnexus-web/src/components/RepoAnalyzer.tsx +++ b/gitnexus-web/src/components/RepoAnalyzer.tsx @@ -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" >
@@ -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(null); const completeTimerRef = useRef | 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(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 = 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" diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index 7038895d8..3b3879fd2 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -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( diff --git a/gitnexus-web/src/components/code-panel-lines.ts b/gitnexus-web/src/components/code-panel-lines.ts new file mode 100644 index 000000000..00956375d --- /dev/null +++ b/gitnexus-web/src/components/code-panel-lines.ts @@ -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; +} diff --git a/gitnexus-web/src/core/llm/tools.ts b/gitnexus-web/src/core/llm/tools.ts index 421725be3..77fd4a626 100644 --- a/gitnexus-web/src/core/llm/tools.ts +++ b/gitnexus-web/src/core/llm/tools.ts @@ -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(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(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 }; } } diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index deeda86bf..742ffb905 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -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[]; nodeIds: string[]; @@ -231,6 +237,8 @@ interface AppState { isCodePanelOpen: boolean; setCodePanelOpen: (open: boolean) => void; addCodeReference: (ref: Omit) => 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, diff --git a/gitnexus-web/src/hooks/useSigma.ts b/gitnexus-web/src/hooks/useSigma.ts index a2121dad4..9853662a3 100644 --- a/gitnexus-web/src/hooks/useSigma.ts +++ b/gitnexus-web/src/hooks/useSigma.ts @@ -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 diff --git a/gitnexus-web/src/lib/path-resolution.ts b/gitnexus-web/src/lib/path-resolution.ts index ca4cb6ebd..48489ba17 100644 --- a/gitnexus-web/src/lib/path-resolution.ts +++ b/gitnexus-web/src/lib/path-resolution.ts @@ -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, + 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: diff --git a/gitnexus-web/src/services/backend-client.ts b/gitnexus-web/src/services/backend-client.ts index c96a0f596..3bddba929 100644 --- a/gitnexus-web/src/services/backend-client.ts +++ b/gitnexus-web/src/services/backend-client.ts @@ -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; + 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( 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( if (controller.signal.aborted) return; (async () => { + let handshakeTimer: ReturnType | 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( 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( } } - // 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 => { return response.json() as Promise; }; +/** Fetch the ops / execution metrics snapshot. */ +export const fetchOpsSnapshot = async (): Promise => { + const response = await fetchWithTimeout(`${_backendUrl}/api/ops`, {}, 5_000); + await assertOk(response); + return response.json() as Promise; +}; + +/** + * 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( + `${_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 => { const response = await fetchWithTimeout( @@ -1083,7 +1218,7 @@ export const cancelAnalyze = async (jobId: string): Promise => { 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( diff --git a/gitnexus-web/test/unit/backend-client-auth.test.ts b/gitnexus-web/test/unit/backend-client-auth.test.ts index 596ab89d1..87379d813 100644 --- a/gitnexus-web/test/unit/backend-client-auth.test.ts +++ b/gitnexus-web/test/unit/backend-client-auth.test.ts @@ -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(() => {})); + 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); + }); }); }); diff --git a/gitnexus-web/test/unit/code-panel-lines.test.ts b/gitnexus-web/test/unit/code-panel-lines.test.ts new file mode 100644 index 000000000..3dd34647f --- /dev/null +++ b/gitnexus-web/test/unit/code-panel-lines.test.ts @@ -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 }); + }); +}); diff --git a/gitnexus-web/test/unit/code-references-panel.test.tsx b/gitnexus-web/test/unit/code-references-panel.test.tsx index ada2a8e9d..0825e6323 100644 --- a/gitnexus-web/test/unit/code-references-panel.test.tsx +++ b/gitnexus-web/test/unit/code-references-panel.test.tsx @@ -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(); + 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(); + + 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(); + await waitFor(() => expect(citationReads()).toHaveLength(1)); + + appState.codeReferences = [ + ...appState.codeReferences, + { + id: 'cite-2', + filePath: 'src/bar.ts', + startLine: 0, + endLine: 0, + source: 'ai', + }, + ]; + rerender(); + + 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(); + await waitFor(() => expect(screen.getByText('FIRST_SNIPPET')).toBeInTheDocument()); + + appState.codeReferences = []; + rerender(); + + 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(); + + await waitFor(() => expect(screen.getByText('SECOND_SNIPPET')).toBeInTheDocument()); + expect(screen.queryByText('FIRST_SNIPPET')).not.toBeInTheDocument(); + expect(citationReads().length).toBeGreaterThanOrEqual(2); + }); }); diff --git a/gitnexus-web/test/unit/embedding-start-failure.test.ts b/gitnexus-web/test/unit/embedding-start-failure.test.ts new file mode 100644 index 000000000..bd7a13c8b --- /dev/null +++ b/gitnexus-web/test/unit/embedding-start-failure.test.ts @@ -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'); + }); +}); diff --git a/gitnexus-web/test/unit/execution-dashboard.test.tsx b/gitnexus-web/test/unit/execution-dashboard.test.tsx new file mode 100644 index 000000000..5f2292a03 --- /dev/null +++ b/gitnexus-web/test/unit/execution-dashboard.test.tsx @@ -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(); + + 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(); + + 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(); + 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(); + 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(); + + 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(); + + await waitFor(() => expect(setBackendUrl).toHaveBeenCalledWith('https://other.example')); + }); +}); diff --git a/gitnexus-web/test/unit/impact-tool.test.ts b/gitnexus-web/test/unit/impact-tool.test.ts index 704240e6e..0892998f0 100644 --- a/gitnexus-web/test/unit/impact-tool.test.ts +++ b/gitnexus-web/test/unit/impact-tool.test.ts @@ -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'); + }); }); diff --git a/gitnexus-web/test/unit/path-resolution.test.ts b/gitnexus-web/test/unit/path-resolution.test.ts index 9249cf3b2..f0f5c32a1 100644 --- a/gitnexus-web/test/unit/path-resolution.test.ts +++ b/gitnexus-web/test/unit/path-resolution.test.ts @@ -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([ @@ -31,3 +35,42 @@ describe('path-resolution utilities', () => { expect(resolveFilePath(contents, '')).toBeNull(); }); }); + +describe('resolveUniqueIndexedPath', () => { + const index = new Map([ + ['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([['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(); + }); +}); diff --git a/gitnexus-web/test/unit/repo-analyzer-complete-identity.test.tsx b/gitnexus-web/test/unit/repo-analyzer-complete-identity.test.tsx index 24e486b70..3e266089c 100644 --- a/gitnexus-web/test/unit/repo-analyzer-complete-identity.test.tsx +++ b/gitnexus-web/test/unit/repo-analyzer-complete-identity.test.tsx @@ -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(); + const { unmount } = render(); 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(); + }); }); diff --git a/gitnexus-web/test/unit/server-connection.test.ts b/gitnexus-web/test/unit/server-connection.test.ts index b5767ec33..fd76567eb 100644 --- a/gitnexus-web/test/unit/server-connection.test.ts +++ b/gitnexus-web/test/unit/server-connection.test.ts @@ -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(() => { diff --git a/gitnexus-web/vercel.json b/gitnexus-web/vercel.json index 3cd408211..dc5e0ad43 100644 --- a/gitnexus-web/vercel.json +++ b/gitnexus-web/vercel.json @@ -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" + } + ] + } + ] } diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index cac9f69d4..b7c810a7c 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -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>>; + entry: RegistryEntry | undefined; + storagePath: string; +}; + +const resolveOwnedCwdStorage = async (refusePrefix: string): Promise => { + 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 => { + 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)) { diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index 2f1d909c2..cad7a9554 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -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); } }; diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index aeee8fbd2..932d36b25 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -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': diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 6d342abef..5fde76516 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -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': '无法检查残留分支索引路径,因此未删除这些槽位。', diff --git a/gitnexus/src/server/analyze-job.ts b/gitnexus/src/server/analyze-job.ts index fcd6af615..2ab767e7d 100644 --- a/gitnexus/src/server/analyze-job.ts +++ b/gitnexus/src/server/analyze-job.ts @@ -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(); private children = new Map(); private abortControllers = new Map(); private timeouts = new Map>(); + private cancelGraceTimers = new Map>(); + /** Cancel reason to apply when a still-running worker exits. */ + private pendingCancelReasons = new Map(); private emitter = new EventEmitter(); private cleanupTimer: ReturnType; @@ -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) { diff --git a/gitnexus/src/server/analyze-launch.ts b/gitnexus/src/server/analyze-launch.ts index 4f576ec14..90761baaa 100644 --- a/gitnexus/src/server/analyze-launch.ts +++ b/gitnexus/src/server/analyze-launch.ts @@ -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 => { 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 diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 1e3bd4762..d10a4fd47 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -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.` 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 => { 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 => { + // 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' }); }); diff --git a/gitnexus/src/server/ops-snapshot.ts b/gitnexus/src/server/ops-snapshot.ts new file mode 100644 index 000000000..18ed36f2d --- /dev/null +++ b/gitnexus/src/server/ops-snapshot.ts @@ -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; + /** 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 => ({ + 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 (`__`), 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 | null, +): Array => [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 => { + 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(); + let out = text; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + // Consume a descendant tail too (`/src/secret.ts`): once the + // prefix became `[path]`, the absolute-path scrub below could no longer see + // the rest. The lookahead keeps `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 => + 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, +): 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'; +}; diff --git a/gitnexus/src/server/public-repo-id.ts b/gitnexus/src/server/public-repo-id.ts new file mode 100644 index 000000000..5aa8af3c4 --- /dev/null +++ b/gitnexus/src/server/public-repo-id.ts @@ -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); +}; diff --git a/gitnexus/src/server/repo-projection.ts b/gitnexus/src/server/repo-projection.ts index b73954482..bfb0171f4 100644 --- a/gitnexus/src/server/repo-projection.ts +++ b/gitnexus/src/server/repo-projection.ts @@ -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, diff --git a/gitnexus/src/server/sse-progress.ts b/gitnexus/src/server/sse-progress.ts index 13b0e09b8..a04170c54 100644 --- a/gitnexus/src/server/sse-progress.ts +++ b/gitnexus/src/server/sse-progress.ts @@ -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); diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 919f8aef8..80c3fc883 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -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(); diff --git a/gitnexus/src/storage/map-pool.ts b/gitnexus/src/storage/map-pool.ts new file mode 100644 index 000000000..afb8effc8 --- /dev/null +++ b/gitnexus/src/storage/map-pool.ts @@ -0,0 +1,22 @@ +/** Rolling worker pool: in-order results, fail-fast mapper errors. */ +export const mapPool = async ( + items: readonly T[], + mapper: (item: T) => Promise, + concurrency: number, +): Promise => { + if (items.length === 0) return []; + const results = new Array(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; +}; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 5bc4ac1c9..70508a1c5 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -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 => { // 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 ( - items: readonly T[], - mapper: (item: T) => Promise, - concurrency: number, -): Promise => { - if (items.length === 0) return []; - const results = new Array(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 => { diff --git a/gitnexus/src/storage/stale-branch-slots.ts b/gitnexus/src/storage/stale-branch-slots.ts index 8a6f69981..aee92c0ed 100644 --- a/gitnexus/src/storage/stale-branch-slots.ts +++ b/gitnexus/src/storage/stale-branch-slots.ts @@ -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 ( - items: readonly T[], - mapper: (item: T) => Promise, -): Promise => { - if (items.length === 0) return []; - const results = new Array(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 => { } }; -const directorySizeBytes = async (root: string): Promise => { - 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 => { - const meta = await loadMeta(dir); - return typeof meta?.branch === 'string' && meta.branch.length > 0 ? meta.branch : null; -}; - -export const listStaleBranchSlots = async ( - input: ListStaleBranchSlotsInput, -): Promise => { - const recorded = input.branches ?? []; - const branchesRoot = path.join(input.storagePath, BRANCHES_DIR); - - const registryByDir = new Map(); - 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> = []; - - 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 => { + 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([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>; + 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 => { + 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 => { + const branchesRoot = path.resolve(storagePath, BRANCHES_DIR); + let branchesStat: Awaited>; + 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 => { + const recorded = input.branches ?? []; + const branchesRoot = path.join(input.storagePath, BRANCHES_DIR); + + const registryByDir = new Map(); + 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> = []; + + 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 => { } }; +type ContainedPathDecision = { kind: 'unlink' } | { kind: 'keep'; real: string }; + +/** Symlink, Windows junction, or any realpath that leaves `containRoot`. */ +const inspectContainedPath = async ( + lexicalPath: string, + stat: Awaited>, + containRoot: string, +): Promise => { + 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 => { + 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 => { + let stat: Awaited>; + 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 = new Set([realSlot]), +): Promise => { + 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>; + 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>; 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>; - 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); diff --git a/gitnexus/test/integration/clean-stale-branch-slots.test.ts b/gitnexus/test/integration/clean-stale-branch-slots.test.ts index 9f594ce50..b287da7e6 100644 --- a/gitnexus/test/integration/clean-stale-branch-slots.test.ts +++ b/gitnexus/test/integration/clean-stale-branch-slots.test.ts @@ -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 }); diff --git a/gitnexus/test/unit/analyze-api.test.ts b/gitnexus/test/unit/analyze-api.test.ts index 302d62674..076e86b70 100644 --- a/gitnexus/test/unit/analyze-api.test.ts +++ b/gitnexus/test/unit/analyze-api.test.ts @@ -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 () => { diff --git a/gitnexus/test/unit/analyze-delete-api.test.ts b/gitnexus/test/unit/analyze-delete-api.test.ts new file mode 100644 index 000000000..e40ff53cc --- /dev/null +++ b/gitnexus/test/unit/analyze-delete-api.test.ts @@ -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()), + loadMeta: vi.fn(async () => ({})), + listRegisteredRepos: vi.fn(async () => []), +})); +vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({ + ...(await importOriginal()), + 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(); + 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); + }); +}); diff --git a/gitnexus/test/unit/analyze-job.test.ts b/gitnexus/test/unit/analyze-job.test.ts index aadb9b09d..382a2e078 100644 --- a/gitnexus/test/unit/analyze-job.test.ts +++ b/gitnexus/test/unit/analyze-job.test.ts @@ -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' }); diff --git a/gitnexus/test/unit/analyze-launch-branch-settle.test.ts b/gitnexus/test/unit/analyze-launch-branch-settle.test.ts index b1a5a064a..02978ff4f 100644 --- a/gitnexus/test/unit/analyze-launch-branch-settle.test.ts +++ b/gitnexus/test/unit/analyze-launch-branch-settle.test.ts @@ -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 = ''; diff --git a/gitnexus/test/unit/analyze-launch-collapse.test.ts b/gitnexus/test/unit/analyze-launch-collapse.test.ts index 233a2807a..258ebe525 100644 --- a/gitnexus/test/unit/analyze-launch-collapse.test.ts +++ b/gitnexus/test/unit/analyze-launch-collapse.test.ts @@ -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>; + let closeDbHandle: Mock<() => Promise>; + + 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'); + }); +}); diff --git a/gitnexus/test/unit/api-file-route.test.ts b/gitnexus/test/unit/api-file-route.test.ts index 904bc0c4a..b2b2a652d 100644 --- a/gitnexus/test/unit/api-file-route.test.ts +++ b/gitnexus/test/unit/api-file-route.test.ts @@ -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. diff --git a/gitnexus/test/unit/clean-stale.test.ts b/gitnexus/test/unit/clean-stale.test.ts index c06194ccc..a402e256b 100644 --- a/gitnexus/test/unit/clean-stale.test.ts +++ b/gitnexus/test/unit/clean-stale.test.ts @@ -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'); diff --git a/gitnexus/test/unit/cors.test.ts b/gitnexus/test/unit/cors.test.ts index d1bcfe792..ce551fdb3 100644 --- a/gitnexus/test/unit/cors.test.ts +++ b/gitnexus/test/unit/cors.test.ts @@ -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); }); diff --git a/gitnexus/test/unit/doctor-format.test.ts b/gitnexus/test/unit/doctor-format.test.ts index 04bcab0ab..14952da77 100644 --- a/gitnexus/test/unit/doctor-format.test.ts +++ b/gitnexus/test/unit/doctor-format.test.ts @@ -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 => ({ 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' }), ]); diff --git a/gitnexus/test/unit/ops-snapshot.test.ts b/gitnexus/test/unit/ops-snapshot.test.ts new file mode 100644 index 000000000..29fa32a9f --- /dev/null +++ b/gitnexus/test/unit/ops-snapshot.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/repo-projection.test.ts b/gitnexus/test/unit/repo-projection.test.ts index edc08b454..86eda3e00 100644 --- a/gitnexus/test/unit/repo-projection.test.ts +++ b/gitnexus/test/unit/repo-projection.test.ts @@ -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(); diff --git a/gitnexus/test/unit/server-api-repo-resolution.test.ts b/gitnexus/test/unit/server-api-repo-resolution.test.ts index 2de01c3e0..66d0c0b2c 100644 --- a/gitnexus/test/unit/server-api-repo-resolution.test.ts +++ b/gitnexus/test/unit/server-api-repo-resolution.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/server-sse-payload.test.ts b/gitnexus/test/unit/server-sse-payload.test.ts index 2d8d9aebe..0b11766c2 100644 --- a/gitnexus/test/unit/server-sse-payload.test.ts +++ b/gitnexus/test/unit/server-sse-payload.test.ts @@ -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]', }); }); }); diff --git a/gitnexus/test/unit/stale-branch-slots.test.ts b/gitnexus/test/unit/stale-branch-slots.test.ts index dde191070..1b32b2ae5 100644 --- a/gitnexus/test/unit/stale-branch-slots.test.ts +++ b/gitnexus/test/unit/stale-branch-slots.test.ts @@ -25,6 +25,9 @@ async function writeSlotMeta(dir: string, branch: string): Promise { ); } +const linkDir = (target: string, dest: string): Promise => + 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[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' });