mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.14.0 fix(zero-config): code-* readiness signal + init embedding-key validation + lock self-heal (#1780) (#1804)
* feat(code-intel): readiness signal on code-def/refs/callers/callees (#1780 Gap 1) New src/core/code-graph-readiness.ts: resolveCodeReadiness() returns a typed status (not_built | indexing | ready | unknown) + ready boolean so callers can tell "graph not built / still indexing" apart from "genuinely no match" when count===0. EXISTS-based (cheap), chunk-grain, resolver-version-matching pending predicate, fail-open. Wired into the 4 CLI envelopes (+ human hint) and the 4 MCP op handlers. def/refs are 2-state brain-wide; callers/callees 3-state scoped. * feat(db-lock): automatic same-host dead-pid cycle-lock takeover (#1780 Gap 3) tryAcquireDbLock now reclaims a held, not-TTL-expired lock when the same-host holder is provably dead (process.kill ESRCH) past a 60s grace, via guarded DELETE + one normal-upsert retry returning the normal handle. New shared injectable classifyHolderLiveness/isHolderDeadLocally (EPERM treated as ALIVE — never steals a live lock). runBreakLock's safe path consumes the shared predicate, fixing its prior EPERM-as-dead bug. Cross-host stays TTL-only. * feat(init): validate the embedding key at gbrain init (#1780 Gap 2) New src/core/init-embed-check.ts: config-only diagnoseEmbedding (missing key, all providers) + best-effort 1-token live test-embed (invalid/expired key, 5s timeout, never blocks). Loud warning to stderr, init still exits 0; skipped by --no-embedding / --skip-embed-check / GBRAIN_INIT_SKIP_EMBED_CHECK=1. Builds the effective env (process.env + file-plane keys + --key) via buildGatewayConfig, extracted to src/core/ai/build-gateway-config.ts (cli.ts re-exports) so the check sees the same keys + provider base URLs as runtime. embedding_check added to --json. * chore: bump version and changelog (v0.42.14.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document the #1780 zero-config gaps for v0.42.14.0 CLAUDE.md Key Files: add src/core/code-graph-readiness.ts, init-embed-check.ts, ai/build-gateway-config.ts, and the db-lock auto-takeover + code-* readiness field behaviors. Regenerate llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bea2d3e6c9
commit
1036f8f752
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* #1780 Gap 1 — code-graph readiness signal.
|
||||
*
|
||||
* Verifies the typed readiness contract that lets code-* callers tell
|
||||
* "graph not built / still indexing" apart from "genuinely no match" when
|
||||
* count === 0:
|
||||
* - empty brain → not_built (both grains)
|
||||
* - code synced, edges not resolved → symbol grain ready, edge grain indexing
|
||||
* - edges resolved → edge grain ready
|
||||
* - count > 0 → ready short-circuit (no query)
|
||||
* - source scoping (scoped miss → not_built; allSources → brain-wide)
|
||||
* - DB error → unknown, fail-open (CRITICAL regression)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
import { importCodeFile } from '../src/core/import-file.ts';
|
||||
import { resolveCodeReadiness, readinessHint } from '../src/core/code-graph-readiness.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean slate per test: remove all chunks + pages so empty-brain cases hold.
|
||||
await engine.executeRaw('DELETE FROM content_chunks');
|
||||
await engine.executeRaw('DELETE FROM pages');
|
||||
});
|
||||
|
||||
const SAMPLE = `export function alpha(x: number): number {
|
||||
return beta(x) + 1;
|
||||
}
|
||||
|
||||
export function beta(y: number): number {
|
||||
return y * 2;
|
||||
}
|
||||
`;
|
||||
|
||||
describe('resolveCodeReadiness — empty brain', () => {
|
||||
test('symbol grain → not_built when no code exists', async () => {
|
||||
const r = await resolveCodeReadiness(engine, { kind: 'symbol', count: 0 });
|
||||
expect(r.status).toBe('not_built');
|
||||
expect(r.ready).toBe(false);
|
||||
expect(r.has_code).toBe(false);
|
||||
});
|
||||
|
||||
test('edge grain → not_built when no code exists', async () => {
|
||||
const r = await resolveCodeReadiness(engine, { kind: 'edge', count: 0 });
|
||||
expect(r.status).toBe('not_built');
|
||||
expect(r.ready).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCodeReadiness — code synced, edges unresolved', () => {
|
||||
beforeEach(async () => {
|
||||
// importCodeFile writes code chunks with edges_backfilled_at = NULL
|
||||
// (resolve phase hasn't run), exactly the "graph still building" state.
|
||||
await importCodeFile(engine, 'src/sample.ts', SAMPLE, { noEmbed: true });
|
||||
});
|
||||
|
||||
test('symbol grain → ready (symbol metadata is at chunk time)', async () => {
|
||||
const r = await resolveCodeReadiness(engine, { kind: 'symbol', count: 0 });
|
||||
expect(r.status).toBe('ready');
|
||||
expect(r.ready).toBe(true);
|
||||
expect(r.has_code).toBe(true);
|
||||
});
|
||||
|
||||
test('edge grain → indexing (edges pending resolution)', async () => {
|
||||
const r = await resolveCodeReadiness(engine, { kind: 'edge', count: 0 });
|
||||
expect(r.status).toBe('indexing');
|
||||
expect(r.ready).toBe(false);
|
||||
expect(r.pending_edges).toBe(true);
|
||||
});
|
||||
|
||||
test('edge grain → ready once edges_backfilled_at is stamped fresh', async () => {
|
||||
// Mirror what the resolve_symbol_edges phase does: stamp every code chunk.
|
||||
await engine.executeRaw('UPDATE content_chunks SET edges_backfilled_at = NOW()');
|
||||
const r = await resolveCodeReadiness(engine, { kind: 'edge', count: 0 });
|
||||
expect(r.status).toBe('ready');
|
||||
expect(r.ready).toBe(true);
|
||||
expect(r.pending_edges).toBe(false);
|
||||
});
|
||||
|
||||
test('count > 0 short-circuits to ready with no probe', async () => {
|
||||
// Even with pending edges, a non-empty result is trivially ready.
|
||||
const r = await resolveCodeReadiness(engine, { kind: 'edge', count: 3 });
|
||||
expect(r.status).toBe('ready');
|
||||
expect(r.ready).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCodeReadiness — source scoping', () => {
|
||||
beforeEach(async () => {
|
||||
await importCodeFile(engine, 'src/sample.ts', SAMPLE, { noEmbed: true });
|
||||
});
|
||||
|
||||
test('scoped to a source with no code → not_built', async () => {
|
||||
const r = await resolveCodeReadiness(engine, { kind: 'symbol', count: 0, sourceId: 'no-such-source' });
|
||||
expect(r.status).toBe('not_built');
|
||||
});
|
||||
|
||||
test('scoped to the default source (where code lives) → ready (symbol)', async () => {
|
||||
const r = await resolveCodeReadiness(engine, { kind: 'symbol', count: 0, sourceId: 'default' });
|
||||
expect(r.status).toBe('ready');
|
||||
});
|
||||
|
||||
test('allSources ignores a non-matching sourceId and goes brain-wide', async () => {
|
||||
const r = await resolveCodeReadiness(engine, {
|
||||
kind: 'symbol', count: 0, sourceId: 'no-such-source', allSources: true,
|
||||
});
|
||||
expect(r.status).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCodeReadiness — fail-open (CRITICAL regression)', () => {
|
||||
test('DB error → status unknown, ready false, never throws', async () => {
|
||||
const broken = {
|
||||
kind: 'pglite',
|
||||
executeRaw: async () => { throw new Error('boom'); },
|
||||
} as unknown as BrainEngine;
|
||||
const r = await resolveCodeReadiness(broken, { kind: 'edge', count: 0 });
|
||||
expect(r.status).toBe('unknown');
|
||||
expect(r.ready).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readinessHint', () => {
|
||||
test('not_built / indexing / unknown produce a hint; ready does not', () => {
|
||||
expect(readinessHint({ status: 'not_built', ready: false, has_code: false, pending_edges: false })).toContain('not built');
|
||||
expect(readinessHint({ status: 'indexing', ready: false, has_code: true, pending_edges: true })).toContain('still building');
|
||||
expect(readinessHint({ status: 'unknown', ready: false, has_code: false, pending_edges: false })).toContain('unavailable');
|
||||
expect(readinessHint({ status: 'ready', ready: true, has_code: true, pending_edges: false })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* #1780 Gap 3 — automatic same-host dead-pid lock takeover.
|
||||
*
|
||||
* Two layers:
|
||||
* - classifyHolderLiveness (pure, injectable process.kill seam): the
|
||||
* decision matrix incl. the CRITICAL EPERM-as-ALIVE rule.
|
||||
* - tryAcquireDbLock auto-takeover (PGLite, real process.kill): a held +
|
||||
* not-TTL-expired lock whose same-host holder is provably dead and past
|
||||
* the 60s grace gets reclaimed; alive / cross-host / young holders don't.
|
||||
* A taken-over lock returns a working handle (refresh + release).
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { hostname } from 'os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import {
|
||||
tryAcquireDbLock,
|
||||
classifyHolderLiveness,
|
||||
isHolderDeadLocally,
|
||||
HOLDER_TAKEOVER_GRACE_MS,
|
||||
} from '../src/core/db-lock.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await engine.executeRaw(`DELETE FROM gbrain_cycle_locks WHERE id LIKE 'test-takeover-%'`);
|
||||
});
|
||||
|
||||
const LOCAL = hostname();
|
||||
const OLD_MS = HOLDER_TAKEOVER_GRACE_MS + 60_000; // comfortably past the grace window
|
||||
const ESRCH = () => { const e = new Error('no such process') as NodeJS.ErrnoException; e.code = 'ESRCH'; throw e; };
|
||||
const EPERM = () => { const e = new Error('operation not permitted') as NodeJS.ErrnoException; e.code = 'EPERM'; throw e; };
|
||||
const EINVAL = () => { const e = new Error('weird') as NodeJS.ErrnoException; e.code = 'EINVAL'; throw e; };
|
||||
const aliveKill = () => { /* no throw → alive */ };
|
||||
|
||||
describe('classifyHolderLiveness', () => {
|
||||
test('same-host + ESRCH + old → dead_eligible', () => {
|
||||
expect(classifyHolderLiveness(123, LOCAL, OLD_MS, { processKill: ESRCH })).toBe('dead_eligible');
|
||||
});
|
||||
|
||||
test('same-host + ESRCH + young → too_young (PID-reuse guard)', () => {
|
||||
expect(classifyHolderLiveness(123, LOCAL, 5_000, { processKill: ESRCH })).toBe('too_young');
|
||||
});
|
||||
|
||||
test('same-host + alive → alive', () => {
|
||||
expect(classifyHolderLiveness(123, LOCAL, OLD_MS, { processKill: aliveKill })).toBe('alive');
|
||||
});
|
||||
|
||||
test('CRITICAL: same-host + EPERM → alive (never steal a live lock)', () => {
|
||||
expect(classifyHolderLiveness(123, LOCAL, OLD_MS, { processKill: EPERM })).toBe('alive');
|
||||
});
|
||||
|
||||
test('same-host + unknown errno → unknown (conservative)', () => {
|
||||
expect(classifyHolderLiveness(123, LOCAL, OLD_MS, { processKill: EINVAL })).toBe('unknown');
|
||||
});
|
||||
|
||||
test('cross-host → cross_host (never probe a remote pid)', () => {
|
||||
expect(classifyHolderLiveness(123, 'some-other-host', OLD_MS, { processKill: ESRCH })).toBe('cross_host');
|
||||
});
|
||||
|
||||
test('isHolderDeadLocally is true only for dead_eligible', () => {
|
||||
expect(isHolderDeadLocally(1, LOCAL, OLD_MS, { processKill: ESRCH })).toBe(true);
|
||||
expect(isHolderDeadLocally(1, LOCAL, 5_000, { processKill: ESRCH })).toBe(false);
|
||||
expect(isHolderDeadLocally(1, LOCAL, OLD_MS, { processKill: EPERM })).toBe(false);
|
||||
expect(isHolderDeadLocally(1, 'other', OLD_MS, { processKill: ESRCH })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** Insert a held, NOT-TTL-expired lock row for the given holder. */
|
||||
async function seedHeldLock(id: string, holderPid: number, holderHost: string, ageSeconds: number) {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
||||
VALUES ($1, $2, $3, NOW() - ($4 || ' seconds')::interval, NOW() + INTERVAL '10 minutes', NOW() - ($4 || ' seconds')::interval)`,
|
||||
[id, holderPid, holderHost, String(ageSeconds)],
|
||||
);
|
||||
}
|
||||
|
||||
/** A reliably-dead PID on this host: spawn a process, wait for it to exit. */
|
||||
async function deadPid(): Promise<number> {
|
||||
const proc = Bun.spawn(['sh', '-c', 'exit 0']);
|
||||
await proc.exited;
|
||||
return proc.pid;
|
||||
}
|
||||
|
||||
describe('tryAcquireDbLock auto-takeover', () => {
|
||||
test('reclaims a same-host dead-pid lock past the grace window', async () => {
|
||||
const pid = await deadPid();
|
||||
await seedHeldLock('test-takeover-dead', pid, LOCAL, 120);
|
||||
const handle = await tryAcquireDbLock(engine, 'test-takeover-dead', 30);
|
||||
expect(handle).not.toBeNull();
|
||||
// The reclaimed handle is the normal one: refresh + release work.
|
||||
await handle!.refresh();
|
||||
await handle!.release();
|
||||
// After release, the row is gone → a fresh acquire succeeds immediately.
|
||||
const again = await tryAcquireDbLock(engine, 'test-takeover-dead', 30);
|
||||
expect(again).not.toBeNull();
|
||||
await again!.release();
|
||||
});
|
||||
|
||||
test('does NOT reclaim a live same-host holder', async () => {
|
||||
// process.pid is alive → no takeover; lock stays held.
|
||||
await seedHeldLock('test-takeover-alive', process.pid, LOCAL, 120);
|
||||
const handle = await tryAcquireDbLock(engine, 'test-takeover-alive', 30);
|
||||
expect(handle).toBeNull();
|
||||
});
|
||||
|
||||
test('does NOT reclaim a cross-host holder (TTL-only)', async () => {
|
||||
const pid = await deadPid();
|
||||
await seedHeldLock('test-takeover-xhost', pid, 'a-different-host', 120);
|
||||
const handle = await tryAcquireDbLock(engine, 'test-takeover-xhost', 30);
|
||||
expect(handle).toBeNull();
|
||||
});
|
||||
|
||||
test('does NOT reclaim a dead-pid lock younger than the grace window', async () => {
|
||||
const pid = await deadPid();
|
||||
await seedHeldLock('test-takeover-young', pid, LOCAL, 5); // 5s < 60s grace
|
||||
const handle = await tryAcquireDbLock(engine, 'test-takeover-young', 30);
|
||||
expect(handle).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -169,6 +169,61 @@ describe('v0.34 W3 — code_def finds definition sites', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1780 Gap 1 — readiness envelope on code_* ops', () => {
|
||||
test('code_callers carries status:ready when callers are found', async () => {
|
||||
await seedTwoFileGraph(engine);
|
||||
const ctx = makeCtx(engine, 'source-a');
|
||||
const result = (await operationsByName.code_callers!.handler(ctx, { symbol: 'parseMarkdown' })) as {
|
||||
count: number; status: string; ready: boolean;
|
||||
};
|
||||
expect(result.count).toBeGreaterThanOrEqual(1);
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
|
||||
test('code_callers → indexing when code exists but edges unresolved + no callers', async () => {
|
||||
// callerInA has no callers; seeded chunks have edges_backfilled_at = NULL.
|
||||
await seedTwoFileGraph(engine);
|
||||
const ctx = makeCtx(engine, 'source-a');
|
||||
const result = (await operationsByName.code_callers!.handler(ctx, { symbol: 'callerInA' })) as {
|
||||
count: number; status: string; ready: boolean;
|
||||
};
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.status).toBe('indexing');
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
test('code_def → not_built on an empty brain', async () => {
|
||||
const ctx = makeCtx(engine, 'source-a');
|
||||
const result = (await operationsByName.code_def!.handler(ctx, { symbol: 'anything' })) as {
|
||||
count: number; status: string; ready: boolean;
|
||||
};
|
||||
expect(result.count).toBe(0);
|
||||
expect(result.status).toBe('not_built');
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
test('code_def → ready when a definition exists (brain-wide)', async () => {
|
||||
await seedDefSite(engine);
|
||||
const ctx = makeCtx(engine, 'source-a');
|
||||
const result = (await operationsByName.code_def!.handler(ctx, { symbol: 'parseMarkdown' })) as {
|
||||
count: number; status: string; ready: boolean;
|
||||
};
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
|
||||
test('code_refs → not_built on an empty brain', async () => {
|
||||
const ctx = makeCtx(engine, 'source-a');
|
||||
const result = (await operationsByName.code_refs!.handler(ctx, { symbol: 'anything' })) as {
|
||||
count: number; status: string; ready: boolean;
|
||||
};
|
||||
expect(result.status).toBe('not_built');
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* #1780 Gap 2 — init-time embedding-key validation.
|
||||
*
|
||||
* Hermetic: drives `runInitEmbedCheck` with the gateway embed-transport seam
|
||||
* (`__setEmbedTransportForTests`) and `withEnv` — no real network, no
|
||||
* mock.module. Covers:
|
||||
* - skip paths (--no-embedding / --skip-embed-check / env / no model)
|
||||
* - missing key → loud warning, ok:false (config diagnose)
|
||||
* - CRITICAL regression: file-plane key (config.json, not env) → NO false
|
||||
* "missing key" warning (the effective-env merge, D1A)
|
||||
* - live probe failure is best-effort (warns, ok stays true, never throws)
|
||||
* - live probe success → live_ok:true
|
||||
* - init-specific message names --no-embedding / --skip-embed-check (not --no-embed)
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import type { GBrainConfig } from '../src/core/config.ts';
|
||||
import { runInitEmbedCheck } from '../src/core/init-embed-check.ts';
|
||||
import { __setEmbedTransportForTests, resetGateway } from '../src/core/ai/gateway.ts';
|
||||
|
||||
const OPENAI = 'openai:text-embedding-3-large';
|
||||
|
||||
beforeEach(() => { resetGateway(); __setEmbedTransportForTests(null); });
|
||||
afterEach(() => { resetGateway(); __setEmbedTransportForTests(null); });
|
||||
|
||||
function capture() {
|
||||
const warned: string[] = [];
|
||||
return { warn: (m: string) => warned.push(m), warned };
|
||||
}
|
||||
|
||||
describe('runInitEmbedCheck — skip paths', () => {
|
||||
test('--no-embedding skips entirely', async () => {
|
||||
const { warn, warned } = capture();
|
||||
const r = await runInitEmbedCheck({ resolvedModel: OPENAI, noEmbedding: true, warn });
|
||||
expect(r.skipped).toBe('no_embedding');
|
||||
expect(warned).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('--skip-embed-check skips entirely', async () => {
|
||||
const { warn, warned } = capture();
|
||||
const r = await runInitEmbedCheck({ resolvedModel: OPENAI, skipFlag: true, warn });
|
||||
expect(r.skipped).toBe('flag');
|
||||
expect(warned).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('GBRAIN_INIT_SKIP_EMBED_CHECK=1 skips entirely', async () => {
|
||||
await withEnv({ GBRAIN_INIT_SKIP_EMBED_CHECK: '1' }, async () => {
|
||||
const { warn, warned } = capture();
|
||||
const r = await runInitEmbedCheck({ resolvedModel: OPENAI, warn });
|
||||
expect(r.skipped).toBe('env');
|
||||
expect(warned).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('no resolved model → skipped no_model', async () => {
|
||||
const r = await runInitEmbedCheck({});
|
||||
expect(r.skipped).toBe('no_model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInitEmbedCheck — config diagnose', () => {
|
||||
test('missing key → ok:false + loud warning naming the right flags', async () => {
|
||||
await withEnv({ OPENAI_API_KEY: undefined }, async () => {
|
||||
const { warn, warned } = capture();
|
||||
const r = await runInitEmbedCheck({
|
||||
resolvedModel: OPENAI,
|
||||
resolvedDim: 1536,
|
||||
apiKey: undefined,
|
||||
loadFileConfig: () => ({} as GBrainConfig),
|
||||
warn,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('missing_env');
|
||||
expect(warned).toHaveLength(1);
|
||||
const msg = warned[0];
|
||||
expect(msg).toContain('OPENAI_API_KEY');
|
||||
// init flag, NOT the sync/embed flag --no-embed
|
||||
expect(msg).toContain('--no-embedding');
|
||||
expect(msg).toContain('--skip-embed-check');
|
||||
expect(msg).not.toMatch(/--no-embed(?!ding)/);
|
||||
});
|
||||
});
|
||||
|
||||
test('CRITICAL: file-plane key (config.json, not env) → no false missing-key warning', async () => {
|
||||
await withEnv({ OPENAI_API_KEY: undefined }, async () => {
|
||||
const { warn, warned } = capture();
|
||||
const r = await runInitEmbedCheck({
|
||||
resolvedModel: OPENAI,
|
||||
resolvedDim: 1536,
|
||||
// key lives in config.json (file plane), not the shell env
|
||||
loadFileConfig: () => ({ openai_api_key: 'sk-from-config-file' } as GBrainConfig),
|
||||
skipLiveProbe: true, // config-only: this is the diagnose regression
|
||||
warn,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(warned).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('opts.apiKey (--key) satisfies the diagnose like a config-file key', async () => {
|
||||
await withEnv({ OPENAI_API_KEY: undefined }, async () => {
|
||||
const { warn, warned } = capture();
|
||||
const r = await runInitEmbedCheck({
|
||||
resolvedModel: OPENAI,
|
||||
resolvedDim: 1536,
|
||||
apiKey: 'sk-from-flag',
|
||||
loadFileConfig: () => ({} as GBrainConfig),
|
||||
skipLiveProbe: true,
|
||||
warn,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(warned).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInitEmbedCheck — live probe (best-effort)', () => {
|
||||
test('config ok + live probe succeeds → live_ok:true, no warning', async () => {
|
||||
// A present key lets embed() reach the installed transport (the transport
|
||||
// seam bypasses the SDK call, not the auth-resolution step).
|
||||
await withEnv({ OPENAI_API_KEY: 'sk-test' }, async () => {
|
||||
__setEmbedTransportForTests(async (args: any) => ({
|
||||
embeddings: (args.values as string[]).map(() => new Array(1536).fill(0)),
|
||||
usage: { tokens: 1 },
|
||||
}) as any);
|
||||
const { warn, warned } = capture();
|
||||
const r = await runInitEmbedCheck({ resolvedModel: OPENAI, resolvedDim: 1536, loadFileConfig: () => ({} as GBrainConfig), warn });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.live_ok).toBe(true);
|
||||
expect(warned).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('config ok + live probe FAILS → ok stays true, warns, never throws', async () => {
|
||||
await withEnv({ OPENAI_API_KEY: 'sk-test' }, async () => {
|
||||
__setEmbedTransportForTests(async () => { throw new Error('401 unauthorized: bad api key'); });
|
||||
const { warn, warned } = capture();
|
||||
const r = await runInitEmbedCheck({ resolvedModel: OPENAI, resolvedDim: 1536, loadFileConfig: () => ({} as GBrainConfig), warn });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.live_ok).toBe(false);
|
||||
expect(r.live_reason).toBe('auth');
|
||||
expect(warned).toHaveLength(1);
|
||||
expect(warned[0]).toContain('test embed failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user