diff --git a/src/commands/init.ts b/src/commands/init.ts index 14e33f6cf..b675206fd 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -249,6 +249,13 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise`)'); + console.error(' (you can configure later with `gbrain init --force --embedding-model :`)'); // D13: surface near-miss env vars (e.g. OPENAPI_API_KEY → OPENAI_API_KEY). if (typos.length > 0) { console.error(''); @@ -833,7 +840,7 @@ async function initPGLite(opts: { let resolvedModel: string | undefined; if (opts.aiOpts?.noEmbedding) { // D9 deferred-setup mode: skip preflight, no model/dim resolved. - console.log(` --no-embedding: deferred setup — configure with \`gbrain config set embedding_model \` before import`); + console.log(` --no-embedding: deferred setup — run \`gbrain init --force --embedding-model :\` before import`); } else if (opts.aiOpts?.embedding_model) { const { resolveSchemaEmbeddingDim } = await import('../core/embedding-dim-check.ts'); const pre = resolveSchemaEmbeddingDim({ @@ -972,6 +979,12 @@ async function initPGLite(opts: { // unless explicitly overridden by --schema-pack on re-init. ...(opts.schemaPack ? { schema_pack: opts.schemaPack } : {}), }; + // #2301: a resolved embedding model supersedes any stale deferred-setup + // sentinel carried over via ...existingFile — otherwise the sentinel + // re-defers embedding on every future init/embed forever. + if (!opts.aiOpts?.noEmbedding && resolvedModel && resolvedDim) { + delete config.embedding_disabled; + } // PR1: new installs publish their skill catalog over MCP by default // (existing config wins on re-init, so a prior opt-out is preserved). config.mcp = { publish_skills: true, ...(config.mcp ?? {}) }; @@ -1056,7 +1069,7 @@ async function initPostgres(opts: { let resolvedDim: number | undefined; let resolvedModel: string | undefined; if (opts.aiOpts?.noEmbedding) { - console.log(` --no-embedding: deferred setup — configure with \`gbrain config set embedding_model \` before import`); + console.log(` --no-embedding: deferred setup — run \`gbrain init --force --embedding-model :\` before import`); } else if (opts.aiOpts?.embedding_model) { const { resolveSchemaEmbeddingDim } = await import('../core/embedding-dim-check.ts'); const pre = resolveSchemaEmbeddingDim({ @@ -1220,6 +1233,10 @@ async function initPostgres(opts: { // v0.42 (T17): same schema_pack default as PGLite path. ...(opts.schemaPack ? { schema_pack: opts.schemaPack } : {}), }; + // #2301: same stale-sentinel drop as the PGLite path above. + if (!opts.aiOpts?.noEmbedding && resolvedModel && resolvedDim) { + delete config.embedding_disabled; + } // PR1: new installs publish their skill catalog over MCP by default // (existing config wins on re-init, so a prior opt-out is preserved). config.mcp = { publish_skills: true, ...(config.mcp ?? {}) }; diff --git a/src/core/embedding-dim-check.ts b/src/core/embedding-dim-check.ts index f4f1e7ee8..e33ab62b6 100644 --- a/src/core/embedding-dim-check.ts +++ b/src/core/embedding-dim-check.ts @@ -71,9 +71,8 @@ export function assertEmbeddingEnabled(cfg: { embedding_disabled?: boolean } | n throw new EmbeddingDisabledError( 'This brain was initialized with `--no-embedding` (deferred setup).\n' + 'Configure an embedding provider before running embed / import:\n' + - ' gbrain config set embedding_model :\n' + - ' gbrain config set embedding_dimensions \n' + - ' gbrain init --force --embedding-model : # re-init to size schema\n', + ' gbrain init --force --embedding-model : # re-init to size schema\n' + + '(`gbrain config set embedding_model` is refused — schema-sizing fields are set at init.)\n', ); } } diff --git a/test/e2e/init-reinit-after-deferred.test.ts b/test/e2e/init-reinit-after-deferred.test.ts new file mode 100644 index 000000000..79b068a92 --- /dev/null +++ b/test/e2e/init-reinit-after-deferred.test.ts @@ -0,0 +1,111 @@ +/** + * #2301 — re-init with an explicit --embedding-model must recover a brain + * that was initialized with --no-embedding (deferred setup). + * + * Pre-fix: resolveAIOptions honored the persisted `embedding_disabled: true` + * sentinel BEFORE the explicit flag and never cleared noEmbedding, and the + * persistence merge carried the sentinel forward via ...existingFile. Result: + * every re-init (including the recovery command the deferred-setup error + * itself recommends) silently re-deferred embedding, forever. + * + * Hermetic: in-process runInit, GBRAIN_HOME pinned to a tmpdir (same pattern + * as test/e2e/fresh-install-pglite.test.ts). + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { configureGateway, resetGateway } from '../../src/core/ai/gateway.ts'; + +describe('E2E: re-init with --embedding-model after --no-embedding init (#2301)', () => { + let tmpHome: string; + let origHome: string | undefined; + let origZeKey: string | undefined; + let origOpenaiKey: string | undefined; + let origVoyageKey: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-e2e-reinit-')); + origHome = process.env.GBRAIN_HOME; + origZeKey = process.env.ZEROENTROPY_API_KEY; + origOpenaiKey = process.env.OPENAI_API_KEY; + origVoyageKey = process.env.VOYAGE_API_KEY; + delete process.env.OPENAI_API_KEY; + delete process.env.VOYAGE_API_KEY; + process.env.GBRAIN_HOME = tmpHome; + process.env.ZEROENTROPY_API_KEY = 'sk-test-ze'; + resetGateway(); + }); + + afterEach(() => { + rmSync(tmpHome, { recursive: true, force: true }); + if (origHome === undefined) delete process.env.GBRAIN_HOME; + else process.env.GBRAIN_HOME = origHome; + if (origZeKey === undefined) delete process.env.ZEROENTROPY_API_KEY; + else process.env.ZEROENTROPY_API_KEY = origZeKey; + if (origOpenaiKey !== undefined) process.env.OPENAI_API_KEY = origOpenaiKey; + if (origVoyageKey !== undefined) process.env.VOYAGE_API_KEY = origVoyageKey; + // Restore legacy-preload gateway state (mirrors fresh-install-pglite.test.ts). + configureGateway({ + embedding_model: 'openai:text-embedding-3-large', + embedding_dimensions: 1536, + env: { ...process.env }, + }); + }); + + async function runInitCapturing(args: string[]): Promise { + const { runInit } = await import('../../src/commands/init.ts'); + const origLog = console.log; + const origWarn = console.warn; + const stdoutBuf: string[] = []; + console.log = (...a: unknown[]) => { + stdoutBuf.push(a.map(x => (typeof x === 'string' ? x : JSON.stringify(x))).join(' ')); + }; + console.warn = () => {}; + try { + await runInit(args); + } finally { + console.log = origLog; + console.warn = origWarn; + } + return stdoutBuf.join('\n'); + } + + const cfgPath = () => join(tmpHome, '.gbrain', 'config.json'); + const readCfg = () => JSON.parse(readFileSync(cfgPath(), 'utf-8')); + + test('explicit --embedding-model clears the persisted embedding_disabled sentinel', async () => { + // Step 1: deferred-setup init writes the sentinel. + const out1 = await runInitCapturing(['--pglite', '--non-interactive', '--no-embedding']); + expect(out1).toContain('deferred setup'); + const cfg1 = readCfg(); + expect(cfg1.embedding_disabled).toBe(true); + expect(cfg1.embedding_model).toBeUndefined(); + + // Step 2: re-init with an explicit embedding model — the recovery path. + // Pre-fix this printed the deferred-setup line again and re-persisted + // embedding_disabled: true. + const out2 = await runInitCapturing([ + '--pglite', '--non-interactive', '--skip-embed-check', + '--embedding-model', 'zeroentropyai:zembed-1', + '--embedding-dimensions', '1280', + ]); + expect(out2).not.toContain('deferred setup'); + expect(out2).toContain('zeroentropyai:zembed-1'); + + const cfg2 = readCfg(); + expect(cfg2.embedding_model).toBe('zeroentropyai:zembed-1'); + expect(cfg2.embedding_dimensions).toBe(1280); + expect(cfg2.embedding_disabled).toBeUndefined(); + }, 60000); + + test('re-init WITHOUT flags still honors the deferred-setup sentinel (no regression)', async () => { + await runInitCapturing(['--pglite', '--non-interactive', '--no-embedding']); + const out = await runInitCapturing(['--pglite', '--non-interactive']); + expect(out).toContain('deferred setup'); + const cfg = readCfg(); + expect(cfg.embedding_disabled).toBe(true); + expect(cfg.embedding_model).toBeUndefined(); + }, 60000); +});