mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* fix: thread source_id through embed --stale to fix silent discard of non-default source embeddings
listStaleChunks correctly finds chunks across all sources, but
embedOneSlug called getChunks(slug) and upsertChunks(slug, merged)
without passing sourceId. Both default to source_id='default', so
for non-default sources (e.g. media-corpus):
1. getChunks returns empty (wrong source)
2. merged array has no existing chunks to merge into
3. upsertChunks writes nothing (or errors silently)
4. Embeddings generated by the API are silently discarded
Fix:
- Add source_id to StaleChunkRow type
- Add p.source_id to listStaleChunks SQL in both postgres + pglite engines
- Extract sourceId from stale row in embed command
- Pass { sourceId } to getChunks and upsertChunks
- Group stale chunks by composite key (source_id::slug) instead of bare slug
to handle same-slug pages across multiple sources
Verified: 97 chunks embedded across 35 pages in first run after fix.
Previously 0 non-default-source chunks were embedded across 3 full runs.
* fix: comprehensive multi-source threading for embed, listPages, and migrate-engine
Multi-source brains (e.g. with a 'media-corpus' source alongside
'default') have a pervasive bug: operations that iterate pages across
all sources then call engine methods (getChunks, upsertChunks,
getChunksWithEmbeddings) without passing sourceId. These methods all
default to source_id='default', silently operating on the wrong page
(or no page at all) for non-default sources.
Changes:
1. Page type + rowToPage: add optional source_id field so downstream
callers can read the source from page objects returned by listPages.
2. PageFilters: add sourceId filter so listPages can scope to a single
source (used by embed --source and future extract --source).
3. listPages (postgres + pglite): wire the sourceId filter into SQL.
4. embed command — three paths fixed:
a. embedPage (single-slug): accepts sourceId, threads to getPage +
getChunks + upsertChunks.
b. embedAll (--all): reads page.source_id from listPages results,
threads to getChunks + upsertChunks per page.
c. embedAllStale (--stale): reads source_id from StaleChunkRow,
groups by composite key (source_id::slug) instead of bare slug,
threads to getChunks + upsertChunks per key.
5. embed CLI: add --source <id> flag, threaded through all paths.
6. migrate-engine: thread page.source_id through
getChunksWithEmbeddings + upsertChunks so engine migrations don't
lose non-default-source chunks.
7. getChunksWithEmbeddings (postgres + pglite + BrainEngine interface):
accept optional { sourceId } to scope the chunk lookup.
8. StaleChunkRow type: add source_id field.
9. listStaleChunks SQL (postgres + pglite): add p.source_id to SELECT.
Verified: embed --stale correctly embeds 97 chunks across 35 pages
(previously 0 non-default-source chunks across 3 full runs).
embed --source media-corpus --dry-run correctly scopes to that source.
* v0.32.4 fix: multi-source threading for embed, listPages, and migrate-engine
Bump VERSION + package.json + CHANGELOG for the comprehensive multi-source
fix. Embed now threads source_id through every page → chunk handoff so
non-default sources stop silently dropping out (~22k chunks recovered on
the brain that surfaced this).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: complete slugs→keys rename in embedAllStale
The composite-key rename in the prior commit missed 4 references in the
worker loop and trailing console.log, so the file failed typecheck
(`Cannot find name 'slugs'`). The author's "Verified compiling + running"
claim was false at the time of the PR.
Also drop the dead `const bySlug = byKey` alias — unused after rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci: add check-source-id-projection.sh + fix getPage/putPage projections
Two SELECT projections fed `rowToPage` without including `source_id`:
- postgres-engine.ts:562 (getPage), :609 (putPage RETURNING)
- pglite-engine.ts:505 (getPage), :548 (putPage RETURNING)
After the type-tightening in the next commit makes `Page.source_id`
required, those projections would silently produce `Page` rows with
source_id=undefined while TypeScript claims `: string`. Codex's plan
review (F2) caught this; this commit closes it.
The new `scripts/check-source-id-projection.sh` greps for the rowToPage
feeder shape (`SELECT id, slug, type, title, ...`) and fails the build
if any projection lacks `source_id`. Wired into `bun run verify`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(engine): Page.source_id required + listAllPageRefs + validateSourceId
Three coordinated changes that unlock the Phase 3 bug-site fixes:
1. `Page.source_id` is now required (was optional, v0.31.12). The DB column
is `NOT NULL DEFAULT 'default'` so every row has it; the type now matches.
`rowToPage` always emits it (falls back to 'default' if a stale projection
somehow misses the column, but `scripts/check-source-id-projection.sh` is
the primary guard).
2. `BrainEngine.listAllPageRefs()` returns `Array<{slug, source_id}>` ordered
by `(source_id, slug)`. Cheap cross-source enumeration for hot loops in
extract-takes / extract / integrity that previously used
`getAllSlugs() → getPage(slug)` (N+1 query AND silently defaulted to
'default'). PGLite + Postgres parity.
3. `validateSourceId(id)` in utils. Allows `[a-z0-9_-]+` only. Used by the
per-source disk-layout fix coming in Phase 3 before any
`join(brainDir, source_id, ...)` call so source_id can't traverse out
of brainDir.
Deferred to v0.33 follow-up:
- D2 strict tightening of BrainEngine slug-method signatures (the compile-
time guard for "future getPage calls must pass sourceId")
- F3 OperationContext.sourceId required at MCP boundary
- F4 LinkBatchInput / TimelineBatchInput required source_id fields
- D6 forEachPage / listPagesAfter helpers (use listPages directly for now)
Those are nice-to-have guardrails for future regressions. Current commit's
correctness via D7 + listAllPageRefs is what blocks the Phase 3 bug-site
fixes from working multi-source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: thread source_id through cycle phases, extract, integrity, migrate-engine
Five bug sites that previously called slug-only engine methods inside a
loop over pages, silently defaulting to source_id='default' for every
non-default-source page. Now all five use listAllPageRefs to enumerate
(slug, source_id) pairs and thread sourceId through to engine.getPage,
getTags, addLink, addTimelineEntry, getRawData, getVersions, etc.
Site-by-site:
- src/core/cycle/extract-takes.ts: listAllPageRefs replaces N+1
getAllSlugs+getPage. Takes for non-default-source pages now extract.
- src/core/cycle/patterns.ts + synthesize.ts: reverseWriteSlugs renamed
to reverseWriteRefs with Array<{slug, source_id}> contract. Disk
layout (F6): non-default sources land at brainDir/.sources/<id>/<slug>.md
so same-slug-different-source pages don't collide. Default-source
pages stay at brainDir/<slug>.md so single-source brains see no
change. source_id validated against [a-z0-9_-]+ at write time to
prevent path traversal.
- src/commands/extract.ts: extractLinksFromDB + extractTimelineFromDB
use listAllPageRefs. Cross-source link resolution rule (F10): origin's
source wins, fall back to default, else skip (don't silently push a
wrong-source edge). addLinksBatch / addTimelineEntriesBatch now fill
from_source_id / to_source_id / origin_source_id / source_id so
multi-source JOINs target the correct page row.
- src/commands/integrity.ts: same listAllPageRefs pattern in both the
primary scan loop and the auto-repair loop.
- src/commands/migrate-engine.ts: end-to-end source_id threading
(page + tags + timeline + raw + versions + links). Resume manifest
keyed on `${source_id}::${slug}` so multi-source resumes don't
collide on same-slug rows (pre-fix entries treated as default for
back-compat).
test/cycle-synthesize-slug-collection.test.ts updated for the new
collectChildPutPageSlugs return shape (Array<{slug, source_id}>
instead of string[]).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(e2e): multi-source bug class regression + CHANGELOG + e2e-test-map wire-up
test/e2e/multi-source-bug-class.test.ts — 7-case PGLite regression suite
pinning every bug site fixed in this PR:
- listAllPageRefs ordering by (source_id, slug) [F11]
- getPage with sourceId picks the right (source, slug) row [F2]
- extract-takes processes both alice pages independently
- listPages filters correctly with PageFilters.sourceId
- addLinksBatch with from/to_source_id targets the right rows [F4]
- validateSourceId rejects path traversal [F6]
- reverse-write disk layout uses .sources/<id>/<slug>.md [F6]
No DATABASE_URL needed (PGLite in-memory + canonical R3+R4 pattern).
Wire into scripts/e2e-test-map.ts so changes to any of the 6 touched
source files automatically trigger this test.
CHANGELOG expanded from the embed-only narrative to cover the full
bug-class extermination — extract, takes, patterns, integrity,
migrate-engine, plus the per-source disk layout, the CI gate, and
the new listAllPageRefs primitive. Voice: lead with what users can
DO that they couldn't before; real numbers from the production brain
that surfaced it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(integrity): batch path scans (source_id, slug) pairs too
The batch-load fast path in scanIntegrity used `SELECT DISTINCT ON (slug)`,
which silently collapsed multi-source duplicate slugs into a single scan —
the same bug class this PR fixes. test/e2e/integrity-batch.test.ts had a
case pinning the broken behavior ("scan once, not once-per-source") that
asserted batchResult.pagesScanned===1 for two real (source, slug) rows.
Switching the projection from `DISTINCT ON (slug)` to a plain `SELECT ...
ORDER BY source_id, slug` makes batch + sequential paths report the same
count (2) and matches the v0.32.4 listAllPageRefs walk.
Test renamed + assertion flipped to lock in the correct multi-source-aware
behavior: both paths now report 2, not 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: sync CLAUDE.md + llms bundles for v0.32.4
CLAUDE.md annotations updated on the 4 files that materially changed in
this PR's bug-class extermination:
- src/core/engine.ts — new listAllPageRefs() method
- src/core/utils.ts — new validateSourceId() helper + Page.source_id
required field plumbing
- src/commands/integrity.ts — batch projection switched from DISTINCT ON
(slug) to ORDER BY (source_id, slug) so multi-source scans aren't
collapsed
- scripts/check-source-id-projection.sh (NEW entry) — CI guard against
SELECT projections that drop source_id
Plus a new test inventory entry for test/e2e/multi-source-bug-class.test.ts
in the E2E section.
llms-full.txt regenerated per CLAUDE.md's iron rule. llms.txt is unchanged
(just an index).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version slot v0.32.4 → v0.32.8
VERSION + package.json + CHANGELOG header only. Annotation
sweep across src/tests/scripts and the CLAUDE.md + llms bundle
regen land in the two follow-up commits so each step bisects
independently.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: retag v0.32.4 → v0.32.8 across src/scripts/tests
Inline "introduced in" annotations follow the version slot bump
in the prior commit. No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: retag CLAUDE.md v0.32.4 → v0.32.8 + regen llms-full.txt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Merge remote-tracking branch 'origin/master' into fix/multi-source-threading
---------
Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
/**
|
|
* Engine migration: transfer brain data between PGLite and Postgres.
|
|
*
|
|
* Usage:
|
|
* gbrain migrate --to supabase [--url <connection_string>]
|
|
* gbrain migrate --to pglite [--path <db_path>]
|
|
* gbrain migrate --to <engine> --force (overwrite non-empty target)
|
|
*/
|
|
|
|
import { createEngine } from '../core/engine-factory.ts';
|
|
import { loadConfig, saveConfig, toEngineConfig, gbrainPath, type GBrainConfig } from '../core/config.ts';
|
|
import type { BrainEngine } from '../core/engine.ts';
|
|
import type { EngineConfig } from '../core/types.ts';
|
|
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
|
|
import { createProgress } from '../core/progress.ts';
|
|
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
|
|
|
interface MigrateOpts {
|
|
targetEngine: 'postgres' | 'pglite';
|
|
targetUrl?: string;
|
|
targetPath?: string;
|
|
force: boolean;
|
|
}
|
|
|
|
function parseArgs(args: string[]): MigrateOpts {
|
|
const toIdx = args.indexOf('--to');
|
|
if (toIdx === -1 || !args[toIdx + 1]) {
|
|
throw new Error('Usage: gbrain migrate --to <supabase|pglite> [--url <url>] [--path <path>] [--force]');
|
|
}
|
|
|
|
const targetRaw = args[toIdx + 1];
|
|
const targetEngine = targetRaw === 'supabase' ? 'postgres' : targetRaw as 'postgres' | 'pglite';
|
|
if (targetEngine !== 'postgres' && targetEngine !== 'pglite') {
|
|
throw new Error(`Unknown target engine: "${targetRaw}". Use: supabase or pglite`);
|
|
}
|
|
|
|
const urlIdx = args.indexOf('--url');
|
|
const pathIdx = args.indexOf('--path');
|
|
|
|
return {
|
|
targetEngine,
|
|
targetUrl: urlIdx !== -1 ? args[urlIdx + 1] : undefined,
|
|
targetPath: pathIdx !== -1 ? args[pathIdx + 1] : undefined,
|
|
force: args.includes('--force'),
|
|
};
|
|
}
|
|
|
|
function getManifestPath(): string {
|
|
return gbrainPath('migrate-manifest.json');
|
|
}
|
|
|
|
interface MigrateManifest {
|
|
completed_slugs: string[];
|
|
target_engine: string;
|
|
started_at: string;
|
|
}
|
|
|
|
function loadManifest(): MigrateManifest | null {
|
|
const path = getManifestPath();
|
|
if (!existsSync(path)) return null;
|
|
try {
|
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveManifest(manifest: MigrateManifest): void {
|
|
writeFileSync(getManifestPath(), JSON.stringify(manifest, null, 2));
|
|
}
|
|
|
|
function clearManifest(): void {
|
|
const path = getManifestPath();
|
|
if (existsSync(path)) unlinkSync(path);
|
|
}
|
|
|
|
export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]): Promise<void> {
|
|
const opts = parseArgs(args);
|
|
const config = loadConfig();
|
|
if (!config) {
|
|
console.error('No brain configured. Run: gbrain init');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check source != target
|
|
if (config.engine === opts.targetEngine) {
|
|
console.error(`Already using ${opts.targetEngine} engine. Nothing to migrate.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Build target config
|
|
const targetConfig: EngineConfig = { engine: opts.targetEngine };
|
|
if (opts.targetEngine === 'postgres') {
|
|
targetConfig.database_url = opts.targetUrl || process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL;
|
|
if (!targetConfig.database_url) {
|
|
console.error('Target is Supabase but no connection string provided. Use: --url <connection_string>');
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
targetConfig.database_path = opts.targetPath || gbrainPath('brain.pglite');
|
|
}
|
|
|
|
// Connect to target
|
|
console.log(`Connecting to target (${opts.targetEngine})...`);
|
|
const targetEngine = await createEngine(targetConfig);
|
|
await targetEngine.connect(targetConfig);
|
|
await targetEngine.initSchema();
|
|
|
|
// Check if target has data
|
|
const targetStats = await targetEngine.getStats();
|
|
if (targetStats.page_count > 0 && !opts.force) {
|
|
console.error(`Target brain is not empty (${targetStats.page_count} pages).`);
|
|
console.error('Run with --force to overwrite, or migrate to an empty brain.');
|
|
await targetEngine.disconnect();
|
|
process.exit(1);
|
|
}
|
|
|
|
if (targetStats.page_count > 0 && opts.force) {
|
|
console.log('--force: wiping target brain...');
|
|
// v0.18.0+ multi-source: deletePage(slug) is now source-scoped (defaults
|
|
// to 'default'), so per-page iteration would skip non-default-source
|
|
// rows. migrate-engine --force is a destructive wipe across the entire
|
|
// brain — all sources, all pages — so we issue a raw DELETE that matches
|
|
// the original semantic. Cascades through content_chunks / page_links /
|
|
// tags / timeline_entries / page_versions via existing FKs.
|
|
await targetEngine.executeRaw('DELETE FROM pages');
|
|
}
|
|
|
|
// Load or create manifest for resume
|
|
let manifest = loadManifest();
|
|
if (manifest && manifest.target_engine !== opts.targetEngine) {
|
|
console.log('Previous migration was to a different target. Starting fresh.');
|
|
manifest = null;
|
|
}
|
|
// v0.32.8 F8: manifest keys are now `${source_id}::${slug}` so multi-source
|
|
// migrations don't collide on same-slug-different-source pages. Pre-v0.32.8
|
|
// entries were bare slugs; we keep treating those as default-source for
|
|
// back-compat resume.
|
|
const completedSet = new Set(manifest?.completed_slugs || []);
|
|
const makeManifestKey = (sourceId: string, slug: string): string =>
|
|
sourceId === 'default' ? slug : `${sourceId}::${slug}`;
|
|
if (!manifest) {
|
|
manifest = {
|
|
completed_slugs: [],
|
|
target_engine: opts.targetEngine,
|
|
started_at: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
// Get all source pages
|
|
const sourceStats = await sourceEngine.getStats();
|
|
const allPages = await sourceEngine.listPages({ limit: 100000 });
|
|
const pagesToMigrate = allPages.filter(p => !completedSet.has(makeManifestKey(p.source_id, p.slug)));
|
|
|
|
console.log(`Migrating ${pagesToMigrate.length} pages (${allPages.length} total, ${completedSet.size} already done)...`);
|
|
|
|
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
|
progress.start('migrate.copy_pages', pagesToMigrate.length);
|
|
|
|
let migrated = 0;
|
|
for (const page of pagesToMigrate) {
|
|
// v0.32.8 F8: thread source_id end-to-end so multi-source pages migrate
|
|
// intact. Pre-fix: putPage / getTags / getTimeline / getRawData / getLinks
|
|
// all silently defaulted to source_id='default', so non-default-source
|
|
// tags / timeline / raw / links were either dropped or attached to the
|
|
// wrong row.
|
|
const sourceOpts = { sourceId: page.source_id };
|
|
|
|
// Copy page (preserve source_id)
|
|
await targetEngine.putPage(page.slug, {
|
|
type: page.type,
|
|
title: page.title,
|
|
compiled_truth: page.compiled_truth,
|
|
timeline: page.timeline,
|
|
frontmatter: page.frontmatter,
|
|
content_hash: page.content_hash,
|
|
}, sourceOpts);
|
|
|
|
// Copy chunks with embeddings.
|
|
const chunks = await sourceEngine.getChunksWithEmbeddings(page.slug, sourceOpts);
|
|
if (chunks.length > 0) {
|
|
await targetEngine.upsertChunks(page.slug, chunks.map(c => ({
|
|
chunk_index: c.chunk_index,
|
|
chunk_text: c.chunk_text,
|
|
chunk_source: c.chunk_source,
|
|
embedding: c.embedding || undefined,
|
|
model: c.model,
|
|
token_count: c.token_count || undefined,
|
|
})), sourceOpts);
|
|
}
|
|
|
|
// Copy tags
|
|
const tags = await sourceEngine.getTags(page.slug, sourceOpts);
|
|
for (const tag of tags) {
|
|
await targetEngine.addTag(page.slug, tag, sourceOpts);
|
|
}
|
|
|
|
// Copy timeline
|
|
const timeline = await sourceEngine.getTimeline(page.slug, sourceOpts);
|
|
for (const entry of timeline) {
|
|
await targetEngine.addTimelineEntry(page.slug, {
|
|
date: entry.date,
|
|
source: entry.source,
|
|
summary: entry.summary,
|
|
detail: entry.detail,
|
|
}, sourceOpts);
|
|
}
|
|
|
|
// Copy raw data
|
|
const rawData = await sourceEngine.getRawData(page.slug, undefined, sourceOpts);
|
|
for (const rd of rawData) {
|
|
await targetEngine.putRawData(page.slug, rd.source, rd.data, sourceOpts);
|
|
}
|
|
|
|
// Copy versions
|
|
const versions = await sourceEngine.getVersions(page.slug, sourceOpts);
|
|
// Versions are snapshots, we recreate them on the target
|
|
// (createVersion takes a snapshot of current state, which we just set)
|
|
|
|
// Track progress with composite key so multi-source resume is correct.
|
|
manifest!.completed_slugs.push(makeManifestKey(page.source_id, page.slug));
|
|
saveManifest(manifest!);
|
|
migrated++;
|
|
progress.tick(1, page.slug);
|
|
}
|
|
progress.finish();
|
|
|
|
// Copy links (after all pages exist in target).
|
|
// v0.32.8 F8: thread source_id so cross-source links migrate correctly.
|
|
console.log('Copying links...');
|
|
progress.start('migrate.copy_links', allPages.length);
|
|
for (const page of allPages) {
|
|
const sourceOpts = { sourceId: page.source_id };
|
|
const links = await sourceEngine.getLinks(page.slug, sourceOpts);
|
|
for (const link of links) {
|
|
await targetEngine.addLink(
|
|
link.from_slug, link.to_slug,
|
|
link.context, link.link_type,
|
|
undefined, undefined, undefined,
|
|
{ fromSourceId: page.source_id, toSourceId: page.source_id },
|
|
);
|
|
}
|
|
progress.tick(1);
|
|
}
|
|
progress.finish();
|
|
|
|
// Copy config (selective)
|
|
const configKeys = ['embedding_model', 'embedding_dimensions', 'chunk_strategy'];
|
|
for (const key of configKeys) {
|
|
const val = await sourceEngine.getConfig(key);
|
|
if (val) await targetEngine.setConfig(key, val);
|
|
}
|
|
|
|
// Update local config
|
|
const newConfig: GBrainConfig = {
|
|
engine: opts.targetEngine,
|
|
...(opts.targetEngine === 'postgres'
|
|
? { database_url: targetConfig.database_url }
|
|
: { database_path: targetConfig.database_path }),
|
|
};
|
|
saveConfig(newConfig);
|
|
|
|
// Clean up
|
|
clearManifest();
|
|
|
|
console.log(`\nMigration complete. ${migrated} pages transferred.`);
|
|
console.log(`Config updated to engine: ${opts.targetEngine}`);
|
|
if (config.engine === 'pglite' && config.database_path) {
|
|
console.log(`Original PGLite brain preserved at ${config.database_path} (backup).`);
|
|
}
|
|
|
|
// Post-migrate verification: confirm the target is healthy before we
|
|
// leave the user. Catches incomplete copies, schema drift, and missing
|
|
// embeddings immediately instead of on next CLI use. Non-fatal — prints
|
|
// warnings and keeps going so the user sees the full picture.
|
|
console.log('\nVerifying target...');
|
|
try {
|
|
await verifyTarget(targetEngine, sourceStats.page_count);
|
|
} catch (e) {
|
|
console.warn(` Verification could not complete: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
|
|
await targetEngine.disconnect();
|
|
}
|
|
|
|
/**
|
|
* Lightweight doctor-style verify run against the migrated target.
|
|
* Prints a small table of signals; does not exit. Callers own engine
|
|
* lifecycle.
|
|
*/
|
|
async function verifyTarget(engine: BrainEngine, expectedPages: number): Promise<void> {
|
|
const stats = await engine.getStats();
|
|
if (stats.page_count === expectedPages) {
|
|
console.log(` ok pages: ${stats.page_count} (matches source)`);
|
|
} else {
|
|
console.warn(` WARN pages: ${stats.page_count} (source had ${expectedPages})`);
|
|
}
|
|
|
|
try {
|
|
const health = await engine.getHealth();
|
|
const pct = (health.embed_coverage * 100).toFixed(0);
|
|
if (health.embed_coverage >= 0.9) {
|
|
console.log(` ok embeddings: ${pct}% coverage, ${health.missing_embeddings} missing`);
|
|
} else {
|
|
console.warn(` WARN embeddings: ${pct}% coverage, ${health.missing_embeddings} missing. Run: gbrain embed --stale`);
|
|
}
|
|
} catch (e) {
|
|
console.warn(` WARN embeddings: could not measure (${e instanceof Error ? e.message : String(e)})`);
|
|
}
|
|
|
|
try {
|
|
const version = await engine.getConfig('version');
|
|
const { LATEST_VERSION } = await import('../core/migrate.ts');
|
|
const schemaVersion = parseInt(version || '0', 10);
|
|
if (schemaVersion >= LATEST_VERSION) {
|
|
console.log(` ok schema: version ${schemaVersion}`);
|
|
} else {
|
|
console.warn(` WARN schema: version ${schemaVersion} (latest: ${LATEST_VERSION}). Run: gbrain apply-migrations --yes`);
|
|
}
|
|
} catch {
|
|
console.warn(' WARN schema: version could not be read');
|
|
}
|
|
|
|
console.log(' Full health check: gbrain doctor');
|
|
}
|