fix: web citation/code panel bugs and serve analyze/route hardening (#3348)

* chore: ignore local Vercel link artifacts

Keep .vercel and env files out of the repo after a local preview link.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): repair citation chips, code panel line math, stale agent state

Audit findings in the web client, each verified against the source:

- RightPanel: citation chips ([[path:10-20]], [[Class:Foo]]) were wired to a
  stub resolver that always returned null, so clicking any citation did
  nothing. Expose resolveFilePath from useAppState and use it.
- CodeReferencesPanel: graph startLine/endLine are 1-based but were treated
  as 0-based, so the highlighted range and scroll target were off by one
  line; AI citation cards always rendered "code not available" because the
  snippet loader was a stub. Fetch per-citation snippets via /api/file.
- useAppState: sendChatMessage read llmSettings.activeProvider outside its
  deps (stale provider capabilities after switching provider);
  initializeAgent trapped projectName at '' for callers without an override
  (system prompt labelled the codebase "project"); the embeddings 409 dedup
  matched a message the server never sends for same-repo jobs.
- tools.ts impact: for path targets every symbol defined in the file shares
  the filePath, so the disambiguation always picked the first row and could
  analyze an arbitrary symbol while reporting a file impact. Prefer the File
  node.
- useSigma: the layout timeout called stop() but never kill(), leaking one
  ForceAtlas2 Web Worker plus four graph listeners per completed layout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): release repo lock on cancel, IPC-first worker cancel, route hardening

Audit findings in gitnexus/src, each verified against the source:

- analyze-launch: cancelJob marks the job failed before the worker exits,
  so the exit handler's terminal early-return skipped releaseLockOnce and
  the repo stayed locked ("Another job is already active") until restart.
  Release on terminal exit and when forkWorker bails on a terminal job.
- analyze-job: cancellation now sends { type: 'cancel' } over IPC first and
  signals only after a 15s grace. On Windows child.kill('SIGTERM') is a
  forceful termination, so leading with it could kill the worker inside a
  LadybugDB write. Mirrors core/auto-sync/analysis-worker-launch.
- api resolveRepo: a job that FAILED during the hold-queue wait fell through
  to the { __timedOut } sentinel ("taking longer than expected") instead of
  404; only /api/repo checked the sentinel, so graph/query/search/file/grep/
  embed/delete crashed on entry.storagePath with a 500 after a 5 minute hang.
  Return null on failed jobs and check the sentinel in every consumer.
- api processes/process/clusters/cluster: resolve ?repo= through the HTTP
  resolver (documented policy on resolveRegisteredRepoEntry) and pass the
  registered absolute path to the backend; add the standard rate limiter.
  The raw param previously reached the MCP resolver, which runs a
  cwd-relative realpathSync probe + registry refresh on a bare-name miss
  and accepts unambiguous partial names.
- api body handling: Express 5 leaves req.body undefined without a JSON
  content type, turning "Missing X" 400s into TypeError 500s; body-parser
  4xx errors (malformed JSON, over-limit) were also reported as 500.
- /api/file: the lexical path.relative check cannot see symlinks; re-check
  containment on realpath so a cloned repo containing evil -> /etc/passwd
  cannot read outside the root.
- repo-manager unregisterRepo: used the lenient reader, so a transient read
  error (EBUSY/EPERM racing another process's atomic rename) turned into
  writing [] and deregistering every repo. Use the strict-if-present reader.
- clean --branch: compared registry paths with raw path.resolve instead of
  the canonical registryPathEquals used everywhere else (macOS /private/var,
  Windows short names / drive-letter case) and reported indexed branches as
  not indexed.

Tests: cancelJob IPC-before-signal contract; /api/file symlink escape (403)
and in-repo symlink (200), skipped where the host cannot create symlinks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: keep example env files visible and ignore local Cursor config

.env* also hid gitnexus/.env.example and eval/.env.example. .vercel was already ignored. The web app's .cursor/ stays local, including its MCP file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add realtime execution ops dashboard for Vercel monitoring.

Expose /api/ops snapshots over the local serve process and a ?view=ops SPA panel so analyze/embed jobs can be watched live from the hosted web UI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: address gitnexus-check review on cite/embed/resolve paths

Pass req into resolveRepo for process/cluster routes, tighten same-repo embed 409 handling, guard empty citation paths, fix snippet retry races, and drop the lone-File ambiguity fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ops): harden CORS, redact paths, and bound ops streams

Restrict Vercel CORS to exact production hosts, omit raw repo paths/URLs from the unauthenticated ops feed, rate-limit and cap SSE connections, and fix dashboard SSE/poll edge cases.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): stabilize citation fetches and file impact matching

Retry cancelled snippet loads without duplicate in-flight reads, cap range-less citation downloads, and make impact file matching unique-suffix-aware with a synthetic File target when LIMIT drops the File node.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/ops): close gitnexus-check review threads on SSE and redaction

