mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-30 01:51:20 +00:00
Merge branch 'main' into codex/xaml-support-3202
This commit is contained in:
commit
5fb3e01b44
60 changed files with 5308 additions and 610 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -65,6 +65,7 @@ repomix-output*
|
|||
# Playwright artifacts
|
||||
gitnexus-web/playwright-report/
|
||||
gitnexus-web/test-results/
|
||||
gitnexus-web/e2e/screenshots/
|
||||
|
||||
# Python test artifacts
|
||||
eval/.coverage
|
||||
|
|
|
|||
28
.vercelignore
Normal file
28
.vercelignore
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Keep Vercel uploads under the 100 MB file limit — SPA only needs web + shared sources.
|
||||
.git
|
||||
.gitnexus
|
||||
.gitnexus/**
|
||||
node_modules
|
||||
**/node_modules
|
||||
gitnexus/**
|
||||
!gitnexus/package.json
|
||||
eval
|
||||
eval/**
|
||||
.claude
|
||||
.cursor
|
||||
.github
|
||||
docs
|
||||
Documentation
|
||||
.devcontainer
|
||||
gitnexus-claude-plugin
|
||||
gitnexus-cursor-integration
|
||||
pr-swarm-review
|
||||
ci-personas
|
||||
*.sqlite*
|
||||
*.db
|
||||
dist
|
||||
**/dist
|
||||
coverage
|
||||
**/coverage
|
||||
playwright-report
|
||||
test-results
|
||||
1
gitnexus-web/.gitignore
vendored
1
gitnexus-web/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
.vercel
|
||||
.env*.local
|
||||
.cursor/
|
||||
|
|
|
|||
301
gitnexus-web/e2e/analyze-public-sse.spec.ts
Normal file
301
gitnexus-web/e2e/analyze-public-sse.spec.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
MISSING_GITHUB,
|
||||
TOKEN_LEAK,
|
||||
assertNoLeaks,
|
||||
bindBackend,
|
||||
capture,
|
||||
fetchOps,
|
||||
livePrereqSkipReason,
|
||||
openAnalyzeForm,
|
||||
postAnalyze,
|
||||
startLiveBackend,
|
||||
stopLiveBackend,
|
||||
waitForAnalyzeSlotFree,
|
||||
waitForJob,
|
||||
writeTinyRepo,
|
||||
type LiveBackend,
|
||||
} from './helpers/public-contract';
|
||||
|
||||
/**
|
||||
* Live e2e for the public analyze flow. Spawns a real `gitnexus serve` and
|
||||
* drives the UI — no `page.route` mocks.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let backend: LiveBackend | undefined;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.setTimeout(300_000);
|
||||
const skip = await livePrereqSkipReason();
|
||||
if (skip) {
|
||||
test.skip(true, skip);
|
||||
return;
|
||||
}
|
||||
backend = await startLiveBackend();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
if (!backend) return;
|
||||
await bindBackend(page, backend.url);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopLiveBackend(backend);
|
||||
});
|
||||
|
||||
function requireBackend(): LiveBackend {
|
||||
if (!backend) throw new Error('live backend was not started');
|
||||
return backend;
|
||||
}
|
||||
|
||||
test.describe('Analyze — happy path', () => {
|
||||
test('local folder path → real analyze → done by basename, no path on screen', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { url, fixtures } = requireBackend();
|
||||
const repoDir = writeTinyRepo(fixtures, 'courses');
|
||||
const repoQueries: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
const u = new URL(req.url());
|
||||
if (u.pathname === '/api/repo' || u.pathname === '/api/graph') {
|
||||
const repo = u.searchParams.get('repo');
|
||||
if (repo) repoQueries.push(repo);
|
||||
}
|
||||
});
|
||||
|
||||
await openAnalyzeForm(page);
|
||||
await capture(page, testInfo, '01-empty-form');
|
||||
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
await page.getByTestId('local-path-input').fill(repoDir);
|
||||
await capture(page, testInfo, '02-filled-local-path');
|
||||
await page.getByRole('button', { name: /Analyze Repository/ }).click();
|
||||
|
||||
await expect(page.locator('[data-testid="analyze-progress"]')).toBeVisible({ timeout: 20_000 });
|
||||
await capture(page, testInfo, '03-progress');
|
||||
|
||||
const done = page.locator('[data-testid="analyze-done"]');
|
||||
const retry = page.getByRole('button', { name: /Try again/ });
|
||||
await expect(done.or(retry)).toBeVisible({ timeout: 120_000 });
|
||||
if (await retry.isVisible()) {
|
||||
throw new Error(`live analyze failed:\n${await page.locator('body').innerText()}`);
|
||||
}
|
||||
await expect(done).toBeVisible();
|
||||
await expect(done.getByText('Analysis complete')).toBeVisible();
|
||||
await expect(done.getByText('courses', { exact: true })).toBeVisible();
|
||||
await expect(done).not.toContainText(repoDir);
|
||||
await expect(done).not.toContainText(fixtures);
|
||||
await capture(page, testInfo, '04-done-basename');
|
||||
|
||||
const snap = JSON.stringify(await fetchOps(url));
|
||||
expect(snap).toContain('courses');
|
||||
expect(snap).not.toContain(repoDir);
|
||||
expect(snap).not.toContain('"repoPath"');
|
||||
|
||||
// Reconnect resolves the SSE repoId against /api/repos and loads the exact
|
||||
// registered path, never a same-named sibling. The path stays off screen.
|
||||
await expect
|
||||
.poll(() => repoQueries.length > 0 && repoQueries.every((q) => q === repoDir), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBe(true);
|
||||
await capture(page, testInfo, '05-after-complete');
|
||||
await assertNoLeaks(page, [fixtures]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Analyze — failure, retry, cancel', () => {
|
||||
test('missing local path fails and Try again restores the form', async ({ page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const { fixtures } = requireBackend();
|
||||
const notARepo = `${fixtures}/not-a-repo.txt`;
|
||||
fs.writeFileSync(notARepo, 'this is a file, not a repository\n');
|
||||
|
||||
await openAnalyzeForm(page);
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
await page.getByTestId('local-path-input').fill(notARepo);
|
||||
await page.getByRole('button', { name: /Analyze Repository/ }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /Try again/ })).toBeVisible({ timeout: 60_000 });
|
||||
await expect(page.locator('body')).not.toContainText(TOKEN_LEAK);
|
||||
await expect(page.locator('body')).not.toContainText(notARepo);
|
||||
await capture(page, testInfo, '07-failed');
|
||||
await assertNoLeaks(page, [fixtures, notARepo]);
|
||||
|
||||
await page.getByRole('button', { name: /Try again/ }).click();
|
||||
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /Analyze Repository/ })).toBeVisible();
|
||||
await capture(page, testInfo, '08-try-again-form');
|
||||
});
|
||||
|
||||
test('cancel during analyze DELETEs the live job and returns the form', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { url, fixtures } = requireBackend();
|
||||
// The previous test's failed local-path job keeps the slot until its worker exits.
|
||||
await waitForAnalyzeSlotFree(url);
|
||||
const repoDir = writeTinyRepo(fixtures, 'cancel-me');
|
||||
const deletes: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (req.method() === 'DELETE' && req.url().includes('/api/analyze/')) {
|
||||
deletes.push(req.url());
|
||||
}
|
||||
});
|
||||
|
||||
await openAnalyzeForm(page);
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
await page.getByTestId('local-path-input').fill(repoDir);
|
||||
await page.getByRole('button', { name: /Analyze Repository/ }).click();
|
||||
|
||||
const progress = page.locator('[data-testid="analyze-progress"]');
|
||||
await expect(progress).toBeVisible({ timeout: 20_000 });
|
||||
await capture(page, testInfo, '09-progress-before-cancel');
|
||||
|
||||
await page.getByRole('button', { name: /^Cancel$/ }).click();
|
||||
await expect.poll(() => deletes.length, { timeout: 15_000 }).toBeGreaterThan(0);
|
||||
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(progress).toBeHidden();
|
||||
await capture(page, testInfo, '10-form-after-cancel');
|
||||
});
|
||||
|
||||
test('second analyze while one is running surfaces the live 409', async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { url, fixtures } = requireBackend();
|
||||
const first = writeTinyRepo(fixtures, 'lock-a');
|
||||
const second = writeTinyRepo(fixtures, 'lock-b');
|
||||
await waitForAnalyzeSlotFree(url);
|
||||
// A real local analyze holds the slot for the whole UI round trip; a
|
||||
// missing-repo clone fails in under a second and would free it early.
|
||||
const held = await postAnalyze(url, { path: first });
|
||||
expect(held.http).toBe(202);
|
||||
expect(held.jobId).toBeTruthy();
|
||||
|
||||
await openAnalyzeForm(page);
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
await page.getByTestId('local-path-input').fill(second);
|
||||
await page.getByRole('button', { name: /Analyze Repository/ }).click();
|
||||
|
||||
await expect(page.getByText(/already (active|in progress)/i)).toBeVisible({ timeout: 20_000 });
|
||||
await capture(page, testInfo, '11-lock-409');
|
||||
if (held.jobId) await waitForJob(url, held.jobId);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Analyze — other sources and token', () => {
|
||||
test('optional GitHub token is posted but never painted after a failed clone', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await waitForAnalyzeSlotFree(requireBackend().url);
|
||||
let posted: Record<string, unknown> = {};
|
||||
page.on('request', (req) => {
|
||||
if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) {
|
||||
posted = (req.postDataJSON() as Record<string, unknown>) ?? {};
|
||||
}
|
||||
});
|
||||
|
||||
await openAnalyzeForm(page);
|
||||
await page.locator('input[type="url"]').fill(MISSING_GITHUB);
|
||||
await page.locator('input[type="password"]').fill(TOKEN_LEAK);
|
||||
await capture(page, testInfo, '13-token-filled');
|
||||
await page.getByRole('button', { name: /Analyze Repository/ }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /Try again/ })).toBeVisible({ timeout: 120_000 });
|
||||
expect(posted).toMatchObject({ url: MISSING_GITHUB, token: TOKEN_LEAK });
|
||||
await assertNoLeaks(page);
|
||||
await capture(page, testInfo, '14-failed-token-masked');
|
||||
});
|
||||
|
||||
test('GitLab URL is posted to the live server and fails closed without leaking the URL host path', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await waitForAnalyzeSlotFree(requireBackend().url);
|
||||
let posted: Record<string, unknown> = {};
|
||||
page.on('request', (req) => {
|
||||
if (req.method() === 'POST' && req.url().endsWith('/api/analyze')) {
|
||||
posted = (req.postDataJSON() as Record<string, unknown>) ?? {};
|
||||
}
|
||||
});
|
||||
|
||||
await openAnalyzeForm(page);
|
||||
await page.getByRole('tab', { name: 'GitLab URL' }).click();
|
||||
await page.locator('input[type="url"]').fill('https://gitlab.com/gitnexus-e2e-missing/project');
|
||||
await capture(page, testInfo, '15-gitlab-filled');
|
||||
await page.getByRole('button', { name: /Analyze Repository/ }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /Try again/ })).toBeVisible({ timeout: 120_000 });
|
||||
expect(posted).toMatchObject({ url: 'https://gitlab.com/gitnexus-e2e-missing/project' });
|
||||
const failureText = page.locator('p.text-red-400');
|
||||
await expect(failureText).toBeVisible();
|
||||
await expect(failureText).not.toContainText('gitlab.com');
|
||||
await expect(failureText).not.toContainText('gitnexus-e2e-missing');
|
||||
await capture(page, testInfo, '16-gitlab-failed');
|
||||
await assertNoLeaks(page);
|
||||
});
|
||||
|
||||
test('folder upload analyzes on the live server and shows the folder name', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { url, fixtures } = requireBackend();
|
||||
await waitForAnalyzeSlotFree(url);
|
||||
const fixtureDir = writeTinyRepo(fixtures, 'myrepo');
|
||||
|
||||
await openAnalyzeForm(page);
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
await capture(page, testInfo, '19-local-folder-tab');
|
||||
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
|
||||
|
||||
const done = page.locator('[data-testid="analyze-done"]');
|
||||
const retry = page.getByRole('button', { name: /Try again/ });
|
||||
await expect(done.or(retry)).toBeVisible({ timeout: 120_000 });
|
||||
if (await retry.isVisible()) {
|
||||
throw new Error(
|
||||
`live folder-upload analyze failed:\n${await page.locator('body').innerText()}`,
|
||||
);
|
||||
}
|
||||
await expect(done.getByText('myrepo', { exact: true })).toBeVisible();
|
||||
await expect(done).not.toContainText(fixtureDir);
|
||||
await capture(page, testInfo, '20-folder-upload-done');
|
||||
await assertNoLeaks(page, [fixtures]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Analyze — form validation and tabs', () => {
|
||||
test('invalid GitHub URL keeps Analyze disabled; tabs each have their own form', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
requireBackend();
|
||||
await openAnalyzeForm(page);
|
||||
const analyzeBtn = page.getByRole('button', { name: /Analyze Repository/ });
|
||||
await expect(analyzeBtn).toBeDisabled();
|
||||
|
||||
await page.locator('input[type="url"]').fill('not-a-url');
|
||||
await expect(analyzeBtn).toBeDisabled();
|
||||
await capture(page, testInfo, '21-invalid-github');
|
||||
|
||||
await page.getByRole('tab', { name: 'GitLab URL' }).click();
|
||||
await expect(page.getByPlaceholder('https://gitlab.com/owner/repo')).toBeVisible();
|
||||
await capture(page, testInfo, '22-gitlab-tab');
|
||||
|
||||
await page.getByRole('tab', { name: 'Azure DevOps' }).click();
|
||||
await expect(
|
||||
page.getByPlaceholder('http://azuredevops.example.com/Collection/Project/_git/Repo'),
|
||||
).toBeVisible();
|
||||
await capture(page, testInfo, '23-azure-tab');
|
||||
|
||||
await page.getByRole('tab', { name: 'Local Folder' }).click();
|
||||
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
|
||||
await capture(page, testInfo, '24-local-tab');
|
||||
|
||||
await page.getByRole('tab', { name: 'GitHub URL' }).click();
|
||||
await expect(page.locator('input[type="url"]')).toHaveValue('');
|
||||
await expect(analyzeBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
396
gitnexus-web/e2e/helpers/public-contract.ts
Normal file
396
gitnexus-web/e2e/helpers/public-contract.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { expect, type Page, type TestInfo } from '@playwright/test';
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||
export const TOKEN_LEAK = 'ghs_secret_e2e_token';
|
||||
export const MISSING_GITHUB = 'https://github.com/gitnexus-e2e-missing/no-such-repo';
|
||||
|
||||
const GALLERY_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'screenshots');
|
||||
const GITNEXUS_DIR = path.resolve(process.cwd(), '..', 'gitnexus');
|
||||
const TSX_BIN = path.join(GITNEXUS_DIR, 'node_modules', '.bin', 'tsx');
|
||||
const CLI_TS = path.join(GITNEXUS_DIR, 'src', 'cli', 'index.ts');
|
||||
const CLI_DIST = path.join(GITNEXUS_DIR, 'dist', 'cli', 'index.js');
|
||||
/** Per-request bound so a stalled connection cannot outlive a helper's deadline. */
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
/** POST /api/analyze allows 10/min per IP; 409 polling must not burn that budget. */
|
||||
const SLOT_POLL_MS = 2_000;
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export interface LiveBackend {
|
||||
url: string;
|
||||
port: number;
|
||||
home: string;
|
||||
fixtures: string;
|
||||
child: ChildProcess;
|
||||
log: string;
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
server.close();
|
||||
reject(new Error('could not bind an ephemeral port'));
|
||||
return;
|
||||
}
|
||||
const { port } = addr;
|
||||
server.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function serveArgs(port: number): { cmd: string; args: string[] } {
|
||||
if (fs.existsSync(TSX_BIN) && fs.existsSync(CLI_TS)) {
|
||||
return { cmd: TSX_BIN, args: [CLI_TS, 'serve', '--port', String(port), '--host', '127.0.0.1'] };
|
||||
}
|
||||
if (fs.existsSync(CLI_DIST)) {
|
||||
return {
|
||||
cmd: process.execPath,
|
||||
args: [CLI_DIST, 'serve', '--port', String(port), '--host', '127.0.0.1'],
|
||||
};
|
||||
}
|
||||
throw new Error(`neither ${TSX_BIN} nor ${CLI_DIST} is available`);
|
||||
}
|
||||
|
||||
/** Spawn an isolated `gitnexus serve` on 127.0.0.1. */
|
||||
export async function startLiveBackend(): Promise<LiveBackend> {
|
||||
const port = await freePort();
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-e2e-home-'));
|
||||
const fixtures = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gn-e2e-fx-')));
|
||||
const { cmd, args } = serveArgs(port);
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: GITNEXUS_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
GITNEXUS_HOME: home,
|
||||
// Real parse workers — same override the CLI integration suite uses on
|
||||
// a loaded host. A 5s default ready budget dies when two live specs
|
||||
// cold-start worker pools at once.
|
||||
GITNEXUS_WORKER_READY_TIMEOUT_MS: process.env.GITNEXUS_WORKER_READY_TIMEOUT_MS ?? '60000',
|
||||
// Still a real pool; size 1 matches a two-file fixture and keeps two
|
||||
// parallel live servers from spawning cores-1 workers each.
|
||||
GITNEXUS_WORKER_POOL_SIZE: process.env.GITNEXUS_WORKER_POOL_SIZE ?? '1',
|
||||
// Serve forks analyze with min(8192, 0.75×RAM) MB. Two parallel specs
|
||||
// both getting an 8GB child is what produced `Worker crashed (code null)`.
|
||||
GITNEXUS_SERVER_ANALYZE_HEAP_MB: process.env.GITNEXUS_SERVER_ANALYZE_HEAP_MB ?? '512',
|
||||
GITNEXUS_WORKER_HEAP_MB: process.env.GITNEXUS_WORKER_HEAP_MB ?? '256',
|
||||
// Ladybug FTS CREATE_FTS_INDEX SIGSEGVs on this host (CLI exit 139).
|
||||
// Graph analyze still runs for real; keyword indexes are the only skip.
|
||||
GITNEXUS_SKIP_FTS: process.env.GITNEXUS_SKIP_FTS ?? '1',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
// Own process group so teardown also reaches the forked analyze worker.
|
||||
detached: process.platform !== 'win32',
|
||||
});
|
||||
const backend: LiveBackend = {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
port,
|
||||
home,
|
||||
fixtures,
|
||||
child,
|
||||
log: '',
|
||||
};
|
||||
const captureLog = (chunk: Buffer) => {
|
||||
backend.log = (backend.log + chunk.toString()).slice(-8_192);
|
||||
};
|
||||
child.stdout?.on('data', captureLog);
|
||||
child.stderr?.on('data', captureLog);
|
||||
|
||||
let exited: number | null | undefined;
|
||||
child.on('exit', (code) => {
|
||||
exited = code;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 45_000;
|
||||
try {
|
||||
for (;;) {
|
||||
if (exited !== undefined) {
|
||||
throw new Error(`live backend exited early (code ${exited}):\n${backend.log}`);
|
||||
}
|
||||
const ok = await fetch(`${backend.url}/api/health`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
})
|
||||
.then((r) => r.ok)
|
||||
.catch(() => false);
|
||||
if (ok) return backend;
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`live backend did not become ready on ${backend.url}:\n${backend.log}`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
} catch (err) {
|
||||
await stopLiveBackend(backend);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopLiveBackend(backend: LiveBackend | undefined): Promise<void> {
|
||||
if (!backend) return;
|
||||
if (backend.child.exitCode === null && backend.child.signalCode === null) {
|
||||
const exited = new Promise<void>((resolve) => backend.child.once('exit', () => resolve()));
|
||||
const pid = backend.child.pid;
|
||||
const signal = (sig: NodeJS.Signals) => {
|
||||
if (pid !== undefined && process.platform !== 'win32') {
|
||||
try {
|
||||
process.kill(-pid, sig);
|
||||
return;
|
||||
} catch {
|
||||
/* group gone or not a leader: fall back to the child */
|
||||
}
|
||||
}
|
||||
backend.child.kill(sig);
|
||||
};
|
||||
signal('SIGTERM');
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const exitedInTime = await Promise.race([
|
||||
exited.then(() => true),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), 5_000);
|
||||
}),
|
||||
]);
|
||||
if (!exitedInTime) {
|
||||
signal('SIGKILL');
|
||||
await exited;
|
||||
}
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.rmSync(backend.home, { recursive: true, force: true });
|
||||
fs.rmSync(backend.fixtures, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
export function writeTinyRepo(parent: string, name: string): string {
|
||||
const dir = path.join(parent, name);
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'README.md'), `# ${name}\n`);
|
||||
fs.writeFileSync(path.join(dir, 'src', 'index.ts'), 'export const ready = true;\n');
|
||||
const git = spawnSync('git', ['-C', dir, 'init', '-q', '-b', 'main'], { stdio: 'ignore' });
|
||||
if (git.status === 0) {
|
||||
spawnSync('git', ['-C', dir, 'config', 'user.email', 'e2e@gitnexus.test'], { stdio: 'ignore' });
|
||||
spawnSync('git', ['-C', dir, 'config', 'user.name', 'e2e'], { stdio: 'ignore' });
|
||||
spawnSync('git', ['-C', dir, 'add', '-A'], { stdio: 'ignore' });
|
||||
spawnSync('git', ['-C', dir, 'commit', '-qm', 'init'], { stdio: 'ignore' });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export interface AnalyzePostResult {
|
||||
jobId: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
http: number;
|
||||
/** From the draft-7 `RateLimit` header; Infinity when absent. */
|
||||
remaining: number;
|
||||
resetMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/analyze; a 429 waits out the limiter window and retries, unless
|
||||
* that wait would pass the caller's `deadline`.
|
||||
*/
|
||||
export async function postAnalyze(
|
||||
backendUrl: string,
|
||||
body: Record<string, unknown>,
|
||||
deadline = Infinity,
|
||||
): Promise<AnalyzePostResult> {
|
||||
for (;;) {
|
||||
const res = await fetch(`${backendUrl}/api/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const rateLimit = res.headers.get('ratelimit') ?? '';
|
||||
const resetMs = Number(/reset=(\d+)/.exec(rateLimit)?.[1] ?? 60) * 1000;
|
||||
if (res.status === 429) {
|
||||
await res.body?.cancel();
|
||||
if (Date.now() + resetMs > deadline) {
|
||||
throw new Error('analyze rate limit outlasts the caller deadline (HTTP 429)');
|
||||
}
|
||||
await sleep(resetMs + 250);
|
||||
continue;
|
||||
}
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
jobId?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
};
|
||||
const remaining = /remaining=(\d+)/.exec(rateLimit)?.[1];
|
||||
return {
|
||||
jobId: json.jobId ?? '',
|
||||
status: json.status,
|
||||
error: json.error,
|
||||
http: res.status,
|
||||
remaining: remaining === undefined ? Infinity : Number(remaining),
|
||||
resetMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the server will accept a new analyze, with at least `budget`
|
||||
* POST /api/analyze calls left in the rate-limit window so the caller's own
|
||||
* posts are not answered with 429.
|
||||
*
|
||||
* Probes with a clone that fails in-server before any worker fork: a `failed`
|
||||
* probe leaves no child holding the slot. A local-path probe would fork a
|
||||
* worker that outlives its own `failed` status and re-occupy the slot.
|
||||
*/
|
||||
export async function waitForAnalyzeSlotFree(
|
||||
backendUrl: string,
|
||||
timeoutMs = 90_000,
|
||||
budget = 3,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const probe = await postAnalyze(backendUrl, { url: MISSING_GITHUB }, deadline);
|
||||
if (probe.http !== 409) {
|
||||
if (probe.jobId) {
|
||||
await waitForJob(backendUrl, probe.jobId, Math.max(0, deadline - Date.now()));
|
||||
}
|
||||
if (probe.remaining < budget) await sleep(probe.resetMs + 250);
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`analyze slot stayed busy: ${probe.error ?? 'HTTP 409'}`);
|
||||
}
|
||||
await sleep(SLOT_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-slot: a failed job still occupies the slot until its child exits. */
|
||||
export async function postAnalyzeWhenIdle(
|
||||
backendUrl: string,
|
||||
body: Record<string, unknown>,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<AnalyzePostResult> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const result = await postAnalyze(backendUrl, body, deadline);
|
||||
if (result.http !== 409) return result;
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`analyze slot stayed busy: ${result.error ?? 'HTTP 409'}`);
|
||||
}
|
||||
await sleep(SLOT_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForJob(
|
||||
backendUrl: string,
|
||||
jobId: string,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<{ status: string; error?: string; repoName?: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const poll = await fetch(`${backendUrl}/api/analyze/${jobId}`, {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
const job = (await poll.json()) as { status: string; error?: string; repoName?: string };
|
||||
if (job.status === 'complete' || job.status === 'failed') {
|
||||
return job;
|
||||
}
|
||||
if (Date.now() > deadline) throw new Error(`job ${jobId} timed out at ${job.status}`);
|
||||
await sleep(400);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOps(backendUrl: string): Promise<Record<string, unknown>> {
|
||||
const res = await fetch(`${backendUrl}/api/ops`, {
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET /api/ops → HTTP ${res.status}`);
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Point the app at this spec's live server before the first navigation. */
|
||||
export async function bindBackend(page: Page, backendUrl: string): Promise<void> {
|
||||
await page.addInitScript((url) => {
|
||||
window.localStorage.setItem('gitnexus-backend-url', url);
|
||||
}, backendUrl);
|
||||
}
|
||||
|
||||
function frontendHref(base: string, pathAndQuery: string): string {
|
||||
const normalized = base.endsWith('/') ? base : `${base}/`;
|
||||
return new URL(pathAndQuery.replace(/^\//, ''), normalized).href;
|
||||
}
|
||||
|
||||
const probeFrontend = (url: string) =>
|
||||
fetch(url, { signal: AbortSignal.timeout(2_000) })
|
||||
.then((r) => r.ok)
|
||||
.catch(() => false);
|
||||
|
||||
/** Prefer an explicit FRONTEND_URL; otherwise the first listener CI or local Vite bound. */
|
||||
async function resolveFrontendUrl(): Promise<string> {
|
||||
if (process.env.FRONTEND_URL) return process.env.FRONTEND_URL;
|
||||
for (const url of ['http://localhost:5173', 'http://127.0.0.1:5173']) {
|
||||
if (await probeFrontend(url)) return url;
|
||||
}
|
||||
return FRONTEND_URL;
|
||||
}
|
||||
|
||||
export async function openAnalyzeForm(page: Page): Promise<void> {
|
||||
// Stay on the reachable frontend (localStorage already has the backend).
|
||||
// `?server=` makes App auto-load the last graph and never show the form.
|
||||
// DropZone on `/` shows onboarding (0 repos) or landing + analyze (N repos).
|
||||
await page.goto(frontendHref(await resolveFrontendUrl(), '/'));
|
||||
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openOps(page: Page, backendUrl: string): Promise<void> {
|
||||
await page.goto(
|
||||
frontendHref(await resolveFrontendUrl(), `/?view=ops&server=${encodeURIComponent(backendUrl)}`),
|
||||
);
|
||||
await expect(page.locator('[data-testid="ops-dashboard"]')).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
/** Empty string means go; otherwise a skip reason (or throw under E2E=1). */
|
||||
export async function livePrereqSkipReason(): Promise<string> {
|
||||
const frontendUp =
|
||||
(await probeFrontend(FRONTEND_URL)) ||
|
||||
(await probeFrontend('http://localhost:5173')) ||
|
||||
(await probeFrontend('http://127.0.0.1:5173'));
|
||||
const cliReady = fs.existsSync(TSX_BIN) || fs.existsSync(CLI_DIST);
|
||||
if (process.env.E2E) {
|
||||
if (!cliReady) throw new Error(`backend CLI missing (${CLI_TS} / ${CLI_DIST})`);
|
||||
if (!frontendUp) throw new Error(`Vite dev server not available at ${FRONTEND_URL}`);
|
||||
return '';
|
||||
}
|
||||
if (!frontendUp) return 'Vite dev server not available';
|
||||
if (!cliReady) return 'backend CLI not available';
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const out = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: out, fullPage: true });
|
||||
fs.mkdirSync(GALLERY_DIR, { recursive: true });
|
||||
const slug = testInfo.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 72);
|
||||
fs.copyFileSync(out, path.join(GALLERY_DIR, `${slug}--${name}.png`));
|
||||
}
|
||||
|
||||
export async function assertNoLeaks(page: Page, extra: string[] = []): Promise<void> {
|
||||
const body = await page.locator('body').innerText();
|
||||
for (const leak of [TOKEN_LEAK, 'ghs_secret', 'x-access-token', ...extra]) {
|
||||
expect(body, `page must not show ${leak}`).not.toContain(leak);
|
||||
}
|
||||
expect(body).not.toMatch(/[A-Za-z]:\\Users\\/);
|
||||
}
|
||||
146
gitnexus-web/e2e/ops-dashboard.spec.ts
Normal file
146
gitnexus-web/e2e/ops-dashboard.spec.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
MISSING_GITHUB,
|
||||
assertNoLeaks,
|
||||
bindBackend,
|
||||
capture,
|
||||
fetchOps,
|
||||
livePrereqSkipReason,
|
||||
openOps,
|
||||
postAnalyze,
|
||||
postAnalyzeWhenIdle,
|
||||
startLiveBackend,
|
||||
stopLiveBackend,
|
||||
waitForJob,
|
||||
writeTinyRepo,
|
||||
type LiveBackend,
|
||||
} from './helpers/public-contract';
|
||||
|
||||
/**
|
||||
* Live e2e for `?view=ops`. Spawns a real `gitnexus serve` and reads the
|
||||
* unauthenticated ops feed the server actually emits — no `page.route` mocks.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let backend: LiveBackend | undefined;
|
||||
let completeName = '';
|
||||
let completePath = '';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.setTimeout(300_000);
|
||||
const skip = await livePrereqSkipReason();
|
||||
if (skip) {
|
||||
test.skip(true, skip);
|
||||
return;
|
||||
}
|
||||
backend = await startLiveBackend();
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
if (!backend) return;
|
||||
await bindBackend(page, backend.url);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopLiveBackend(backend);
|
||||
});
|
||||
|
||||
function requireBackend(): LiveBackend {
|
||||
if (!backend) throw new Error('live backend was not started');
|
||||
return backend;
|
||||
}
|
||||
|
||||
test.describe('Ops dashboard — empty and connection states', () => {
|
||||
test('empty server shows vacant lanes and waiting table', async ({ page }, testInfo) => {
|
||||
const { url } = requireBackend();
|
||||
await openOps(page, url);
|
||||
await expect(page.getByText('No jobs in this lane yet')).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByText('Waiting for analyze / embed jobs on the connected server…'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('0 active · 0 queued · 0 done · 0 failed')).toHaveCount(2);
|
||||
await capture(page, testInfo, '03-empty');
|
||||
await assertNoLeaks(page);
|
||||
});
|
||||
|
||||
test('unreachable backend shows offline and a connect error', async ({ page }, testInfo) => {
|
||||
await openOps(page, 'http://127.0.0.1:5999');
|
||||
await expect(page.getByText(/offline/)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Backend unreachable')).toBeVisible();
|
||||
await capture(page, testInfo, '04-unreachable');
|
||||
});
|
||||
|
||||
test('Connect to a dead server updates the URL and goes offline', async ({ page }, testInfo) => {
|
||||
const { url } = requireBackend();
|
||||
await openOps(page, url);
|
||||
await expect(page.getByText(/live · (sse|poll)/)).toBeVisible({ timeout: 15_000 });
|
||||
await capture(page, testInfo, '06-before-reconnect');
|
||||
|
||||
const serverInput = page.locator('input[placeholder="http://localhost:4747"]');
|
||||
await serverInput.fill('http://127.0.0.1:5999');
|
||||
await page.getByRole('button', { name: 'Connect' }).click();
|
||||
|
||||
await expect(page.getByText('Backend unreachable')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/offline/)).toBeVisible();
|
||||
expect(decodeURIComponent(page.url())).toContain('127.0.0.1:5999');
|
||||
expect(page.url()).toContain('view=ops');
|
||||
await capture(page, testInfo, '07-after-dead-connect');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Ops dashboard — live public jobs', () => {
|
||||
test('renders a real failed + complete analyze without leaking the repo path', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const { url, fixtures } = requireBackend();
|
||||
completeName = 'private-repo';
|
||||
completePath = writeTinyRepo(fixtures, completeName);
|
||||
|
||||
const fail = await postAnalyze(url, { url: MISSING_GITHUB });
|
||||
if (fail.jobId) await waitForJob(url, fail.jobId);
|
||||
const ok = await postAnalyzeWhenIdle(url, { path: completePath });
|
||||
expect(ok.http).toBeLessThan(400);
|
||||
const done = await waitForJob(url, ok.jobId);
|
||||
expect(done.status, done.error).toMatch(/complete/);
|
||||
|
||||
const snap = await fetchOps(url);
|
||||
const raw = JSON.stringify(snap);
|
||||
expect(raw).not.toContain(completePath);
|
||||
expect(raw).not.toContain(fixtures);
|
||||
expect(raw).not.toContain('"repoPath"');
|
||||
expect(raw).not.toContain('"repoUrl"');
|
||||
expect(raw).not.toContain('"branch"');
|
||||
|
||||
await openOps(page, url);
|
||||
await expect(page.getByRole('heading', { name: 'Execution Ops' })).toBeVisible();
|
||||
await expect(page.getByText(/live · (sse|poll)/)).toBeVisible();
|
||||
await capture(page, testInfo, '01-live-jobs');
|
||||
|
||||
await expect(page.getByText('Analyze lane')).toBeVisible();
|
||||
await expect(page.getByText(/1 failed/)).toBeVisible();
|
||||
await expect(page.getByText(/1 done/)).toBeVisible();
|
||||
|
||||
const jobs = page.locator('[data-testid="ops-job"]');
|
||||
await expect(jobs).toHaveCount(2);
|
||||
await expect(page.getByText(completeName).first()).toBeVisible();
|
||||
await expect(page.getByText('failed', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('complete', { exact: true }).first()).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Recent activity' })).toBeVisible();
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(2);
|
||||
|
||||
await capture(page, testInfo, '01b-live-jobs-detail');
|
||||
await assertNoLeaks(page, [fixtures, completePath]);
|
||||
});
|
||||
|
||||
test('mobile viewport still shows public rows only', async ({ page }, testInfo) => {
|
||||
const { url, fixtures } = requireBackend();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openOps(page, url);
|
||||
await expect(page.locator('[data-testid="ops-job"]').first()).toBeVisible();
|
||||
await capture(page, testInfo, '02-mobile');
|
||||
await assertNoLeaks(page, [fixtures]);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import { spawn, type ChildProcess } from 'node:child_process';
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { postAnalyzeWhenIdle } from './helpers/public-contract';
|
||||
|
||||
/**
|
||||
* E2E tests for repo *path* identity with duplicate display names (#2419).
|
||||
|
|
@ -50,9 +51,6 @@ const CLI_PATH = path.resolve(process.cwd(), '..', 'gitnexus', 'dist', 'cli', 'i
|
|||
const DUPE_NAME = 'pr2419-dupe';
|
||||
const READY_TIMEOUT_MS = 45_000;
|
||||
|
||||
interface AnalyzeJobResponse {
|
||||
jobId: string;
|
||||
}
|
||||
interface AnalyzeJobStatus {
|
||||
status: string;
|
||||
error?: string;
|
||||
|
|
@ -119,13 +117,13 @@ function markerFile(repoPath: string): string {
|
|||
}
|
||||
|
||||
async function analyzeAndWait(repoPath: string): Promise<void> {
|
||||
const res = await fetch(`${BACKEND_URL}/api/analyze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: repoPath, force: true }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST /api/analyze for ${repoPath} → HTTP ${res.status}`);
|
||||
const { jobId } = (await res.json()) as AnalyzeJobResponse;
|
||||
// The previous analyze's worker holds the single slot until it exits, even
|
||||
// after its job reports complete — wait out that 409 (and any 429).
|
||||
const res = await postAnalyzeWhenIdle(BACKEND_URL, { path: repoPath, force: true });
|
||||
if (res.http !== 202 || !res.jobId) {
|
||||
throw new Error(`POST /api/analyze for ${repoPath} → HTTP ${res.http}`);
|
||||
}
|
||||
const { jobId } = res;
|
||||
const deadline = Date.now() + 120_000;
|
||||
for (;;) {
|
||||
const poll = await fetch(`${BACKEND_URL}/api/analyze/${jobId}`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { SettingsPanel } from './components/SettingsPanel';
|
|||
import { StatusBar } from './components/StatusBar';
|
||||
import { FileTreePanel } from './components/FileTreePanel';
|
||||
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
|
||||
import { ExecutionDashboard } from './components/ExecutionDashboard';
|
||||
import { getActiveProviderConfig } from './core/llm/settings-service';
|
||||
import { buildGraphFromConnectResult } from './lib/apply-connect-result';
|
||||
import {
|
||||
|
|
@ -45,6 +46,11 @@ const BOTTOM_BANNER_CLASS =
|
|||
export const pickRestoreRepo = (params: URLSearchParams): string | undefined =>
|
||||
params.get('repo') ?? params.get('project') ?? undefined;
|
||||
|
||||
const isOpsView = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return new URLSearchParams(window.location.search).get('view') === 'ops';
|
||||
};
|
||||
|
||||
const AppContent = () => {
|
||||
const { t } = useTranslation(['common', 'errors']);
|
||||
const {
|
||||
|
|
@ -465,6 +471,9 @@ const AppContent = () => {
|
|||
};
|
||||
|
||||
function App() {
|
||||
if (isOpsView()) {
|
||||
return <ExecutionDashboard />;
|
||||
}
|
||||
return (
|
||||
<AppStateProvider>
|
||||
<AppContent />
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) =>
|
|||
const pct = Math.max(0, Math.min(100, progress.percent));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-testid="analyze-progress">
|
||||
{/* Phase label + elapsed */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-text-secondary">{label}</span>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ import { useAppState } from '../hooks/useAppState';
|
|||
import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { NODE_COLORS } from '../lib/constants';
|
||||
import { BackendError, readFile, type ReadFileResult } from '../services/backend-client';
|
||||
import {
|
||||
selectedNodeDisplayLine,
|
||||
selectedNodeFileRange,
|
||||
selectedNodeLineHighlighted,
|
||||
} from './code-panel-lines';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const getSyntaxLanguage = (filePath: string | undefined): string => {
|
||||
|
|
@ -46,6 +51,41 @@ export interface CodeReferencesPanelProps {
|
|||
onFocusNode: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
/** A fetched code excerpt for one AI citation card. Line numbers are 0-based file offsets. */
|
||||
interface CitationSnippet {
|
||||
content: string;
|
||||
start: number;
|
||||
end: number;
|
||||
/** Highlight range relative to `start`. */
|
||||
highlightStart: number;
|
||||
highlightEnd: number;
|
||||
totalLines: number;
|
||||
}
|
||||
const CITATION_CONTEXT_LINES = 5;
|
||||
/** Max lines to fetch when a citation has no start/end range. */
|
||||
const RANGELESS_CITATION_LINES = 80;
|
||||
/** Cap simultaneous `/api/file` reads when a reply cites many files. */
|
||||
const CITATION_SNIPPET_CONCURRENCY = 4;
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
if (items.length === 0) return [];
|
||||
const results: R[] = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
results[currentIndex] = await worker(items[currentIndex]);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
return results;
|
||||
}
|
||||
|
||||
export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) => {
|
||||
const { t } = useTranslation(['common', 'graph']);
|
||||
const {
|
||||
|
|
@ -180,19 +220,103 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
};
|
||||
}, [codeReferenceFocus, aiReferences]);
|
||||
|
||||
// Per-citation snippets, fetched from the server around each reference's
|
||||
// (0-based) line range. Keyed by reference id; a failed read stays absent so
|
||||
// the card falls back to the "code not available" notice.
|
||||
const [citationSnippets, setCitationSnippets] = useState<Map<string, CitationSnippet>>(
|
||||
() => new Map(),
|
||||
);
|
||||
// Ids already requested (loaded or failed) — a failed read is not retried.
|
||||
const requestedSnippetIds = useRef<Set<string>>(new Set());
|
||||
|
||||
const snippetRepoKey = currentRepo || projectName || undefined;
|
||||
const snippetRepoKeyRef = useRef<string | undefined>(undefined);
|
||||
// Live citation ids at apply time — in-flight batches must not resurrect
|
||||
// excerpts after clearAICodeReferences() mints a fresh list.
|
||||
const liveCitationIdsRef = useRef<Set<string>>(new Set());
|
||||
liveCitationIdsRef.current = new Set(aiReferences.map((ref) => ref.id));
|
||||
|
||||
useEffect(() => {
|
||||
if (snippetRepoKeyRef.current !== snippetRepoKey) {
|
||||
snippetRepoKeyRef.current = snippetRepoKey;
|
||||
requestedSnippetIds.current.clear();
|
||||
setCitationSnippets(new Map());
|
||||
}
|
||||
|
||||
const liveIds = liveCitationIdsRef.current;
|
||||
for (const id of [...requestedSnippetIds.current]) {
|
||||
if (!liveIds.has(id)) requestedSnippetIds.current.delete(id);
|
||||
}
|
||||
setCitationSnippets((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
let removed = false;
|
||||
const next = new Map<string, CitationSnippet>();
|
||||
for (const [id, snippet] of prev) {
|
||||
if (liveIds.has(id)) next.set(id, snippet);
|
||||
else removed = true;
|
||||
}
|
||||
return removed ? next : prev;
|
||||
});
|
||||
|
||||
const pending = aiReferences.filter((ref) => !requestedSnippetIds.current.has(ref.id));
|
||||
if (pending.length === 0) return;
|
||||
for (const ref of pending) requestedSnippetIds.current.add(ref.id);
|
||||
|
||||
mapWithConcurrency(pending, CITATION_SNIPPET_CONCURRENCY, async (ref) => {
|
||||
const hasRange = typeof ref.startLine === 'number';
|
||||
// Range-less citations must not download/highlight the entire file.
|
||||
const refStart = hasRange ? (ref.startLine as number) : 0;
|
||||
const refEnd = hasRange
|
||||
? (ref.endLine ?? refStart)
|
||||
: Math.max(0, RANGELESS_CITATION_LINES - 1);
|
||||
const options = hasRange
|
||||
? selectedNodeFileRange(refStart, refEnd, CITATION_CONTEXT_LINES)
|
||||
: { startLine: 0, endLine: refEnd };
|
||||
try {
|
||||
const result = await readFile(ref.filePath, { ...options, repo: snippetRepoKey });
|
||||
const start = result.startLine ?? 0;
|
||||
const lineCount = result.content.split('\n').length;
|
||||
const snippet: CitationSnippet = {
|
||||
content: result.content,
|
||||
start,
|
||||
end: result.endLine ?? start + lineCount - 1,
|
||||
highlightStart: hasRange ? refStart - start : 0,
|
||||
highlightEnd: hasRange ? refEnd - start : 0,
|
||||
totalLines: result.totalLines,
|
||||
};
|
||||
return [ref.id, snippet] as const;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).then((entries) => {
|
||||
// Repo switch already cleared the set and started a replacement batch.
|
||||
if (snippetRepoKeyRef.current !== snippetRepoKey) return;
|
||||
const loaded = entries.filter((e): e is readonly [string, CitationSnippet] => e !== null);
|
||||
if (loaded.length === 0) return;
|
||||
setCitationSnippets((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [id, snippet] of loaded) {
|
||||
if (liveCitationIdsRef.current.has(id)) next.set(id, snippet);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, [aiReferences, snippetRepoKey]);
|
||||
|
||||
const refsWithSnippets = useMemo(() => {
|
||||
return aiReferences.map((ref) => {
|
||||
const snippet = citationSnippets.get(ref.id);
|
||||
return {
|
||||
ref,
|
||||
content: null as string | null,
|
||||
start: 0,
|
||||
end: 0,
|
||||
highlightStart: 0,
|
||||
highlightEnd: 0,
|
||||
totalLines: 0,
|
||||
content: snippet?.content ?? null,
|
||||
start: snippet?.start ?? 0,
|
||||
end: snippet?.end ?? 0,
|
||||
highlightStart: snippet?.highlightStart ?? 0,
|
||||
highlightEnd: snippet?.highlightEnd ?? 0,
|
||||
totalLines: snippet?.totalLines ?? 0,
|
||||
};
|
||||
});
|
||||
}, [aiReferences]);
|
||||
}, [aiReferences, citationSnippets]);
|
||||
|
||||
const selectedFilePath = selectedNode?.properties?.filePath;
|
||||
const selectedIsFile = selectedNode?.label === 'File' && !!selectedFilePath;
|
||||
|
|
@ -224,17 +348,13 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
setFileResult(null);
|
||||
setSourceUnavailable(false);
|
||||
|
||||
// Determine read range: full file for File nodes, buffered for symbols
|
||||
// Determine read range: full file for File nodes, buffered for symbols.
|
||||
// Graph node lines are 0-based (#2377); /api/file ranges are 0-indexed.
|
||||
const startLine = selectedNode?.properties?.startLine as number | undefined;
|
||||
const endLine = selectedNode?.properties?.endLine as number | undefined;
|
||||
const isWholeFile = selectedIsFile || startLine === undefined;
|
||||
|
||||
const options = isWholeFile
|
||||
? {}
|
||||
: {
|
||||
startLine: Math.max(0, startLine - CONTEXT_LINES),
|
||||
endLine: (endLine ?? startLine) + CONTEXT_LINES,
|
||||
};
|
||||
const options = isWholeFile ? {} : selectedNodeFileRange(startLine, endLine, CONTEXT_LINES);
|
||||
|
||||
// Prefer the repo path identity over the display name — duplicate display
|
||||
// names would otherwise resolve to the wrong repository's file (#2420).
|
||||
|
|
@ -267,9 +387,10 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
currentRepo,
|
||||
]);
|
||||
|
||||
// Scroll to the selected node's startLine after content loads
|
||||
// Scroll to the selected node's startLine after content loads.
|
||||
// GraphNode startLine is 0-based; displayed gutters are 1-based.
|
||||
useEffect(() => {
|
||||
if (!selectedFileContent || !selectedNode?.properties?.startLine) return;
|
||||
if (!selectedFileContent || typeof selectedNode?.properties?.startLine !== 'number') return;
|
||||
const startLine = selectedNode.properties.startLine as number;
|
||||
|
||||
// Double rAF: wait for SyntaxHighlighter to fully render before scrolling
|
||||
|
|
@ -279,15 +400,23 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
if (cancelled) return;
|
||||
const container = selectedViewerRef.current;
|
||||
if (!container) return;
|
||||
// Index into the rendered lines: the viewer starts at `fileStartLine`
|
||||
// (0-based), so the symbol's 0-based file line minus that offset.
|
||||
const renderedIndex = Math.max(0, startLine - fileStartLine);
|
||||
const lineEl =
|
||||
(container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement) ??
|
||||
(container.querySelectorAll('.linenumber')[startLine] as HTMLElement);
|
||||
(container.querySelector(
|
||||
`[data-line-number="${selectedNodeDisplayLine(startLine)}"]`,
|
||||
) as HTMLElement) ??
|
||||
(container.querySelectorAll('.linenumber')[renderedIndex] as HTMLElement);
|
||||
if (lineEl) {
|
||||
lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
// Fallback: estimate scroll position based on line height
|
||||
const lineHeight = 20.8; // 13px font * 1.6 line-height
|
||||
container.scrollTop = Math.max(0, startLine * lineHeight - container.clientHeight / 3);
|
||||
container.scrollTop = Math.max(
|
||||
0,
|
||||
renderedIndex * lineHeight - container.clientHeight / 3,
|
||||
);
|
||||
}
|
||||
});
|
||||
rafIds.push(innerRaf);
|
||||
|
|
@ -297,7 +426,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
cancelled = true;
|
||||
rafIds.forEach((id) => cancelAnimationFrame(id));
|
||||
};
|
||||
}, [selectedFileContent, selectedNode?.properties?.startLine]);
|
||||
}, [selectedFileContent, selectedNode?.properties?.startLine, fileStartLine]);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
|
|
@ -410,12 +539,12 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
|||
userSelect: 'none',
|
||||
}}
|
||||
lineProps={(lineNumber) => {
|
||||
// `lineNumber` is 1-based (startingLineNumber); node lines are 0-based.
|
||||
const symStart = selectedNode?.properties?.startLine;
|
||||
const symEnd = selectedNode?.properties?.endLine ?? symStart;
|
||||
const isHighlighted =
|
||||
typeof symStart === 'number' &&
|
||||
lineNumber >= symStart + 1 &&
|
||||
lineNumber <= (symEnd ?? symStart) + 1;
|
||||
selectedNodeLineHighlighted(lineNumber, symStart, symEnd);
|
||||
return {
|
||||
style: {
|
||||
display: 'block',
|
||||
|
|
|
|||
493
gitnexus-web/src/components/ExecutionDashboard.tsx
Normal file
493
gitnexus-web/src/components/ExecutionDashboard.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
connectHeartbeat,
|
||||
fetchOpsSnapshot,
|
||||
getAuthToken,
|
||||
getBackendUrl,
|
||||
normalizeServerUrl,
|
||||
probeBackendStatus,
|
||||
setBackendUrl,
|
||||
streamOpsSnapshot,
|
||||
type OpsJobView,
|
||||
type OpsLaneMetrics,
|
||||
type OpsSnapshot,
|
||||
} from '../services/backend-client';
|
||||
import { DEFAULT_BACKEND_URL } from '../config/ui-constants';
|
||||
|
||||
/** GET /api/ops is 60/min; safety REST + immediate tick + 1s would 429. */
|
||||
const OPS_POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
const STATUS_COLORS: Record<OpsJobView['status'], string> = {
|
||||
queued: 'text-text-muted',
|
||||
cloning: 'text-sky-400',
|
||||
analyzing: 'text-amber-400',
|
||||
loading: 'text-violet-400',
|
||||
complete: 'text-emerald-400',
|
||||
failed: 'text-red-400',
|
||||
};
|
||||
|
||||
const TONE_CLASS: Record<'default' | 'ok' | 'warn' | 'bad', string> = {
|
||||
default: 'border-border-default text-text-primary',
|
||||
ok: 'border-emerald-500/30 text-emerald-300',
|
||||
warn: 'border-amber-500/30 text-amber-300',
|
||||
bad: 'border-red-500/30 text-red-300',
|
||||
};
|
||||
|
||||
const EMPTY_LANE_METRICS: OpsLaneMetrics = {
|
||||
total: 0,
|
||||
active: 0,
|
||||
queued: 0,
|
||||
complete: 0,
|
||||
failed: 0,
|
||||
byStatus: {
|
||||
queued: 0,
|
||||
cloning: 0,
|
||||
analyzing: 0,
|
||||
loading: 0,
|
||||
complete: 0,
|
||||
failed: 0,
|
||||
},
|
||||
avgDurationMs: null,
|
||||
maxDurationMs: null,
|
||||
activeProgressSum: 0,
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const rem = s % 60;
|
||||
if (m < 60) return `${m}m ${rem}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
};
|
||||
|
||||
const formatClock = (ts: number): string =>
|
||||
new Date(ts).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
const MetricCard = ({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone = 'default',
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
hint?: string;
|
||||
tone?: 'default' | 'ok' | 'warn' | 'bad';
|
||||
}) => {
|
||||
const toneClass = TONE_CLASS[tone];
|
||||
return (
|
||||
<div className={`rounded-xl border bg-surface/80 px-4 py-3 ${toneClass}`}>
|
||||
<div className="text-[11px] tracking-wide text-text-muted uppercase">{label}</div>
|
||||
<div className="mt-1 font-mono text-2xl font-semibold tabular-nums">{value}</div>
|
||||
{hint ? <div className="mt-1 text-xs text-text-secondary">{hint}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const JobRow = ({ job }: { job: OpsJobView }) => {
|
||||
const pct = Math.max(0, Math.min(100, job.progress.percent));
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-border-subtle bg-elevated/60 px-3 py-2.5"
|
||||
data-testid="ops-job"
|
||||
data-job-id={job.id}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-text-primary">
|
||||
{job.repoName || job.id.slice(0, 8)}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono text-[11px] text-text-muted">
|
||||
{job.lane} · {job.id.slice(0, 8)}
|
||||
{job.branch ? ` · ${job.branch}` : ''}
|
||||
{job.retryCount > 0 ? ` · retry ${job.retryCount}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-right">
|
||||
<span
|
||||
className={`font-mono text-xs font-semibold uppercase ${STATUS_COLORS[job.status]}`}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-text-muted">
|
||||
{formatDuration(job.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-void">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between gap-2 text-[11px] text-text-secondary">
|
||||
<span className="truncate">{job.progress.message || job.progress.phase}</span>
|
||||
<span className="shrink-0 font-mono">{pct}%</span>
|
||||
</div>
|
||||
{job.error ? <div className="mt-1 truncate text-[11px] text-red-400">{job.error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LanePanel = ({
|
||||
title,
|
||||
jobs,
|
||||
metrics,
|
||||
}: {
|
||||
title: string;
|
||||
jobs: OpsJobView[];
|
||||
metrics: OpsSnapshot['analyze']['metrics'];
|
||||
}) => (
|
||||
<section className="flex min-h-0 flex-1 flex-col rounded-2xl border border-border-default bg-deep/80">
|
||||
<header className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">{title}</h2>
|
||||
<p className="text-xs text-text-muted">
|
||||
{metrics.active} active · {metrics.queued} queued · {metrics.complete} done ·{' '}
|
||||
{metrics.failed} failed
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right font-mono text-[11px] text-text-muted">
|
||||
<div>avg {metrics.avgDurationMs != null ? formatDuration(metrics.avgDurationMs) : '—'}</div>
|
||||
<div>max {metrics.maxDurationMs != null ? formatDuration(metrics.maxDurationMs) : '—'}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border-subtle px-3 py-8 text-center text-sm text-text-muted">
|
||||
No jobs in this lane yet
|
||||
</div>
|
||||
) : (
|
||||
jobs.map((job) => <JobRow key={`${job.lane}-${job.id}`} job={job} />)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export const ExecutionDashboard = () => {
|
||||
const [backendInput, setBackendInput] = useState(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('server') || getBackendUrl() || DEFAULT_BACKEND_URL;
|
||||
});
|
||||
// Applied URL the connection effect binds to — distinct from the input so
|
||||
// keystrokes do not restart SSE/heartbeat, and Connect always reconnects.
|
||||
const [connectedServer, setConnectedServer] = useState<string | null>(null);
|
||||
const [connectNonce, setConnectNonce] = useState(0);
|
||||
const [snapshot, setSnapshot] = useState<OpsSnapshot | null>(null);
|
||||
const [live, setLive] = useState(false);
|
||||
const [streamMode, setStreamMode] = useState<'sse' | 'poll' | 'offline'>('offline');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastTick, setLastTick] = useState<number | null>(null);
|
||||
const validationErrorRef = useRef(false);
|
||||
|
||||
const applyBackend = useCallback((raw: string) => {
|
||||
try {
|
||||
const url = normalizeServerUrl(raw.trim() || DEFAULT_BACKEND_URL);
|
||||
setBackendUrl(url);
|
||||
setBackendInput(url);
|
||||
setConnectedServer(url);
|
||||
setSnapshot(null);
|
||||
setLastTick(null);
|
||||
setConnectNonce((n) => n + 1);
|
||||
const next = new URL(window.location.href);
|
||||
next.searchParams.set('view', 'ops');
|
||||
next.searchParams.set('server', url);
|
||||
window.history.replaceState({}, '', next.toString());
|
||||
validationErrorRef.current = false;
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
validationErrorRef.current = true;
|
||||
setLive(false);
|
||||
setStreamMode('offline');
|
||||
setError(err instanceof Error ? err.message : 'Invalid backend URL');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// A `?server=` link must not carry the session's deploy token to another
|
||||
// origin on its own: prefill it and wait for Connect when a token is held.
|
||||
const linked = new URLSearchParams(window.location.search).get('server');
|
||||
if (linked && getAuthToken()) {
|
||||
let foreign: boolean;
|
||||
try {
|
||||
foreign = normalizeServerUrl(linked) !== getBackendUrl();
|
||||
} catch {
|
||||
// Invalid input: applyBackend below reports it without connecting.
|
||||
foreign = false;
|
||||
}
|
||||
if (foreign) return;
|
||||
}
|
||||
applyBackend(backendInput);
|
||||
// Mount-only: wire ?server= into the client once.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectedServer) return;
|
||||
|
||||
let cancelled = false;
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let streamAbort: AbortController | undefined;
|
||||
let stopHeartbeat: (() => void) | undefined;
|
||||
|
||||
const ingest = (next: OpsSnapshot) => {
|
||||
if (cancelled) return;
|
||||
setSnapshot(next);
|
||||
setLastTick(Date.now());
|
||||
// A failed Connect leaves this stream running; do not wipe its error.
|
||||
if (!validationErrorRef.current) setError(null);
|
||||
setLive(true);
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (cancelled) return;
|
||||
stopPolling();
|
||||
setStreamMode('poll');
|
||||
let polling = false;
|
||||
const tick = async () => {
|
||||
if (polling || cancelled) return;
|
||||
polling = true;
|
||||
try {
|
||||
const status = await probeBackendStatus();
|
||||
if (cancelled) return;
|
||||
if (status !== 'ok') {
|
||||
setLive(false);
|
||||
setError(status === 'unauthorized' ? 'Backend requires auth' : 'Backend unreachable');
|
||||
return;
|
||||
}
|
||||
ingest(await fetchOpsSnapshot());
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setLive(false);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch ops snapshot');
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
pollTimer = setInterval(() => void tick(), OPS_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
const status = await probeBackendStatus();
|
||||
if (cancelled) return;
|
||||
if (status !== 'ok') {
|
||||
setLive(false);
|
||||
setError(status === 'unauthorized' ? 'Backend requires auth' : 'Backend unreachable');
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
stopHeartbeat = connectHeartbeat(
|
||||
() => setLive(true),
|
||||
() => setLive(false),
|
||||
);
|
||||
|
||||
streamAbort = streamOpsSnapshot(
|
||||
(next) => {
|
||||
// Safety poll may already be running after a transient REST miss.
|
||||
stopPolling();
|
||||
setStreamMode('sse');
|
||||
ingest(next);
|
||||
},
|
||||
() => {
|
||||
// Fall back to polling if SSE cannot stay up.
|
||||
streamAbort?.abort();
|
||||
streamAbort = undefined;
|
||||
startPolling();
|
||||
},
|
||||
);
|
||||
|
||||
// Safety poll in case the first SSE frame is delayed.
|
||||
try {
|
||||
ingest(await fetchOpsSnapshot());
|
||||
if (cancelled) return;
|
||||
setStreamMode((mode) => (mode === 'offline' ? 'poll' : mode));
|
||||
} catch {
|
||||
// REST snapshot failed — do not abort a live SSE handshake. Polling
|
||||
// covers the gap until the stream opens or its own onError fires.
|
||||
startPolling();
|
||||
}
|
||||
};
|
||||
|
||||
void start();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopPolling();
|
||||
streamAbort?.abort();
|
||||
stopHeartbeat?.();
|
||||
};
|
||||
}, [connectedServer, connectNonce]);
|
||||
|
||||
const allJobs = useMemo(() => {
|
||||
if (!snapshot) return [] as OpsJobView[];
|
||||
return [...snapshot.analyze.jobs, ...snapshot.embed.jobs].sort(
|
||||
(a, b) => b.startedAt - a.startedAt,
|
||||
);
|
||||
}, [snapshot]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen flex-col bg-void text-text-primary"
|
||||
data-testid="ops-dashboard"
|
||||
>
|
||||
<header className="border-b border-border-subtle bg-deep/90 px-4 py-3 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs tracking-[0.2em] text-accent uppercase">GitNexus</div>
|
||||
<h1 className="text-lg font-semibold">Execution Ops</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium ${
|
||||
live
|
||||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-300'
|
||||
: 'border-red-500/40 bg-red-500/10 text-red-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${live ? 'bg-emerald-400' : 'bg-red-400'}`}
|
||||
/>
|
||||
{live ? 'live' : 'offline'} · {streamMode}
|
||||
</span>
|
||||
{snapshot ? (
|
||||
<span className="font-mono text-[11px] text-text-muted">
|
||||
up {formatDuration(snapshot.uptimeMs)} · tick{' '}
|
||||
{lastTick ? formatClock(lastTick) : '—'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="mx-auto mt-3 flex max-w-7xl gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
applyBackend(backendInput);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={backendInput}
|
||||
onChange={(e) => setBackendInput(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border-default bg-surface px-3 py-2 font-mono text-sm outline-none focus:border-accent"
|
||||
placeholder="http://localhost:4747"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent-dim"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</form>
|
||||
{error ? <p className="mx-auto mt-2 max-w-7xl text-sm text-red-400">{error}</p> : null}
|
||||
</header>
|
||||
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-4 p-4">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard
|
||||
label="Active jobs"
|
||||
value={snapshot?.totals.active ?? 0}
|
||||
tone={snapshot && snapshot.totals.active > 0 ? 'warn' : 'default'}
|
||||
/>
|
||||
<MetricCard label="Completed" value={snapshot?.totals.complete ?? 0} tone="ok" />
|
||||
<MetricCard
|
||||
label="Failed"
|
||||
value={snapshot?.totals.failed ?? 0}
|
||||
tone={snapshot && snapshot.totals.failed > 0 ? 'bad' : 'default'}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Server"
|
||||
value={snapshot?.server.version ?? '—'}
|
||||
hint={
|
||||
snapshot
|
||||
? `${snapshot.server.launchContext} · ${snapshot.server.nodeVersion}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-[28rem] flex-1 gap-4 lg:grid-cols-2">
|
||||
<LanePanel
|
||||
title="Analyze lane"
|
||||
jobs={snapshot?.analyze.jobs ?? []}
|
||||
metrics={snapshot?.analyze.metrics ?? EMPTY_LANE_METRICS}
|
||||
/>
|
||||
<LanePanel
|
||||
title="Embed lane"
|
||||
jobs={snapshot?.embed.jobs ?? []}
|
||||
metrics={snapshot?.embed.metrics ?? EMPTY_LANE_METRICS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-2xl border border-border-default bg-deep/80">
|
||||
<header className="border-b border-border-subtle px-4 py-3">
|
||||
<h2 className="text-sm font-semibold">Recent activity</h2>
|
||||
<p className="text-xs text-text-muted">Both lanes, newest first</p>
|
||||
</header>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[40rem] text-left text-sm">
|
||||
<thead className="text-[11px] tracking-wide text-text-muted uppercase">
|
||||
<tr className="border-b border-border-subtle">
|
||||
<th className="px-4 py-2 font-medium">Lane</th>
|
||||
<th className="px-4 py-2 font-medium">Repo</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Phase</th>
|
||||
<th className="px-4 py-2 font-medium">%</th>
|
||||
<th className="px-4 py-2 font-medium">Duration</th>
|
||||
<th className="px-4 py-2 font-medium">Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allJobs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-text-muted">
|
||||
Waiting for analyze / embed jobs on the connected server…
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
allJobs.map((job) => (
|
||||
<tr key={`${job.lane}-${job.id}`} className="border-b border-border-subtle/60">
|
||||
<td className="px-4 py-2 font-mono text-xs text-text-secondary">
|
||||
{job.lane}
|
||||
</td>
|
||||
<td className="max-w-[14rem] truncate px-4 py-2">{job.repoName || '—'}</td>
|
||||
<td
|
||||
className={`px-4 py-2 font-mono text-xs uppercase ${STATUS_COLORS[job.status]}`}
|
||||
>
|
||||
{job.status}
|
||||
</td>
|
||||
<td className="max-w-[16rem] truncate px-4 py-2 text-text-secondary">
|
||||
{job.progress.phase}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">{job.progress.percent}%</td>
|
||||
<td className="px-4 py-2 font-mono text-xs">
|
||||
{formatDuration(job.durationMs)}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-text-muted">
|
||||
{formatClock(job.startedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import {
|
||||
startAnalyze,
|
||||
cancelAnalyze,
|
||||
fetchRepos,
|
||||
streamAnalyzeProgress,
|
||||
uploadFolder,
|
||||
type JobProgress,
|
||||
|
|
@ -190,6 +191,7 @@ function DoneState({ repoName }: { repoName: string }) {
|
|||
className="flex animate-fade-in flex-col items-center gap-3 py-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="analyze-done"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-emerald-500/30 bg-emerald-500/15 shadow-[0_0_20px_rgba(16,185,129,0.15)]">
|
||||
<Check className="h-6 w-6 text-emerald-400" />
|
||||
|
|
@ -210,9 +212,13 @@ type InternalPhase = 'input' | 'starting' | 'analyzing' | 'done' | 'error';
|
|||
export interface RepoAnalyzerProps {
|
||||
variant: 'onboarding' | 'sheet';
|
||||
/**
|
||||
* Receives the repo IDENTITY to reconnect with — the analyzed path when the
|
||||
* server provides one (`repoPath` on the SSE complete event), otherwise the
|
||||
* display name. Never rendered; the done screen shows the display name.
|
||||
* Receives the repo identity used to reconnect. Prefers `repoPath` when an
|
||||
* older server still sends it on the SSE complete event. Current servers omit
|
||||
* it (an unauthenticated ops-listed job id must not leak a filesystem path)
|
||||
* and send an opaque `repoId`, which is resolved to the matching
|
||||
* `GET /api/repos` entry's `path`. Falls back to the display name (`repoName`,
|
||||
* then the input basename, then the i18n default) when neither resolves.
|
||||
* Never rendered; the done screen shows the display name.
|
||||
*/
|
||||
onComplete: (repoIdentity: string) => void;
|
||||
onCancel?: () => void;
|
||||
|
|
@ -255,13 +261,19 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
// arriving after a mode switch / cancel / unmount can never drive state.
|
||||
const requestControllerRef = useRef<AbortController | null>(null);
|
||||
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// The completion identity may still be resolving (`/api/repos`) when the
|
||||
// dwell timer fires; clearing the timer cannot cancel that continuation.
|
||||
const unmountedRef = useRef(false);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
// dragenter/dragleave fire for every child boundary crossed; count them so
|
||||
// the highlight does not flicker while the cursor moves over the button.
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset on (re)mount: StrictMode runs cleanup then setup again.
|
||||
unmountedRef.current = false;
|
||||
return () => {
|
||||
unmountedRef.current = true;
|
||||
sseControllerRef.current?.abort();
|
||||
requestControllerRef.current?.abort();
|
||||
if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
|
||||
|
|
@ -409,24 +421,34 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
// Display vs identity split: the done screen renders the display name
|
||||
// (never an absolute path), while onComplete receives the identity —
|
||||
// the analyzed path when the server provides it, so the reconnect
|
||||
// targets the exact repo even when basenames collide. Old servers omit
|
||||
// repoPath and degrade to today's name behavior.
|
||||
// (never an absolute path). Current servers omit repoPath on the
|
||||
// unauthenticated SSE terminal frame and send an opaque repoId that
|
||||
// selects the exact /api/repos entry (names are not unique).
|
||||
// Older servers that still send repoPath keep collision-safe reconnect.
|
||||
const displayName =
|
||||
data.repoName ??
|
||||
(fallbackNameSource
|
||||
? fallbackNameSource.split(/[/\\]/).filter(Boolean).at(-1)
|
||||
: undefined) ??
|
||||
t('onboarding:repoAnalyzer.defaultRepoName');
|
||||
const identity = data.repoPath ?? displayName;
|
||||
const repoId = data.repoId;
|
||||
const identity: Promise<string> = data.repoPath
|
||||
? Promise.resolve(data.repoPath)
|
||||
: repoId
|
||||
? fetchRepos().then(
|
||||
(repos) => repos.find((r) => r.id === repoId)?.path ?? displayName,
|
||||
() => displayName,
|
||||
)
|
||||
: Promise.resolve(displayName);
|
||||
setCompletedRepoName(displayName);
|
||||
setGithubToken('');
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
completeTimerRef.current = setTimeout(() => {
|
||||
completeTimerRef.current = null;
|
||||
onComplete(identity);
|
||||
void identity.then((id) => {
|
||||
if (!unmountedRef.current) onComplete(id);
|
||||
});
|
||||
}, 1200);
|
||||
},
|
||||
(errMsg) => {
|
||||
|
|
@ -829,6 +851,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
}}
|
||||
disabled={isLoading}
|
||||
placeholder={isWindows ? 'C:\\Users\\you\\project' : '/home/you/project'}
|
||||
data-testid="local-path-input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
30
gitnexus-web/src/components/code-panel-lines.ts
Normal file
30
gitnexus-web/src/components/code-panel-lines.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* GraphNode startLine/endLine are 0-based (#2377 / line-base.ts).
|
||||
* `/api/file` ranges are also 0-indexed. Convert to 1-based only for display.
|
||||
*/
|
||||
|
||||
export function selectedNodeFileRange(
|
||||
startLine: number,
|
||||
endLine: number | undefined,
|
||||
contextLines: number,
|
||||
): { startLine: number; endLine: number } {
|
||||
return {
|
||||
startLine: Math.max(0, startLine - contextLines),
|
||||
endLine: (endLine ?? startLine) + contextLines,
|
||||
};
|
||||
}
|
||||
|
||||
/** 1-based gutter / `data-line-number` for a 0-based GraphNode line. */
|
||||
export function selectedNodeDisplayLine(storedStartLine: number): number {
|
||||
return storedStartLine + 1;
|
||||
}
|
||||
|
||||
export function selectedNodeLineHighlighted(
|
||||
displayedLineNumber: number,
|
||||
storedStart: number,
|
||||
storedEnd: number | undefined,
|
||||
): boolean {
|
||||
const displayStart = selectedNodeDisplayLine(storedStart);
|
||||
const displayEnd = selectedNodeDisplayLine(storedEnd ?? storedStart);
|
||||
return displayedLineNumber >= displayStart && displayedLineNumber <= displayEnd;
|
||||
}
|
||||
|
|
@ -932,18 +932,48 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
return `⚠️ AMBIGUOUS TARGET: Multiple files named "${target}" found:\n\n${allPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease specify which file you mean by using a more specific path, e.g.:\n- impact("${allPaths[0].split('/').slice(-3).join('/')}")\n- impact("${allPaths[1]?.split('/').slice(-3).join('/') || allPaths[0]}")`;
|
||||
}
|
||||
|
||||
// If target contains a path, try to find matching file
|
||||
// If target contains a path, pick the File node for that path. The
|
||||
// lookup above matched on `filePath CONTAINS target`, so every symbol
|
||||
// DEFINED in the file (functions, classes, ...) is also in the result
|
||||
// set and shares the same filePath — a plain "first row" pick could
|
||||
// silently analyze one arbitrary symbol while reporting a file impact.
|
||||
let targetNode = targetResults[0];
|
||||
if (target.includes('/') && targetResults.length > 1) {
|
||||
const exactMatch = targetResults.find((r: any) => {
|
||||
const path = Array.isArray(r) ? r[2] : r.filePath;
|
||||
return path && path.toLowerCase().includes(target.toLowerCase());
|
||||
});
|
||||
if (exactMatch) {
|
||||
targetNode = exactMatch;
|
||||
const rowType = (r: any) => (Array.isArray(r) ? r[1] : r.nodeType);
|
||||
const rowPath = (r: any): string | undefined => (Array.isArray(r) ? r[2] : r.filePath);
|
||||
const targetLower = target.toLowerCase();
|
||||
const fileRows = targetResults.filter((r: any) => rowType(r) === 'File');
|
||||
// Exact path first; suffix match only when unique among File rows.
|
||||
const exactFile = fileRows.find((r: any) => rowPath(r)?.toLowerCase() === targetLower);
|
||||
const suffixFiles = exactFile
|
||||
? []
|
||||
: fileRows.filter((r: any) => rowPath(r)?.toLowerCase()?.endsWith(`/${targetLower}`));
|
||||
const fileMatch = exactFile ?? (suffixFiles.length === 1 ? suffixFiles[0] : undefined);
|
||||
if (suffixFiles.length > 1) {
|
||||
const paths = suffixFiles.map((r: any) => rowPath(r)).filter(Boolean) as string[];
|
||||
return `⚠️ AMBIGUOUS TARGET: Multiple files match "${target}":\n\n${paths.map((p, i) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
|
||||
}
|
||||
// LIMIT 10 is a cap. A single suffix-matching File in that page can
|
||||
// still hide another File past the limit — only exact path is safe.
|
||||
if (!exactFile && fileMatch && targetResults.length >= 10) {
|
||||
const distinctPaths = [...new Set<string>(allPaths)];
|
||||
return `⚠️ AMBIGUOUS TARGET: Could not uniquely match "${target}". Found:\n\n${distinctPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
|
||||
}
|
||||
if (fileMatch) {
|
||||
targetNode = fileMatch;
|
||||
} else {
|
||||
// Still ambiguous even with path
|
||||
return `⚠️ AMBIGUOUS TARGET: Could not uniquely match "${target}". Found:\n\n${allPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
|
||||
const distinctPaths = [...new Set<string>(allPaths)];
|
||||
const uniquePath = distinctPaths.length === 1 ? distinctPaths[0] : undefined;
|
||||
const uniqueLower = uniquePath?.toLowerCase();
|
||||
const uniqueIsBounded =
|
||||
uniqueLower === targetLower || uniqueLower?.endsWith(`/${targetLower}`) === true;
|
||||
// LIMIT 10 is a cap, not a complete result set. One CONTAINS hit
|
||||
// that is only a filename substring (src/mylib/foo.ts vs lib/foo.ts)
|
||||
// must not be rebound as a unique File.
|
||||
if (!uniqueIsBounded || targetResults.length >= 10 || !uniquePath) {
|
||||
return `⚠️ AMBIGUOUS TARGET: Could not uniquely match "${target}". Found:\n\n${distinctPaths.map((p: string, i: number) => `${i + 1}. ${p}`).join('\n')}\n\nPlease use a more specific path.`;
|
||||
}
|
||||
targetNode = { id: `file:${uniquePath}`, nodeType: 'File', filePath: uniquePath };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import {
|
|||
} from '../services/backend-client';
|
||||
import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
|
||||
import i18n from '../i18n';
|
||||
import { normalizePath } from '../lib/path-resolution';
|
||||
import { normalizePath, resolveUniqueIndexedPath } from '../lib/path-resolution';
|
||||
import { FILE_REF_REGEX, NODE_REF_REGEX } from '../lib/grounding-patterns';
|
||||
import { GraphStateProvider, useGraphState, type GraphMode } from './app-state/graph';
|
||||
|
||||
|
|
@ -69,6 +69,12 @@ export type ViewMode = 'onboarding' | 'loading' | 'exploring';
|
|||
export type RightPanelTab = 'code' | 'chat';
|
||||
export type EmbeddingStatus = 'idle' | 'loading' | 'embedding' | 'indexing' | 'ready' | 'error';
|
||||
|
||||
/**
|
||||
* POST /api/embed 409 "Another job is already active for this repository"
|
||||
* is the shared analyze/embed lock, not proof this repo is embedding.
|
||||
*/
|
||||
export const embeddingStatusForStartFailure = (_error: unknown): EmbeddingStatus => 'error';
|
||||
|
||||
export interface QueryResult {
|
||||
rows: Record<string, any>[];
|
||||
nodeIds: string[];
|
||||
|
|
@ -231,6 +237,8 @@ interface AppState {
|
|||
isCodePanelOpen: boolean;
|
||||
setCodePanelOpen: (open: boolean) => void;
|
||||
addCodeReference: (ref: Omit<CodeReference, 'id'>) => void;
|
||||
/** Resolve a (possibly partial) file path cited by the agent to a real graph file path. */
|
||||
resolveFilePath: (requestedPath: string) => string | null;
|
||||
removeCodeReference: (id: string) => void;
|
||||
clearAICodeReferences: () => void;
|
||||
clearCodeReferences: () => void;
|
||||
|
|
@ -418,16 +426,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
}, [graph]);
|
||||
|
||||
const resolveFilePath = useCallback(
|
||||
(requestedPath: string): string | null => {
|
||||
const normalized = normalizePath(requestedPath);
|
||||
// Exact match
|
||||
if (filePathIndex.has(normalized)) return filePathIndex.get(normalized)!;
|
||||
// Suffix match (partial paths like "src/utils.ts")
|
||||
for (const [key, value] of filePathIndex) {
|
||||
if (key.endsWith(normalized)) return value;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(requestedPath: string): string | null =>
|
||||
resolveUniqueIndexedPath(filePathIndex, requestedPath),
|
||||
[filePathIndex],
|
||||
);
|
||||
|
||||
|
|
@ -558,13 +558,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
},
|
||||
);
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('already in progress')) {
|
||||
// Dedup — embeddings already running, just wait
|
||||
setEmbeddingStatus('embedding');
|
||||
return;
|
||||
}
|
||||
setEmbeddingStatus('error');
|
||||
} catch (error: unknown) {
|
||||
// Shared acquireRepoLock 409 is used for both analyze-held and embed-held
|
||||
// locks. Never treat it as in-progress embedding — that hid an analyze
|
||||
// occupant as a successful embed start.
|
||||
setEmbeddingStatus(embeddingStatusForStartFailure(error));
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -630,6 +628,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
useEffect(() => {
|
||||
graphModeRef.current = graphMode;
|
||||
}, [graphMode]);
|
||||
// Same trick for the display name: initializeAgent has empty deps, so a plain
|
||||
// `projectName` read would be trapped at the initial '' for callers that pass
|
||||
// no override (settings-saved re-init, lazy init from sendChatMessage) and
|
||||
// the system prompt would label the codebase the literal 'project'.
|
||||
const projectNameRef = useRef(projectName);
|
||||
useEffect(() => {
|
||||
projectNameRef.current = projectName;
|
||||
}, [projectName]);
|
||||
|
||||
const initializeAgent = useCallback(
|
||||
async (
|
||||
|
|
@ -649,7 +655,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
setAgentError(null);
|
||||
|
||||
try {
|
||||
const effectiveProjectName = overrideProjectName || projectName || 'project';
|
||||
const effectiveProjectName = overrideProjectName || projectNameRef.current || 'project';
|
||||
|
||||
// Sync repoRef so all agent backend calls target the correct repo.
|
||||
// initializeAgent can be called from App.tsx (handleServerConnect) which
|
||||
|
|
@ -911,10 +917,14 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
addCodeReference({
|
||||
filePath: resolvedPath,
|
||||
startLine: node.properties.startLine
|
||||
? node.properties.startLine - 1
|
||||
: undefined,
|
||||
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
|
||||
startLine:
|
||||
typeof node.properties.startLine === 'number'
|
||||
? node.properties.startLine
|
||||
: undefined,
|
||||
endLine:
|
||||
typeof node.properties.endLine === 'number'
|
||||
? node.properties.endLine
|
||||
: undefined,
|
||||
nodeId: node.id,
|
||||
label: node.label,
|
||||
name: node.properties.name,
|
||||
|
|
@ -1126,6 +1136,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
clearAIToolHighlights,
|
||||
graph,
|
||||
embeddingStatus,
|
||||
llmSettings.activeProvider,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -1588,6 +1599,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
isCodePanelOpen,
|
||||
setCodePanelOpen,
|
||||
addCodeReference,
|
||||
resolveFilePath,
|
||||
removeCodeReference,
|
||||
clearAICodeReferences,
|
||||
clearCodeReferences,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,31 @@ export const normalizePath = (p: string): string => {
|
|||
return p.replace(/\\/g, '/').replace(/^\.?\//, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a citation against a normalized→original graph path index.
|
||||
* Exact match wins. A suffix match is accepted only when it is unique among
|
||||
* keys equal to the request or ending in `/${normalized}` — `index.ts` must
|
||||
* not resolve `src/myindex.ts`, nor silently pick the first filePathIndex entry.
|
||||
*/
|
||||
export const resolveUniqueIndexedPath = (
|
||||
filePathIndex: ReadonlyMap<string, string>,
|
||||
requestedPath: string,
|
||||
): string | null => {
|
||||
const normalized = normalizePath(requestedPath);
|
||||
if (!normalized) return null;
|
||||
const exact = filePathIndex.get(normalized);
|
||||
if (exact !== undefined) return exact;
|
||||
|
||||
const boundedSuffix = `/${normalized}`;
|
||||
let unique: string | undefined;
|
||||
for (const [key, value] of filePathIndex) {
|
||||
if (!key.endsWith(boundedSuffix)) continue;
|
||||
if (unique !== undefined) return null;
|
||||
unique = value;
|
||||
}
|
||||
return unique ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied path (which may be partial) to an exact file path in the repo.
|
||||
* Follows the same heuristics previously embedded in useAppState:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { decideSkipGraph } from '../lib/graph-load-decision';
|
|||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BackendRepo {
|
||||
/** Opaque per-server-process handle; matches `repoId` on analyze completion. */
|
||||
id?: string;
|
||||
name: string;
|
||||
path: string;
|
||||
repoPath?: string; // git HEAD returns "repoPath"; older versions return "path"
|
||||
|
|
@ -110,6 +112,52 @@ export interface JobStatus {
|
|||
completedAt?: number;
|
||||
}
|
||||
|
||||
/** Snapshot from GET /api/ops — execution metrics for the ops dashboard. */
|
||||
export interface OpsLaneMetrics {
|
||||
total: number;
|
||||
active: number;
|
||||
queued: number;
|
||||
complete: number;
|
||||
failed: number;
|
||||
byStatus: Record<JobStatus['status'], number>;
|
||||
avgDurationMs: number | null;
|
||||
maxDurationMs: number | null;
|
||||
activeProgressSum: number;
|
||||
}
|
||||
|
||||
export interface OpsJobView extends JobStatus {
|
||||
lane: 'analyze' | 'embed';
|
||||
branch?: string;
|
||||
retryCount: number;
|
||||
durationMs: number;
|
||||
partial?: {
|
||||
kind: 'embedding-partial';
|
||||
pendingNodeCount: number;
|
||||
nodesProcessed: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpsSnapshot {
|
||||
generatedAt: number;
|
||||
uptimeMs: number;
|
||||
health: 'ok';
|
||||
server: {
|
||||
version: string;
|
||||
launchContext: string;
|
||||
nodeVersion: string;
|
||||
latestVersion?: string;
|
||||
updateAvailable?: boolean;
|
||||
};
|
||||
analyze: { jobs: OpsJobView[]; metrics: OpsLaneMetrics };
|
||||
embed: { jobs: OpsJobView[]; metrics: OpsLaneMetrics };
|
||||
totals: {
|
||||
jobs: number;
|
||||
active: number;
|
||||
failed: number;
|
||||
complete: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class BackendError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
|
|
@ -190,6 +238,18 @@ export interface SSEOptions {
|
|||
* the edge's token gate resolves itself once a token is entered.
|
||||
*/
|
||||
retryOnHttpError?: boolean;
|
||||
/**
|
||||
* When true (default), a successful HTTP open resets the retry counter so a
|
||||
* long-lived stream can reconnect forever after transient drops. Set false
|
||||
* for finite budgets (ops → poll fallback): otherwise a 200 that then closes
|
||||
* would reset the counter on every reconnect and never reach `onError`.
|
||||
*/
|
||||
resetRetriesOnOpen?: boolean;
|
||||
/**
|
||||
* Abort the handshake if response headers do not arrive in this window.
|
||||
* Fires `onError` so callers (ops → poll) are not stuck on a pending fetch.
|
||||
*/
|
||||
connectTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -210,6 +270,7 @@ export function streamSSE<T = unknown>(
|
|||
const maxRetries = options.maxRetries ?? 3;
|
||||
const baseDelayMs = options.baseDelayMs ?? 1_000;
|
||||
const capDelayMs = options.capDelayMs ?? Infinity;
|
||||
const resetRetriesOnOpen = options.resetRetriesOnOpen ?? true;
|
||||
|
||||
let lastEventId = '';
|
||||
|
||||
|
|
@ -225,13 +286,26 @@ export function streamSSE<T = unknown>(
|
|||
if (controller.signal.aborted) return;
|
||||
|
||||
(async () => {
|
||||
let handshakeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const headers = withAuthHeader(new Headers());
|
||||
if (lastEventId) {
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
if (options.connectTimeoutMs && options.connectTimeoutMs > 0) {
|
||||
handshakeTimer = setTimeout(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
handlers.onError?.('SSE handshake timed out');
|
||||
controller.abort();
|
||||
}, options.connectTimeoutMs);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal, headers });
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer);
|
||||
handshakeTimer = undefined;
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (options.retryOnHttpError && scheduleRetry(retryCount)) return;
|
||||
handlers.onError?.(`Server returned ${response.status}`);
|
||||
|
|
@ -244,8 +318,10 @@ export function streamSSE<T = unknown>(
|
|||
return;
|
||||
}
|
||||
|
||||
// Reset retry count on successful connection
|
||||
retryCount = 0;
|
||||
// Long-lived streams reset; finite budgets (ops poll fallback) must not.
|
||||
if (resetRetriesOnOpen) {
|
||||
retryCount = 0;
|
||||
}
|
||||
handlers.onOpen?.();
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
|
@ -292,12 +368,20 @@ export function streamSSE<T = unknown>(
|
|||
}
|
||||
}
|
||||
|
||||
// Stream ended without terminal event — try to reconnect
|
||||
scheduleRetry(retryCount);
|
||||
// Stream ended without terminal event — try to reconnect; when the
|
||||
// retry budget is spent, surface the same onError path the catch arm
|
||||
// already uses so callers (e.g. ops dashboard → poll fallback) can run.
|
||||
// scheduleRetry also returns false when aborted — mirror the catch arm
|
||||
// and do not invoke onError after the caller cancelled the stream.
|
||||
if (!controller.signal.aborted && !scheduleRetry(retryCount)) {
|
||||
handlers.onError?.('Stream ended');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (handshakeTimer) clearTimeout(handshakeTimer);
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
// Network error — attempt reconnect with backoff
|
||||
if (!scheduleRetry(retryCount)) {
|
||||
// Network error — attempt reconnect with backoff. Skip onError when the
|
||||
// caller already aborted (scheduleRetry returns false for abort too).
|
||||
if (!controller.signal.aborted && !scheduleRetry(retryCount)) {
|
||||
handlers.onError?.(err instanceof Error ? err.message : 'Stream error');
|
||||
}
|
||||
}
|
||||
|
|
@ -342,10 +426,27 @@ export const setBackendUrl = (url: string): void => {
|
|||
|
||||
export const getBackendUrl = (): string => _backendUrl;
|
||||
|
||||
/**
|
||||
* Strip `user[:password]@` userinfo from an http(s) URL so credentials never
|
||||
* land in `?server=`, history, or `_backendUrl` display/storage paths.
|
||||
*/
|
||||
function stripBackendUrlCredentials(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!parsed.username && !parsed.password) return url;
|
||||
parsed.username = '';
|
||||
parsed.password = '';
|
||||
// URL() may add a trailing slash for bare origins; keep normalize's contract.
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return url.replace(/^(https?:\/\/)[^/]*@/i, '$1');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a user-entered server URL into a base URL suitable for setBackendUrl().
|
||||
* Adds protocol if missing, strips trailing slashes, and strips a trailing /api suffix
|
||||
* (since all API methods append their own /api/... paths to _backendUrl).
|
||||
* Adds protocol if missing, strips trailing slashes / userinfo, and strips a
|
||||
* trailing /api suffix (since all API methods append their own /api/... paths).
|
||||
*/
|
||||
export function normalizeServerUrl(input: string): string {
|
||||
let url = input.trim().replace(/\/+$/, '');
|
||||
|
|
@ -361,7 +462,7 @@ export function normalizeServerUrl(input: string): string {
|
|||
// Strip /api suffix if present — _backendUrl stores the base, not the /api path
|
||||
url = url.replace(/\/api$/, '');
|
||||
|
||||
return url;
|
||||
return stripBackendUrlCredentials(url);
|
||||
}
|
||||
|
||||
// ── Access token ───────────────────────────────────────────────────────────
|
||||
|
|
@ -1070,6 +1171,40 @@ export const getAnalyzeStatus = async (jobId: string): Promise<JobStatus> => {
|
|||
return response.json() as Promise<JobStatus>;
|
||||
};
|
||||
|
||||
/** Fetch the ops / execution metrics snapshot. */
|
||||
export const fetchOpsSnapshot = async (): Promise<OpsSnapshot> => {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/ops`, {}, 5_000);
|
||||
await assertOk(response);
|
||||
return response.json() as Promise<OpsSnapshot>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream ops snapshots via SSE (≈1 Hz). Falls back callers should use
|
||||
* `fetchOpsSnapshot` polling when the stream cannot be established.
|
||||
*/
|
||||
export const streamOpsSnapshot = (
|
||||
onSnapshot: (snapshot: OpsSnapshot) => void,
|
||||
onError: (error: string) => void,
|
||||
): AbortController => {
|
||||
return streamSSE<OpsSnapshot>(
|
||||
`${_backendUrl}/api/ops/stream`,
|
||||
{
|
||||
onMessage: onSnapshot,
|
||||
onError,
|
||||
},
|
||||
// Finite retries so onError can fire and the dashboard falls back to poll.
|
||||
// Do not reset the budget on a successful open — short-lived 200s must count.
|
||||
{
|
||||
maxRetries: 3,
|
||||
baseDelayMs: 1_000,
|
||||
capDelayMs: 5_000,
|
||||
retryOnHttpError: true,
|
||||
resetRetriesOnOpen: false,
|
||||
connectTimeoutMs: 5_000,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/** Cancel a running analysis job. */
|
||||
export const cancelAnalyze = async (jobId: string): Promise<void> => {
|
||||
const response = await fetchWithTimeout(
|
||||
|
|
@ -1083,7 +1218,7 @@ export const cancelAnalyze = async (jobId: string): Promise<void> => {
|
|||
export const streamAnalyzeProgress = (
|
||||
jobId: string,
|
||||
onProgress: (progress: JobProgress) => void,
|
||||
onComplete: (data: { repoName?: string; repoPath?: string }) => void,
|
||||
onComplete: (data: { repoName?: string; repoPath?: string; repoId?: string }) => void,
|
||||
onError: (error: string) => void,
|
||||
): AbortController => {
|
||||
return streamSSE<JobProgress>(
|
||||
|
|
|
|||
|
|
@ -235,5 +235,34 @@ describe('backend-client access token', () => {
|
|||
// Budget spent → the caller finally hears about it.
|
||||
expect(onError).toHaveBeenCalledWith('Server returned 401');
|
||||
});
|
||||
|
||||
it('fires onError when the handshake exceeds connectTimeoutMs', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onError = vi.fn();
|
||||
|
||||
streamSSE(`${BASE}/api/ops/stream`, { onError }, { maxRetries: 0, connectTimeoutMs: 50 });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(onError).toHaveBeenCalledWith('SSE handshake timed out');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('exhausts a finite budget across successful-then-closed streams when resetRetriesOnOpen is false', async () => {
|
||||
// Ops dashboard needs this: otherwise every short 200 resets the counter
|
||||
// and onError (poll fallback) never fires.
|
||||
const fetchMock = vi.fn(async () => sseResponse(['data: {"ok":true}\n\n']));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onError = vi.fn();
|
||||
|
||||
streamSSE(
|
||||
`${BASE}/api/ops/stream`,
|
||||
{ onError },
|
||||
{ baseDelayMs: 0, maxRetries: 2, resetRetriesOnOpen: false },
|
||||
);
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith('Stream ended'));
|
||||
// Initial + 2 retries = 3 opens before the budget is spent.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
25
gitnexus-web/test/unit/code-panel-lines.test.ts
Normal file
25
gitnexus-web/test/unit/code-panel-lines.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
selectedNodeDisplayLine,
|
||||
selectedNodeFileRange,
|
||||
selectedNodeLineHighlighted,
|
||||
} from '../../src/components/code-panel-lines';
|
||||
|
||||
describe('selected-node GraphNode line math (0-based storage)', () => {
|
||||
it('requests /api/file starting at storedStart - context (not storedStart - 1 - context)', () => {
|
||||
// GraphNode startLine 99 is editor line 100. CONTEXT_LINES = 50.
|
||||
expect(selectedNodeFileRange(99, 99, 50)).toEqual({ startLine: 49, endLine: 149 });
|
||||
});
|
||||
|
||||
it('highlights the 1-based display line for a 0-based node span', () => {
|
||||
expect(selectedNodeLineHighlighted(100, 99, 99)).toBe(true);
|
||||
expect(selectedNodeLineHighlighted(99, 99, 99)).toBe(false);
|
||||
expect(selectedNodeDisplayLine(99)).toBe(100);
|
||||
});
|
||||
|
||||
it('still highlights a first-line symbol stored as startLine 0', () => {
|
||||
expect(selectedNodeLineHighlighted(1, 0, 0)).toBe(true);
|
||||
expect(selectedNodeDisplayLine(0)).toBe(1);
|
||||
expect(selectedNodeFileRange(0, 0, 50)).toEqual({ startLine: 0, endLine: 50 });
|
||||
});
|
||||
});
|
||||
|
|
@ -56,9 +56,16 @@ vi.mock('react-i18next', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
const citationReads = () =>
|
||||
vi.mocked(readFile).mock.calls.filter(([, opts]) => opts && 'startLine' in (opts as object));
|
||||
|
||||
describe('CodeReferencesPanel repo identity (#2420)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
appState.codeReferences = [];
|
||||
appState.selectedNode = fileNode;
|
||||
appState.projectName = 'reels';
|
||||
appState.currentRepo = undefined;
|
||||
vi.mocked(readFile).mockResolvedValue({ content: 'const a = 1;', totalLines: 1 });
|
||||
});
|
||||
|
||||
|
|
@ -91,4 +98,142 @@ describe('CodeReferencesPanel repo identity (#2420)', () => {
|
|||
expect(screen.getByText('graph:codePanel.sourceUnavailable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not free replacement-batch citation ids when a cancelled repo-switch batch settles', async () => {
|
||||
appState.codeReferences = [
|
||||
{
|
||||
id: 'cite-1',
|
||||
filePath: 'src/foo.ts',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
source: 'ai',
|
||||
},
|
||||
];
|
||||
appState.currentRepo = '/ws/a/reels';
|
||||
|
||||
let releaseFirst!: (value: { content: string; startLine: number; totalLines: number }) => void;
|
||||
const firstCitation = new Promise<{ content: string; startLine: number; totalLines: number }>(
|
||||
(resolve) => {
|
||||
releaseFirst = resolve;
|
||||
},
|
||||
);
|
||||
vi.mocked(readFile).mockImplementation((_path, opts) => {
|
||||
if (opts && 'startLine' in opts) return firstCitation;
|
||||
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
|
||||
});
|
||||
|
||||
const { rerender } = render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
|
||||
await waitFor(() => expect(citationReads()).toHaveLength(1));
|
||||
|
||||
vi.mocked(readFile).mockImplementation((_path, opts) => {
|
||||
if (opts && 'startLine' in opts) {
|
||||
return Promise.resolve({ content: 'const a = 1;', startLine: 0, totalLines: 1 });
|
||||
}
|
||||
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
|
||||
});
|
||||
appState.currentRepo = '/ws/b/reels';
|
||||
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(citationReads()).toHaveLength(2));
|
||||
expect(citationReads()[1]?.[1]).toEqual(expect.objectContaining({ repo: '/ws/b/reels' }));
|
||||
|
||||
releaseFirst({ content: 'stale', startLine: 0, totalLines: 1 });
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(citationReads()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not re-issue in-flight citation reads when a new reference is appended', async () => {
|
||||
appState.codeReferences = [
|
||||
{
|
||||
id: 'cite-1',
|
||||
filePath: 'src/foo.ts',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
source: 'ai',
|
||||
},
|
||||
];
|
||||
appState.currentRepo = '/ws/a/reels';
|
||||
|
||||
let releaseFirst!: (value: { content: string; startLine: number; totalLines: number }) => void;
|
||||
const firstCitation = new Promise<{ content: string; startLine: number; totalLines: number }>(
|
||||
(resolve) => {
|
||||
releaseFirst = resolve;
|
||||
},
|
||||
);
|
||||
vi.mocked(readFile).mockImplementation((filePath, opts) => {
|
||||
if (opts && 'startLine' in opts) {
|
||||
if (filePath === 'src/foo.ts') return firstCitation;
|
||||
return Promise.resolve({ content: 'const b = 2;', startLine: 0, totalLines: 1 });
|
||||
}
|
||||
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
|
||||
});
|
||||
|
||||
const { rerender } = render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
|
||||
await waitFor(() => expect(citationReads()).toHaveLength(1));
|
||||
|
||||
appState.codeReferences = [
|
||||
...appState.codeReferences,
|
||||
{
|
||||
id: 'cite-2',
|
||||
filePath: 'src/bar.ts',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
source: 'ai',
|
||||
},
|
||||
];
|
||||
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(citationReads()).toHaveLength(2));
|
||||
expect(citationReads()[1]?.[0]).toBe('src/bar.ts');
|
||||
|
||||
releaseFirst({ content: 'first', startLine: 0, totalLines: 1 });
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(citationReads()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('prunes cached citation snippets when AI references are cleared', async () => {
|
||||
appState.codeReferences = [
|
||||
{
|
||||
id: 'cite-1',
|
||||
filePath: 'src/foo.ts',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
source: 'ai',
|
||||
},
|
||||
];
|
||||
appState.currentRepo = '/ws/a/reels';
|
||||
vi.mocked(readFile).mockImplementation((_path, opts) => {
|
||||
if (opts && 'startLine' in opts) {
|
||||
return Promise.resolve({ content: 'FIRST_SNIPPET', startLine: 0, totalLines: 1 });
|
||||
}
|
||||
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
|
||||
});
|
||||
|
||||
const { rerender } = render(<CodeReferencesPanel onFocusNode={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('FIRST_SNIPPET')).toBeInTheDocument());
|
||||
|
||||
appState.codeReferences = [];
|
||||
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
|
||||
|
||||
vi.mocked(readFile).mockImplementation((_path, opts) => {
|
||||
if (opts && 'startLine' in opts) {
|
||||
return Promise.resolve({ content: 'SECOND_SNIPPET', startLine: 0, totalLines: 1 });
|
||||
}
|
||||
return Promise.resolve({ content: 'const a = 1;', totalLines: 1 });
|
||||
});
|
||||
appState.codeReferences = [
|
||||
{
|
||||
id: 'cite-1',
|
||||
filePath: 'src/foo.ts',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
source: 'ai',
|
||||
},
|
||||
];
|
||||
rerender(<CodeReferencesPanel onFocusNode={vi.fn()} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('SECOND_SNIPPET')).toBeInTheDocument());
|
||||
expect(screen.queryByText('FIRST_SNIPPET')).not.toBeInTheDocument();
|
||||
expect(citationReads().length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
19
gitnexus-web/test/unit/embedding-start-failure.test.ts
Normal file
19
gitnexus-web/test/unit/embedding-start-failure.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { embeddingStatusForStartFailure } from '../../src/hooks/useAppState';
|
||||
import { BackendError } from '../../src/services/backend-client';
|
||||
|
||||
describe('embeddingStatusForStartFailure', () => {
|
||||
it('maps the shared analyze/embed lock 409 to error, not embedding', () => {
|
||||
const error = new BackendError(
|
||||
'Another job is already active for this repository',
|
||||
409,
|
||||
'client',
|
||||
);
|
||||
expect(embeddingStatusForStartFailure(error)).toBe('error');
|
||||
expect(embeddingStatusForStartFailure(error)).not.toBe('embedding');
|
||||
});
|
||||
|
||||
it('maps other start failures to error', () => {
|
||||
expect(embeddingStatusForStartFailure(new Error('boom'))).toBe('error');
|
||||
});
|
||||
});
|
||||
183
gitnexus-web/test/unit/execution-dashboard.test.tsx
Normal file
183
gitnexus-web/test/unit/execution-dashboard.test.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ExecutionDashboard } from '../../src/components/ExecutionDashboard';
|
||||
import {
|
||||
connectHeartbeat,
|
||||
fetchOpsSnapshot,
|
||||
getAuthToken,
|
||||
normalizeServerUrl,
|
||||
probeBackendStatus,
|
||||
setBackendUrl,
|
||||
streamOpsSnapshot,
|
||||
type OpsSnapshot,
|
||||
} from '../../src/services/backend-client';
|
||||
|
||||
vi.mock('../../src/services/backend-client', () => ({
|
||||
connectHeartbeat: vi.fn(() => () => {}),
|
||||
fetchOpsSnapshot: vi.fn(),
|
||||
getAuthToken: vi.fn(() => ''),
|
||||
getBackendUrl: vi.fn(() => 'http://127.0.0.1:4747'),
|
||||
normalizeServerUrl: vi.fn((url: string) => url),
|
||||
probeBackendStatus: vi.fn(),
|
||||
setBackendUrl: vi.fn(),
|
||||
streamOpsSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
const emptyMetrics = {
|
||||
total: 0,
|
||||
active: 0,
|
||||
queued: 0,
|
||||
complete: 0,
|
||||
failed: 0,
|
||||
byStatus: {
|
||||
queued: 0,
|
||||
cloning: 0,
|
||||
analyzing: 0,
|
||||
loading: 0,
|
||||
complete: 0,
|
||||
failed: 0,
|
||||
},
|
||||
avgDurationMs: null,
|
||||
maxDurationMs: null,
|
||||
activeProgressSum: 0,
|
||||
};
|
||||
|
||||
const emptySnap = (): OpsSnapshot => ({
|
||||
generatedAt: 1,
|
||||
uptimeMs: 1,
|
||||
health: 'ok',
|
||||
server: { version: '1', launchContext: 'local', nodeVersion: 'v22' },
|
||||
analyze: { jobs: [], metrics: emptyMetrics },
|
||||
embed: { jobs: [], metrics: emptyMetrics },
|
||||
totals: { jobs: 0, active: 0, failed: 0, complete: 0 },
|
||||
});
|
||||
|
||||
describe('ExecutionDashboard safety poll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(probeBackendStatus).mockResolvedValue('ok');
|
||||
vi.mocked(connectHeartbeat).mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('stops the safety poll once the SSE stream delivers a frame', async () => {
|
||||
let onFrame: ((snapshot: OpsSnapshot) => void) | undefined;
|
||||
vi.mocked(streamOpsSnapshot).mockImplementation((onSnapshot) => {
|
||||
onFrame = onSnapshot;
|
||||
return { abort: vi.fn() } as unknown as AbortController;
|
||||
});
|
||||
vi.mocked(fetchOpsSnapshot).mockRejectedValue(new Error('rest down'));
|
||||
|
||||
const setIntervalSpy = vi.spyOn(window, 'setInterval');
|
||||
const clearIntervalSpy = vi.spyOn(window, 'clearInterval');
|
||||
|
||||
const { getByText } = render(<ExecutionDashboard />);
|
||||
|
||||
await waitFor(() => expect(setIntervalSpy).toHaveBeenCalled());
|
||||
const timerId = setIntervalSpy.mock.results[0]?.value;
|
||||
expect(onFrame).toBeTypeOf('function');
|
||||
|
||||
onFrame!(emptySnap());
|
||||
|
||||
await waitFor(() => expect(clearIntervalSpy).toHaveBeenCalledWith(timerId));
|
||||
expect(getByText(/· sse/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('polls /api/ops at 2s so the fallback stays under the 60/min route limit', async () => {
|
||||
vi.mocked(streamOpsSnapshot).mockImplementation(
|
||||
() => ({ abort: vi.fn() }) as unknown as AbortController,
|
||||
);
|
||||
vi.mocked(fetchOpsSnapshot).mockRejectedValue(new Error('rest down'));
|
||||
const setIntervalSpy = vi.spyOn(window, 'setInterval');
|
||||
|
||||
render(<ExecutionDashboard />);
|
||||
|
||||
await waitFor(() => expect(setIntervalSpy).toHaveBeenCalled());
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 2_000);
|
||||
});
|
||||
|
||||
it('clears the prior snapshot when connecting to an unreachable server', async () => {
|
||||
const first = emptySnap();
|
||||
first.server = { ...first.server, version: 'old-server' };
|
||||
|
||||
vi.mocked(streamOpsSnapshot).mockImplementation((onSnapshot) => {
|
||||
onSnapshot(first);
|
||||
return { abort: vi.fn() } as unknown as AbortController;
|
||||
});
|
||||
vi.mocked(fetchOpsSnapshot).mockResolvedValue(first);
|
||||
|
||||
const { getByText, getByPlaceholderText, queryByText } = render(<ExecutionDashboard />);
|
||||
await waitFor(() => expect(getByText('old-server')).toBeInTheDocument());
|
||||
|
||||
vi.mocked(probeBackendStatus).mockResolvedValue('unreachable');
|
||||
vi.mocked(fetchOpsSnapshot).mockRejectedValue(new Error('down'));
|
||||
vi.mocked(streamOpsSnapshot).mockImplementation(
|
||||
() => ({ abort: vi.fn() }) as unknown as AbortController,
|
||||
);
|
||||
|
||||
const input = getByPlaceholderText('http://localhost:4747');
|
||||
fireEvent.change(input, { target: { value: 'http://127.0.0.1:9999' } });
|
||||
fireEvent.submit(input.closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('old-server')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an invalid-server error when the prior stream delivers a snapshot', async () => {
|
||||
let onFrame: ((snapshot: OpsSnapshot) => void) | undefined;
|
||||
vi.mocked(streamOpsSnapshot).mockImplementation((onSnapshot) => {
|
||||
onFrame = onSnapshot;
|
||||
return { abort: vi.fn() } as unknown as AbortController;
|
||||
});
|
||||
vi.mocked(fetchOpsSnapshot).mockResolvedValue(emptySnap());
|
||||
vi.mocked(normalizeServerUrl).mockImplementation((url: string) => {
|
||||
if (url.includes('bad')) throw new Error('Invalid backend URL');
|
||||
return url;
|
||||
});
|
||||
|
||||
const { getByText, getByPlaceholderText } = render(<ExecutionDashboard />);
|
||||
await waitFor(() => expect(onFrame).toBeTypeOf('function'));
|
||||
|
||||
const input = getByPlaceholderText('http://localhost:4747');
|
||||
fireEvent.change(input, { target: { value: 'http://bad' } });
|
||||
fireEvent.submit(input.closest('form')!);
|
||||
|
||||
await waitFor(() => expect(getByText('Invalid backend URL')).toBeInTheDocument());
|
||||
onFrame!(emptySnap());
|
||||
await waitFor(() => expect(getByText('Invalid backend URL')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExecutionDashboard ?server= link', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(probeBackendStatus).mockResolvedValue('ok');
|
||||
vi.mocked(fetchOpsSnapshot).mockResolvedValue(emptySnap());
|
||||
vi.mocked(streamOpsSnapshot).mockReturnValue({ abort: vi.fn() } as unknown as AbortController);
|
||||
window.history.replaceState({}, '', '/?view=ops&server=https://other.example');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
vi.mocked(getAuthToken).mockReturnValue('');
|
||||
});
|
||||
|
||||
it('does not auto-connect a foreign server while a deploy token is held', async () => {
|
||||
vi.mocked(getAuthToken).mockReturnValue('deploy-token');
|
||||
const { getByDisplayValue } = render(<ExecutionDashboard />);
|
||||
|
||||
expect(getByDisplayValue('https://other.example')).toBeInTheDocument();
|
||||
expect(setBackendUrl).not.toHaveBeenCalled();
|
||||
expect(streamOpsSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('auto-connects the linked server when no token is held', async () => {
|
||||
render(<ExecutionDashboard />);
|
||||
|
||||
await waitFor(() => expect(setBackendUrl).toHaveBeenCalledWith('https://other.example'));
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizePath, resolveFilePath } from '../../src/lib/path-resolution';
|
||||
import {
|
||||
normalizePath,
|
||||
resolveFilePath,
|
||||
resolveUniqueIndexedPath,
|
||||
} from '../../src/lib/path-resolution';
|
||||
|
||||
describe('path-resolution utilities', () => {
|
||||
const contents = new Map<string, string>([
|
||||
|
|
@ -31,3 +35,42 @@ describe('path-resolution utilities', () => {
|
|||
expect(resolveFilePath(contents, '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUniqueIndexedPath', () => {
|
||||
const index = new Map<string, string>([
|
||||
['src/components/Header.tsx', 'src/components/Header.tsx'],
|
||||
['src/index.ts', 'src/index.ts'],
|
||||
['lib/index.ts', 'lib/index.ts'],
|
||||
['packages/core/utils.ts', 'packages/core/utils.ts'],
|
||||
]);
|
||||
|
||||
it('prefers an exact indexed path', () => {
|
||||
expect(resolveUniqueIndexedPath(index, 'src/index.ts')).toBe('src/index.ts');
|
||||
});
|
||||
|
||||
it('resolves a unique suffix', () => {
|
||||
expect(resolveUniqueIndexedPath(index, 'core/utils.ts')).toBe('packages/core/utils.ts');
|
||||
});
|
||||
|
||||
it('resolves a unique filename only at a path-component boundary', () => {
|
||||
expect(resolveUniqueIndexedPath(index, 'Header.tsx')).toBe('src/components/Header.tsx');
|
||||
});
|
||||
|
||||
it('does not treat a filename substring as a unique suffix', () => {
|
||||
const substringIndex = new Map<string, string>([['src/myindex.ts', 'src/myindex.ts']]);
|
||||
expect(resolveUniqueIndexedPath(substringIndex, 'index.ts')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when more than one file shares the suffix', () => {
|
||||
expect(resolveUniqueIndexedPath(index, 'index.ts')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty request instead of matching every key', () => {
|
||||
expect(resolveUniqueIndexedPath(index, '')).toBeNull();
|
||||
expect(resolveUniqueIndexedPath(index, './')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when nothing matches', () => {
|
||||
expect(resolveUniqueIndexedPath(index, 'missing.ts')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@
|
|||
* The SSE complete event may carry `repoPath` (the analyzed path). RepoAnalyzer
|
||||
* must pass that IDENTITY to onComplete — so the post-analyze reconnect targets
|
||||
* the exact repo even when basenames collide — while the done screen keeps
|
||||
* rendering the display NAME and never shows an absolute path. Old servers
|
||||
* omit repoPath; the name fallback must be preserved.
|
||||
* rendering the display NAME and never shows an absolute path. Current servers
|
||||
* omit repoPath and send an opaque repoId, resolved against /api/repos; with
|
||||
* neither, the name fallback must be preserved.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
|
|
@ -13,6 +14,7 @@ import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
|
|||
import { i18nReady } from '../../src/i18n';
|
||||
import {
|
||||
cancelAnalyze,
|
||||
fetchRepos,
|
||||
streamAnalyzeProgress,
|
||||
uploadFolder,
|
||||
} from '../../src/services/backend-client';
|
||||
|
|
@ -31,13 +33,14 @@ vi.mock('../../src/services/backend-client', () => ({
|
|||
},
|
||||
startAnalyze: vi.fn(),
|
||||
cancelAnalyze: vi.fn(),
|
||||
fetchRepos: vi.fn(),
|
||||
streamAnalyzeProgress: vi.fn(),
|
||||
uploadFolder: vi.fn(),
|
||||
}));
|
||||
|
||||
const JOB = { jobId: 'job-1', status: 'queued' };
|
||||
|
||||
type CompleteData = { repoName?: string; repoPath?: string };
|
||||
type CompleteData = { repoName?: string; repoPath?: string; repoId?: string };
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
|
|
@ -64,7 +67,7 @@ async function startTrackedJob() {
|
|||
|
||||
vi.useFakeTimers();
|
||||
const onDone = vi.fn<(repoIdentity: string) => void>();
|
||||
render(<RepoAnalyzer variant="onboarding" onComplete={onDone} />);
|
||||
const { unmount } = render(<RepoAnalyzer variant="onboarding" onComplete={onDone} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
fireEvent.change(screen.getByTestId('folder-upload-input'), {
|
||||
target: { files: [new File(['x'], 'a.ts')] },
|
||||
|
|
@ -73,7 +76,7 @@ async function startTrackedJob() {
|
|||
await act(async () => {});
|
||||
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
|
||||
|
||||
return { onDone, complete: (data: CompleteData) => sseComplete?.(data) };
|
||||
return { onDone, unmount, complete: (data: CompleteData) => sseComplete?.(data) };
|
||||
}
|
||||
|
||||
describe('analyze completion identity', () => {
|
||||
|
|
@ -90,7 +93,7 @@ describe('analyze completion identity', () => {
|
|||
|
||||
// onComplete fires after the ~1200ms done-screen dwell, with the identity.
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -105,10 +108,64 @@ describe('analyze completion identity', () => {
|
|||
});
|
||||
|
||||
expect(screen.getByText('reels')).toBeInTheDocument();
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
expect(onDone).toHaveBeenCalledWith('reels');
|
||||
});
|
||||
|
||||
it('resolves repoId to the exact /api/repos entry when names collide', async () => {
|
||||
vi.mocked(fetchRepos).mockResolvedValue([
|
||||
{ id: 'id-a', name: 'reels', path: '/ws/a/reels', indexedAt: '' },
|
||||
{ id: 'id-b', name: 'reels', path: '/ws/b/reels', indexedAt: '' },
|
||||
]);
|
||||
const { onDone, complete } = await startTrackedJob();
|
||||
|
||||
act(() => {
|
||||
complete({ repoName: 'reels', repoId: 'id-b' });
|
||||
});
|
||||
expect(screen.getByText('reels')).toBeInTheDocument();
|
||||
expect(screen.queryByText('/ws/b/reels')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
expect(onDone).toHaveBeenCalledWith('/ws/b/reels');
|
||||
});
|
||||
|
||||
it('falls back to the display name when repoId matches no entry', async () => {
|
||||
vi.mocked(fetchRepos).mockResolvedValue([]);
|
||||
const { onDone, complete } = await startTrackedJob();
|
||||
|
||||
act(() => {
|
||||
complete({ repoName: 'reels', repoId: 'gone' });
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
expect(onDone).toHaveBeenCalledWith('reels');
|
||||
});
|
||||
|
||||
it('does not call onComplete when unmounted while repoId is still resolving', async () => {
|
||||
let resolveRepos: ((repos: never[]) => void) | undefined;
|
||||
vi.mocked(fetchRepos).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRepos = resolve;
|
||||
}),
|
||||
);
|
||||
const { onDone, complete, unmount } = await startTrackedJob();
|
||||
|
||||
act(() => {
|
||||
complete({ repoName: 'reels', repoId: 'id-b' });
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
unmount();
|
||||
await act(async () => {
|
||||
resolveRepos?.([]);
|
||||
});
|
||||
expect(onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
unregisterRepo,
|
||||
listRegisteredRepos,
|
||||
getStoragePaths,
|
||||
type RegistryEntry,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js';
|
||||
import { formatStaleSlotLine } from './stale-branch-format.js';
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
listStaleBranchSlots,
|
||||
removeBranchSlot,
|
||||
staleListingBlock,
|
||||
type StaleBranchSlot,
|
||||
} from '../storage/stale-branch-slots.js';
|
||||
import {
|
||||
cleanParkedLbugSidecars,
|
||||
|
|
@ -32,6 +34,119 @@ import {
|
|||
} from '../core/lbug/sidecar-recovery.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
type OwnedCwdStorage = {
|
||||
repo: NonNullable<Awaited<ReturnType<typeof findRepo>>>;
|
||||
entry: RegistryEntry | undefined;
|
||||
storagePath: string;
|
||||
};
|
||||
|
||||
const resolveOwnedCwdStorage = async (refusePrefix: string): Promise<OwnedCwdStorage | null> => {
|
||||
const repo = await findRepo(process.cwd());
|
||||
if (!repo) {
|
||||
console.log(t('clean.notFoundHere'));
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const [entries, storagePath] = await Promise.all([
|
||||
listRegisteredRepos(),
|
||||
requireDeletableStoragePath({
|
||||
path: repo.repoPath,
|
||||
storagePath: repo.storagePath,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
repo,
|
||||
entry: findRegistryEntryByRepoPath(entries, repo.repoPath),
|
||||
storagePath,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`${refusePrefix}${err.message}`);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const printStaleSlotLines = (message: string, slots: readonly StaleBranchSlot[]): void => {
|
||||
console.log(message);
|
||||
for (const slot of slots) {
|
||||
console.log(` - ${formatStaleSlotLine(slot)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanStaleBranchSlots = async (force: boolean): Promise<void> => {
|
||||
const owned = await resolveOwnedCwdStorage('Refusing to clean leftover branch indexes: ');
|
||||
if (!owned) return;
|
||||
const { repo, entry, storagePath } = owned;
|
||||
const slots = await listStaleBranchSlots({
|
||||
repoPath: repo.repoPath,
|
||||
storagePath,
|
||||
branches: entry?.branches,
|
||||
includeSize: !force,
|
||||
});
|
||||
const listingBlock = staleListingBlock(slots);
|
||||
if (listingBlock === 'heads-unavailable') {
|
||||
printStaleSlotLines(
|
||||
t('clean.stale.headsUnavailable'),
|
||||
slots.filter((row) => row.reason === 'heads-unavailable'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (listingBlock === 'listing-failed') {
|
||||
console.log(t('clean.stale.listingFailed'));
|
||||
return;
|
||||
}
|
||||
const candidates = slots.filter(isDeleteCandidate);
|
||||
const probeFailed = slots.filter((slot) => slot.reason === 'probe-failed');
|
||||
if (candidates.length === 0) {
|
||||
if (probeFailed.length > 0) {
|
||||
printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed);
|
||||
return;
|
||||
}
|
||||
console.log(t('clean.stale.none'));
|
||||
return;
|
||||
}
|
||||
if (!force) {
|
||||
printStaleSlotLines(t('clean.stale.preview', { count: candidates.length }), candidates);
|
||||
if (probeFailed.length > 0) {
|
||||
printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed);
|
||||
}
|
||||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
let deletedAny = false;
|
||||
for (const slot of candidates) {
|
||||
const heads = listLocalHeads(repo.repoPath);
|
||||
if (heads === null) {
|
||||
console.log(
|
||||
deletedAny ? t('clean.stale.remainingSkipped') : t('clean.stale.headsUnavailable'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (heads.includes(slot.branch)) {
|
||||
console.log(t('clean.stale.skippedLive', { branch: slot.branch }));
|
||||
continue;
|
||||
}
|
||||
const result = await removeBranchSlot({
|
||||
repoPath: repo.repoPath,
|
||||
storagePath,
|
||||
branch: slot.branch,
|
||||
dir: slot.dir,
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.log(t('clean.stale.failed', { branch: slot.branch }));
|
||||
logger.error({ err: result.error }, 'Failed to delete leftover branch index:');
|
||||
continue;
|
||||
}
|
||||
deletedAny = true;
|
||||
console.log(t('clean.stale.deleted', { branch: slot.branch }));
|
||||
}
|
||||
if (probeFailed.length > 0) {
|
||||
printStaleSlotLines(t('clean.stale.probeFailed'), probeFailed);
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanCommand = async (options?: {
|
||||
force?: boolean;
|
||||
all?: boolean;
|
||||
|
|
@ -42,98 +157,7 @@ export const cleanCommand = async (options?: {
|
|||
// --stale: reclaim leftover per-branch slots whose recorded branch is not
|
||||
// a live local head (#3331). Exclusive arm before --branch.
|
||||
if (options?.stale) {
|
||||
const cwd = process.cwd();
|
||||
const repo = await findRepo(cwd);
|
||||
if (!repo) {
|
||||
console.log(t('clean.notFoundHere'));
|
||||
return;
|
||||
}
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = findRegistryEntryByRepoPath(entries, repo.repoPath);
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireDeletableStoragePath({
|
||||
path: repo.repoPath,
|
||||
storagePath: repo.storagePath,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to clean leftover branch indexes: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const slots = await listStaleBranchSlots({
|
||||
repoPath: repo.repoPath,
|
||||
storagePath,
|
||||
branches: entry?.branches,
|
||||
includeSize: !options.force,
|
||||
});
|
||||
const listingBlock = staleListingBlock(slots);
|
||||
if (listingBlock === 'heads-unavailable') {
|
||||
console.log(t('clean.stale.headsUnavailable'));
|
||||
for (const slot of slots.filter((row) => row.reason === 'heads-unavailable')) {
|
||||
console.log(` - ${formatStaleSlotLine(slot)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (listingBlock === 'listing-failed') {
|
||||
console.log(t('clean.stale.listingFailed'));
|
||||
return;
|
||||
}
|
||||
const candidates = slots.filter(isDeleteCandidate);
|
||||
const probeFailed = slots.filter((slot) => slot.reason === 'probe-failed');
|
||||
const printProbeFailed = (): void => {
|
||||
console.log(t('clean.stale.probeFailed'));
|
||||
for (const slot of probeFailed) {
|
||||
console.log(` - ${formatStaleSlotLine(slot)}`);
|
||||
}
|
||||
};
|
||||
if (candidates.length === 0) {
|
||||
if (probeFailed.length > 0) {
|
||||
printProbeFailed();
|
||||
return;
|
||||
}
|
||||
console.log(t('clean.stale.none'));
|
||||
return;
|
||||
}
|
||||
if (!options.force) {
|
||||
console.log(t('clean.stale.preview', { count: candidates.length }));
|
||||
for (const slot of candidates) {
|
||||
console.log(` - ${formatStaleSlotLine(slot)}`);
|
||||
}
|
||||
if (probeFailed.length > 0) {
|
||||
printProbeFailed();
|
||||
}
|
||||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
for (const slot of candidates) {
|
||||
const heads = listLocalHeads(repo.repoPath);
|
||||
if (heads === null) {
|
||||
console.log(t('clean.stale.headsUnavailable'));
|
||||
return;
|
||||
}
|
||||
if (heads.includes(slot.branch)) {
|
||||
console.log(t('clean.stale.skippedLive', { branch: slot.branch }));
|
||||
continue;
|
||||
}
|
||||
const result = await removeBranchSlot({
|
||||
repoPath: repo.repoPath,
|
||||
storagePath,
|
||||
branch: slot.branch,
|
||||
dir: slot.dir,
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.log(t('clean.stale.failed', { branch: slot.branch }));
|
||||
logger.error({ err: result.error }, 'Failed to delete leftover branch index:');
|
||||
continue;
|
||||
}
|
||||
console.log(t('clean.stale.deleted', { branch: slot.branch }));
|
||||
}
|
||||
if (probeFailed.length > 0) {
|
||||
printProbeFailed();
|
||||
}
|
||||
await cleanStaleBranchSlots(options.force === true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -141,32 +165,14 @@ export const cleanCommand = async (options?: {
|
|||
// Resolve against the RECORDED branches[] summary (never by slugging the
|
||||
// user's raw input, which can disagree with the index-time-sanitized label).
|
||||
if (options?.branch) {
|
||||
const cwd = process.cwd();
|
||||
const repo = await findRepo(cwd);
|
||||
if (!repo) {
|
||||
console.log(t('clean.notFoundHere'));
|
||||
return;
|
||||
}
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = findRegistryEntryByRepoPath(entries, repo.repoPath);
|
||||
const owned = await resolveOwnedCwdStorage('Refusing to clean branch index: ');
|
||||
if (!owned) return;
|
||||
const { repo, entry, storagePath } = owned;
|
||||
const summary = entry?.branches?.find((b) => b.branch === options.branch);
|
||||
if (!summary) {
|
||||
console.log(t('clean.branchNotIndexed', { branch: options.branch }));
|
||||
return;
|
||||
}
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireDeletableStoragePath({
|
||||
path: repo.repoPath,
|
||||
storagePath: repo.storagePath,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to clean branch index: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const { lbugPath } = getStoragePaths(repo.repoPath, summary.branch, storagePath);
|
||||
const branchDir = path.dirname(lbugPath);
|
||||
if (!isContainedBranchDir(storagePath, branchDir)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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': '无法检查残留分支索引路径,因此未删除这些槽位。',
|
||||
|
|
|
|||
|
|
@ -88,12 +88,17 @@ export interface AnalyzeJob {
|
|||
const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const JOB_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
/** How long a cancelled worker gets to exit via IPC before a signal is sent. */
|
||||
const CANCEL_GRACE_MS = 15_000;
|
||||
|
||||
export class JobManager {
|
||||
private jobs = new Map<string, AnalyzeJob>();
|
||||
private children = new Map<string, ChildProcess>();
|
||||
private abortControllers = new Map<string, AbortController>();
|
||||
private timeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private cancelGraceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Cancel reason to apply when a still-running worker exits. */
|
||||
private pendingCancelReasons = new Map<string, string>();
|
||||
private emitter = new EventEmitter();
|
||||
private cleanupTimer: ReturnType<typeof setInterval>;
|
||||
|
||||
|
|
@ -113,13 +118,20 @@ export class JobManager {
|
|||
* reject the request outright, which is a truthful answer.
|
||||
*/
|
||||
createJob(params: { repoUrl?: string; repoPath?: string; branch?: string }): AnalyzeJob {
|
||||
// Dedup: return existing active job for the same repo (by URL or path) and branch
|
||||
// Dedup: return existing active job for the same repo (by URL or path) and branch.
|
||||
// A cancelled job still occupies the slot while its worker is registered —
|
||||
// flipping to `failed` before exit used to let a second POST start cloneOrPull
|
||||
// against a LadybugDB file the first worker was still writing.
|
||||
for (const job of this.jobs.values()) {
|
||||
if (!this.isTerminal(job.status)) {
|
||||
if (this.isSlotOccupied(job)) {
|
||||
const isSameRepo =
|
||||
(params.repoUrl && job.repoUrl === params.repoUrl) ||
|
||||
(params.repoPath && job.repoPath === params.repoPath);
|
||||
if (isSameRepo && job.branch === params.branch) {
|
||||
// A dying job still occupies the slot (pending cancel, or already
|
||||
// failed while the child/lock is held until exit). Do not 202-reuse
|
||||
// it — fall through to the single-slot throw.
|
||||
if (this.hasPendingCancel(job.id) || this.isTerminal(job.status)) continue;
|
||||
return job;
|
||||
}
|
||||
}
|
||||
|
|
@ -127,7 +139,7 @@ export class JobManager {
|
|||
|
||||
// Single-slot: reject if another job is active (different repo)
|
||||
for (const job of this.jobs.values()) {
|
||||
if (!this.isTerminal(job.status)) {
|
||||
if (this.isSlotOccupied(job)) {
|
||||
throw new Error(`Analysis already in progress (job ${job.id})`);
|
||||
}
|
||||
}
|
||||
|
|
@ -207,15 +219,58 @@ export class JobManager {
|
|||
}, JOB_TIMEOUT_MS);
|
||||
this.timeouts.set(jobId, timer);
|
||||
|
||||
// Clean up tracking when child exits
|
||||
child.on('exit', () => {
|
||||
this.children.delete(jobId);
|
||||
const t = this.timeouts.get(jobId);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
this.timeouts.delete(jobId);
|
||||
}
|
||||
});
|
||||
// Apply a pending cancel BEFORE other `exit` listeners (analyze-launch's
|
||||
// crash-retry) see a still-non-terminal job and fork a replacement worker.
|
||||
const onExit = (): void => {
|
||||
this.releaseChild(jobId);
|
||||
this.applyPendingCancel(jobId);
|
||||
};
|
||||
if (typeof child.prependListener === 'function') {
|
||||
child.prependListener('exit', onExit);
|
||||
} else {
|
||||
child.on('exit', onExit);
|
||||
}
|
||||
}
|
||||
|
||||
/** True while cancel was requested and the worker has not exited yet. */
|
||||
hasPendingCancel(jobId: string): boolean {
|
||||
return this.pendingCancelReasons.has(jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a registered child without waiting for `exit`. Spawn failures emit
|
||||
* `error` and never `exit`, which would otherwise leave `isSlotOccupied`
|
||||
* true forever after the job is already failed.
|
||||
*/
|
||||
releaseChild(jobId: string): void {
|
||||
this.children.delete(jobId);
|
||||
const t = this.timeouts.get(jobId);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
this.timeouts.delete(jobId);
|
||||
}
|
||||
const grace = this.cancelGraceTimers.get(jobId);
|
||||
if (grace) {
|
||||
clearTimeout(grace);
|
||||
this.cancelGraceTimers.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a stored cancel reason if one is pending. Returns true when a reason
|
||||
* was consumed. Used by the worker-exit handler and by analyze-launch when
|
||||
* the child reports a generic cancel IPC — that message must not overwrite
|
||||
* the caller's reason (timeout vs user cancel).
|
||||
*/
|
||||
applyPendingCancel(jobId: string): boolean {
|
||||
const reason = this.pendingCancelReasons.get(jobId);
|
||||
if (reason === undefined) return false;
|
||||
this.pendingCancelReasons.delete(jobId);
|
||||
const current = this.jobs.get(jobId);
|
||||
if (current && !this.isTerminal(current.status)) {
|
||||
this.updateJob(jobId, { status: 'failed', error: reason });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Register cancellable in-process work for a job. */
|
||||
|
|
@ -228,21 +283,38 @@ export class JobManager {
|
|||
this.abortControllers.set(jobId, controller);
|
||||
}
|
||||
|
||||
/** Cancel a running job — sends SIGTERM to child process. */
|
||||
/**
|
||||
* Cancel a running job.
|
||||
*
|
||||
* The worker is asked to stop over IPC first (`{ type: 'cancel' }`), which
|
||||
* lets it reach a JS-visible safe point and checkpoint before exiting —
|
||||
* the same cross-platform control path `core/auto-sync` uses. A signal is
|
||||
* sent only as a bounded fallback: on Windows `child.kill('SIGTERM')` is a
|
||||
* forceful termination (Node ignores the signal name there), so leading
|
||||
* with it could kill the worker mid LadybugDB write.
|
||||
*/
|
||||
cancelJob(jobId: string, reason?: string): boolean {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (!job || this.isTerminal(job.status)) return false;
|
||||
|
||||
const child = this.children.get(jobId);
|
||||
if (child) {
|
||||
child.kill('SIGTERM');
|
||||
this.requestChildShutdown(jobId, child);
|
||||
}
|
||||
this.abortControllers.get(jobId)?.abort();
|
||||
this.abortControllers.delete(jobId);
|
||||
|
||||
const cancelReason = reason || 'Analysis cancelled';
|
||||
if (child) {
|
||||
// Keep the job non-terminal until the worker exits so createJob and
|
||||
// the resolveRepo hold-queue still see the slot as occupied.
|
||||
this.pendingCancelReasons.set(jobId, cancelReason);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: reason || 'Analysis cancelled',
|
||||
error: cancelReason,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
|
@ -255,12 +327,57 @@ export class JobManager {
|
|||
return () => this.emitter.off(event, listener);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// Kill all active child processes
|
||||
for (const child of this.children.values()) {
|
||||
/**
|
||||
* Ask a worker to shut down: IPC cancel now, signal after a grace period if
|
||||
* it has not exited on its own. The grace timer is cleared by the child's
|
||||
* `exit` handler registered in `registerChild`.
|
||||
*/
|
||||
private requestChildShutdown(jobId: string, child: ChildProcess): void {
|
||||
let ipcSent = false;
|
||||
if (child.connected) {
|
||||
try {
|
||||
child.send({ type: 'cancel' });
|
||||
ipcSent = true;
|
||||
} catch {
|
||||
// Channel already closed — fall through to the signal path.
|
||||
}
|
||||
}
|
||||
if (!ipcSent) {
|
||||
child.kill('SIGTERM');
|
||||
return;
|
||||
}
|
||||
if (this.cancelGraceTimers.has(jobId)) return;
|
||||
const grace = setTimeout(() => {
|
||||
this.cancelGraceTimers.delete(jobId);
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
// IPC already set the worker's cooperative cancel flag; a second
|
||||
// SIGTERM is a no-op there. SIGKILL is the actual bounded fallback
|
||||
// (on Windows `kill()` is already TerminateProcess).
|
||||
child.kill('SIGKILL');
|
||||
}
|
||||
}, CANCEL_GRACE_MS);
|
||||
grace.unref?.();
|
||||
this.cancelGraceTimers.set(jobId, grace);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// IPC first so a worker that checks in can stop at a safe point.
|
||||
// On Windows `child.kill('SIGTERM')` is TerminateProcess — skip that
|
||||
// immediate kill and leave the 15s grace timer to SIGKILL. On Unix,
|
||||
// SIGTERM after IPC is cooperative, so the grace timers can be dropped.
|
||||
const windows = process.platform === 'win32';
|
||||
for (const [jobId, child] of this.children) {
|
||||
this.requestChildShutdown(jobId, child);
|
||||
if (!windows) {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
this.children.clear();
|
||||
if (!windows) {
|
||||
for (const timer of this.cancelGraceTimers.values()) clearTimeout(timer);
|
||||
this.cancelGraceTimers.clear();
|
||||
}
|
||||
this.pendingCancelReasons.clear();
|
||||
for (const controller of this.abortControllers.values()) controller.abort();
|
||||
this.abortControllers.clear();
|
||||
|
||||
|
|
@ -278,6 +395,10 @@ export class JobManager {
|
|||
return isTerminalJobStatus(status);
|
||||
}
|
||||
|
||||
private isSlotOccupied(job: AnalyzeJob): boolean {
|
||||
return !this.isTerminal(job.status) || this.children.has(job.id);
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [id, job] of this.jobs) {
|
||||
|
|
|
|||
|
|
@ -116,15 +116,18 @@ const settleDirFor = (
|
|||
*
|
||||
* Never rejects. Returns `true` once the index is settled. Timing out logs
|
||||
* a warning and returns `false` — the caller must fail the job without
|
||||
* publishing. The `alreadyUpToDate` fast path never rewrites `lbug` (see
|
||||
* `run-analyze.ts`) and is treated as settled without waiting so it does
|
||||
* not hold the analyze slot for 60s of polling.
|
||||
* publishing. `shouldAbort` short-circuits the poll (no timeout warning)
|
||||
* so a pending cancel or already-terminal job does not hold the write lock
|
||||
* for the remaining 60s. The `alreadyUpToDate` fast path never rewrites
|
||||
* `lbug` (see `run-analyze.ts`) and is treated as settled without waiting
|
||||
* so it does not hold the analyze slot for 60s of polling.
|
||||
*/
|
||||
const waitForSettledIndex = async (
|
||||
storagePath: string,
|
||||
jobStartMs: number,
|
||||
branch?: string,
|
||||
isPrimaryBranch?: boolean,
|
||||
shouldAbort?: () => boolean,
|
||||
): Promise<boolean> => {
|
||||
const settled = (probePath: string): boolean => {
|
||||
try {
|
||||
|
|
@ -170,6 +173,7 @@ const waitForSettledIndex = async (
|
|||
};
|
||||
const deadline = Date.now() + FINALIZE_SETTLE_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
if (shouldAbort?.()) return false;
|
||||
if (settled(settleDirFor(storagePath, branch, isPrimaryBranch))) return true;
|
||||
if (Date.now() > deadline) {
|
||||
logger.warn(
|
||||
|
|
@ -235,9 +239,20 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
const workerHeapMb =
|
||||
Number.isInteger(envHeapMb) && envHeapMb > 0 ? envHeapMb : Math.min(8192, autoHeapCapMb());
|
||||
|
||||
const launchAborted = (jobId: string): boolean => {
|
||||
const current = jobManager.getJob(jobId);
|
||||
return !current || isTerminalJobStatus(current.status) || jobManager.hasPendingCancel(jobId);
|
||||
};
|
||||
|
||||
const forkWorker = () => {
|
||||
const currentJob = jobManager.getJob(job.id);
|
||||
if (!currentJob || isTerminalJobStatus(currentJob.status)) return;
|
||||
if (launchAborted(job.id)) {
|
||||
// Cancelled (or timed out) between lock acquisition and the fork, or
|
||||
// during a crash-retry delay. A pending-cancel job stays non-terminal
|
||||
// until the worker exits — do not fork a replacement. Nothing else
|
||||
// drops the lock here, so release or the repo stays "busy" until restart.
|
||||
releaseLockOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
const child = fork(workerPath, [], {
|
||||
execArgv: [...tsxHookArgs, `--max-old-space-size=${workerHeapMb}`],
|
||||
|
|
@ -253,6 +268,13 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
// below reads that clean exit as a crash and retries a SUCCESSFUL
|
||||
// analysis, three times, before failing it (#3199 review).
|
||||
let terminalIpcSeen = false;
|
||||
// Cancel `error` IPC arrives before the worker's `finally` checkpoint.
|
||||
// Hold the write lock until `exit` so embed cannot acquire under a
|
||||
// still-open native handle. Exit must release when this is set —
|
||||
// `terminalIpcSeen` is already true for that IPC, so a naive
|
||||
// "don't release on cancel error" would leak the lock.
|
||||
let holdLockUntilExit = false;
|
||||
let childExited = false;
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderrChunks += chunk.toString();
|
||||
if (stderrChunks.length > 4096) stderrChunks = stderrChunks.slice(-4096);
|
||||
|
|
@ -274,6 +296,12 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
progress: { phase: msg.phase, percent: msg.percent, message: msg.message },
|
||||
});
|
||||
} else if (msg.type === 'complete') {
|
||||
if (jobManager.applyPendingCancel(job.id)) {
|
||||
// Same as cancel `error` IPC: the worker still runs
|
||||
// `boundedCheckpointBeforeExit` after sending terminal IPC.
|
||||
holdLockUntilExit = true;
|
||||
return;
|
||||
}
|
||||
// Hold the write lock through settle AND the collapse/publish
|
||||
// decision. Release in `finally` so timeout / collapse / init
|
||||
// failure / complete each drop it exactly once. alreadyUpToDate
|
||||
|
|
@ -303,9 +331,15 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
jobStartMs,
|
||||
opts.branch,
|
||||
msg.result.isPrimaryBranch,
|
||||
() => launchAborted(job.id),
|
||||
);
|
||||
settle
|
||||
.then((settled) => {
|
||||
if (launchAborted(job.id)) {
|
||||
jobManager.applyPendingCancel(job.id);
|
||||
if (!childExited) holdLockUntilExit = true;
|
||||
return false;
|
||||
}
|
||||
if (!settled) {
|
||||
// Finalization never became visible. Do not evict the cached
|
||||
// handle (a previously published index should keep being
|
||||
|
|
@ -324,6 +358,11 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
})
|
||||
.then((readyToPublish) => {
|
||||
if (!readyToPublish) return;
|
||||
if (launchAborted(job.id)) {
|
||||
jobManager.applyPendingCancel(job.id);
|
||||
if (!childExited) holdLockUntilExit = true;
|
||||
return;
|
||||
}
|
||||
// PARITY WITH THE CLI, which is what the IPC projection was added
|
||||
// for. `analyze-worker-ipc.ts` carries `graphWriteCollapsed`
|
||||
// "so a server-side caller sees the same degraded outcome the CLI
|
||||
|
|
@ -398,27 +437,59 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) {
|
|||
});
|
||||
})
|
||||
.finally(() => {
|
||||
releaseLockOnce();
|
||||
if (!holdLockUntilExit) releaseLockOnce();
|
||||
});
|
||||
} else if (msg.type === 'error') {
|
||||
releaseLockOnce();
|
||||
// A failed (force) analyze may still have rewritten DB files first.
|
||||
void closeDbHandle().catch(() => {});
|
||||
jobManager.updateJob(job.id, { status: 'failed', error: msg.message });
|
||||
// Cancel path: the worker sends this IPC first, then
|
||||
// `boundedCheckpointBeforeExit` in `finally`. Hold the lock until
|
||||
// `exit` so embed (same `acquireRepoLock`) cannot open mid-checkpoint.
|
||||
if (jobManager.hasPendingCancel(job.id)) {
|
||||
jobManager.applyPendingCancel(job.id);
|
||||
holdLockUntilExit = true;
|
||||
} else {
|
||||
releaseLockOnce();
|
||||
// A failed (force) analyze may still have rewritten DB files first.
|
||||
void closeDbHandle().catch(() => {});
|
||||
jobManager.updateJob(job.id, { status: 'failed', error: msg.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
// Fake test children have no `pid`. Treat that as pre-spawn so the
|
||||
// spawn-failure path still frees the slot without waiting for `exit`.
|
||||
// A numeric pid is a live child — Node also emits `error` for
|
||||
// post-spawn send/kill failures (e.g. write EPIPE); those still get
|
||||
// `exit`, which drops the lock when `!terminalIpcSeen || holdLockUntilExit`.
|
||||
const preSpawn = typeof child.pid !== 'number';
|
||||
if (!jobManager.applyPendingCancel(job.id)) {
|
||||
jobManager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: `Worker process error: ${err.message}`,
|
||||
});
|
||||
}
|
||||
if (!preSpawn) return;
|
||||
releaseLockOnce();
|
||||
jobManager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: `Worker process error: ${err.message}`,
|
||||
});
|
||||
// `fork`/`error` without `exit` (spawn failure) would otherwise keep
|
||||
// the child in JobManager and block every later createJob.
|
||||
jobManager.releaseChild(job.id);
|
||||
});
|
||||
|
||||
child.on('exit', (code) => {
|
||||
childExited = true;
|
||||
const j = jobManager.getJob(job.id);
|
||||
if (!j || isTerminalJobStatus(j.status)) return;
|
||||
if (!j || isTerminalJobStatus(j.status) || jobManager.hasPendingCancel(job.id)) {
|
||||
// (a) complete/error IPC is in flight (`terminalIpcSeen`) — that
|
||||
// chain owns the lock through settle/`backend.init()`/`finally`.
|
||||
// Releasing here lets a second analyze acquire under a publish.
|
||||
// (b) cancel is pending or already failed BEFORE any terminal IPC —
|
||||
// this exit is the only remaining place that can drop the lock.
|
||||
// (c) cancel `error` IPC set `holdLockUntilExit`: `terminalIpcSeen`
|
||||
// is true, so without this extra clause the lock would leak
|
||||
// until process restart.
|
||||
if (!terminalIpcSeen || holdLockUntilExit) releaseLockOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
// The worker already reported a terminal outcome; this exit is it
|
||||
// winding down, not dying. The job is still non-terminal only because
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ import {
|
|||
buildServerInfo,
|
||||
createServeUpdateController,
|
||||
} from './update-controller.js';
|
||||
import { buildOpsSnapshot, isGitNexusVercelOrigin, serializeOpsJob } from './ops-snapshot.js';
|
||||
|
||||
export {
|
||||
bindServeUpdateControllerLifecycle,
|
||||
|
|
@ -125,7 +126,11 @@ export {
|
|||
* 10.0.0.0/8 → 10.x.x.x
|
||||
* 172.16.0.0/12 → 172.16.x.x – 172.31.x.x
|
||||
* 192.168.0.0/16 → 192.168.x.x
|
||||
* - https://gitnexus.vercel.app — the deployed GitNexus web UI
|
||||
* - https://gitnexus.vercel.app and https://gitnexus-web.vercel.app —
|
||||
* first-party GitNexus web UI production hosts (ops dashboard included).
|
||||
* Preview hosts cannot set GITNEXUS_PUBLIC_ORIGIN until serve auth exists
|
||||
* (`assertServeAuthForPublicOrigin` refuses to start). Reach them through a
|
||||
* proxy that authenticates, or bind loopback.
|
||||
* - the origin named by GITNEXUS_PUBLIC_ORIGIN, when set — matched on hostname
|
||||
* always, and on scheme and port when the configured value carries them
|
||||
*
|
||||
|
|
@ -146,7 +151,7 @@ export const isAllowedOrigin = (origin: string | undefined): boolean => {
|
|||
origin === 'http://127.0.0.1' ||
|
||||
origin.startsWith('http://[::1]:') ||
|
||||
origin === 'http://[::1]' ||
|
||||
origin === 'https://gitnexus.vercel.app'
|
||||
isGitNexusVercelOrigin(origin)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -613,6 +618,17 @@ const requestedRepo = (req: express.Request): string | undefined => {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hold-queue opt-out for process/cluster GETs. Default is wait (same as
|
||||
* `/api/repo`). `?awaitAnalysis=false` skips the 300s resolveRepo hold so a
|
||||
* caller can fail fast instead of parking a connection on an in-flight analyze.
|
||||
*/
|
||||
export const parseAwaitAnalysisQuery = (value: unknown): boolean => {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof raw !== 'string') return true;
|
||||
return raw !== 'false' && raw !== '0';
|
||||
};
|
||||
|
||||
const repoParamBasename = (repoName: string): string =>
|
||||
repoName.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? repoName;
|
||||
|
||||
|
|
@ -663,6 +679,19 @@ export const resolveRegisteredRepoEntry = (
|
|||
);
|
||||
};
|
||||
|
||||
/** HTTP omit-`?repo=` policy: MCP returns 400 when multiple repos are indexed. */
|
||||
export const resolveOmittedRepoSelection = (
|
||||
repos: RegistryEntry[],
|
||||
): { ok: true; entry: RegistryEntry } | { ok: false; status: 400 | 404; error: string } => {
|
||||
if (repos.length === 1) return { ok: true, entry: repos[0]! };
|
||||
if (repos.length === 0) return { ok: false, status: 404, error: 'Repository not found' };
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: `Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${repos.map((r) => r.name).join(', ')}`,
|
||||
};
|
||||
};
|
||||
|
||||
export interface SourceAvailability {
|
||||
available: boolean;
|
||||
reason?: 'content-retention' | 'checkout-missing';
|
||||
|
|
@ -774,7 +803,19 @@ export const handleFileRequest = async (
|
|||
return;
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(fullPath, 'utf-8');
|
||||
// The lexical check above cannot see symlinks: a repo cloned from an
|
||||
// untrusted remote can contain `evil -> /etc/passwd` (or `-> ../../..`)
|
||||
// that passes `path.relative` and is then followed by readFile. Re-check
|
||||
// containment on the resolved (realpath) form of both sides. A missing
|
||||
// file throws ENOENT here and keeps its 404 below.
|
||||
const [realRoot, realFull] = await Promise.all([fs.realpath(repoRoot), fs.realpath(fullPath)]);
|
||||
const realRel = path.relative(realRoot, realFull);
|
||||
if (realRel === '..' || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
|
||||
res.status(403).json({ error: 'Path traversal denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(realFull, 'utf-8');
|
||||
|
||||
// Optional line-range support: ?startLine=10&endLine=50
|
||||
// Returns only the requested slice (0-indexed), plus metadata.
|
||||
|
|
@ -831,6 +872,26 @@ function readOnlyFtsOptions(skipFts?: true): { readOnly: true; skipFts?: true }
|
|||
return skipFts ? { readOnly: true, skipFts: true } : { readOnly: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's `resolveRepo` returns `{ __timedOut: true, repoName }` when it
|
||||
* waited the full hold-queue window for an in-flight analysis. Every route
|
||||
* that resolves a repo must handle that sentinel — treating it as a registry
|
||||
* entry crashes on `entry.storagePath` ("path argument must be of type
|
||||
* string", surfaced as a 500). Returns true when a 503 was sent and the
|
||||
* caller should return.
|
||||
*/
|
||||
export const respondIfAnalysisPending = (
|
||||
entry: unknown,
|
||||
res: { status: (code: number) => { json: (body: unknown) => void } },
|
||||
): boolean => {
|
||||
const sentinel = entry as { __timedOut?: boolean; repoName?: string } | null | undefined;
|
||||
if (!sentinel?.__timedOut) return false;
|
||||
res.status(503).json({
|
||||
error: `Repository analysis for "${sentinel.repoName}" is taking longer than expected. Please try again in a moment.`,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleQueryRequest = async (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
|
|
@ -855,6 +916,7 @@ export const handleQueryRequest = async (
|
|||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const { skipFts } = await loadFtsSession(entry.storagePath);
|
||||
const result = await withLbugDb(
|
||||
|
|
@ -921,6 +983,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
const serverStartedAt = Date.now();
|
||||
|
||||
// Which upstream hops may set X-Forwarded-*. Process-wide: every route's
|
||||
// req.ip, and so the per-IP rate limiter, resolves through this.
|
||||
|
|
@ -954,6 +1017,14 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// global body parser so rejected requests do not consume the JSON budget.
|
||||
installServeMcpAuth(app);
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
// Express 5 leaves `req.body` undefined when no parser matched (e.g. a POST
|
||||
// without `Content-Type: application/json`). Route handlers read
|
||||
// `req.body.<field>` directly, so normalize to an empty object and let their
|
||||
// own "Missing X in request body" 400s fire instead of a TypeError 500.
|
||||
app.use((req, _res, next) => {
|
||||
if (req.body == null) req.body = {};
|
||||
next();
|
||||
});
|
||||
|
||||
// Origin guard for write routes: loopback, the server's own bound host, and
|
||||
// any configured public origin — prevents CSRF from other devices.
|
||||
|
|
@ -1024,7 +1095,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
repoName?: string,
|
||||
isRetry = false,
|
||||
req?: any,
|
||||
options: { validateStorage?: boolean } = {},
|
||||
options: { validateStorage?: boolean; awaitAnalysis?: boolean } = {},
|
||||
): Promise<any> => {
|
||||
const repos = await listRegisteredRepos({
|
||||
validate: options.validateStorage !== false,
|
||||
|
|
@ -1039,7 +1110,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// analyzing this repo. Hold the connection open (up to 5 minutes) until it completes.
|
||||
// We only wait for in-progress jobs ('queued'|'cloning'|'analyzing') — a 'complete' job
|
||||
// whose repo is still missing means the registry sync failed; the fallback below handles it.
|
||||
if (!found && normalizedName) {
|
||||
if (!found && normalizedName && options.awaitAnalysis !== false) {
|
||||
const lower = normalizedName.toLowerCase();
|
||||
|
||||
// Track client disconnect to cancel the wait early
|
||||
|
|
@ -1068,7 +1139,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
for (let wait = 0; wait < HOLD_QUEUE_TIMEOUT_SECS; wait++) {
|
||||
if (clientGone) return null; // client disconnected — stop polling
|
||||
const currentJob = jobManager.getJob(job.id);
|
||||
if (!currentJob || currentJob.status === 'failed') break;
|
||||
if (!currentJob || currentJob.status === 'failed') {
|
||||
// The job is over and produced no registry entry. This is a
|
||||
// plain "not found", not "still analyzing" — falling through to
|
||||
// the timed-out sentinel here told callers to keep waiting for
|
||||
// a job that had already failed.
|
||||
return null;
|
||||
}
|
||||
if (currentJob.status === 'complete') {
|
||||
await backend.init();
|
||||
const freshRepos = await listRegisteredRepos({
|
||||
|
|
@ -1178,12 +1255,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
// Timed out waiting for an active analysis job
|
||||
if (entry.__timedOut) {
|
||||
res.status(503).json({
|
||||
error: `Repository analysis for "${entry.repoName}" is taking longer than expected. Please try again in a moment.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
const meta = await loadMeta(entry.storagePath);
|
||||
const [staleness, availability] = await Promise.all([
|
||||
checkStalenessAsync(entry.path, resolveLastCommit(entry, meta)),
|
||||
|
|
@ -1217,6 +1289,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireDeletableStoragePath(entry);
|
||||
|
|
@ -1306,6 +1379,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const includeContent = req.query.includeContent === 'true';
|
||||
const stream = req.query.stream === 'true';
|
||||
|
|
@ -1398,6 +1472,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
const lbugPath = path.join(entry.storagePath, 'lbug');
|
||||
const parsedLimit = Number(req.body.limit ?? 10);
|
||||
const { ftsDisabledReason, skipFts } = await loadFtsSession(entry.storagePath);
|
||||
|
|
@ -1572,6 +1647,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
await handleFileRequest(req, res, entry.path, await getSourceAvailability(entry));
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
|
|
@ -1591,6 +1667,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(404).json({ error: 'Repository not found' });
|
||||
return;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
const sourceAvailability = await getSourceAvailability(entry);
|
||||
if (!sourceAvailability.available) {
|
||||
sendSourceUnavailable(res, sourceAvailability);
|
||||
|
|
@ -1634,18 +1711,58 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
}
|
||||
});
|
||||
|
||||
// Process / cluster routes resolve `?repo=` through the HTTP resolver
|
||||
// (`resolveRepo` → `resolveRegisteredRepoEntry`) and hand the backend the
|
||||
// registered ABSOLUTE path. Passing the raw param straight to the MCP
|
||||
// resolver bypassed the policy documented on `resolveRegisteredRepoEntry`:
|
||||
// a bare-name miss ran a CWD-relative realpathSync probe on attacker input
|
||||
// and a full registry refresh, and a partial name (`?repo=core`) could
|
||||
// silently pick `my-core-lib`. Same 60 rpm/IP limiter as `/api/repo`.
|
||||
const resolveBackendRepoPath = async (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<string | null> => {
|
||||
// Pass `req` so resolveRepo can abort its hold-queue wait when the client
|
||||
// disconnects (close listener is only registered when `req` is supplied).
|
||||
const requested = requestedRepo(req);
|
||||
let entry;
|
||||
if (!requested) {
|
||||
const omitted = resolveOmittedRepoSelection(await listRegisteredRepos({ validate: true }));
|
||||
if (omitted.ok === false) {
|
||||
res.status(omitted.status).json({ error: omitted.error });
|
||||
return null;
|
||||
}
|
||||
// Keep this snapshot's sole entry. resolveRepo(undefined) would list
|
||||
// again and map an omitted name to repos[0] of a newer registry.
|
||||
entry = await validateResolvedRepoEntry(omitted.entry);
|
||||
} else {
|
||||
entry = await resolveRepo(requested, false, req, {
|
||||
awaitAnalysis: parseAwaitAnalysisQuery(req.query.awaitAnalysis),
|
||||
});
|
||||
}
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: 'Repository not found' });
|
||||
return null;
|
||||
}
|
||||
if (respondIfAnalysisPending(entry, res)) return null;
|
||||
return entry.path as string;
|
||||
};
|
||||
|
||||
// List all processes
|
||||
app.get('/api/processes', async (req, res) => {
|
||||
app.get('/api/processes', createRouteLimiter(), async (req, res) => {
|
||||
try {
|
||||
const result = await backend.queryProcesses(requestedRepo(req));
|
||||
const repoPath = await resolveBackendRepoPath(req, res);
|
||||
if (repoPath === null) return;
|
||||
const result = await backend.queryProcesses(repoPath);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
res.status(statusFromError(err)).json(httpErrorBody(err, 'Failed to query processes'));
|
||||
}
|
||||
});
|
||||
|
||||
// Process detail
|
||||
app.get('/api/process', async (req, res) => {
|
||||
app.get('/api/process', createRouteLimiter(), async (req, res) => {
|
||||
try {
|
||||
const name = String(req.query.name ?? '').trim();
|
||||
if (!name) {
|
||||
|
|
@ -1653,29 +1770,35 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
|
||||
const result = await backend.queryProcessDetail(name, requestedRepo(req));
|
||||
const repoPath = await resolveBackendRepoPath(req, res);
|
||||
if (repoPath === null) return;
|
||||
const result = await backend.queryProcessDetail(name, repoPath);
|
||||
if (result?.error) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
res.status(statusFromError(err)).json(httpErrorBody(err, 'Failed to query process detail'));
|
||||
}
|
||||
});
|
||||
|
||||
// List all clusters
|
||||
app.get('/api/clusters', async (req, res) => {
|
||||
app.get('/api/clusters', createRouteLimiter(), async (req, res) => {
|
||||
try {
|
||||
const result = await backend.queryClusters(requestedRepo(req));
|
||||
const repoPath = await resolveBackendRepoPath(req, res);
|
||||
if (repoPath === null) return;
|
||||
const result = await backend.queryClusters(repoPath);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
res.status(statusFromError(err)).json(httpErrorBody(err, 'Failed to query clusters'));
|
||||
}
|
||||
});
|
||||
|
||||
// Cluster detail
|
||||
app.get('/api/cluster', async (req, res) => {
|
||||
app.get('/api/cluster', createRouteLimiter(), async (req, res) => {
|
||||
try {
|
||||
const name = String(req.query.name ?? '').trim();
|
||||
if (!name) {
|
||||
|
|
@ -1683,13 +1806,16 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
|
||||
const result = await backend.queryClusterDetail(name, requestedRepo(req));
|
||||
const repoPath = await resolveBackendRepoPath(req, res);
|
||||
if (repoPath === null) return;
|
||||
const result = await backend.queryClusterDetail(name, repoPath);
|
||||
if (result?.error) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (sendStorageRequirementHttp(err, res)) return;
|
||||
res.status(statusFromError(err)).json(httpErrorBody(err, 'Failed to query cluster detail'));
|
||||
}
|
||||
});
|
||||
|
|
@ -1831,7 +1957,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
// url+branch: same value as registryName (dir basename), not
|
||||
// the extractWebRepoName stem used only as getCloneDir's first arg.
|
||||
repoName: analyzeBranch ? path.basename(targetPath) : repoName,
|
||||
progress: { phase: 'cloning', percent: 0, message: `Cloning ${repoUrl}...` },
|
||||
// Never put repoUrl in progress — ops feed is unauthenticated
|
||||
// and may still serialize message (credentials via userinfo).
|
||||
progress: {
|
||||
phase: 'cloning',
|
||||
percent: 0,
|
||||
message: `Cloning ${analyzeBranch ? path.basename(targetPath) : repoName}...`,
|
||||
},
|
||||
});
|
||||
|
||||
await cloneOrPull(
|
||||
|
|
@ -1912,17 +2044,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(404).json({ error: 'Job not found' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
repoUrl: job.repoUrl,
|
||||
repoPath: job.repoPath,
|
||||
repoName: job.repoName,
|
||||
progress: job.progress,
|
||||
error: job.error,
|
||||
startedAt: job.startedAt,
|
||||
completedAt: job.completedAt,
|
||||
});
|
||||
// Same public serializer as `/api/ops` — job ids on the ops feed must not
|
||||
// unlock raw repoUrl/repoPath/userinfo through this pre-existing poll.
|
||||
res.json(serializeOpsJob(job, 'analyze'));
|
||||
});
|
||||
|
||||
// GET /api/analyze/:jobId/progress — SSE stream (shared helper)
|
||||
|
|
@ -1941,7 +2065,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
jobManager.cancelJob(jobId, 'Cancelled by user');
|
||||
res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' });
|
||||
// Live JobManager view: a registered child stays non-terminal until exit.
|
||||
// Hard-coding `failed` here made clients retry immediately and then 409.
|
||||
res.json(serializeOpsJob(jobManager.getJob(jobId) ?? job, 'analyze'));
|
||||
});
|
||||
|
||||
// ── Embedding endpoints ────────────────────────────────────────────
|
||||
|
|
@ -1961,6 +2087,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
|
||||
if (respondIfAnalysisPending(entry, res)) return;
|
||||
|
||||
// Re-check the exact registered slot immediately before taking the lock.
|
||||
// The query resolver already validates it, but this closes the gap between
|
||||
// lookup and a long-running metadata-writing job.
|
||||
|
|
@ -2255,18 +2383,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.status(404).json({ error: 'Job not found' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
repoName: job.repoName,
|
||||
progress: job.progress,
|
||||
error: job.error,
|
||||
// Absent unless the run was a partial one — omitted by JSON.stringify, so
|
||||
// the response shape is unchanged for every other outcome (#2790).
|
||||
partial: job.partial,
|
||||
startedAt: job.startedAt,
|
||||
completedAt: job.completedAt,
|
||||
});
|
||||
res.json(serializeOpsJob(job, 'embed'));
|
||||
});
|
||||
|
||||
// GET /api/embed/:jobId/progress — SSE stream (shared helper)
|
||||
|
|
@ -2285,7 +2402,59 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
return;
|
||||
}
|
||||
embedJobManager.cancelJob(jobId, 'Cancelled by user');
|
||||
res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' });
|
||||
// Same live serialize as analyze DELETE / GET poll — do not invent `failed`.
|
||||
res.json(serializeOpsJob(embedJobManager.getJob(jobId) ?? job, 'embed'));
|
||||
});
|
||||
|
||||
const currentOpsSnapshot = () =>
|
||||
buildOpsSnapshot({
|
||||
analyzeJobs: jobManager.listJobs(),
|
||||
embedJobs: embedJobManager.listJobs(),
|
||||
serverStartedAt,
|
||||
server: buildServerInfo(updateController.snapshot()),
|
||||
});
|
||||
|
||||
// GET /api/ops — realtime execution snapshot for the ops dashboard.
|
||||
// In-memory only (analyze + embed JobManagers); no git/fs work, safe to poll.
|
||||
// Rate-limited: snapshot serialization is cheap per call but unbounded
|
||||
// polling from many clients is not.
|
||||
app.get('/api/ops', createRouteLimiter({ limit: 60 }), (_req, res) => {
|
||||
res.json(currentOpsSnapshot());
|
||||
});
|
||||
|
||||
// Cap concurrent ops SSE streams — each holds two intervals and serializes
|
||||
// the full job list every second for as long as the client stays connected.
|
||||
let opsStreamConnections = 0;
|
||||
const MAX_OPS_STREAM_CONNECTIONS = 8;
|
||||
|
||||
// GET /api/ops/stream — SSE push of the same snapshot every second.
|
||||
app.get('/api/ops/stream', createRouteLimiter({ limit: 30 }), (req, res) => {
|
||||
if (opsStreamConnections >= MAX_OPS_STREAM_CONNECTIONS) {
|
||||
res.status(429).json({ error: 'Too many ops stream connections' });
|
||||
return;
|
||||
}
|
||||
opsStreamConnections += 1;
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.flushHeaders();
|
||||
|
||||
const push = () => {
|
||||
res.write(`data: ${JSON.stringify(currentOpsSnapshot())}\n\n`);
|
||||
};
|
||||
|
||||
push();
|
||||
const interval = setInterval(push, 1_000);
|
||||
const keepAlive = setInterval(() => res.write(':ping\n\n'), 15_000);
|
||||
const release = () => {
|
||||
clearInterval(interval);
|
||||
clearInterval(keepAlive);
|
||||
opsStreamConnections = Math.max(0, opsStreamConnections - 1);
|
||||
};
|
||||
req.on('close', release);
|
||||
});
|
||||
|
||||
// ── Web UI (served at root) ───────────────────────────────────────
|
||||
|
|
@ -2299,8 +2468,19 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
const staticDir = await resolveWebDistDir(webDistDir, devWebDistDir);
|
||||
registerWebUI(app, staticDir);
|
||||
|
||||
// Global error handler — catch anything the route handlers miss
|
||||
// Global error handler — catch anything the route handlers miss.
|
||||
// body-parser rejections (malformed JSON → 400, > limit → 413, wrong
|
||||
// charset → 415) arrive here as http-errors with a 4xx `status`/`statusCode`
|
||||
// and `expose: true`; report them as the client errors they are instead of
|
||||
// logging them at error level as a 500.
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
const status = Number(err?.status ?? err?.statusCode);
|
||||
if (Number.isInteger(status) && status >= 400 && status < 500) {
|
||||
const message =
|
||||
err?.expose && typeof err.message === 'string' && err.message ? err.message : 'Bad request';
|
||||
res.status(status).json({ error: message });
|
||||
return;
|
||||
}
|
||||
logger.error({ err }, 'Unhandled error:');
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
|
|
|||
352
gitnexus/src/server/ops-snapshot.ts
Normal file
352
gitnexus/src/server/ops-snapshot.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
/**
|
||||
* Ops / execution dashboard snapshot helpers.
|
||||
*
|
||||
* Pure serialization + metrics over in-memory JobManager state so the
|
||||
* HTTP route stays thin and unit tests do not boot Express.
|
||||
*/
|
||||
|
||||
import {
|
||||
isTerminalJobStatus,
|
||||
type AnalyzeJob,
|
||||
type AnalyzeJobProgress,
|
||||
type AnalyzeJobStatus,
|
||||
} from './analyze-job.js';
|
||||
import { publicRepoId } from './public-repo-id.js';
|
||||
import { escapeRegExp } from './validation.js';
|
||||
|
||||
export interface OpsJobView {
|
||||
id: string;
|
||||
lane: 'analyze' | 'embed';
|
||||
status: AnalyzeJobStatus;
|
||||
repoName?: string;
|
||||
/** Opaque `GET /api/repos` entry id; set once the job is complete. */
|
||||
repoId?: string;
|
||||
branch?: string;
|
||||
progress: AnalyzeJob['progress'];
|
||||
error?: string;
|
||||
partial?: AnalyzeJob['partial'];
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
retryCount: number;
|
||||
/** Elapsed wall time; uses completedAt when terminal, else `now`. */
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface OpsLaneMetrics {
|
||||
total: number;
|
||||
active: number;
|
||||
queued: number;
|
||||
complete: number;
|
||||
failed: number;
|
||||
byStatus: Record<AnalyzeJobStatus, number>;
|
||||
/** Mean duration of terminal jobs that have completedAt; null when none. */
|
||||
avgDurationMs: number | null;
|
||||
/** Max duration among terminal jobs; null when none. */
|
||||
maxDurationMs: number | null;
|
||||
/** Sum of progress.percent across non-terminal jobs (0–100 each). */
|
||||
activeProgressSum: number;
|
||||
}
|
||||
|
||||
export interface OpsSnapshot {
|
||||
generatedAt: number;
|
||||
uptimeMs: number;
|
||||
health: 'ok';
|
||||
server: {
|
||||
version: string;
|
||||
launchContext: string;
|
||||
nodeVersion: string;
|
||||
latestVersion?: string;
|
||||
updateAvailable?: boolean;
|
||||
};
|
||||
analyze: {
|
||||
jobs: OpsJobView[];
|
||||
metrics: OpsLaneMetrics;
|
||||
};
|
||||
embed: {
|
||||
jobs: OpsJobView[];
|
||||
metrics: OpsLaneMetrics;
|
||||
};
|
||||
totals: {
|
||||
jobs: number;
|
||||
active: number;
|
||||
failed: number;
|
||||
complete: number;
|
||||
};
|
||||
}
|
||||
|
||||
const emptyByStatus = (): Record<AnalyzeJobStatus, number> => ({
|
||||
queued: 0,
|
||||
cloning: 0,
|
||||
analyzing: 0,
|
||||
loading: 0,
|
||||
complete: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
/** Basename for ops UI — strip query/fragment so tokens never leak into repoName. */
|
||||
export const publicRepoNameFromUrl = (repoUrl: string | undefined): string | undefined => {
|
||||
if (!repoUrl) return undefined;
|
||||
try {
|
||||
const parsed = new URL(repoUrl);
|
||||
// new URL accepts Windows drive paths as `c:` URLs; split on `\` too so we
|
||||
// never emit a full filesystem pathname on the unauthenticated ops feed.
|
||||
const segments = parsed.pathname
|
||||
.replace(/[\\/]+$/, '')
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean);
|
||||
const last = segments.pop();
|
||||
if (!last) return undefined;
|
||||
return last.replace(/\.git$/i, '') || undefined;
|
||||
} catch {
|
||||
// Non-URL fallbacks (scp-like git@host:org/repo.git) — drop query/hash then basename.
|
||||
const cleaned = repoUrl
|
||||
.split(/[?#]/, 1)[0]!
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\.git$/i, '');
|
||||
const last = cleaned.split(/[/\\]/).pop();
|
||||
return last || undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const publicRepoNameFromPath = (repoPath: string | undefined): string | undefined => {
|
||||
if (!repoPath) return undefined;
|
||||
return (
|
||||
repoPath
|
||||
.replace(/[/\\]+$/, '')
|
||||
.split(/[/\\]/)
|
||||
.pop() || undefined
|
||||
);
|
||||
};
|
||||
|
||||
const preserveTrailingPunct = (raw: string, token: string): string => {
|
||||
const trailing = raw.match(/(\.{2,}|[),;]+)$/);
|
||||
return trailing ? `${token}${trailing[0]}` : token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Display name for public payloads. A branch-pinned URL clone registers under
|
||||
* its clone-directory name (`<repo>__<branch slug>`), which would put the
|
||||
* requested branch on the unauthenticated feed — use the URL's repo name.
|
||||
* Reconnect goes through {@link publicJobRepoId}, not this label.
|
||||
*/
|
||||
export const publicJobRepoName = (job: AnalyzeJob): string | undefined =>
|
||||
(job.branch ? publicRepoNameFromUrl(job.repoUrl) : undefined) ||
|
||||
job.repoName ||
|
||||
publicRepoNameFromUrl(job.repoUrl) ||
|
||||
publicRepoNameFromPath(job.repoPath);
|
||||
|
||||
/** Opaque registry handle, only once the job's index is published. */
|
||||
export const publicJobRepoId = (job: AnalyzeJob): string | undefined =>
|
||||
job.status === 'complete' && job.repoPath ? publicRepoId(job.repoPath) : undefined;
|
||||
|
||||
export const knownJobLocations = (
|
||||
job?: Pick<AnalyzeJob, 'repoPath' | 'repoUrl'> | null,
|
||||
): Array<string | undefined> => [job?.repoPath, job?.repoUrl];
|
||||
|
||||
/** HTTP(S)/ssh URLs and scp-like remotes redact as `[repo]`; filesystem paths as `[path]`. */
|
||||
const knownRedactionToken = (value: string): '[repo]' | '[path]' =>
|
||||
/^(https?:\/\/|ssh:\/\/)/i.test(value) || /^[\w.-]+@[\w.-]+:/.test(value) ? '[repo]' : '[path]';
|
||||
|
||||
/**
|
||||
* After a path separator, consume a single space only when another `/` or `\`
|
||||
* still follows — so `/home/Jane Doe/.gitnexus/foo` is one path, but
|
||||
* `/home/alice/src/private-repo has no remote.origin` keeps the sentence.
|
||||
*/
|
||||
const PATH_WITH_INTERNAL_SPACE = String.raw`[^\s"')]+(?: [^\s"')]*[\\/][^\s"')]*)*`;
|
||||
|
||||
const redactKnownLocations = (text: string, known?: Array<string | undefined>): string => {
|
||||
const values = (known ?? [])
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
const seen = new Set<string>();
|
||||
let out = text;
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
// Consume a descendant tail too (`<repoPath>/src/secret.ts`): once the
|
||||
// prefix became `[path]`, the absolute-path scrub below could no longer see
|
||||
// the rest. The lookahead keeps `<repoPath>2/…` (a sibling) for that scrub.
|
||||
out = out.replace(
|
||||
new RegExp(
|
||||
`${escapeRegExp(value)}(?:[/\\\\]${PATH_WITH_INTERNAL_SPACE}|[/\\\\]+)?(?![\\w-]|\\.\\w)`,
|
||||
'g',
|
||||
),
|
||||
(raw) => preserveTrailingPunct(raw, knownRedactionToken(value)),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mid-string scrub for unauthenticated ops/poll payloads. Clone progress and
|
||||
* worker errors embed the URL after a prefix ("Cloning https://…") and also
|
||||
* embed local clone paths ("Existing clone at /home/alice/…"). Replace the
|
||||
* whole HTTP(S) URL — host, path, and query — not only userinfo, replace
|
||||
* scp-like `user@host:path` and `ssh://` remotes, and replace absolute POSIX /
|
||||
* Windows / UNC filesystem paths so a LAN or official-Vercel origin cannot
|
||||
* recover home-directory layout or private org/repo names from /api/ops.
|
||||
*
|
||||
* Remote URL forms are replaced first. Known `repoPath` / `repoUrl` literals
|
||||
* then run (longest first) so a clone dir with spaces cannot leak around
|
||||
* `[^\s]+`, then the filesystem path regexes.
|
||||
*/
|
||||
const redactRemoteUrls = (text: string): string =>
|
||||
text
|
||||
.replace(/https?:\/\/[^\s]+/gi, (raw) => preserveTrailingPunct(raw, '[repo]'))
|
||||
.replace(/ssh:\/\/[^\s]+/gi, (raw) => preserveTrailingPunct(raw, '[repo]'))
|
||||
.replace(/file:\/\/[^\s"']+/gi, (raw) => preserveTrailingPunct(raw, '[path]'))
|
||||
.replace(/[\w.-]+@[\w.-]+:[^\s"')]+/g, (raw) => preserveTrailingPunct(raw, '[repo]'));
|
||||
|
||||
const redactFilesystemPaths = (text: string): string =>
|
||||
text
|
||||
.replace(new RegExp(`[A-Za-z]:[\\\\/]${PATH_WITH_INTERNAL_SPACE}`, 'g'), (raw) =>
|
||||
preserveTrailingPunct(raw, '[path]'),
|
||||
)
|
||||
.replace(new RegExp(`\\\\\\\\${PATH_WITH_INTERNAL_SPACE}`, 'g'), (raw) =>
|
||||
preserveTrailingPunct(raw, '[path]'),
|
||||
)
|
||||
.replace(
|
||||
new RegExp(`(^|[\\s"'=(])(/${PATH_WITH_INTERNAL_SPACE})`, 'g'),
|
||||
(_m, prefix: string, absPath: string) =>
|
||||
`${prefix}${preserveTrailingPunct(absPath, '[path]')}`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Remote URL forms first so a known `repoUrl` that is a prefix of a longer
|
||||
* URL cannot punch a hole (`[repo]/other?token=`) before the URL scrubber
|
||||
* runs. Known filesystem literals still run before the path regexes so a
|
||||
* clone dir with spaces cannot leak around `[^\s]+`.
|
||||
*/
|
||||
export const redactPublicText = (text: string, known?: Array<string | undefined>): string =>
|
||||
redactFilesystemPaths(redactKnownLocations(redactRemoteUrls(text), known));
|
||||
|
||||
/**
|
||||
* Ops feed is unauthenticated — never emit raw repo URLs (or userinfo) via
|
||||
* progress.message even when the in-memory job still holds them for cloning.
|
||||
*/
|
||||
export const publicOpsProgress = (
|
||||
progress: AnalyzeJobProgress,
|
||||
known?: Array<string | undefined>,
|
||||
): AnalyzeJobProgress => ({
|
||||
phase: progress.phase,
|
||||
percent: progress.percent,
|
||||
message: redactPublicText(progress.message, known),
|
||||
});
|
||||
|
||||
export const serializeOpsJob = (
|
||||
job: AnalyzeJob,
|
||||
lane: 'analyze' | 'embed',
|
||||
now: number = Date.now(),
|
||||
): OpsJobView => {
|
||||
const end = job.completedAt ?? now;
|
||||
const known = knownJobLocations(job);
|
||||
// Never emit raw repoUrl/repoPath or the requested branch on the
|
||||
// unauthenticated ops feed (a ref can name a private project the same way a
|
||||
// path would).
|
||||
return {
|
||||
id: job.id,
|
||||
lane,
|
||||
status: job.status,
|
||||
repoName: publicJobRepoName(job),
|
||||
repoId: publicJobRepoId(job),
|
||||
progress: publicOpsProgress(job.progress, known),
|
||||
error: job.error ? redactPublicText(job.error, known) : undefined,
|
||||
partial: job.partial,
|
||||
startedAt: job.startedAt,
|
||||
completedAt: job.completedAt,
|
||||
retryCount: job.retryCount,
|
||||
durationMs: Math.max(0, end - job.startedAt),
|
||||
};
|
||||
};
|
||||
|
||||
export const summarizeOpsLane = (jobs: OpsJobView[]): OpsLaneMetrics => {
|
||||
const byStatus = emptyByStatus();
|
||||
let active = 0;
|
||||
let queued = 0;
|
||||
let complete = 0;
|
||||
let failed = 0;
|
||||
let durationSum = 0;
|
||||
let durationCount = 0;
|
||||
let maxDurationMs: number | null = null;
|
||||
let activeProgressSum = 0;
|
||||
|
||||
for (const job of jobs) {
|
||||
byStatus[job.status] += 1;
|
||||
if (job.status === 'queued') queued += 1;
|
||||
if (job.status === 'complete') complete += 1;
|
||||
if (job.status === 'failed') failed += 1;
|
||||
if (!isTerminalJobStatus(job.status)) {
|
||||
active += 1;
|
||||
activeProgressSum += Math.max(0, Math.min(100, job.progress.percent));
|
||||
} else if (job.completedAt !== undefined) {
|
||||
durationSum += job.durationMs;
|
||||
durationCount += 1;
|
||||
maxDurationMs =
|
||||
maxDurationMs === null ? job.durationMs : Math.max(maxDurationMs, job.durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: jobs.length,
|
||||
active,
|
||||
queued,
|
||||
complete,
|
||||
failed,
|
||||
byStatus,
|
||||
avgDurationMs: durationCount === 0 ? null : Math.round(durationSum / durationCount),
|
||||
maxDurationMs,
|
||||
activeProgressSum,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildOpsSnapshot = (input: {
|
||||
analyzeJobs: AnalyzeJob[];
|
||||
embedJobs: AnalyzeJob[];
|
||||
serverStartedAt: number;
|
||||
server: OpsSnapshot['server'];
|
||||
now?: number;
|
||||
}): OpsSnapshot => {
|
||||
const now = input.now ?? Date.now();
|
||||
const analyzeJobs = input.analyzeJobs
|
||||
.map((j) => serializeOpsJob(j, 'analyze', now))
|
||||
.sort((a, b) => b.startedAt - a.startedAt);
|
||||
const embedJobs = input.embedJobs
|
||||
.map((j) => serializeOpsJob(j, 'embed', now))
|
||||
.sort((a, b) => b.startedAt - a.startedAt);
|
||||
const analyze = { jobs: analyzeJobs, metrics: summarizeOpsLane(analyzeJobs) };
|
||||
const embed = { jobs: embedJobs, metrics: summarizeOpsLane(embedJobs) };
|
||||
|
||||
return {
|
||||
generatedAt: now,
|
||||
uptimeMs: Math.max(0, now - input.serverStartedAt),
|
||||
health: 'ok',
|
||||
server: input.server,
|
||||
analyze,
|
||||
embed,
|
||||
totals: {
|
||||
jobs: analyze.metrics.total + embed.metrics.total,
|
||||
active: analyze.metrics.active + embed.metrics.active,
|
||||
failed: analyze.metrics.failed + embed.metrics.failed,
|
||||
complete: analyze.metrics.complete + embed.metrics.complete,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** True when Origin is an exact first-party GitNexus Vercel production host. */
|
||||
export const isGitNexusVercelOrigin = (origin: string): boolean => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'https:') return false;
|
||||
// Browsers omit the default port; non-default ports such as :8443 are not
|
||||
// documented production Origin strings. Explicit :443 is the same origin as the bare host.
|
||||
if (parsed.port) return false;
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
// Exact hosts only — a prefix like `gitnexus-web-` would also match any
|
||||
// attacker-controlled Vercel project named `gitnexus-web-*`. Preview
|
||||
// deployments should set GITNEXUS_PUBLIC_ORIGIN instead.
|
||||
return host === 'gitnexus.vercel.app' || host === 'gitnexus-web.vercel.app';
|
||||
};
|
||||
23
gitnexus/src/server/public-repo-id.ts
Normal file
23
gitnexus/src/server/public-repo-id.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Opaque repo handle for unauthenticated payloads.
|
||||
*
|
||||
* Public job views and SSE terminal frames must not carry the analyzed path,
|
||||
* and a registry name is not unique (see `repo-manager.ts`). An HMAC of the
|
||||
* canonical registry path under a per-process random key is unique per entry,
|
||||
* reveals nothing about the path, and matches the `id` on `GET /api/repos`
|
||||
* entries, so a client can select the exact entry a job just analyzed.
|
||||
*
|
||||
* Stable only for the lifetime of this server process: resolve it right after
|
||||
* the job finishes, never persist it.
|
||||
*/
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
import { canonicalizePath, registryPathEquals } from '../storage/repo-manager.js';
|
||||
|
||||
const KEY = randomBytes(32);
|
||||
|
||||
export const publicRepoId = (repoPath: string): string => {
|
||||
const canonical = canonicalizePath(repoPath);
|
||||
// Same equality the registry uses: case-insensitive on Windows only.
|
||||
const key = registryPathEquals('A', 'a') ? canonical.toLowerCase() : canonical;
|
||||
return createHmac('sha256', KEY).update(key).digest('base64url').slice(0, 22);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
22
gitnexus/src/storage/map-pool.ts
Normal file
22
gitnexus/src/storage/map-pool.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/** Rolling worker pool: in-order results, fail-fast mapper errors. */
|
||||
export const mapPool = async <T, R>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<R>,
|
||||
concurrency: number,
|
||||
): Promise<R[]> => {
|
||||
if (items.length === 0) return [];
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index] as T);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
};
|
||||
|
|
@ -27,6 +27,7 @@ import { stripWindowsLongPathPrefix } from '../lib/utils.js';
|
|||
import { writeFileAtomic } from './fs-atomic.js';
|
||||
import { getGlobalDir } from './global-dir.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { mapPool } from './map-pool.js';
|
||||
import {
|
||||
acquireIndexLock,
|
||||
IndexLockTimeoutError,
|
||||
|
|
@ -1206,8 +1207,16 @@ const unregisterRepoUnlocked = async (repoPath: string): Promise<void> => {
|
|||
// and vice versa. Matches the semantics of `registerRepo` and
|
||||
// `resolveRegistryEntry` post-#1003 review.
|
||||
const resolved = canonicalizePath(repoPath);
|
||||
const entries = await readRegistry();
|
||||
// Same rule as `registerRepoUnlocked` (#3094): a mutating write must not
|
||||
// treat an unreadable/truncated registry as empty. The lenient reader
|
||||
// returned `[]` on any read error (EBUSY/EPERM racing another gitnexus
|
||||
// process's atomic rename on Windows, EIO, a half-written file) and this
|
||||
// function then wrote `[]` back — deregistering every repo on the machine
|
||||
// to remove one. A missing file means nothing to remove.
|
||||
const entries = await readRegistryStrictIfPresent();
|
||||
if (entries === undefined) return;
|
||||
const filtered = entries.filter((e) => !registryPathEquals(canonicalizePath(e.path), resolved));
|
||||
if (filtered.length === entries.length) return;
|
||||
await writeRegistry(filtered);
|
||||
};
|
||||
|
||||
|
|
@ -1689,28 +1698,6 @@ export const findRegistryEntryByName = (
|
|||
* I/O storm cannot make the registry disappear; it remains unconfirmed until a
|
||||
* later validating read succeeds.
|
||||
*/
|
||||
const mapPool = async <T, R>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<R>,
|
||||
concurrency: number,
|
||||
): Promise<R[]> => {
|
||||
if (items.length === 0) return [];
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index] as T);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
};
|
||||
|
||||
export const listRegisteredRepos = async (opts?: {
|
||||
validate?: boolean;
|
||||
}): Promise<RegistryEntry[]> => {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import path from 'path';
|
|||
import { BRANCHES_DIR } from './branch-index.js';
|
||||
import { listLocalHeads } from './git.js';
|
||||
import { isMissingFilesystemError, loadMeta } from './repo-meta.js';
|
||||
import { mapPool } from './map-pool.js';
|
||||
import { getStoragePaths, removeBranchIndex } from './repo-manager.js';
|
||||
|
||||
export type StaleBranchReason =
|
||||
|
|
@ -42,30 +43,9 @@ export interface ListStaleBranchSlotsInput {
|
|||
const slotDirForBranch = (repoPath: string, storagePath: string, branch: string): string =>
|
||||
path.dirname(getStoragePaths(repoPath, branch, storagePath).metaPath);
|
||||
|
||||
/** Same bound as `mapPool` in repo-manager: cap concurrent slot I/O. */
|
||||
/** Same bound as `listRegisteredRepos`: cap concurrent slot I/O. */
|
||||
const STALE_SLOT_IO_CONCURRENCY = 8;
|
||||
|
||||
const mapPool = async <T, R>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<R>,
|
||||
): Promise<R[]> => {
|
||||
if (items.length === 0) return [];
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const workerCount = Math.max(1, Math.min(STALE_SLOT_IO_CONCURRENCY, items.length));
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index] as T);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
};
|
||||
|
||||
/** Proven directory, proven absence, or a probe error that is not ENOENT/ENOTDIR. */
|
||||
type DirectoryProbe = 'dir' | 'missing' | 'unreadable';
|
||||
|
||||
|
|
@ -77,123 +57,6 @@ const probeDirectory = async (dir: string): Promise<DirectoryProbe> => {
|
|||
}
|
||||
};
|
||||
|
||||
const directorySizeBytes = async (root: string): Promise<number> => {
|
||||
let total = 0;
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current === undefined) break;
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => path.join(current, entry.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) stack.push(path.join(current, entry.name));
|
||||
}
|
||||
for (const file of files) {
|
||||
try {
|
||||
total += (await fs.stat(file)).size;
|
||||
} catch {
|
||||
// Skip files that disappear or become unreadable mid-walk.
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const metadataBranch = async (dir: string): Promise<string | null> => {
|
||||
const meta = await loadMeta(dir);
|
||||
return typeof meta?.branch === 'string' && meta.branch.length > 0 ? meta.branch : null;
|
||||
};
|
||||
|
||||
export const listStaleBranchSlots = async (
|
||||
input: ListStaleBranchSlotsInput,
|
||||
): Promise<StaleBranchSlot[]> => {
|
||||
const recorded = input.branches ?? [];
|
||||
const branchesRoot = path.join(input.storagePath, BRANCHES_DIR);
|
||||
|
||||
const registryByDir = new Map<string, string>();
|
||||
for (const row of recorded) {
|
||||
registryByDir.set(
|
||||
path.resolve(slotDirForBranch(input.repoPath, input.storagePath, row.branch)),
|
||||
row.branch,
|
||||
);
|
||||
}
|
||||
|
||||
let diskDirs: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(branchesRoot, { withFileTypes: true });
|
||||
diskDirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(branchesRoot, entry.name));
|
||||
} catch (err) {
|
||||
if (!isMissingFilesystemError(err)) {
|
||||
return [{ branch: '', dir: null, sizeBytes: 0, reason: 'listing-failed' }];
|
||||
}
|
||||
diskDirs = [];
|
||||
}
|
||||
|
||||
if (recorded.length === 0 && diskDirs.length === 0) return [];
|
||||
|
||||
const heads = input.heads !== undefined ? input.heads : listLocalHeads(input.repoPath);
|
||||
const live = heads === null ? null : new Set(heads);
|
||||
|
||||
const pending: Array<Omit<StaleBranchSlot, 'sizeBytes'>> = [];
|
||||
|
||||
const registryProbes = await mapPool([...registryByDir], async ([resolvedDir, branch]) => ({
|
||||
resolvedDir,
|
||||
branch,
|
||||
probe: await probeDirectory(resolvedDir),
|
||||
}));
|
||||
for (const { resolvedDir, branch, probe } of registryProbes) {
|
||||
if (probe === 'unreadable') {
|
||||
pending.push({ branch, dir: resolvedDir, reason: 'probe-failed' });
|
||||
continue;
|
||||
}
|
||||
const exists = probe === 'dir';
|
||||
const dir = exists ? resolvedDir : null;
|
||||
if (live === null) {
|
||||
pending.push({ branch, dir, reason: 'heads-unavailable' });
|
||||
continue;
|
||||
}
|
||||
if (!exists) {
|
||||
pending.push({ branch, dir: null, reason: 'registry-only' });
|
||||
continue;
|
||||
}
|
||||
if (!live.has(branch)) {
|
||||
pending.push({ branch, dir, reason: 'ref-missing' });
|
||||
}
|
||||
}
|
||||
|
||||
const leftoverDirs = diskDirs.filter((dir) => !registryByDir.has(path.resolve(dir)));
|
||||
const leftoverMeta = await mapPool(leftoverDirs, async (dir) => ({
|
||||
dir,
|
||||
branch: await metadataBranch(dir),
|
||||
}));
|
||||
for (const { dir, branch } of leftoverMeta) {
|
||||
if (branch === null) continue;
|
||||
const resolved = path.resolve(dir);
|
||||
if (live === null) {
|
||||
pending.push({ branch, dir: resolved, reason: 'heads-unavailable' });
|
||||
continue;
|
||||
}
|
||||
if (!live.has(branch)) {
|
||||
pending.push({ branch, dir: resolved, reason: 'disk-only' });
|
||||
}
|
||||
}
|
||||
|
||||
const includeSize = input.includeSize !== false;
|
||||
return mapPool(pending, async (row) => ({
|
||||
...row,
|
||||
sizeBytes: includeSize && row.dir ? await directorySizeBytes(row.dir) : 0,
|
||||
}));
|
||||
};
|
||||
|
||||
/** Lexical / realpath containment: `child` is a proper descendant of `parent`. */
|
||||
const isProperChildPath = (parent: string, child: string): boolean => {
|
||||
const root = path.resolve(parent);
|
||||
|
|
@ -210,9 +73,221 @@ const isProperChildPath = (parent: string, child: string): boolean => {
|
|||
const isSameNormalizedPath = (left: string, right: string): boolean =>
|
||||
path.relative(path.resolve(path.normalize(left)), path.resolve(path.normalize(right))) === '';
|
||||
|
||||
const expectedRealSlotPath = (realBranches: string, dir: string): string =>
|
||||
path.join(realBranches, path.basename(path.resolve(dir)));
|
||||
|
||||
const directorySizeBytes = async (root: string): Promise<number> => {
|
||||
let realRoot: string;
|
||||
try {
|
||||
const rootStat = await fs.lstat(root);
|
||||
if (rootStat.isSymbolicLink()) return 0;
|
||||
realRoot = await fs.realpath(root);
|
||||
const realParent = await fs.realpath(path.dirname(path.resolve(root)));
|
||||
if (!isSameNormalizedPath(realRoot, expectedRealSlotPath(realParent, root))) {
|
||||
return 0;
|
||||
}
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const seen = new Set<string>([realRoot]);
|
||||
let total = 0;
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current === undefined) break;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(current);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of entries) {
|
||||
const child = path.join(current, name);
|
||||
let stat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
stat = await fs.lstat(child);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isSymbolicLink()) continue;
|
||||
let real: string;
|
||||
try {
|
||||
real = await fs.realpath(child);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!isProperChildPath(realRoot, real) || seen.has(real)) continue;
|
||||
seen.add(real);
|
||||
if (stat.isDirectory()) {
|
||||
stack.push(child);
|
||||
continue;
|
||||
}
|
||||
if (stat.isFile()) {
|
||||
try {
|
||||
total += (await fs.stat(child)).size;
|
||||
} catch {
|
||||
// Skip files that disappear or become unreadable mid-walk.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const metadataBranch = async (dir: string): Promise<string | null> => {
|
||||
const meta = await loadMeta(dir);
|
||||
return typeof meta?.branch === 'string' && meta.branch.length > 0 ? meta.branch : null;
|
||||
};
|
||||
|
||||
export const isContainedBranchDir = (storagePath: string, dir: string): boolean =>
|
||||
isProperChildPath(path.resolve(storagePath, BRANCHES_DIR), dir);
|
||||
|
||||
/**
|
||||
* One containment rule for preview and force: `branches/` must be a real
|
||||
* directory whose realpath is `realpath(storagePath)/branches`.
|
||||
*/
|
||||
type BranchesRootProbe =
|
||||
| { status: 'ok'; realBranches: string }
|
||||
| { status: 'missing' }
|
||||
| { status: 'escaped' }
|
||||
| { status: 'unreadable' };
|
||||
|
||||
const probeContainedBranchesRoot = async (storagePath: string): Promise<BranchesRootProbe> => {
|
||||
const branchesRoot = path.resolve(storagePath, BRANCHES_DIR);
|
||||
let branchesStat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
branchesStat = await fs.lstat(branchesRoot);
|
||||
} catch (err) {
|
||||
return isMissingFilesystemError(err) ? { status: 'missing' } : { status: 'unreadable' };
|
||||
}
|
||||
if (branchesStat.isSymbolicLink() || !branchesStat.isDirectory()) {
|
||||
return { status: 'escaped' };
|
||||
}
|
||||
try {
|
||||
const realStorage = await fs.realpath(storagePath);
|
||||
const realBranches = await fs.realpath(branchesRoot);
|
||||
const expectedBranches = path.normalize(path.join(realStorage, BRANCHES_DIR));
|
||||
if (!isSameNormalizedPath(realBranches, expectedBranches)) {
|
||||
return { status: 'escaped' };
|
||||
}
|
||||
return { status: 'ok', realBranches };
|
||||
} catch (err) {
|
||||
return isMissingFilesystemError(err) ? { status: 'missing' } : { status: 'unreadable' };
|
||||
}
|
||||
};
|
||||
|
||||
const listingFailedRow = (): StaleBranchSlot => ({
|
||||
branch: '',
|
||||
dir: null,
|
||||
sizeBytes: 0,
|
||||
reason: 'listing-failed',
|
||||
});
|
||||
|
||||
export const listStaleBranchSlots = async (
|
||||
input: ListStaleBranchSlotsInput,
|
||||
): Promise<StaleBranchSlot[]> => {
|
||||
const recorded = input.branches ?? [];
|
||||
const branchesRoot = path.join(input.storagePath, BRANCHES_DIR);
|
||||
|
||||
const registryByDir = new Map<string, string>();
|
||||
for (const row of recorded) {
|
||||
registryByDir.set(
|
||||
path.resolve(slotDirForBranch(input.repoPath, input.storagePath, row.branch)),
|
||||
row.branch,
|
||||
);
|
||||
}
|
||||
|
||||
const branchesProbe = await probeContainedBranchesRoot(input.storagePath);
|
||||
if (branchesProbe.status === 'escaped' || branchesProbe.status === 'unreadable') {
|
||||
return [listingFailedRow()];
|
||||
}
|
||||
|
||||
let diskDirs: string[] = [];
|
||||
if (branchesProbe.status === 'ok') {
|
||||
try {
|
||||
const entries = await fs.readdir(branchesRoot, { withFileTypes: true });
|
||||
diskDirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(branchesRoot, entry.name));
|
||||
} catch (err) {
|
||||
if (!isMissingFilesystemError(err)) {
|
||||
return [listingFailedRow()];
|
||||
}
|
||||
diskDirs = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (recorded.length === 0 && diskDirs.length === 0) return [];
|
||||
|
||||
const heads = input.heads !== undefined ? input.heads : listLocalHeads(input.repoPath);
|
||||
const live = heads === null ? null : new Set(heads);
|
||||
|
||||
const pending: Array<Omit<StaleBranchSlot, 'sizeBytes'>> = [];
|
||||
|
||||
const leftoverDirs = diskDirs.filter((dir) => !registryByDir.has(path.resolve(dir)));
|
||||
// Sequential phases so STALE_SLOT_IO_CONCURRENCY caps total slot I/O.
|
||||
const registryProbes = await mapPool(
|
||||
[...registryByDir],
|
||||
async ([resolvedDir, branch]) => ({
|
||||
resolvedDir,
|
||||
branch,
|
||||
probe: await probeDirectory(resolvedDir),
|
||||
}),
|
||||
STALE_SLOT_IO_CONCURRENCY,
|
||||
);
|
||||
const leftoverMeta = await mapPool(
|
||||
leftoverDirs,
|
||||
async (dir) => ({
|
||||
dir,
|
||||
branch: await metadataBranch(dir),
|
||||
}),
|
||||
STALE_SLOT_IO_CONCURRENCY,
|
||||
);
|
||||
for (const { resolvedDir, branch, probe } of registryProbes) {
|
||||
if (probe === 'unreadable') {
|
||||
pending.push({ branch, dir: resolvedDir, reason: 'probe-failed' });
|
||||
continue;
|
||||
}
|
||||
const exists = probe === 'dir';
|
||||
const dir = exists ? resolvedDir : null;
|
||||
if (live === null) {
|
||||
pending.push({ branch, dir, reason: 'heads-unavailable' });
|
||||
continue;
|
||||
}
|
||||
if (!exists) {
|
||||
if (!live.has(branch)) {
|
||||
pending.push({ branch, dir: null, reason: 'registry-only' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!live.has(branch)) {
|
||||
pending.push({ branch, dir, reason: 'ref-missing' });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { dir, branch } of leftoverMeta) {
|
||||
if (branch === null) continue;
|
||||
const resolved = path.resolve(dir);
|
||||
if (live === null) {
|
||||
pending.push({ branch, dir: resolved, reason: 'heads-unavailable' });
|
||||
continue;
|
||||
}
|
||||
if (!live.has(branch)) {
|
||||
pending.push({ branch, dir: resolved, reason: 'disk-only' });
|
||||
}
|
||||
}
|
||||
|
||||
const includeSize = input.includeSize !== false;
|
||||
return mapPool(
|
||||
pending,
|
||||
async (row) => ({
|
||||
...row,
|
||||
sizeBytes: includeSize && row.dir ? await directorySizeBytes(row.dir) : 0,
|
||||
}),
|
||||
STALE_SLOT_IO_CONCURRENCY,
|
||||
);
|
||||
};
|
||||
|
||||
const toError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err)));
|
||||
|
||||
const keepRegistryFailure = (error: Error): RemoveBranchSlotResult => ({
|
||||
|
|
@ -236,6 +311,111 @@ const slotPathExists = async (slotDir: string): Promise<boolean> => {
|
|||
}
|
||||
};
|
||||
|
||||
type ContainedPathDecision = { kind: 'unlink' } | { kind: 'keep'; real: string };
|
||||
|
||||
/** Symlink, Windows junction, or any realpath that leaves `containRoot`. */
|
||||
const inspectContainedPath = async (
|
||||
lexicalPath: string,
|
||||
stat: Awaited<ReturnType<typeof fs.lstat>>,
|
||||
containRoot: string,
|
||||
): Promise<ContainedPathDecision> => {
|
||||
if (stat.isSymbolicLink()) return { kind: 'unlink' };
|
||||
try {
|
||||
const real = await fs.realpath(lexicalPath);
|
||||
return isProperChildPath(containRoot, real) ? { kind: 'keep', real } : { kind: 'unlink' };
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) return { kind: 'unlink' };
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
type SlotRootClass =
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'escape' }
|
||||
| { kind: 'file' }
|
||||
| { kind: 'dir'; realSlot: string };
|
||||
|
||||
type ContainedBranchesGate =
|
||||
| { kind: 'gone' }
|
||||
| { kind: 'refuse'; result: RemoveBranchSlotResult }
|
||||
| { kind: 'ok'; realBranches: string };
|
||||
|
||||
const gateContainedBranches = async (
|
||||
storagePath: string,
|
||||
dir: string,
|
||||
): Promise<ContainedBranchesGate> => {
|
||||
const probe = await probeContainedBranchesRoot(storagePath);
|
||||
if (probe.status === 'missing') return { kind: 'gone' };
|
||||
if (probe.status !== 'ok') return { kind: 'refuse', result: refuseOutsideSlot(dir) };
|
||||
return { kind: 'ok', realBranches: probe.realBranches };
|
||||
};
|
||||
|
||||
const classifySlotRoot = async (dir: string, realBranches: string): Promise<SlotRootClass> => {
|
||||
let stat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
stat = await fs.lstat(dir);
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) return { kind: 'missing' };
|
||||
throw err;
|
||||
}
|
||||
if (stat.isSymbolicLink()) return { kind: 'escape' };
|
||||
const realSlot = await fs.realpath(dir);
|
||||
if (!isSameNormalizedPath(realSlot, expectedRealSlotPath(realBranches, dir))) {
|
||||
return { kind: 'escape' };
|
||||
}
|
||||
return stat.isDirectory() ? { kind: 'dir', realSlot } : { kind: 'file' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Close nested junctions/symlinks whose realpath leaves the leftover slot so a
|
||||
* later `fs.rm` cannot walk a sibling index or an outside tree.
|
||||
*/
|
||||
const unlinkEscapingDescendants = async (
|
||||
dir: string,
|
||||
realSlot: string,
|
||||
expectedReal: string = realSlot,
|
||||
seen: Set<string> = new Set([realSlot]),
|
||||
): Promise<void> => {
|
||||
let entries: string[];
|
||||
try {
|
||||
// Revalidate right before readdir: the caller's lstat/realpath awaited, so
|
||||
// this directory may since have been swapped for a symlink/junction.
|
||||
// ponytail: narrows the window, not closes it — Node has no openat/fd walk.
|
||||
const stat = await fs.lstat(dir);
|
||||
if (stat.isSymbolicLink() || !isSameNormalizedPath(await fs.realpath(dir), expectedReal)) {
|
||||
throw new Error(`Branch index directory changed during cleanup: ${dir}`);
|
||||
}
|
||||
entries = await fs.readdir(dir);
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) return;
|
||||
throw err;
|
||||
}
|
||||
for (const name of entries) {
|
||||
const child = path.join(dir, name);
|
||||
let stat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
stat = await fs.lstat(child);
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) continue;
|
||||
throw err;
|
||||
}
|
||||
const decision = await inspectContainedPath(child, stat, realSlot);
|
||||
if (decision.kind === 'unlink' || seen.has(decision.real)) {
|
||||
try {
|
||||
await fs.unlink(child);
|
||||
} catch (err) {
|
||||
// Already gone between inspection and unlink: nothing left to close.
|
||||
if (!isMissingFilesystemError(err)) throw err;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seen.add(decision.real);
|
||||
if (stat.isDirectory()) {
|
||||
await unlinkEscapingDescendants(child, realSlot, decision.real, seen);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a lexically contained slot. Returns a failure result, or `null` when
|
||||
* the slot path is gone and the registry row may drop.
|
||||
|
|
@ -248,41 +428,12 @@ const removeValidatedSlotDir = async (
|
|||
return refuseOutsideSlot(dir);
|
||||
}
|
||||
|
||||
const branchesRoot = path.resolve(storagePath, BRANCHES_DIR);
|
||||
const branchesGate = await gateContainedBranches(storagePath, dir);
|
||||
if (branchesGate.kind === 'gone') return null;
|
||||
if (branchesGate.kind === 'refuse') return branchesGate.result;
|
||||
|
||||
let branchesStat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
branchesStat = await fs.lstat(branchesRoot);
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) return null;
|
||||
return keepRegistryFailure(toError(err));
|
||||
}
|
||||
|
||||
// Never walk a branches/ symlink (rm of a child would delete the target).
|
||||
if (branchesStat.isSymbolicLink()) {
|
||||
return refuseOutsideSlot(dir);
|
||||
}
|
||||
|
||||
let realStorage: string;
|
||||
let realBranches: string;
|
||||
try {
|
||||
realStorage = await fs.realpath(storagePath);
|
||||
realBranches = await fs.realpath(branchesRoot);
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) return null;
|
||||
return keepRegistryFailure(toError(err));
|
||||
}
|
||||
|
||||
// Junctions may not report as symlinks from lstat; realpath must still land
|
||||
// on storagePath/branches, not an outside tree.
|
||||
const expectedBranches = path.normalize(path.join(realStorage, BRANCHES_DIR));
|
||||
if (!isSameNormalizedPath(realBranches, expectedBranches)) {
|
||||
return refuseOutsideSlot(dir);
|
||||
}
|
||||
|
||||
let slotStat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
slotStat = await fs.lstat(dir);
|
||||
await fs.lstat(dir);
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) return null;
|
||||
return keepRegistryFailure(toError(err));
|
||||
|
|
@ -290,30 +441,27 @@ const removeValidatedSlotDir = async (
|
|||
|
||||
let deleteError: Error | undefined;
|
||||
try {
|
||||
// A symlink, or a Windows junction that lstat reports as a directory,
|
||||
// must be unlinked at the lexical path. Never fs.rm through a target
|
||||
// that realpath places outside branches/.
|
||||
const realDir = slotStat.isSymbolicLink() ? null : await fs.realpath(dir);
|
||||
const unlinkOnly =
|
||||
slotStat.isSymbolicLink() || (realDir !== null && !isProperChildPath(realBranches, realDir));
|
||||
|
||||
// Revalidate immediately before the destructive op. Another process can
|
||||
// replace branches/ or the slot after the earlier lstat/realpath awaits.
|
||||
const lastBranches = await fs.lstat(branchesRoot);
|
||||
if (lastBranches.isSymbolicLink()) {
|
||||
return refuseOutsideSlot(dir);
|
||||
}
|
||||
const lastSlot = await fs.lstat(dir);
|
||||
const lastUnlinkOnly = lastSlot.isSymbolicLink() || unlinkOnly;
|
||||
if (lastUnlinkOnly) {
|
||||
await fs.unlink(dir);
|
||||
} else {
|
||||
const lastReal = await fs.realpath(dir);
|
||||
if (!isProperChildPath(realBranches, lastReal)) {
|
||||
await fs.unlink(dir);
|
||||
} else {
|
||||
const lastGate = await gateContainedBranches(storagePath, dir);
|
||||
if (lastGate.kind === 'gone') return null;
|
||||
if (lastGate.kind === 'refuse') return lastGate.result;
|
||||
const lastSlot = await classifySlotRoot(dir, lastGate.realBranches);
|
||||
if (lastSlot.kind === 'missing') return null;
|
||||
if (lastSlot.kind === 'dir') {
|
||||
await unlinkEscapingDescendants(dir, lastSlot.realSlot);
|
||||
const preRmGate = await gateContainedBranches(storagePath, dir);
|
||||
if (preRmGate.kind === 'gone') return null;
|
||||
if (preRmGate.kind === 'refuse') return preRmGate.result;
|
||||
const preRmSlot = await classifySlotRoot(dir, preRmGate.realBranches);
|
||||
if (preRmSlot.kind === 'missing') return null;
|
||||
if (preRmSlot.kind === 'dir') {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
} else {
|
||||
await fs.unlink(dir);
|
||||
}
|
||||
} else {
|
||||
await fs.unlink(dir);
|
||||
}
|
||||
} catch (err) {
|
||||
deleteError = toError(err);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
199
gitnexus/test/unit/analyze-delete-api.test.ts
Normal file
199
gitnexus/test/unit/analyze-delete-api.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* DELETE /api/analyze/:jobId and DELETE /api/embed/:jobId body must match
|
||||
* live JobManager state after cancelJob — not a hard-coded `failed`.
|
||||
*
|
||||
* A registered child keeps the in-memory job non-terminal until exit;
|
||||
* clients that trusted a synthetic `failed` DELETE body retried and 409'd.
|
||||
*
|
||||
* Boots createServer the same way as api-fts-mode.test.ts (mocked listen,
|
||||
* no LadybugDB/MCP). analyze-api.test.ts cannot import api.ts.
|
||||
*/
|
||||
import express from 'express';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { isTerminalJobStatus, type JobManager } from '../../src/server/analyze-job.js';
|
||||
|
||||
const captured = vi.hoisted(() => ({
|
||||
managers: [] as JobManager[],
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/storage/repo-manager.js')>()),
|
||||
loadMeta: vi.fn(async () => ({})),
|
||||
listRegisteredRepos: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock('../../src/storage/storage-resolver.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../src/storage/storage-resolver.js')>()),
|
||||
requireRegisteredStoragePath: vi.fn(async (entry: { storagePath: string }) => entry.storagePath),
|
||||
}));
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
withLbugDb: vi.fn(),
|
||||
executeQuery: vi.fn(async () => []),
|
||||
executePrepared: vi.fn(async () => []),
|
||||
executeWithReusedStatement: vi.fn(async () => []),
|
||||
streamQuery: vi.fn(async () => 0),
|
||||
flushWAL: vi.fn(),
|
||||
closeLbug: vi.fn(),
|
||||
isReadOnlyDbError: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock('../../src/core/search/bm25-index.js', () => ({ searchFTSFromLbug: vi.fn() }));
|
||||
vi.mock('../../src/mcp/local/local-backend.js', () => ({
|
||||
LocalBackend: class {
|
||||
async init() {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/server/mcp-http.js', () => ({
|
||||
installServeMcpAuth: vi.fn(),
|
||||
mountMCPEndpoints: vi.fn(async () => vi.fn()),
|
||||
}));
|
||||
vi.mock('../../src/server/upload-sweep.js', () => ({ sweepStaleUploads: vi.fn(async () => {}) }));
|
||||
vi.mock('../../src/server/update-controller.js', () => ({
|
||||
createServeUpdateController: vi.fn(() => ({ stop: vi.fn() })),
|
||||
bindServeUpdateControllerLifecycle: vi.fn(),
|
||||
buildServerInfo: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../src/server/grep-scan.js', () => ({
|
||||
runGrepScanInWorker: vi.fn(async () => ({ results: [], timedOut: false })),
|
||||
}));
|
||||
vi.mock('../../src/server/sse-progress.js', () => ({ mountSSEProgress: vi.fn() }));
|
||||
vi.mock('../../src/server/analyze-job.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/server/analyze-job.js')>();
|
||||
return {
|
||||
...actual,
|
||||
JobManager: class extends actual.JobManager {
|
||||
constructor() {
|
||||
super();
|
||||
captured.managers.push(this);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { createServer } from '../../src/server/api.js';
|
||||
|
||||
let app: express.Express;
|
||||
const events = ['SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'] as const;
|
||||
const originalListeners = new Map(events.map((event) => [event, process.listeners(event)]));
|
||||
|
||||
beforeAll(async () => {
|
||||
const listen = vi.spyOn(express.application, 'listen').mockImplementation(function (
|
||||
this: express.Express,
|
||||
...args: any[]
|
||||
) {
|
||||
app = this;
|
||||
queueMicrotask(args.at(-1));
|
||||
return new EventEmitter() as any;
|
||||
});
|
||||
try {
|
||||
await createServer(0);
|
||||
} finally {
|
||||
listen.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const manager of captured.managers) manager.dispose();
|
||||
for (const event of events) {
|
||||
for (const listener of process.listeners(event)) {
|
||||
if (!originalListeners.get(event)!.includes(listener)) {
|
||||
process.removeListener(event, listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const manager of captured.managers) {
|
||||
for (const job of manager.listJobs()) {
|
||||
manager.releaseChild(job.id);
|
||||
if (!isTerminalJobStatus(job.status)) {
|
||||
manager.updateJob(job.id, { status: 'failed', error: 'test cleanup' });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type Lane = 'analyze' | 'embed';
|
||||
|
||||
const lanes: Array<{ lane: Lane; route: string; manager: () => JobManager }> = [
|
||||
{ lane: 'analyze', route: '/api/analyze/:jobId', manager: () => captured.managers[0]! },
|
||||
{ lane: 'embed', route: '/api/embed/:jobId', manager: () => captured.managers[1]! },
|
||||
];
|
||||
|
||||
const fakeChild = () => {
|
||||
const child = {
|
||||
connected: true,
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
send: () => true,
|
||||
kill: () => true,
|
||||
on: () => child,
|
||||
};
|
||||
return child;
|
||||
};
|
||||
|
||||
const invokeDelete = (route: string, jobId: string): { statusCode: number; body: any } => {
|
||||
const layer = app.router.stack.find(
|
||||
(item: any) => item.route?.path === route && item.route.methods?.delete,
|
||||
);
|
||||
expect(layer, `DELETE ${route}`).toBeDefined();
|
||||
const handler = layer.route.stack.at(-1).handle;
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as any,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body: unknown) {
|
||||
this.body = body;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
handler({ params: { jobId } }, res);
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('DELETE analyze/embed cancel body matches JobManager', () => {
|
||||
it('boots two JobManagers (analyze then embed)', () => {
|
||||
expect(captured.managers.length).toBe(2);
|
||||
});
|
||||
|
||||
it.each(lanes)(
|
||||
'DELETE $route with a registered child stays $lane analyzing, not failed',
|
||||
({ lane, route, manager }) => {
|
||||
const jobs = manager();
|
||||
const job = jobs.createJob({ repoPath: `/tmp/cancel-child-${lane}` });
|
||||
jobs.updateJob(job.id, { status: 'analyzing', repoName: `cancel-child-${lane}` });
|
||||
jobs.registerChild(job.id, fakeChild() as any);
|
||||
|
||||
const res = invokeDelete(route, job.id);
|
||||
const live = jobs.getJob(job.id);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.status).toBe(live?.status);
|
||||
expect(res.body.status).toBe('analyzing');
|
||||
expect(res.body.lane).toBe(lane);
|
||||
expect(res.body.id).toBe(job.id);
|
||||
expect(isTerminalJobStatus(res.body.status)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(lanes)('DELETE $route without a child marks $lane failed', ({ lane, route, manager }) => {
|
||||
const jobs = manager();
|
||||
const job = jobs.createJob({ repoPath: `/tmp/cancel-no-child-${lane}` });
|
||||
jobs.updateJob(job.id, { status: 'analyzing', repoName: `cancel-no-child-${lane}` });
|
||||
|
||||
const res = invokeDelete(route, job.id);
|
||||
const live = jobs.getJob(job.id);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body.status).toBe(live?.status);
|
||||
expect(res.body.status).toBe('failed');
|
||||
expect(res.body.error).toBe('Cancelled by user');
|
||||
expect(res.body.lane).toBe(lane);
|
||||
expect(res.body.id).toBe(job.id);
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ interface FakeChild extends EventEmitter {
|
|||
stderr: EventEmitter;
|
||||
send: Mock<(msg: unknown) => boolean>;
|
||||
kill: Mock<(signal?: NodeJS.Signals) => boolean>;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
const makeChild = (): FakeChild => {
|
||||
|
|
@ -389,3 +390,152 @@ describe('createLaunchAnalysisWorker — collapsed index is never published', ()
|
|||
expect(calls.indexOf('backend.init')).toBeLessThan(calls.indexOf('releaseRepoLock'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createLaunchAnalysisWorker — pending cancel', () => {
|
||||
let jobManager: JobManager;
|
||||
let child: FakeChild;
|
||||
let backendInit: Mock<() => Promise<unknown>>;
|
||||
let closeDbHandle: Mock<() => Promise<void>>;
|
||||
|
||||
const launchOne = async () => {
|
||||
const launch = createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
acquireRepoLock: () => null,
|
||||
releaseRepoLock: () => {},
|
||||
closeDbHandle,
|
||||
});
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
await launch(job, REPO_PATH, {});
|
||||
return job;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
H.settleOk = true;
|
||||
jobManager = new JobManager();
|
||||
child = makeChild();
|
||||
forkMock.mockImplementation(() => child);
|
||||
backendInit = vi.fn(async () => true);
|
||||
closeDbHandle = vi.fn(async () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
jobManager.dispose();
|
||||
vi.restoreAllMocks();
|
||||
forkMock.mockReset();
|
||||
H.settleOk = true;
|
||||
});
|
||||
|
||||
it('keeps the caller cancel reason when the worker reports a generic cancel error', async () => {
|
||||
const job = await launchOne();
|
||||
expect(jobManager.cancelJob(job.id, 'Analysis timed out (30 minute limit)')).toBe(true);
|
||||
child.emit('message', {
|
||||
type: 'error',
|
||||
message: 'Analysis cancelled (parent requested cancellation)',
|
||||
});
|
||||
const done = jobManager.getJob(job.id);
|
||||
expect(done?.status).toBe('failed');
|
||||
expect(done?.error).toBe('Analysis timed out (30 minute limit)');
|
||||
});
|
||||
|
||||
it('does not publish after complete IPC if cancel is already pending', async () => {
|
||||
const job = await launchOne();
|
||||
expect(jobManager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
|
||||
child.emit('message', completeMessage());
|
||||
await vi.waitFor(() => expect(jobManager.getJob(job.id)?.status).toBe('failed'));
|
||||
expect(backendInit).not.toHaveBeenCalled();
|
||||
expect(jobManager.getJob(job.id)?.error).toBe('Cancelled by user');
|
||||
});
|
||||
|
||||
it('does not publish if cancel arrives while settle is in flight', async () => {
|
||||
vi.useFakeTimers();
|
||||
H.settleOk = false;
|
||||
const job = await launchOne();
|
||||
child.emit('message', completeMessage());
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
|
||||
expect(jobManager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
|
||||
H.settleOk = true;
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(backendInit).not.toHaveBeenCalled();
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('failed');
|
||||
expect(jobManager.getJob(job.id)?.error).toBe('Cancelled by user');
|
||||
});
|
||||
|
||||
it('releases the analyze slot when the worker emits error without exit', async () => {
|
||||
const job = await launchOne();
|
||||
child.emit('error', new Error('spawn ENOENT'));
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('failed');
|
||||
expect(jobManager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
|
||||
});
|
||||
|
||||
it('holds the repo lock until exit after complete IPC when cancel is already pending', async () => {
|
||||
const releaseRepoLock = vi.fn();
|
||||
const launch = createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
acquireRepoLock: () => null,
|
||||
releaseRepoLock,
|
||||
closeDbHandle,
|
||||
});
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
await launch(job, REPO_PATH, {});
|
||||
|
||||
expect(jobManager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
|
||||
child.emit('message', completeMessage());
|
||||
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('failed');
|
||||
expect(jobManager.getJob(job.id)?.error).toBe('Cancelled by user');
|
||||
expect(backendInit).not.toHaveBeenCalled();
|
||||
expect(releaseRepoLock).not.toHaveBeenCalled();
|
||||
expect(() => jobManager.createJob({ repoPath: '/tmp/other' })).toThrow(/already in progress/);
|
||||
|
||||
child.emit('exit', 0);
|
||||
|
||||
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
||||
expect(jobManager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
|
||||
});
|
||||
|
||||
it('holds the repo lock until exit after cancel error IPC', async () => {
|
||||
const releaseRepoLock = vi.fn();
|
||||
const launch = createLaunchAnalysisWorker({
|
||||
jobManager,
|
||||
backend: { init: backendInit },
|
||||
acquireRepoLock: () => null,
|
||||
releaseRepoLock,
|
||||
closeDbHandle,
|
||||
});
|
||||
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
||||
await launch(job, REPO_PATH, {});
|
||||
|
||||
expect(jobManager.cancelJob(job.id, 'Cancelled by user')).toBe(true);
|
||||
child.emit('message', {
|
||||
type: 'error',
|
||||
message: 'Analysis cancelled (parent requested cancellation)',
|
||||
});
|
||||
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('failed');
|
||||
expect(jobManager.getJob(job.id)?.error).toBe('Cancelled by user');
|
||||
expect(releaseRepoLock).not.toHaveBeenCalled();
|
||||
expect(() => jobManager.createJob({ repoPath: '/tmp/other' })).toThrow(/already in progress/);
|
||||
|
||||
child.emit('exit', 0);
|
||||
|
||||
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
||||
expect(jobManager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
|
||||
});
|
||||
|
||||
it('does not release the analyze slot on post-spawn child error until exit', async () => {
|
||||
const job = await launchOne();
|
||||
child.pid = 123;
|
||||
child.emit('error', new Error('write EPIPE'));
|
||||
|
||||
expect(jobManager.getJob(job.id)?.status).toBe('failed');
|
||||
expect(jobManager.getJob(job.id)?.error).toMatch(/write EPIPE/);
|
||||
expect(() => jobManager.createJob({ repoPath: '/tmp/other' })).toThrow(/already in progress/);
|
||||
|
||||
child.emit('exit', 1);
|
||||
|
||||
expect(jobManager.createJob({ repoPath: '/tmp/other' }).status).toBe('queued');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
displayWidth,
|
||||
doctorCommand,
|
||||
localEmbeddingDoctorStatus,
|
||||
orphanedBranchSlotDoctorLines,
|
||||
leftoverBranchSlotDoctorLines,
|
||||
padDisplayEnd,
|
||||
nativeStatusLine,
|
||||
pageSizeDoctorLines,
|
||||
|
|
@ -380,7 +380,7 @@ describe('doctor survives a malformed GITNEXUS_EMBEDDING_DIMS (#2385)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('orphanedBranchSlotDoctorLines (#3331)', () => {
|
||||
describe('leftoverBranchSlotDoctorLines (#3331)', () => {
|
||||
const slot = (overrides: Partial<StaleBranchSlot>): StaleBranchSlot => ({
|
||||
branch: 'feature/x',
|
||||
dir: '/tmp/branches/feature_x',
|
||||
|
|
@ -390,11 +390,11 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
|
|||
});
|
||||
|
||||
it('returns no lines when there are no leftover slots', () => {
|
||||
expect(orphanedBranchSlotDoctorLines([])).toEqual([]);
|
||||
expect(leftoverBranchSlotDoctorLines([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('prints branch, reason, size, total, and the clean --stale reclaim line', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([slot({ sizeBytes: 4_800_000 })]);
|
||||
const lines = leftoverBranchSlotDoctorLines([slot({ sizeBytes: 4_800_000 })]);
|
||||
expect(lines[0]).toBe(t('doctor.orphanedBranches'));
|
||||
expect(lines.join('\n')).toContain('feature/x');
|
||||
expect(lines.join('\n')).toContain(t('clean.stale.reason.refMissing'));
|
||||
|
|
@ -404,7 +404,7 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
|
|||
});
|
||||
|
||||
it('prints retry-git copy instead of reclaim when heads cannot be listed (#3337)', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([
|
||||
const lines = leftoverBranchSlotDoctorLines([
|
||||
slot({ reason: 'heads-unavailable', sizeBytes: 2048 }),
|
||||
slot({ branch: 'other', reason: 'ref-missing', sizeBytes: 4096 }),
|
||||
]);
|
||||
|
|
@ -414,7 +414,7 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
|
|||
});
|
||||
|
||||
it('prints only listingFailed when listing-failed is mixed with ref-missing', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([
|
||||
const lines = leftoverBranchSlotDoctorLines([
|
||||
slot({ reason: 'listing-failed', branch: '', dir: null, sizeBytes: 0 }),
|
||||
slot({ reason: 'ref-missing' }),
|
||||
]);
|
||||
|
|
@ -424,7 +424,7 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
|
|||
});
|
||||
|
||||
it('prints heading and probe-failed row without reclaim', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([slot({ reason: 'probe-failed' })]);
|
||||
const lines = leftoverBranchSlotDoctorLines([slot({ reason: 'probe-failed' })]);
|
||||
expect(lines[0]).toBe(t('doctor.orphanedBranches'));
|
||||
expect(lines.join('\n')).toContain('feature/x');
|
||||
expect(lines.join('\n')).toContain(t('clean.stale.reason.probeFailed'));
|
||||
|
|
@ -432,7 +432,7 @@ describe('orphanedBranchSlotDoctorLines (#3331)', () => {
|
|||
});
|
||||
|
||||
it('includes reclaim when probe-failed is mixed with ref-missing', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([
|
||||
const lines = leftoverBranchSlotDoctorLines([
|
||||
slot({ reason: 'probe-failed' }),
|
||||
slot({ branch: 'other', reason: 'ref-missing' }),
|
||||
]);
|
||||
|
|
|
|||
367
gitnexus/test/unit/ops-snapshot.test.ts
Normal file
367
gitnexus/test/unit/ops-snapshot.test.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { JobManager } from '../../src/server/analyze-job.js';
|
||||
import {
|
||||
buildOpsSnapshot,
|
||||
isGitNexusVercelOrigin,
|
||||
serializeOpsJob,
|
||||
summarizeOpsLane,
|
||||
} from '../../src/server/ops-snapshot.js';
|
||||
import { publicRepoId } from '../../src/server/public-repo-id.js';
|
||||
|
||||
describe('serializeOpsJob / summarizeOpsLane', () => {
|
||||
let manager: JobManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new JobManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
it('computes duration from startedAt to now for active jobs', () => {
|
||||
const job = manager.createJob({ repoUrl: 'https://github.com/user/repo' });
|
||||
manager.updateJob(job.id, { status: 'analyzing' });
|
||||
const now = job.startedAt + 5_000;
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze', now);
|
||||
expect(view.durationMs).toBe(5_000);
|
||||
expect(view.lane).toBe('analyze');
|
||||
expect(view.repoName).toBe('repo');
|
||||
expect(view.repoUrl).toBeUndefined();
|
||||
expect(view.repoPath).toBeUndefined();
|
||||
expect(view.branch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits the requested branch from the public ops view', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: 'https://github.com/user/repo',
|
||||
branch: 'customer/acme-release',
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.branch).toBeUndefined();
|
||||
expect(JSON.stringify(view)).not.toContain('customer');
|
||||
expect(JSON.stringify(view)).not.toContain('acme-release');
|
||||
});
|
||||
|
||||
it('labels a branch-pinned clone by its repo name, not the branch-slug registry name', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: 'https://github.com/user/repo',
|
||||
branch: 'customer/acme-release',
|
||||
});
|
||||
manager.updateJob(job.id, { status: 'complete', repoName: 'repo__customer-acme-r-1a2b3c4d' });
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.repoName).toBe('repo');
|
||||
expect(JSON.stringify(view)).not.toContain('acme');
|
||||
});
|
||||
|
||||
it('exposes an opaque repoId only once the job is complete', () => {
|
||||
const job = manager.createJob({ repoPath: '/home/alice/src/api' });
|
||||
manager.updateJob(job.id, { status: 'analyzing' });
|
||||
expect(serializeOpsJob(manager.getJob(job.id)!, 'analyze').repoId).toBeUndefined();
|
||||
|
||||
manager.updateJob(job.id, { status: 'complete' });
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.repoId).toBe(publicRepoId('/home/alice/src/api'));
|
||||
expect(JSON.stringify(view)).not.toContain('alice');
|
||||
});
|
||||
|
||||
it('strips query and fragment when deriving repoName from repoUrl', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: 'https://github.com/user/repo.git?access_token=secret#frag',
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.repoName).toBe('repo');
|
||||
expect(view.repoName).not.toContain('access_token');
|
||||
expect(view.repoUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts the full repository URL from job.error on the public ops view', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: 'https://x-access-token:ghs_secret@github.com/user/repo.git',
|
||||
});
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: 'fatal: unable to access https://x-access-token:ghs_secret@github.com/user/repo.git/',
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toBe('fatal: unable to access [repo]');
|
||||
expect(JSON.stringify(view)).not.toContain('ghs_secret');
|
||||
expect(JSON.stringify(view)).not.toContain('x-access-token');
|
||||
expect(JSON.stringify(view)).not.toContain('github.com');
|
||||
});
|
||||
|
||||
it('redacts the full repository URL from progress.message on the public ops view', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: 'https://x-access-token:ghs_secret@github.com/user/repo.git',
|
||||
});
|
||||
manager.updateJob(job.id, {
|
||||
status: 'cloning',
|
||||
progress: {
|
||||
phase: 'cloning',
|
||||
percent: 0,
|
||||
message: 'Cloning https://x-access-token:ghs_secret@github.com/user/repo.git...',
|
||||
},
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.progress.message).toBe('Cloning [repo]...');
|
||||
expect(JSON.stringify(view)).not.toContain('ghs_secret');
|
||||
expect(JSON.stringify(view)).not.toContain('x-access-token');
|
||||
expect(JSON.stringify(view)).not.toContain('github.com');
|
||||
});
|
||||
|
||||
it('redacts a longer URL even when the known repoUrl is a prefix of it', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: 'https://host/org/repo',
|
||||
});
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: 'clone failed https://host/org/repo/other?token=secret',
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toBe('clone failed [repo]');
|
||||
expect(JSON.stringify(view)).not.toContain('token=secret');
|
||||
expect(JSON.stringify(view)).not.toContain('/other');
|
||||
expect(JSON.stringify(view)).not.toContain('host/org');
|
||||
});
|
||||
|
||||
it('redacts host, path, and query from a credential-stripped clone URL', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: 'https://git.example/private/repo?token=x',
|
||||
});
|
||||
manager.updateJob(job.id, {
|
||||
status: 'cloning',
|
||||
progress: {
|
||||
phase: 'cloning',
|
||||
percent: 0,
|
||||
message: 'Cloning https://git.example/private/repo?token=x',
|
||||
},
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.progress.message).toBe('Cloning [repo]');
|
||||
expect(JSON.stringify(view)).not.toContain('git.example');
|
||||
expect(JSON.stringify(view)).not.toContain('private/repo');
|
||||
expect(JSON.stringify(view)).not.toContain('token=x');
|
||||
});
|
||||
|
||||
it('basenames Windows-like drive paths instead of emitting the full path', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: String.raw`C:\Users\alice\private\repo`,
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.repoName).toBe('repo');
|
||||
expect(JSON.stringify(view)).not.toContain('Users');
|
||||
expect(JSON.stringify(view)).not.toContain('alice');
|
||||
});
|
||||
|
||||
it('redacts a home-directory clone path from job.error on the public ops view', () => {
|
||||
const job = manager.createJob({
|
||||
repoPath: '/home/alice/src/private-repo',
|
||||
});
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: 'Existing clone at /home/alice/src/private-repo has no remote.origin',
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toBe('Existing clone at [path] has no remote.origin');
|
||||
expect(view.repoName).toBe('private-repo');
|
||||
expect(JSON.stringify(view)).not.toContain('alice');
|
||||
expect(JSON.stringify(view)).not.toContain('/home/');
|
||||
});
|
||||
|
||||
it('redacts a descendant file under the known repoPath, not just the prefix', () => {
|
||||
const job = manager.createJob({ repoPath: '/home/alice/repo' });
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: 'Parse failed in /home/alice/repo/src/secret.ts, aborting',
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toBe('Parse failed in [path], aborting');
|
||||
});
|
||||
|
||||
it('does not treat a same-prefix sibling as the known repoPath', () => {
|
||||
const job = manager.createJob({ repoPath: '/home/alice/repo' });
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: 'Lock held by /home/alice/repo2/x.lock',
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toBe('Lock held by [path]');
|
||||
});
|
||||
|
||||
it('redacts a spaced POSIX clone path from job.error on the public ops view', () => {
|
||||
const repoPath = '/home/Jane Doe/.gitnexus/repos/foo';
|
||||
const job = manager.createJob({ repoPath });
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: `Existing clone at ${repoPath} has no remote.origin`,
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toContain('[path]');
|
||||
expect(view.error).toBe('Existing clone at [path] has no remote.origin');
|
||||
expect(JSON.stringify(view)).not.toContain('Jane');
|
||||
expect(JSON.stringify(view)).not.toContain('Doe');
|
||||
expect(JSON.stringify(view)).not.toContain('/home/');
|
||||
});
|
||||
|
||||
it('redacts a spaced Windows clone path from job.error on the public ops view', () => {
|
||||
const repoPath = String.raw`C:\Users\Jane Doe\My Projects\repo`;
|
||||
const job = manager.createJob({ repoPath });
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: `Existing clone at ${repoPath} has no remote.origin`,
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toContain('[path]');
|
||||
expect(view.error).toBe('Existing clone at [path] has no remote.origin');
|
||||
expect(JSON.stringify(view)).not.toContain('Jane');
|
||||
expect(JSON.stringify(view)).not.toContain('Projects');
|
||||
});
|
||||
|
||||
it('redacts a quoted Node open() path and a file:// URL from job.error', () => {
|
||||
const job = manager.createJob({
|
||||
repoPath: '/home/alice/src/private-repo',
|
||||
});
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error:
|
||||
"ENOENT: no such file or directory, open '/home/alice/src/private-repo' (file:///home/alice/src/private-repo)",
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toBe("ENOENT: no such file or directory, open '[path]' ([path])");
|
||||
expect(JSON.stringify(view)).not.toContain('alice');
|
||||
expect(JSON.stringify(view)).not.toContain('/home/');
|
||||
});
|
||||
|
||||
it('redacts an scp-style remote from job.error on the public ops view', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: 'git@github.com:private-org/secret.git',
|
||||
});
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: 'fatal: could not read from remote git@github.com:private-org/secret.git',
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toBe('fatal: could not read from remote [repo]');
|
||||
expect(JSON.stringify(view)).not.toContain('private-org');
|
||||
expect(JSON.stringify(view)).not.toContain('github.com');
|
||||
expect(JSON.stringify(view)).not.toContain('secret.git');
|
||||
});
|
||||
|
||||
it('redacts an ssh:// remote from job.error on the public ops view', () => {
|
||||
const job = manager.createJob({
|
||||
repoUrl: 'ssh://git@github.com/private-org/secret.git',
|
||||
});
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: 'fatal: could not read from remote ssh://git@github.com/private-org/secret.git',
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toBe('fatal: could not read from remote [repo]');
|
||||
expect(JSON.stringify(view)).not.toContain('private-org');
|
||||
expect(JSON.stringify(view)).not.toContain('github.com');
|
||||
});
|
||||
|
||||
it('redacts a Windows drive path from job.error on the public ops view', () => {
|
||||
const job = manager.createJob({
|
||||
repoPath: String.raw`C:\Users\alice\private\repo`,
|
||||
});
|
||||
manager.updateJob(job.id, {
|
||||
status: 'failed',
|
||||
error: String.raw`Existing clone at C:\Users\alice\private\repo has no remote.origin`,
|
||||
});
|
||||
const view = serializeOpsJob(manager.getJob(job.id)!, 'analyze');
|
||||
expect(view.error).toBe('Existing clone at [path] has no remote.origin');
|
||||
expect(JSON.stringify(view)).not.toContain('alice');
|
||||
expect(JSON.stringify(view)).not.toContain('Users');
|
||||
});
|
||||
|
||||
it('summarizes lane metrics including avg duration of terminal jobs', () => {
|
||||
const a = manager.createJob({ repoUrl: 'https://github.com/user/a' });
|
||||
manager.updateJob(a.id, { status: 'analyzing' });
|
||||
manager.updateJob(a.id, {
|
||||
status: 'complete',
|
||||
completedAt: a.startedAt + 2_000,
|
||||
});
|
||||
|
||||
const b = manager.createJob({ repoUrl: 'https://github.com/user/b' });
|
||||
manager.updateJob(b.id, { status: 'analyzing' });
|
||||
manager.updateJob(b.id, {
|
||||
status: 'failed',
|
||||
error: 'boom',
|
||||
completedAt: b.startedAt + 4_000,
|
||||
});
|
||||
|
||||
const views = manager.listJobs().map((j) => serializeOpsJob(j, 'analyze', Date.now()));
|
||||
const metrics = summarizeOpsLane(views);
|
||||
expect(metrics.total).toBe(2);
|
||||
expect(metrics.complete).toBe(1);
|
||||
expect(metrics.failed).toBe(1);
|
||||
expect(metrics.active).toBe(0);
|
||||
expect(metrics.avgDurationMs).toBe(3_000);
|
||||
expect(metrics.maxDurationMs).toBe(4_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildOpsSnapshot', () => {
|
||||
let analyze: JobManager;
|
||||
let embed: JobManager;
|
||||
|
||||
beforeEach(() => {
|
||||
analyze = new JobManager();
|
||||
embed = new JobManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
analyze.dispose();
|
||||
embed.dispose();
|
||||
});
|
||||
|
||||
it('aggregates both lanes and server uptime', () => {
|
||||
const job = analyze.createJob({ repoUrl: 'https://github.com/user/repo' });
|
||||
analyze.updateJob(job.id, { status: 'analyzing' });
|
||||
const startedAt = 1_000;
|
||||
const now = 6_000;
|
||||
const snap = buildOpsSnapshot({
|
||||
analyzeJobs: analyze.listJobs(),
|
||||
embedJobs: embed.listJobs(),
|
||||
serverStartedAt: startedAt,
|
||||
server: { version: '1.0.0', launchContext: 'local', nodeVersion: 'v22.0.0' },
|
||||
now,
|
||||
});
|
||||
expect(snap.health).toBe('ok');
|
||||
expect(snap.uptimeMs).toBe(5_000);
|
||||
expect(snap.totals.active).toBe(1);
|
||||
expect(snap.analyze.jobs).toHaveLength(1);
|
||||
expect(snap.embed.jobs).toHaveLength(0);
|
||||
expect(snap.server.version).toBe('1.0.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGitNexusVercelOrigin', () => {
|
||||
it('allows exact official vercel hosts only', () => {
|
||||
expect(isGitNexusVercelOrigin('https://gitnexus.vercel.app')).toBe(true);
|
||||
expect(isGitNexusVercelOrigin('https://gitnexus-web.vercel.app')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects preview and unrelated vercel hosts', () => {
|
||||
expect(isGitNexusVercelOrigin('https://gitnexus-web-mesquitafelipe571-5486.vercel.app')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isGitNexusVercelOrigin(
|
||||
'https://gitnexus-web-git-local-bridge-v1-mesquitafelipe571-5486.vercel.app',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isGitNexusVercelOrigin('https://gitnexus-web-evil.vercel.app')).toBe(false);
|
||||
expect(isGitNexusVercelOrigin('https://evil.vercel.app')).toBe(false);
|
||||
expect(isGitNexusVercelOrigin('https://gitnexus-web-attacker.com')).toBe(false);
|
||||
expect(isGitNexusVercelOrigin('http://gitnexus-web.vercel.app')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-default ports on the official hostnames', () => {
|
||||
expect(isGitNexusVercelOrigin('https://gitnexus-web.vercel.app:8443')).toBe(false);
|
||||
// :443 is the HTTPS default — URL.port is empty, same as the bare origin.
|
||||
expect(isGitNexusVercelOrigin('https://gitnexus.vercel.app:443')).toBe(true);
|
||||
expect(isGitNexusVercelOrigin('https://gitnexus.vercel.app')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ async function writeSlotMeta(dir: string, branch: string): Promise<void> {
|
|||
);
|
||||
}
|
||||
|
||||
const linkDir = (target: string, dest: string): Promise<void> =>
|
||||
fs.symlink(target, dest, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
|
||||
describe('listStaleBranchSlots (#3331)', () => {
|
||||
let fixture: TestDBHandle;
|
||||
let repoPath: string;
|
||||
|
|
@ -64,6 +67,74 @@ describe('listStaleBranchSlots (#3331)', () => {
|
|||
expect(isDeleteCandidate(rows[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not include a sibling-slot junction in leftover size', async () => {
|
||||
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
const sibling = path.join(storagePath, 'branches', branchSlug('main'));
|
||||
await writeSlotMeta(leftover, 'feature/x');
|
||||
await writeSlotMeta(sibling, 'main');
|
||||
const secret = Buffer.alloc(64 * 1024, 7);
|
||||
await fs.writeFile(path.join(sibling, 'payload.bin'), secret);
|
||||
await fs.writeFile(path.join(leftover, 'tiny.bin'), 'x');
|
||||
const inner = path.join(leftover, 'inner');
|
||||
await fs.mkdir(inner, { recursive: true });
|
||||
await linkDir(sibling, path.join(inner, 'escape'));
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.sizeBytes).toBeGreaterThan(0);
|
||||
expect(rows[0]?.sizeBytes).toBeLessThan(secret.length);
|
||||
});
|
||||
|
||||
it('reports zero size when the leftover path is a junction to a sibling slot', async () => {
|
||||
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
const sibling = path.join(storagePath, 'branches', branchSlug('main'));
|
||||
await writeSlotMeta(sibling, 'main');
|
||||
await fs.writeFile(path.join(sibling, 'payload.bin'), Buffer.alloc(64 * 1024, 7));
|
||||
await fs.mkdir(path.dirname(leftover), { recursive: true });
|
||||
await linkDir(sibling, leftover);
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
branch: 'feature/x',
|
||||
dir: leftover,
|
||||
sizeBytes: 0,
|
||||
reason: 'ref-missing',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not hang sizing a leftover slot with a directory cycle', async () => {
|
||||
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(leftover, 'feature/x');
|
||||
await fs.writeFile(path.join(leftover, 'tiny.bin'), 'x');
|
||||
const inner = path.join(leftover, 'inner');
|
||||
await fs.mkdir(inner, { recursive: true });
|
||||
await linkDir(inner, path.join(inner, 'loop'));
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.sizeBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not classify a recorded branch that is still a local head', async () => {
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
|
|
@ -155,6 +226,7 @@ describe('listStaleBranchSlots (#3331)', () => {
|
|||
|
||||
it('returns listing-failed when branches/ readdir fails with a non-missing error', async () => {
|
||||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
await fs.mkdir(branchesRoot, { recursive: true });
|
||||
const realReaddir = fs.readdir.bind(fs);
|
||||
const readdirSpy = vi.spyOn(fs, 'readdir').mockImplementation((async (
|
||||
target: unknown,
|
||||
|
|
@ -194,6 +266,7 @@ describe('listStaleBranchSlots (#3331)', () => {
|
|||
|
||||
it('does not classify registry rows when branches/ listing fails', async () => {
|
||||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
await fs.mkdir(branchesRoot, { recursive: true });
|
||||
const realReaddir = fs.readdir.bind(fs);
|
||||
const readdirSpy = vi.spyOn(fs, 'readdir').mockImplementation((async (
|
||||
target: unknown,
|
||||
|
|
@ -248,6 +321,42 @@ describe('listStaleBranchSlots (#3331)', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('does not classify a live-head registry row whose directory is gone', async () => {
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['feature/x'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns listing-failed when branches/ is a symlink or junction', async () => {
|
||||
const outside = path.join(fixture.dbPath, 'outside-tree');
|
||||
const slug = branchSlug('feature/x');
|
||||
await writeSlotMeta(path.join(outside, slug), 'feature/x');
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await linkDir(outside, path.join(storagePath, 'branches'));
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
branch: '',
|
||||
dir: null,
|
||||
sizeBytes: 0,
|
||||
reason: 'listing-failed',
|
||||
},
|
||||
]);
|
||||
expect(isDeleteCandidate(rows[0]!)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not classify a leftover directory with unreadable metadata and no registry row', async () => {
|
||||
const dir = path.join(storagePath, 'branches', 'mystery-deadbeef');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
|
@ -469,7 +578,7 @@ describe('removeBranchSlot (#3331)', () => {
|
|||
await fs.writeFile(path.join(outsideSlot, 'payload.bin'), 'secret');
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
await fs.symlink(outside, branchesRoot, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
await linkDir(outside, branchesRoot);
|
||||
const dir = path.join(branchesRoot, slug);
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
|
|
@ -499,7 +608,7 @@ describe('removeBranchSlot (#3331)', () => {
|
|||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
await fs.mkdir(branchesRoot, { recursive: true });
|
||||
const dir = path.join(branchesRoot, branchSlug('feature/x'));
|
||||
await fs.symlink(outside, dir, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
await linkDir(outside, dir);
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
|
|
@ -516,6 +625,138 @@ describe('removeBranchSlot (#3331)', () => {
|
|||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
|
||||
it('unlinks a nested junction inside a leftover slot and leaves the outside target', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
await fs.writeFile(path.join(dir, 'payload.bin'), 'stale');
|
||||
const inner = path.join(dir, 'inner');
|
||||
await fs.mkdir(inner, { recursive: true });
|
||||
const outside = path.join(fixture.dbPath, 'outside-nested');
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
await fs.writeFile(path.join(outside, 'secret.bin'), 'keep');
|
||||
await linkDir(outside, path.join(inner, 'escape'));
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, emptiedBranchesDir: true, keptRegistry: false });
|
||||
await expect(fs.access(dir)).rejects.toThrow();
|
||||
await expect(fs.access(outside)).resolves.toBeUndefined();
|
||||
await expect(fs.readFile(path.join(outside, 'secret.bin'), 'utf8')).resolves.toBe('keep');
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not walk a nested directory swapped for a junction mid-cleanup', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
const inner = path.join(dir, 'inner');
|
||||
await fs.mkdir(inner, { recursive: true });
|
||||
const outside = path.join(fixture.dbPath, 'outside-swapped');
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
const victim = path.join(outside, 'victim-link');
|
||||
await linkDir(fixture.dbPath, victim);
|
||||
|
||||
// Swap `inner` for a junction to `outside` right after its containment
|
||||
// realpath resolves — past the per-child checks, before the recursion.
|
||||
const realRealpath = fs.realpath.bind(fs);
|
||||
let swapped = false;
|
||||
const realpathSpy = vi.spyOn(fs, 'realpath').mockImplementation((async (
|
||||
target: Parameters<typeof fs.realpath>[0],
|
||||
) => {
|
||||
const resolved = await realRealpath(target);
|
||||
if (!swapped && path.resolve(String(target)) === path.resolve(inner)) {
|
||||
swapped = true;
|
||||
await fs.rm(inner, { recursive: true });
|
||||
await linkDir(outside, inner);
|
||||
}
|
||||
return resolved;
|
||||
}) as typeof fs.realpath);
|
||||
|
||||
try {
|
||||
await removeBranchSlot({ repoPath, storagePath, branch: 'feature/x', dir });
|
||||
} finally {
|
||||
realpathSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(swapped).toBe(true);
|
||||
await expect(fs.lstat(victim)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('unlinks a nested junction aimed at a sibling slot', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
const sibling = path.join(storagePath, 'branches', branchSlug('main'));
|
||||
await writeSlotMeta(leftover, 'feature/x');
|
||||
await writeSlotMeta(sibling, 'main');
|
||||
await fs.writeFile(path.join(sibling, 'live.bin'), 'keep');
|
||||
const inner = path.join(leftover, 'inner');
|
||||
await fs.mkdir(inner, { recursive: true });
|
||||
await linkDir(sibling, path.join(inner, 'escape'));
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir: leftover,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
await expect(fs.access(leftover)).rejects.toThrow();
|
||||
await expect(fs.readFile(path.join(sibling, 'live.bin'), 'utf8')).resolves.toBe('keep');
|
||||
});
|
||||
|
||||
it('unlinks a slot junction aimed at a sibling slot', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
const sibling = path.join(storagePath, 'branches', branchSlug('main'));
|
||||
await writeSlotMeta(sibling, 'main');
|
||||
await fs.writeFile(path.join(sibling, 'live.bin'), 'keep');
|
||||
await fs.mkdir(path.dirname(leftover), { recursive: true });
|
||||
await linkDir(sibling, leftover);
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir: leftover,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, emptiedBranchesDir: false, keptRegistry: false });
|
||||
await expect(fs.lstat(leftover)).rejects.toThrow();
|
||||
await expect(fs.readFile(path.join(sibling, 'live.bin'), 'utf8')).resolves.toBe('keep');
|
||||
});
|
||||
|
||||
it('deletes a leftover slot that contains a directory cycle', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const leftover = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(leftover, 'feature/x');
|
||||
const inner = path.join(leftover, 'inner');
|
||||
await fs.mkdir(inner, { recursive: true });
|
||||
await linkDir(inner, path.join(inner, 'loop'));
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir: leftover,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
await expect(fs.access(leftover)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('deletes a normal leftover slot directory', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue