mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-19 05:58:01 +08:00
79543c8f83
* 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>
300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
/**
|
|
* The finalization gate must watch the index the run actually WROTE.
|
|
*
|
|
* `registerRepo` always records the flat `.gitnexus` as `entry.storagePath`, but
|
|
* a pinned `--branch` run whose label differs from the flat slot's owner writes
|
|
* `lbug`/`gitnexus.json` under `branches/<slug>/`. The gate used to probe the
|
|
* flat path unconditionally, so for such a run it watched files this job never
|
|
* rewrote:
|
|
*
|
|
* - the gate never settles, so the job stays non-terminal for the full 60s;
|
|
* - meanwhile the worker, having already sent `complete`, calls
|
|
* `process.exit(0)` ~500ms later;
|
|
* - the exit handler saw a non-terminal job and classified that clean exit as
|
|
* a crash — retrying a SUCCESSFUL analysis three times before failing it
|
|
* with `Worker crashed 3 times (code 0)`.
|
|
*
|
|
* Reported twice on #3199 (maintainer review + @azizur100389's repro). These
|
|
* tests pin both halves: the gate follows the placement, and a terminal IPC
|
|
* makes a subsequent exit 0 settlement rather than a crash.
|
|
*
|
|
* The filesystem mock is deliberately PATH-SENSITIVE — only the branch sub-slot
|
|
* looks freshly written. A gate that probes the flat path therefore cannot pass
|
|
* these tests by accident.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
|
import { EventEmitter } from 'node:events';
|
|
import path from 'node:path';
|
|
|
|
// `vi.hoisted` is lifted above the imports, so nothing in here may reference
|
|
// them — these stay plain literals and `path` is only used below.
|
|
const H = vi.hoisted(() => ({
|
|
forkMock: vi.fn(),
|
|
STORAGE_PATH: '/tmp/gitnexus-settle-storage',
|
|
REPO_PATH: '/tmp/gitnexus-settle-repo',
|
|
METADATA_FILE: 'gitnexus.json',
|
|
// Set per-test: the only directory the fake filesystem reports as freshly
|
|
// written. Anything else looks stale, exactly like a slot this job skipped.
|
|
settledDir: '',
|
|
}));
|
|
const { forkMock, REPO_PATH } = H;
|
|
|
|
vi.mock('child_process', async () => {
|
|
const actual = await vi.importActual<typeof import('child_process')>('child_process');
|
|
return { ...actual, fork: H.forkMock };
|
|
});
|
|
|
|
vi.mock('../../src/storage/repo-manager.js', () => ({
|
|
INDEX_METADATA_FILE: H.METADATA_FILE,
|
|
}));
|
|
|
|
vi.mock('../../src/storage/storage-resolver.js', () => ({
|
|
ANALYZE_STORAGE_REQUIREMENTS: { allowedStates: ['missing', 'empty', 'owned'] },
|
|
ANALYZE_FORCE_STORAGE_REQUIREMENTS: {
|
|
allowedStates: ['missing', 'empty', 'owned', 'unowned', 'foreign'],
|
|
},
|
|
requireStoragePath: async () => H.STORAGE_PATH,
|
|
}));
|
|
|
|
vi.mock('node:fs', async () => {
|
|
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
|
return {
|
|
...actual,
|
|
statSync: (p: string) => {
|
|
// Fresh only inside the directory this run is pretending to have written.
|
|
// Plain string work rather than `path.dirname`: this factory is hoisted
|
|
// above the imports too.
|
|
const file = String(p);
|
|
const dir = file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')));
|
|
if (H.settledDir && dir === H.settledDir) {
|
|
return { mtimeMs: Number.MAX_SAFE_INTEGER };
|
|
}
|
|
return { mtimeMs: 0 };
|
|
},
|
|
existsSync: () => false, // no WAL/shadow/checkpoint sidecars anywhere
|
|
};
|
|
});
|
|
|
|
import { createLaunchAnalysisWorker } from '../../src/server/analyze-launch.js';
|
|
import { JobManager } from '../../src/server/analyze-job.js';
|
|
import { projectAnalyzeResultForIpc } from '../../src/server/analyze-worker-ipc.js';
|
|
import { BRANCHES_DIR, branchSlug } from '../../src/storage/branch-index.js';
|
|
import type { AnalyzeResult } from '../../src/core/run-analyze.js';
|
|
import type { CompleteMessage } from '../../src/server/analyze-worker.js';
|
|
|
|
const BRANCH = 'feature/settle';
|
|
|
|
/** The `complete` message the worker really sends, via the production projection. */
|
|
const completeMessage = (
|
|
isPrimaryBranch: boolean,
|
|
extras?: { alreadyUpToDate?: boolean },
|
|
): CompleteMessage => {
|
|
const result = {
|
|
repoName: 'settle-fixture',
|
|
repoPath: REPO_PATH,
|
|
stats: { files: 3, nodes: 9, edges: 12 },
|
|
isPrimaryBranch,
|
|
...(extras?.alreadyUpToDate ? { alreadyUpToDate: true } : {}),
|
|
} satisfies Partial<AnalyzeResult> as AnalyzeResult;
|
|
return { type: 'complete', result: projectAnalyzeResultForIpc(result) };
|
|
};
|
|
|
|
interface FakeChild extends EventEmitter {
|
|
stderr: EventEmitter;
|
|
send: Mock<(msg: unknown) => boolean>;
|
|
kill: Mock<(signal?: NodeJS.Signals) => boolean>;
|
|
}
|
|
|
|
const makeChild = (): FakeChild => {
|
|
const child = new EventEmitter() as FakeChild;
|
|
child.stderr = new EventEmitter();
|
|
child.send = vi.fn();
|
|
child.kill = vi.fn();
|
|
return child;
|
|
};
|
|
|
|
describe('finalization gate follows the placement the run chose', () => {
|
|
let jobManager: JobManager;
|
|
let child: FakeChild;
|
|
let backendInit: Mock<() => Promise<unknown>>;
|
|
let closeDbHandle: Mock<() => Promise<void>>;
|
|
|
|
const launcher = (extras?: { releaseRepoLock?: () => void }) =>
|
|
createLaunchAnalysisWorker({
|
|
jobManager,
|
|
backend: { init: backendInit },
|
|
acquireRepoLock: () => null,
|
|
releaseRepoLock: extras?.releaseRepoLock ?? (() => {}),
|
|
closeDbHandle,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
jobManager = new JobManager();
|
|
child = makeChild();
|
|
forkMock.mockImplementation(() => child);
|
|
backendInit = vi.fn(async () => true);
|
|
closeDbHandle = vi.fn(async () => {});
|
|
H.settledDir = '';
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
jobManager.dispose();
|
|
vi.restoreAllMocks();
|
|
forkMock.mockReset();
|
|
});
|
|
|
|
it('completes a branch sub-slot run, whose files are NOT in the flat slot', async () => {
|
|
// Only `branches/<slug>/` looks written. The flat slot is stale, so a gate
|
|
// probing it would spin to the 60s timeout instead of settling here.
|
|
H.settledDir = path.join(H.STORAGE_PATH, BRANCHES_DIR, branchSlug(BRANCH));
|
|
|
|
const job = jobManager.createJob({ repoPath: REPO_PATH, branch: BRANCH });
|
|
await launcher()(job, REPO_PATH, { branch: BRANCH });
|
|
|
|
child.emit('message', completeMessage(false));
|
|
|
|
await vi.waitFor(() => expect(jobManager.getJob(job.id)?.status).toBe('complete'));
|
|
expect(jobManager.getJob(job.id)?.error).toBeUndefined();
|
|
expect(backendInit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('still settles a flat-slot run against the flat slot', async () => {
|
|
// Control: the primary-branch case must keep watching `entry.storagePath`.
|
|
H.settledDir = H.STORAGE_PATH;
|
|
|
|
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
|
await launcher()(job, REPO_PATH, {});
|
|
|
|
child.emit('message', completeMessage(true));
|
|
|
|
await vi.waitFor(() => expect(jobManager.getJob(job.id)?.status).toBe('complete'));
|
|
expect(backendInit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('settles a first-pin (branch SET, isPrimaryBranch true) against the flat slot', async () => {
|
|
// Fresh clone: first pin adopts the flat slot. The slug dir is stale, so a
|
|
// gate that does `branch ? slugDir : flat` would spin the 60s timeout here.
|
|
H.settledDir = H.STORAGE_PATH;
|
|
|
|
const job = jobManager.createJob({ repoPath: REPO_PATH, branch: BRANCH });
|
|
await launcher()(job, REPO_PATH, { branch: BRANCH });
|
|
|
|
child.emit('message', completeMessage(true));
|
|
|
|
await vi.waitFor(() => expect(jobManager.getJob(job.id)?.status).toBe('complete'));
|
|
expect(jobManager.getJob(job.id)?.error).toBeUndefined();
|
|
expect(backendInit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('completes alreadyUpToDate quickly even when the slot is stale, without retrying', async () => {
|
|
// No directory looks freshly written. Without the alreadyUpToDate skip the
|
|
// mtime gate would hold the analyze slot for the full 60s settle timeout.
|
|
H.settledDir = '';
|
|
const releaseRepoLock = vi.fn();
|
|
|
|
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
|
await launcher({ releaseRepoLock })(job, REPO_PATH, {});
|
|
|
|
child.emit('message', completeMessage(true, { alreadyUpToDate: true }));
|
|
child.emit('exit', 0);
|
|
|
|
await vi.waitFor(() => expect(jobManager.getJob(job.id)?.status).toBe('complete'), {
|
|
timeout: 2_000,
|
|
});
|
|
expect(jobManager.getJob(job.id)?.error).toBeUndefined();
|
|
expect(backendInit).toHaveBeenCalledTimes(1);
|
|
expect(forkMock).toHaveBeenCalledTimes(1);
|
|
expect(jobManager.getJob(job.id)?.retryCount).toBe(0);
|
|
// Short path: skip the mtime wait, then drop the lock once it finishes.
|
|
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not fork a retry when the worker exits 0 after reporting complete', async () => {
|
|
// The worker exits ~500ms after the `complete` IPC, while the gate is still
|
|
// running and the job is deliberately non-terminal. That exit is the worker
|
|
// winding down, not dying.
|
|
H.settledDir = path.join(H.STORAGE_PATH, BRANCHES_DIR, branchSlug(BRANCH));
|
|
|
|
const job = jobManager.createJob({ repoPath: REPO_PATH, branch: BRANCH });
|
|
await launcher()(job, REPO_PATH, { branch: BRANCH });
|
|
|
|
child.emit('message', completeMessage(false));
|
|
child.emit('exit', 0);
|
|
|
|
await vi.waitFor(() => expect(jobManager.getJob(job.id)?.status).toBe('complete'));
|
|
expect(jobManager.getJob(job.id)?.error).toBeUndefined();
|
|
// One fork for the run itself; a retry would be a second.
|
|
expect(forkMock).toHaveBeenCalledTimes(1);
|
|
expect(jobManager.getJob(job.id)?.retryCount).toBe(0);
|
|
});
|
|
|
|
it('fails and does not publish when the settle gate times out', 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(releaseRepoLock).not.toHaveBeenCalled();
|
|
expect(backendInit).not.toHaveBeenCalled();
|
|
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
|
|
|
|
// Must match FINALIZE_SETTLE_TIMEOUT_MS + one poll in analyze-launch.ts.
|
|
await vi.advanceTimersByTimeAsync(61_000);
|
|
|
|
const done = jobManager.getJob(job.id);
|
|
expect(done?.status).toBe('failed');
|
|
expect(done?.error).toMatch(/finalization not visible after timeout/i);
|
|
expect(backendInit).not.toHaveBeenCalled();
|
|
expect(closeDbHandle).not.toHaveBeenCalled();
|
|
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('holds the write lock until settle resolves, then releases once after publish', async () => {
|
|
vi.useFakeTimers();
|
|
H.settledDir = '';
|
|
const order: string[] = [];
|
|
const releaseRepoLock = vi.fn(() => {
|
|
order.push('releaseRepoLock');
|
|
});
|
|
backendInit = vi.fn(async () => {
|
|
order.push('backend.init');
|
|
return true;
|
|
});
|
|
|
|
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
|
await launcher({ releaseRepoLock })(job, REPO_PATH, {});
|
|
child.emit('message', completeMessage(true));
|
|
|
|
expect(releaseRepoLock).not.toHaveBeenCalled();
|
|
expect(backendInit).not.toHaveBeenCalled();
|
|
expect(jobManager.getJob(job.id)?.status).toBe('analyzing');
|
|
|
|
H.settledDir = H.STORAGE_PATH;
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
|
|
expect(jobManager.getJob(job.id)?.status).toBe('complete');
|
|
expect(backendInit).toHaveBeenCalledTimes(1);
|
|
expect(releaseRepoLock).toHaveBeenCalledTimes(1);
|
|
expect(order).toEqual(['backend.init', 'releaseRepoLock']);
|
|
});
|
|
|
|
it('still treats an exit with no terminal IPC as a crash worth retrying', async () => {
|
|
// The guard must not swallow real crashes: no `complete`/`error` was sent.
|
|
H.settledDir = H.STORAGE_PATH;
|
|
|
|
const job = jobManager.createJob({ repoPath: REPO_PATH });
|
|
await launcher()(job, REPO_PATH, {});
|
|
|
|
child.emit('exit', 1);
|
|
|
|
// The first retry is scheduled on a 1s backoff, so this needs more than
|
|
// vi.waitFor's default budget.
|
|
await vi.waitFor(() => expect(forkMock).toHaveBeenCalledTimes(2), { timeout: 4_000 });
|
|
expect(jobManager.getJob(job.id)?.retryCount).toBe(1);
|
|
});
|
|
});
|