Cap citation reads, reconnect ops on applied server URL, skip SSE onError after abort, and strip URL query/fragment from public repoName.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ops): abort SSE on poll fallback and harden repoName parsing

Prevent dual SSE+poll after a failed safety snapshot, skip overlapping poll ticks, ignore aborted streamSSE onError, and basename Windows drive-letter URLs so ops never leaks path segments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ops/web): close gitnexus-check threads on SSE budget and credential leak

Keep finite SSE retries across short 200s, strip backend URL userinfo before ?server=, and redact progress messages on the public ops feed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

Restore 0-based GraphNode line math, redact public job poll/error fields, fix omit-?repo= 400, hold the analyze lock across cancel-during-settle, and restore the Vercel shared compile.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): contain leftover-slot reclaim to the slot and keep --stale status honest

Preview and force now share one branches/ containment rule, nested junctions cannot walk a sibling index, and a mid-loop git failure no longer claims leftovers were not deleted after a successful rm.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(cli): share leftover-slot helpers without changing reclaim behavior

Pull the rolling I/O pool and owned-cwd storage lookup into one place so clean --stale/--branch and leftover listing stop restating the same ownership and concurrency paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3348)

Keep the omitted-repo snapshot instead of re-listing, stop citation and ops races, redact full public repo URLs, and restore fake timers in teardown.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3348)

Store graph node citation lines as 0-based offsets and correct the default-port origin comment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3348)

Redact public SSE progress text, stop citation append retries from
cancelling in-flight reads, and keep the ops dashboard from showing a
stale snapshot or clearing a failed Connect.

Note: pre-existing failure in incremental-index-extension-dml-gate and other lbug/env unit tests not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3348)

Prune citation snippets when AI refs are cleared, and poll /api/ops at 2s so the fallback stays under the 60/min limit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): apply prettier class order for format check (#3348)

CI quality/format runs root-only npm ci, so prettier-plugin-tailwindcss
sorts scrollbar-thin without the web Tailwind catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

- Require a unique suffix match for graph-backed citation paths so
  ambiguous names like index.ts no longer open the first graph file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

- Require a path-component boundary so unique citation suffixes cannot match filename substrings like myindex.ts

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): redact filesystem paths in ops text

Unauthenticated /api/ops and poll replay worker errors. URLs were
scrubbed but home-directory and Windows paths still leaked.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): omit repoPath from public SSE frames

/api/ops lists job ids, so the unauthenticated progress stream
must not replay the analyzed filesystem path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): treat embed lock 409 as a busy error

Analyze and embed share the same lock string. Mapping that 409 to
embedding hid an in-flight analyze as a successful embed start.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): bound impact File path suffix matches

Unbounded endsWith let lib/foo.ts select src/mylib/foo.ts. Require
an exact path or a unique /suffix, matching resolveUniqueIndexedPath.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): keep canceled analyze slot until exit

Marking failed before the worker exited let a second POST start
cloneOrPull against a LadybugDB file still being written.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): skip Windows SIGTERM on job dispose

child.kill('SIGTERM') is TerminateProcess there. Ask over IPC first
and leave the 15s grace timer to SIGKILL.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): let process routes skip the analyze hold

GET /api/processes and /api/clusters always waited up to 300s.
?awaitAnalysis=false fails fast; default still waits like /api/repo.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): cover public ops and analyze SSE user flows

Lock the unauthenticated dashboard and analyze complete/fail/cancel paths so a leaked repoPath, token, or home path cannot ship unnoticed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

Keep caller cancel reasons over the worker's generic IPC, skip publish while cancel is pending, and redact scp-style remotes on the public ops feed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3348)

Release the analyze slot when a worker fails to spawn, and omit branch refs from the unauthenticated ops feed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): do not reuse an analyze job that is pending cancel

A dying same-repo job still occupies the single slot; 202-reuse would
attach a new client to a cancel in flight.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): hold the analyze lock until the worker exits after cancel

Cancel error IPC used to drop the repo lock while the child was still
checkpointing. Abort settle immediately on pending cancel so the slot
is not held for a 60s disk poll.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): redact known repo paths with spaces in public ops text

Known repoPath/repoUrl literals are replaced first so a clone dir with
spaces cannot leak past the whitespace-bounded path regex.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): return the live job on analyze and embed DELETE

Hard-coding failed made clients retry immediately and 409 while the
child still occupied the slot. resolveRepo now returns not-found as
soon as that job fails instead of waiting out the hold timeout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): drive public analyze and ops through a live gitnexus serve

Spawn the real backend and observe requests instead of intercepting
them, so slot occupancy, redaction, and reconnect stay honest.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(web): reuse code-panel helpers and drop dead UI aliases

