GitNexus/gitnexus/test/unit/analyze-api.test.ts
mengkaka 79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

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

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

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

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

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

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

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

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

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

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

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

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

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

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

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

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

* Wrap the bridge stamp predicate so prettier --check stays green.

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

* Require a quiet interval before stamping a settled bridge file.

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

* Reuse shared storage and settle helpers instead of local copies.

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

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00

710 lines
30 KiB
TypeScript

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { JobManager } from '../../src/server/analyze-job.js';
import {
startSSEHarness,
terminalFrame,
terminalFrameCount,
type SSEHarness,
} from '../helpers/sse-harness.js';
import {
resolveEmbedRunOutcome,
withMeasuredEmbeddingCount,
type EmbeddingRunResult,
} from '../../src/server/embed-run-outcome.js';
import { mintInterruptedCheckpoint } from '../../src/core/embedding-checkpoint.js';
import {
measurePersistedEmbeddingCount,
persistedEmbeddingCountOrUndefined,
} from '../../src/core/embedding-count.js';
import { loadMeta, saveMeta, type RepoMeta } from '../../src/storage/repo-manager.js';
import { deriveEmbeddingMode } from '../../src/core/embedding-mode.js';
/**
* NOTHING in this file imports `src/server/api.ts` for behavior. That module
* pulls Express, cors, the LadybugDB native adapter and the whole MCP wiring:
* reaching three pure helpers through it cost one 30s TIMEOUT and ~20s/~22s on
* the runs that passed, against a 30s `testTimeout` (#2790 review, finding 9).
* The helpers now live in `src/server/{sse-progress,embed-run-outcome}.ts` and
* `src/core/embedding-{count,checkpoint}.ts`, none of which import a database
* or a server.
*/
describe('analyze API logic', () => {
let manager: JobManager;
beforeEach(() => {
manager = new JobManager();
});
afterEach(() => {
manager.dispose();
});
it('creates a job and returns 202 shape', () => {
const job = manager.createJob({ repoUrl: 'https://github.com/user/repo' });
const response = { jobId: job.id, status: job.status };
expect(response.jobId).toBeTruthy();
expect(response.status).toBe('queued');
});
it('rejects when job already active for different repo', () => {
const job1 = manager.createJob({ repoUrl: 'https://github.com/user/repo1' });
manager.updateJob(job1.id, { status: 'analyzing' });
expect(() => manager.createJob({ repoUrl: 'https://github.com/user/repo2' })).toThrow(
/already in progress/,
);
});
it('returns existing job for same repo URL', () => {
const job1 = manager.createJob({ repoUrl: 'https://github.com/user/repo' });
manager.updateJob(job1.id, { status: 'analyzing' });
const job2 = manager.createJob({ repoUrl: 'https://github.com/user/repo' });
expect(job2.id).toBe(job1.id);
});
it('SSE progress listener receives all events including terminal', () => {
const job = manager.createJob({ repoUrl: 'https://github.com/user/sse-test' });
const events: Array<{ phase: string; percent: number }> = [];
const unsub = manager.onProgress(job.id, (progress) => {
events.push({ phase: progress.phase, percent: progress.percent });
});
manager.updateJob(job.id, {
status: 'analyzing',
progress: { phase: 'parsing', percent: 30, message: 'Parsing' },
});
manager.updateJob(job.id, {
progress: { phase: 'calls', percent: 50, message: 'Tracing calls' },
});
manager.updateJob(job.id, { status: 'complete', repoName: 'sse-test' });
unsub();
expect(events).toEqual([
{ phase: 'parsing', percent: 30 },
{ phase: 'calls', percent: 50 },
{ phase: 'complete', percent: 100 },
]);
});
});
const IDENTITY = { model: 'test-model', dimensions: 384, provider: 'local' };
const CLEAN_RUN: EmbeddingRunResult = {
nodesProcessed: 412,
chunksProcessed: 900,
failedNodeIds: [],
};
/** Progress figures an in-flight checkpoint records. */
const PROGRESS = { nodesProcessed: 4, totalNodes: 12, chunksProcessed: 9 };
/**
* ── #2790: an SSE client must not be told a partial run succeeded ──────────
*
* `runEmbeddingPipeline` emits `phase: 'ready'` / 100% UNCONDITIONALLY before
* returning — including when it dropped nodes to endpoint failures — and
* /api/embed relayed that as a progress phase before it had measured anything
* or decided the outcome. The relay treated a progress PHASE STRING of
* 'complete'/'failed' as terminal, so it wrote `event: complete` with
* `error: undefined`, called `res.end()` and unsubscribed; the route's later
* `updateJob({status:'failed'})` went into a stream with no listener. The web
* client fired `onComplete` and showed "ready" while a `GET /api/embed/:jobId`
* poller saw `failed` — the two consumers of one job disagreeing about whether
* the data is complete, and a regression against the pre-#2790 behavior where
* the pipeline threw and the client received the failure.
*
* These tests drive the REAL relay over a REAL HTTP server (same harness as
* server-sse-payload.test.ts) and subscribe BEFORE the misleading event is
* emitted — subscribing after it is exactly why the previous version of this
* suite passed while the bug was live.
*/
describe('mountSSEProgress terminality (#2790)', () => {
let harness: SSEHarness;
let manager: JobManager;
let baseUrl = '';
beforeEach(async () => {
// Mirrors both production mounts in createServer().
harness = await startSSEHarness('/api/embed/:jobId/progress');
manager = harness.manager;
baseUrl = harness.baseUrl;
});
afterEach(() => harness.close());
it('a partial run reaches the client as a failure, not a success', async () => {
const job = manager.createJob({ repoPath: '/ws/embed-partial' });
manager.updateJob(job.id, {
repoName: 'embed-partial',
status: 'analyzing',
progress: { phase: 'embedding', percent: 40, message: 'Embedding nodes (40%)...' },
});
// The client is connected and listening BEFORE anything terminal-looking is
// emitted. `fetch` resolves once headers arrive, and the handler subscribes
// synchronously before that (see server-sse-payload.test.ts).
const response = await fetch(`${baseUrl}/api/embed/${job.id}/progress`);
// A progress event that CLAIMS to be terminal. Production now maps the
// pipeline's `ready` to 'finalizing' instead, but a phase string must not be
// able to end the stream no matter who sends it — that is the invariant.
manager.updateJob(job.id, {
progress: { phase: 'complete', percent: 100, message: 'Embeddings complete' },
});
// Only now does the route learn the run dropped nodes.
const outcome = resolveEmbedRunOutcome(IDENTITY, {
nodesProcessed: 10,
chunksProcessed: 24,
failedNodeIds: ['node-a', 'node-b'],
});
manager.updateJob(job.id, {
status: 'failed',
error: outcome.error,
partial: outcome.partial,
progress: { phase: 'failed', percent: 100, message: String(outcome.error) },
});
const body = await response.text();
expect(body).not.toContain('event: complete');
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.
partial: { kind: 'embedding-partial', pendingNodeCount: 2, nodesProcessed: 10 },
});
});
it('a clean run produces exactly one terminal complete event', async () => {
const job = manager.createJob({ repoPath: '/ws/embed-clean' });
manager.updateJob(job.id, {
repoName: 'embed-clean',
status: 'analyzing',
progress: { phase: 'embedding', percent: 40, message: 'Embedding nodes (40%)...' },
});
const response = await fetch(`${baseUrl}/api/embed/${job.id}/progress`);
// What the route actually emits between the pipeline returning and the
// outcome being known.
manager.updateJob(job.id, {
progress: { phase: 'finalizing', percent: 100, message: 'Finalizing embeddings...' },
});
manager.updateJob(job.id, {
status: 'complete',
progress: { phase: 'complete', percent: 100, message: 'Embeddings complete' },
});
const body = await response.text();
expect(body).not.toContain('event: failed');
// 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);
expect(terminalFrame(body, 'complete')).toEqual({
repoName: 'embed-clean',
repoPath: '/ws/embed-clean',
});
// The 'finalizing' frame was relayed as ordinary progress, not swallowed.
expect(body).toContain('"phase":"finalizing"');
});
it('the analyze path still closes on its own terminal update', async () => {
// /api/analyze mounts the same relay. Its worker reports phases like
// 'parsing' and 'done' (never 'complete'), so the fix must not leave that
// stream open — it closes when the job's STATUS becomes terminal.
const job = manager.createJob({ repoPath: '/ws/reels' });
manager.updateJob(job.id, {
status: 'analyzing',
progress: { phase: 'parsing', percent: 30, message: 'Parsing' },
});
const response = await fetch(`${baseUrl}/api/embed/${job.id}/progress`);
manager.updateJob(job.id, {
progress: { phase: 'done', percent: 100, message: 'Done' },
});
manager.updateJob(job.id, { status: 'complete', repoName: 'reels' });
const body = await response.text();
expect(terminalFrameCount(body)).toBe(1);
expect(terminalFrame(body, 'complete')).toEqual({ repoName: 'reels', repoPath: '/ws/reels' });
});
it('a job that finished before the client connected replays its outcome', async () => {
const job = manager.createJob({ repoPath: '/ws/embed-late' });
const outcome = resolveEmbedRunOutcome(IDENTITY, {
nodesProcessed: 3,
chunksProcessed: 9,
failedNodeIds: ['node-a'],
});
manager.updateJob(job.id, {
status: 'failed',
repoName: 'embed-late',
error: outcome.error,
partial: outcome.partial,
});
const body = await (await fetch(`${baseUrl}/api/embed/${job.id}/progress`)).text();
expect(terminalFrameCount(body)).toBe(1);
expect(terminalFrame(body, 'failed')).toMatchObject({
error: expect.stringContaining('finished partially') as unknown as string,
partial: { kind: 'embedding-partial', pendingNodeCount: 1, nodesProcessed: 3 },
});
});
});
/**
* ── #2790: POST /api/embed must not report unqualified success ─────────
*
* The pipeline no longer throws when a sub-batch loses its endpoint — it
* deletes the affected nodes' rows and names them in `failedNodeIds`. The route
* discarded that receipt: it cleared `embeddingCheckpoint` and marked the job
* 'complete', so a partial run looked identical to a clean one and the dropped
* nodes were never retried (pre-#2790 the pipeline threw and the catch marked
* the job failed).
*/
describe('resolveEmbedRunOutcome (#2790)', () => {
it('clears the checkpoint and reports no error on a clean, measured run', () => {
const outcome = resolveEmbedRunOutcome(IDENTITY, CLEAN_RUN, { measuredEmbeddings: 412 });
expect(outcome.checkpoint).toBeUndefined();
expect(outcome.error).toBeUndefined();
expect(outcome.partial).toBeUndefined();
});
it('retains the checkpoint with the dropped ids and reports an error on a partial run', () => {
const outcome = resolveEmbedRunOutcome(IDENTITY, {
nodesProcessed: 10,
chunksProcessed: 24,
failedNodeIds: ['node-a', 'node-b'],
});
// The record of what failed survives — this is the pending set the next
// run's `forceReembedNodeIds` re-embeds.
expect(outcome.checkpoint).toMatchObject({
pendingNodeIds: ['node-a', 'node-b'],
nodesProcessed: 10,
totalNodes: 12,
chunksProcessed: 24,
model: 'test-model',
dimensions: 384,
provider: 'local',
// The run COMPLETED: these nodes provably hold zero rows, so a later
// identity mismatch may drop the set with a warning instead of wedging
// every subsequent run (repo-manager.ts).
kind: 'partial',
});
expect(outcome.error).toMatch(/2 node\(s\)/);
expect(outcome.partial).toEqual({
kind: 'embedding-partial',
pendingNodeCount: 2,
nodesProcessed: 10,
});
});
it('stamps no attempt count on a fresh partial run', () => {
const outcome = resolveEmbedRunOutcome(
IDENTITY,
{ nodesProcessed: 10, chunksProcessed: 24, failedNodeIds: ['node-a'] },
// Resumed from an in-flight marker, not a partial one.
{ resumedFrom: mintInterruptedCheckpoint(IDENTITY, PROGRESS, ['node-a']) },
);
expect(outcome.checkpoint).toMatchObject({ kind: 'partial' });
expect(outcome.checkpoint?.attempts).toBeUndefined();
});
it('advances the attempt count only when a resumed pending node fails again', () => {
const resumedFrom: RepoMeta['embeddingCheckpoint'] = {
at: new Date(0).toISOString(),
nodesProcessed: 10,
totalNodes: 12,
chunksProcessed: 24,
...IDENTITY,
kind: 'partial',
attempts: 1,
pendingNodeIds: ['node-a', 'node-b'],
};
// Same node failed again → the retry is not converging; the budget advances.
expect(
resolveEmbedRunOutcome(
IDENTITY,
{ nodesProcessed: 11, chunksProcessed: 26, failedNodeIds: ['node-a'] },
{ resumedFrom },
).checkpoint,
).toMatchObject({ kind: 'partial', attempts: 2 });
// The resumed set cleared and DIFFERENT nodes were lost → a fresh partial,
// so the budget resets. The bound exists for a node the endpoint rejects
// deterministically, not for an endpoint that is merely flaky.
expect(
resolveEmbedRunOutcome(
IDENTITY,
{ nodesProcessed: 11, chunksProcessed: 26, failedNodeIds: ['node-z'] },
{ resumedFrom },
).checkpoint?.attempts,
).toBeUndefined();
});
});
describe('the mid-run marker /api/embed writes (mintInterruptedCheckpoint, #2790)', () => {
it('stamps interrupted, so resume regenerates a possibly half-written window', () => {
const checkpoint = mintInterruptedCheckpoint(IDENTITY, PROGRESS, ['node-a', 'node-b']);
expect(checkpoint).toMatchObject({
kind: 'interrupted',
nodesProcessed: 4,
totalNodes: 12,
chunksProcessed: 9,
model: 'test-model',
dimensions: 384,
provider: 'local',
pendingNodeIds: ['node-a', 'node-b'],
});
// `attempts` bounds retries of a 'partial' set; an in-flight marker has no
// such budget because its rows may exist.
expect(checkpoint.attempts).toBeUndefined();
});
});
/**
* ── The /api/embed count omission (silent embedding loss) ──────────────
*
* The route generated embeddings and wrote `embeddingCheckpoint`, but never
* `stats.embeddings`. A repo embedded purely through the server therefore kept
* whatever count the last CLI `analyze` stamped — `0` for a repo analyzed
* without embeddings. The next CLI run reads that as `existingEmbeddingCount`,
* `deriveEmbeddingMode` sees `hasExisting: false` → `shouldLoadCache: false`,
* and `gitnexus analyze --force` wipes the database with no cache load: every
* server-generated embedding is destroyed with no warning.
*
* The route body is an inline closure inside `createServer`, so its finalize
* sequence is replayed here over the SAME helpers the route calls, with real
* meta.json I/O and the real `deriveEmbeddingMode`. The consequence is what
* these tests pin, not the field.
*/
describe('POST /api/embed records the embedding count it measured', () => {
let metaDir: string;
let seeded: RepoMeta;
beforeEach(async () => {
metaDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-embed-count-'));
});
afterEach(async () => {
await fs.rm(metaDir, { recursive: true, force: true });
});
/** What a CLI `analyze` (plus any mid-run checkpoint) leaves on disk. */
const seedMeta = async (
embeddings: number | undefined,
embeddingCheckpoint?: RepoMeta['embeddingCheckpoint'],
): Promise<void> => {
seeded = {
repoPath: '/repo/embed-count',
lastCommit: 'abc123',
indexedAt: new Date(0).toISOString(),
stats: { nodes: 500, ...(embeddings === undefined ? {} : { embeddings }) },
embeddingCheckpoint,
};
await saveMeta(metaDir, seeded);
};
const rowsWith = (cnt: unknown) => async () => [{ cnt } as Record<string, unknown>];
/** The route's finalize sequence: measure → re-read meta → resolve → write. */
const finalizeEmbedRun = async (
runQuery: (cypher: string) => Promise<Array<Record<string, unknown>> | undefined>,
pipelineResult: EmbeddingRunResult,
): Promise<RepoMeta | null> => {
const measured = await measurePersistedEmbeddingCount(runQuery);
const finalMeta = (await loadMeta(metaDir)) ?? seeded;
const outcome = resolveEmbedRunOutcome(IDENTITY, pipelineResult, {
measuredEmbeddings: persistedEmbeddingCountOrUndefined(measured),
onDisk: finalMeta,
});
await saveMeta(
metaDir,
withMeasuredEmbeddingCount(
{ ...finalMeta, embeddingCheckpoint: outcome.checkpoint },
measured,
),
);
return loadMeta(metaDir);
};
const embeddingCountOf = (meta: RepoMeta | null): number => meta?.stats?.embeddings ?? 0;
it('writes the measured count into meta on a clean run, without disturbing the other stats', async () => {
await seedMeta(0);
const asked: string[] = [];
const written = await finalizeEmbedRun(async (cypher) => {
asked.push(cypher);
return [{ cnt: 412 }];
}, CLEAN_RUN);
expect(written).toMatchObject({ stats: { nodes: 500, embeddings: 412 } });
// A clean, MEASURED run clears the checkpoint (#2790 contract).
expect(written?.embeddingCheckpoint).toBeUndefined();
// Measured, not restated: the count comes from the live embedding table.
expect(asked).toEqual([expect.stringMatching(/MATCH \(e:\w+\) RETURN count\(e\) AS cnt/)]);
});
it('is what makes the next CLI run preserve instead of wipe', async () => {
await seedMeta(0);
// Pre-fix state: the server embedded 412 nodes but meta still says 0.
const stale = embeddingCountOf(await loadMeta(metaDir));
expect(stale).toBe(0);
expect(deriveEmbeddingMode({ force: true }, stale)).toMatchObject({
// `--force` rebuilds without loading the embedding cache → the 412
// server-generated vectors are destroyed.
shouldLoadCache: false,
preserveExistingEmbeddings: false,
});
const written = await finalizeEmbedRun(rowsWith(412), CLEAN_RUN);
const honest = embeddingCountOf(written);
expect(honest).toBe(412);
// Post-fix: `--force` loads the cache and regenerates on top of it rather
// than discarding the index. (`preserveExistingEmbeddings` is false here by
// design — `--force` upgrades to `forceRegenerateEmbeddings`; the wipe
// protection is `shouldLoadCache`.)
expect(deriveEmbeddingMode({ force: true }, honest)).toMatchObject({
shouldLoadCache: true,
forceRegenerateEmbeddings: true,
});
// A routine `analyze` preserves them outright.
expect(deriveEmbeddingMode({}, honest)).toMatchObject({
shouldLoadCache: true,
preserveExistingEmbeddings: true,
});
});
it('treats an unanswerable count query as unknown rather than 0', async () => {
// The query throws for reasons unrelated to how many rows were written.
await expect(
measurePersistedEmbeddingCount(async () => {
throw new Error('Connection closed');
}),
).resolves.toMatchObject({ kind: 'unknown', reason: 'Connection closed' });
// No row / no cell: an empty table would still answer with a 0.
await expect(measurePersistedEmbeddingCount(async () => [])).resolves.toMatchObject({
kind: 'unknown',
});
await expect(measurePersistedEmbeddingCount(async () => undefined)).resolves.toMatchObject({
kind: 'unknown',
});
// Non-numeric cell — same class of unknown.
await expect(measurePersistedEmbeddingCount(rowsWith('many'))).resolves.toMatchObject({
kind: 'unknown',
});
// A real zero is still a real answer.
await expect(measurePersistedEmbeddingCount(rowsWith(0))).resolves.toEqual({
kind: 'measured',
count: 0,
});
});
it('leaves the previous count alone when the measurement fails, never writing a fabricated 0', async () => {
await seedMeta(137);
const written = await finalizeEmbedRun(async () => {
throw new Error('Connection closed');
}, CLEAN_RUN);
expect(written).toMatchObject({ stats: { embeddings: 137 } });
// The dangerous direction is wrong-LOW: a fabricated 0 here would arm the
// wipe the test above describes.
expect(deriveEmbeddingMode({ force: true }, embeddingCountOf(written))).toMatchObject({
shouldLoadCache: true,
});
});
it('keeps the recovery marker when a clean run cannot verify its own count', async () => {
// The state that arms the silent wipe: meta records 0 embeddings (a repo
// analyzed without them, embedded through the server), the run succeeded,
// and the count query cannot answer — so no honest count can be stamped.
const midRunMarker = mintInterruptedCheckpoint(IDENTITY, PROGRESS, ['node-a']);
await seedMeta(0, midRunMarker);
const written = await finalizeEmbedRun(async () => {
throw new Error('Connection closed');
}, CLEAN_RUN);
// No fabricated value: neither a 0 nor a NaN/null lands in meta.
expect(written).toMatchObject({ stats: { nodes: 500, embeddings: 0 } });
// …and the marker this run wrote SURVIVES, so something on disk still
// records that embeddings were produced. Clearing it here would leave the
// index with zero evidence of its own embeddings.
expect(written?.embeddingCheckpoint).toMatchObject({
kind: 'interrupted',
pendingNodeIds: ['node-a'],
});
});
it('still clears the marker on an unverifiable run once meta records embeddings', async () => {
// Same unmeasurable run, but the recorded count already proves the index is
// accounted for — nothing needs preserving, so the clean-run contract wins.
await seedMeta(412, mintInterruptedCheckpoint(IDENTITY, PROGRESS, ['node-a']));
const written = await finalizeEmbedRun(async () => {
throw new Error('Connection closed');
}, CLEAN_RUN);
expect(written).toMatchObject({ stats: { embeddings: 412 } });
expect(written?.embeddingCheckpoint).toBeUndefined();
});
it('records the honest count on a partial run, alongside the pending checkpoint', async () => {
await seedMeta(0);
const written = await finalizeEmbedRun(rowsWith(300), {
nodesProcessed: 300,
chunksProcessed: 700,
failedNodeIds: ['node-a', 'node-b'],
});
// A partial index that is honest about itself survives the next run: the
// count keeps `--force` from wiping it, the checkpoint re-embeds the rest.
expect(written).toMatchObject({
stats: { embeddings: 300 },
embeddingCheckpoint: {
pendingNodeIds: ['node-a', 'node-b'],
nodesProcessed: 300,
kind: 'partial',
},
});
expect(deriveEmbeddingMode({ force: true }, embeddingCountOf(written))).toMatchObject({
shouldLoadCache: true,
});
});
});
/**
* Wiring guard for the route. Everything the helpers DECIDE is pinned
* behaviorally above; what remains is that the inline route closure inside
* `createServer` still asks them — the helper being right while the call site
* keeps writing `embeddingCheckpoint: undefined` is exactly the regression
* #2790 is about, and that closure cannot be reached without booting a server
* over a real repo + LadybugDB + embedding endpoint. Static-analysis layer of
* last resort, same precedent as api-readonly-wiring.test.ts.
*/
describe('POST /api/embed route wiring (#2790)', () => {
const readSource = () =>
fs.readFile(path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'), 'utf-8');
/**
* The body of the route's `withLbugDb` callback — everything that may only
* run while the database connection is open. Sliced rather than matched with
* a character-distance regex so a comment edit cannot silently un-assert it.
*/
const insideWithLbugDb = (source: string): string => {
// The open is a multi-line `withLbugDb(lbugPath, async () => {…}, opts)` call
// since #3091 added the FTS-mode options argument, so anchor on the call head
// and close on that options argument rather than a fixed-indent literal.
const head = source.match(/await withLbugDb\(\s*lbugPath,\s*async \(\) => \{/);
expect(head).not.toBeNull();
const start = head!.index!;
const end = source.indexOf('skipFtsOption(ftsSession.skipFts)', start);
expect(end).toBeGreaterThan(start);
return source.slice(start, end);
};
it('feeds the pipeline result through resolveEmbedRunOutcome into the finalize write', async () => {
const source = await readSource();
// The result is captured, not discarded…
expect(source).toContain('const pipelineResult = await runEmbeddingPipeline(');
// …handed to the helper with the finalize context…
expect(source).toMatch(
/resolveEmbedRunOutcome\(\s*embeddingIdentity,\s*pipelineResult,\s*finalizeContext,\s*\)/,
);
// …and its checkpoint is what the finalize meta write persists (pre-fix: a
// hardcoded `embeddingCheckpoint: undefined`).
expect(source).toContain('embeddingCheckpoint: outcome.checkpoint');
expect(source).toContain('partialRunError = outcome.error;');
// A partial run does not reach `status: 'complete'`, and carries its detail.
expect(source).toMatch(
/partialRunError === undefined[\s\S]{0,400}status: 'complete'[\s\S]{0,600}status: 'failed'/,
);
expect(source).toContain('partial: partialRunDetail,');
});
it('measures after the WAL flush, inside withLbugDb, and folds the result into the write', async () => {
const source = await readSource();
const region = insideWithLbugDb(source);
// Inside the open connection — this is the route's only chance to stamp
// `stats.embeddings`, and the next CLI run's preserve-or-wipe decision
// hangs on it.
expect(region).toContain('const measuredEmbeddings = await countPersistedEmbeddings();');
expect(region).toContain('await saveMeta(storagePath, embeddingMeta);');
// Ordering, without brittle character spans: flush → measure → decide →
// write. Counting before the flush would describe rows still in the WAL.
const flushed = region.lastIndexOf('await flushWAL();');
const measured = region.indexOf('const measuredEmbeddings = await countPersistedEmbeddings();');
const decided = region.indexOf('const outcome = resolveEmbedRunOutcome(');
const folded = region.indexOf('embeddingMeta = withMeasuredEmbeddingCount(', measured);
expect(flushed).toBeLessThan(measured);
expect(measured).toBeLessThan(decided);
expect(decided).toBeLessThan(folded);
expect(region.slice(folded)).toContain('measuredEmbeddings,');
});
it('measures in the post-flush checkpoint callback and nowhere else in the pipeline options', async () => {
const source = await readSource();
expect(source).toMatch(
/await saveEmbeddingCheckpoint\(\s*checkpoint,\s*\[\],\s*await countPersistedEmbeddings\(\),?\s*\)/,
);
// The window-start callback fires before any row exists — it must pass no
// count rather than restate a stale one.
expect(source).toMatch(
/onCheckpointWindowStart: async \(\{ nodeIds, \.\.\.checkpoint \}\) => \{\s*await saveEmbeddingCheckpoint\(checkpoint, nodeIds\);\s*\},/,
);
});
it('resolves a found checkpoint through the shared resume decision', async () => {
const source = await readSource();
const region = insideWithLbugDb(source);
// The route asks the SAME decider the CLI does, instead of hard-throwing on
// any identity mismatch and ignoring `attempts` — the disagreement that let
// a CLI-written `'partial'` marker wedge every later `POST /api/embed`.
expect(region).toMatch(/decideEmbeddingResume\(priorCheckpoint, embeddingIdentity\)/);
// Every action is routed: abort fails the run, abandon warns and proceeds
// with an empty pending set, resume hands the decision's ids to the pipeline.
expect(region).toContain("if (resume?.action === 'abort') throw new Error(resume.error);");
expect(region).toMatch(/resume\?\.action === 'resume'\s*\?\s*resume\.pendingNodeIds/);
// No second copy of the gate: the route no longer authors its own message.
expect(region).not.toContain('Cannot resume embedding checkpoint:');
});
it('never maps the pipeline ready phase to a phase a client can read as terminal', async () => {
const source = await readSource();
// `ready` fires unconditionally before the route knows the outcome (#2790).
expect(source).toMatch(/p\.phase === 'ready'\s*\?\s*'finalizing'/);
expect(source).not.toMatch(/p\.phase === 'ready' \? 'complete'/);
});
});
describe('HTTP repo catalog validation', () => {
const readSource = () =>
fs.readFile(path.join(__dirname, '..', '..', 'src', 'server', 'api.ts'), 'utf-8');
it('lists and resolves repos with validate: true, and maps StorageRequirementError', async () => {
const source = await readSource();
expect(source).toMatch(/const repos = await listRegisteredRepos\(\{\s*validate:\s*true\s*\}\)/);
expect(source).toMatch(
/const freshRepos = await listRegisteredRepos\(\{\s*validate:\s*options\.validateStorage !== false,\s*\}\)/,
);
expect(source).toMatch(
/app\.get\('\/api\/repos'[\s\S]*listRegisteredRepos\(\{\s*validate:\s*true\s*\}\)/,
);
expect(source).toMatch(/sendStorageRequirementHttp\(err, res\)/);
expect(source).toMatch(/storageRequirementToHttp\(err\)/);
expect(source).toMatch(/code: 'index-unavailable'/);
});
});