Citation fetches already had selectedNodeFileRange and snippetRepoKey;
the impact File suffix filter already handled exact paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3348)

- Skip createJob reuse after cancel IPC is consumed while the child remains
- Hold the repo lock until exit when complete IPC races a pending cancel
- Scrub full remote URLs before known repoUrl prefixes in public ops text
- Reject unique impact File suffix matches from a truncated LIMIT 10 page
- Make live e2e helpers bound probes, clean up failed startups, and wait out the cancel slot

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): open live e2e pages on the Vite host CI actually bound

Absolute 127.0.0.1:5173 navigation refused on Actions because wait-on
and Vite use localhost (often ::1). Honor FRONTEND_URL when set, else
pick the first of localhost / 127.0.0.1 that answers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

Hold the analyze lock until worker exit when cancel aborts settle, and assert GitLab failure chrome does not leak host or path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep live analyze e2e under the analyze rate limit

POST /api/analyze allows 10 requests per minute per IP. The slot-free
helper re-POSTed every 400ms while a cancelled worker was exiting, spent
that budget, and the lock test's hold request got 429 instead of 202.

- postAnalyze waits out a 429 using the RateLimit reset and retries
- slot polling backs off to 2s and leaves a small POST budget for callers
- the slot probe is a clone that fails before any worker fork, so the
  probe itself no longer holds the slot after reporting failed
- the lock test holds the slot with a real local analyze
- token and GitLab tests wait for a free slot before posting from the UI
- request fetches carry a timeout; teardown signals the serve process group
- an empty FRONTEND_URL falls back to the default base URL

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback (#3348)

- ops view: a `?server=` link no longer auto-connects to another origin
  while a deploy token is held; it prefills and waits for Connect
- analyze completion: a local-path run reconnects by the path this client
  submitted, so duplicate basenames stay collision-safe without repoPath
  on the public SSE frame
- stale slot cleanup: revalidate each nested directory (lstat + realpath)
  right before readdir, so a mid-cleanup junction swap aborts instead of
  walking an outside tree; list phases run sequentially so the slot-I/O
  cap is global
- e2e: 429 backoff honours the caller deadline; the unreachable-backend
  ops test navigates through the resolved frontend URL
- drop the unused `repo` member from the clean integration helper

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(server): reconnect after analyze by an opaque repo id

Public job views and the SSE terminal frame no longer carry repoPath, and
repoName is not unique, so a post-analyze reconnect by name could load a
same-named sibling. The server now issues `repoId`: an HMAC of the
canonical registry path under a per-process random key. It is set on
complete jobs (ops view, analyze poll, SSE terminal frame) and matches
the new `id` on `GET /api/repos` entries. The web client resolves it to
the exact entry path on completion; unknown ids fall back to the name.
This covers URL clones and folder uploads, and replaces the local-path
only fallback.

With reconnect off the label, public `repoName` for a branch-pinned URL
clone is the repository name, not the `<repo>__<branch slug>` registry
name, so the requested branch stays off the unauthenticated feed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback (#3348)

- stale slot cleanup: a descendant that vanishes before its unlink is
  treated as removed instead of aborting the reclaim
- e2e teardown: escalate to SIGKILL on the process group when the live
  backend ignores SIGTERM for 5s
- ops view: drop the dead initial value CodeQL flagged

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback (#3348)

- public redaction: a known repoPath now also consumes its descendant
  tail, so `<repoPath>/src/secret.ts` becomes `[path]` instead of
  `[path]/src/secret.ts`; a same-prefix sibling is left to the path scrub
- e2e: the cancel test waits for the analyze slot the previous failed
  local-path job still holds; `fetchOps` carries the request timeout
- docs: SSE terminal payload and RepoAnalyzer `onComplete` describe
  `repoId` resolution

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Address PR review feedback (#3348)

- RepoAnalyzer: drop a completion that resolves after unmount, so a slow
  /api/repos lookup cannot switch repos after the sheet was dismissed
- e2e: select the local-path input by test id (the placeholder differs on
  Windows); the slot probe's job wait honours the caller's deadline

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(server): assert the public SSE terminal frame in analyze-api

The #2790 terminality tests still expected `repoPath` on the terminal
frame. This PR replaced it with the opaque `repoId`, so assert that shape
and that the analyzed path never appears in the stream.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(web): wait for the analyze slot between duplicate-repo setup runs

The server now keeps the single analyze slot until the worker exits, even
after its job reports complete. repo-path-identity posted the second
duplicate's analyze immediately and got 409 in CI. Use the shared
slot-aware POST, which also waits out a 429.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Felipe 2026-09-23 09:27:22 -03:00 • committed by GitHub
parent ca816e901a
commit c2ca132620
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 5308 additions and 610 deletions

1
.gitignore vendored
View file

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

28
.vercelignore Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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