mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
* fix: 8 root-cause fixes from /investigate wave
Consolidated bundle of bug fixes from /investigate on the 8 deferred bugs.
Each fix was designed to go at the structural gap, not the symptom. Codex
verified 20 load-bearing claims on the plan; 12 triggered plan revisions.
Bug 2 — GBRAIN_POOL_SIZE env knob + init finally blocks (no auto-detect).
Covers both the singleton pool (db.ts) and instance pool (import.ts:140).
Bug 3 — Centralize migration ledger writes in apply-migrations runner.
Removed appendCompletedMigration from v0_11_0, v0_12_0, v0_12_2,
v0_13_0, v0_13_1. Added 3-partial wedge cap + --force-retry reset.
'complete wins' preserved; no partial can regress a completed migration.
Bug 5 — v0.14.0 migration registered. src/commands/migrations/v0_14_0.ts
ships Phase A (ALTER minion_jobs.max_stalled SET DEFAULT 3) + Phase B
(pending-host-work ping for shell-jobs adoption).
Bug 6/10 — jsonb_agg(DISTINCT ...) in legacy traverseGraph (both engines).
Presentation-level dedup; schema still preserves provenance rows.
Bug 7 — doctor --fast reads DB URL source via getDbUrlSource() in config.ts.
Precise message: 'Skipping DB checks (--fast mode, URL present from env)'
replaces the misleading 'No database configured'.
Bug 8 — max_stalled default bumped 1→3 in schema-embedded.ts, pglite-schema.ts,
schema.sql (new installs). v0_14_0 Phase A ALTER for existing installs.
autopilot-cycle handler yields to event loop between phases so the
worker's lock-renewal timer fires on huge brains. (Deep AbortSignal
threading through runEmbedCore/runExtractCore/runBacklinksCore/performSync
deferred to v0.15 queue polish.)
Bug 9 — Gate sync.last_commit on no-failures across all three sync paths
(incremental, full via runImport, gbrain import git continuity).
recordSyncFailures() helper + ~/.gbrain/sync-failures.jsonl with
dedup key path+commit+error-hash. New flags: --skip-failed (ack) +
--retry-failed (re-attempt). Doctor surfaces unacknowledged failures.
Bug 11 — brain_score breakdown fields on BrainHealth (embed_coverage_score,
link_density_score, timeline_coverage_score, no_orphans_score,
no_dead_links_score); sum equals brain_score by construction.
dead_links now on the type (resolves featuresTeaserForDoctor drift).
orphan_pages kept as 'islanded' (no inbound AND no outbound) and
docs updated to match — explicit semantic instead of doc drift.
New tests: test/traverse-graph-dedup.test.ts, test/sync-failures.test.ts,
test/brain-score-breakdown.test.ts, test/migration-resume.test.ts,
test/migrations-v0_14_0.test.ts. Extended: migrate, doctor, apply-migrations.
All 1696 unit tests pass locally. postgres-jsonb E2E regression unchanged
(none of these touch the JSONB write surface).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: v0.14.2 CHANGELOG + CLAUDE.md; align migration-flow E2E with runner-owned ledger
CHANGELOG: v0.14.2 entry in the standard release-summary format
(two-line headline + lead + numbers table + "what this means" +
"To take advantage of v0.14.2" self-repair block + itemized
changes grouped by reliability / observability / graph correctness /
new migration / tests / deferred-to-v0.15).
CLAUDE.md: new "Key commands added in v0.14.2" section covers
--skip-failed, --retry-failed, --force-retry, GBRAIN_POOL_SIZE env,
and the new doctor checks (sync_failures, brain_score breakdown).
Migration orchestrator docs updated to describe v0_14_0.ts + the
runner-owned ledger contract from Bug 3.
test/e2e/migration-flow.test.ts: three assertions updated to match
the Bug 3 contract — orchestrators no longer append to completed.jsonl
directly, so direct-orchestrator E2E calls leave the ledger empty.
Preferences assertions remain (that's still the orchestrator's side
of the contract). Runner's ledger write is covered by the unit suite
(test/apply-migrations.test.ts + test/migration-resume.test.ts).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
import { execSync } from 'child_process';
|
|
import { readdirSync, lstatSync, existsSync, copyFileSync, mkdirSync, readFileSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { homedir } from 'os';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
import { saveConfig, loadConfig, toEngineConfig, type GBrainConfig } from '../core/config.ts';
|
|
import { createEngine } from '../core/engine-factory.ts';
|
|
|
|
export async function runInit(args: string[]) {
|
|
const isSupabase = args.includes('--supabase');
|
|
const isPGLite = args.includes('--pglite');
|
|
const isNonInteractive = args.includes('--non-interactive');
|
|
const isMigrateOnly = args.includes('--migrate-only');
|
|
const jsonOutput = args.includes('--json');
|
|
const urlIndex = args.indexOf('--url');
|
|
const manualUrl = urlIndex !== -1 ? args[urlIndex + 1] : null;
|
|
const keyIndex = args.indexOf('--key');
|
|
const apiKey = keyIndex !== -1 ? args[keyIndex + 1] : null;
|
|
const pathIndex = args.indexOf('--path');
|
|
const customPath = pathIndex !== -1 ? args[pathIndex + 1] : null;
|
|
|
|
// Schema-only path: apply initSchema against the already-configured engine
|
|
// without ever calling saveConfig. Used by apply-migrations, the stopgap
|
|
// script, and the postinstall hook. Bare `gbrain init` defaults to PGLite
|
|
// and overwrites any existing Postgres config — we must never take that
|
|
// branch from a migration orchestrator.
|
|
if (isMigrateOnly) {
|
|
return initMigrateOnly({ jsonOutput });
|
|
}
|
|
|
|
// Explicit PGLite mode
|
|
if (isPGLite || (!isSupabase && !manualUrl && !isNonInteractive)) {
|
|
// Smart detection: scan for .md files unless --pglite flag forces it
|
|
if (!isPGLite && !isSupabase) {
|
|
const fileCount = countMarkdownFiles(process.cwd());
|
|
if (fileCount >= 1000) {
|
|
console.log(`Found ~${fileCount} .md files. For a brain this size, Supabase gives faster`);
|
|
console.log('search and remote access ($25/mo). PGLite works too but search will be slower at scale.');
|
|
console.log('');
|
|
console.log(' gbrain init --supabase Set up with Supabase (recommended for large brains)');
|
|
console.log(' gbrain init --pglite Use local PGLite anyway');
|
|
console.log('');
|
|
// Default to PGLite, let the user choose Supabase if they want
|
|
}
|
|
}
|
|
|
|
return initPGLite({ jsonOutput, apiKey, customPath });
|
|
}
|
|
|
|
// Supabase/Postgres mode
|
|
let databaseUrl: string;
|
|
if (manualUrl) {
|
|
databaseUrl = manualUrl;
|
|
} else if (isNonInteractive) {
|
|
const envUrl = process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL;
|
|
if (envUrl) {
|
|
databaseUrl = envUrl;
|
|
} else {
|
|
console.error('--non-interactive requires --url <connection_string> or GBRAIN_DATABASE_URL env var');
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
databaseUrl = await supabaseWizard();
|
|
}
|
|
|
|
return initPostgres({ databaseUrl, jsonOutput, apiKey });
|
|
}
|
|
|
|
/**
|
|
* Apply the schema against the already-configured engine. No saveConfig.
|
|
* No PGLite fallback when no config exists. Used by migration orchestrators
|
|
* to bump an existing brain's schema to the latest version without
|
|
* clobbering the user's chosen engine.
|
|
*/
|
|
async function initMigrateOnly(opts: { jsonOutput: boolean }) {
|
|
const config = loadConfig();
|
|
if (!config) {
|
|
const msg = 'No brain configured. Run `gbrain init` (interactive) or `gbrain init --pglite` / `gbrain init --supabase` first.';
|
|
if (opts.jsonOutput) {
|
|
console.log(JSON.stringify({ status: 'error', reason: 'no_config', message: msg }));
|
|
} else {
|
|
console.error(msg);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
const engine = await createEngine(toEngineConfig(config));
|
|
try {
|
|
await engine.connect(toEngineConfig(config));
|
|
await engine.initSchema();
|
|
} finally {
|
|
try { await engine.disconnect(); } catch { /* best-effort */ }
|
|
}
|
|
|
|
if (opts.jsonOutput) {
|
|
console.log(JSON.stringify({ status: 'success', engine: config.engine, mode: 'migrate-only' }));
|
|
} else {
|
|
console.log(`Schema up to date (engine: ${config.engine}).`);
|
|
}
|
|
}
|
|
|
|
async function initPGLite(opts: { jsonOutput: boolean; apiKey: string | null; customPath: string | null }) {
|
|
const dbPath = opts.customPath || join(homedir(), '.gbrain', 'brain.pglite');
|
|
console.log(`Setting up local brain with PGLite (no server needed)...`);
|
|
|
|
const engine = await createEngine({ engine: 'pglite' });
|
|
try {
|
|
await engine.connect({ database_path: dbPath, engine: 'pglite' });
|
|
await engine.initSchema();
|
|
|
|
const config: GBrainConfig = {
|
|
engine: 'pglite',
|
|
database_path: dbPath,
|
|
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
|
|
};
|
|
saveConfig(config);
|
|
|
|
const stats = await engine.getStats();
|
|
|
|
if (opts.jsonOutput) {
|
|
console.log(JSON.stringify({ status: 'success', engine: 'pglite', path: dbPath, pages: stats.page_count }));
|
|
} else {
|
|
console.log(`\nBrain ready at ${dbPath}`);
|
|
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
|
|
if (stats.page_count > 0) {
|
|
console.log('');
|
|
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
|
|
console.log(' gbrain extract links --source db (typed link backfill)');
|
|
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
|
|
console.log(' gbrain stats (verify links > 0)');
|
|
} else {
|
|
console.log('Next: gbrain import <dir>');
|
|
}
|
|
console.log('');
|
|
console.log('When you outgrow local: gbrain migrate --to supabase');
|
|
reportModStatus();
|
|
}
|
|
} finally {
|
|
try { await engine.disconnect(); } catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
async function initPostgres(opts: { databaseUrl: string; jsonOutput: boolean; apiKey: string | null }) {
|
|
const { databaseUrl } = opts;
|
|
|
|
// Detect Supabase direct connection URLs and warn about IPv6
|
|
if (databaseUrl.match(/db\.[a-z]+\.supabase\.co/) || databaseUrl.includes('.supabase.co:5432')) {
|
|
console.warn('');
|
|
console.warn('WARNING: You provided a Supabase direct connection URL (db.*.supabase.co:5432).');
|
|
console.warn(' Direct connections are IPv6 only and fail in many environments.');
|
|
console.warn(' Use the Session pooler connection string instead (port 6543):');
|
|
console.warn(' Supabase Dashboard > gear icon (Project Settings) > Database >');
|
|
console.warn(' Connection string > URI tab > change dropdown to "Session pooler"');
|
|
console.warn('');
|
|
}
|
|
|
|
console.log('Connecting to database...');
|
|
const engine = await createEngine({ engine: 'postgres' });
|
|
try {
|
|
try {
|
|
await engine.connect({ database_url: databaseUrl });
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
if (databaseUrl.includes('supabase.co') && (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT'))) {
|
|
console.error('Connection failed. Supabase direct connections (db.*.supabase.co:5432) are IPv6 only.');
|
|
console.error('Use the Session pooler connection string instead (port 6543).');
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
// Check and auto-create pgvector extension
|
|
try {
|
|
const conn = (engine as any).sql || (await import('../core/db.ts')).getConnection();
|
|
const ext = await conn`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
|
|
if (ext.length === 0) {
|
|
console.log('pgvector extension not found. Attempting to create...');
|
|
try {
|
|
await conn`CREATE EXTENSION IF NOT EXISTS vector`;
|
|
console.log('pgvector extension created successfully.');
|
|
} catch {
|
|
console.error('Could not auto-create pgvector extension. Run manually in SQL Editor:');
|
|
console.error(' CREATE EXTENSION vector;');
|
|
// Throw so the outer finally runs engine.disconnect() before we die.
|
|
throw new Error('pgvector extension missing');
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
|
|
console.log('Running schema migration...');
|
|
await engine.initSchema();
|
|
|
|
const config: GBrainConfig = {
|
|
engine: 'postgres',
|
|
database_url: databaseUrl,
|
|
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
|
|
};
|
|
saveConfig(config);
|
|
console.log('Config saved to ~/.gbrain/config.json');
|
|
|
|
const stats = await engine.getStats();
|
|
|
|
if (opts.jsonOutput) {
|
|
console.log(JSON.stringify({ status: 'success', engine: 'postgres', pages: stats.page_count }));
|
|
} else {
|
|
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
|
|
if (stats.page_count > 0) {
|
|
console.log('');
|
|
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
|
|
console.log(' gbrain extract links --source db (typed link backfill)');
|
|
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
|
|
console.log(' gbrain stats (verify links > 0)');
|
|
} else {
|
|
console.log('Next: gbrain import <dir>');
|
|
}
|
|
reportModStatus();
|
|
}
|
|
} finally {
|
|
try { await engine.disconnect(); } catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Quick count of .md files in a directory (stops early at 1000).
|
|
*/
|
|
function countMarkdownFiles(dir: string, maxScan = 1500): number {
|
|
let count = 0;
|
|
try {
|
|
const scan = (d: string) => {
|
|
if (count >= maxScan) return;
|
|
for (const entry of readdirSync(d)) {
|
|
if (count >= maxScan) return;
|
|
if (entry.startsWith('.') || entry === 'node_modules') continue;
|
|
const full = join(d, entry);
|
|
try {
|
|
let stat;
|
|
try {
|
|
stat = lstatSync(full);
|
|
} catch { continue; }
|
|
if (stat.isSymbolicLink()) continue;
|
|
if (stat.isDirectory()) scan(full);
|
|
else if (entry.endsWith('.md')) count++;
|
|
} catch { /* skip unreadable */ }
|
|
}
|
|
};
|
|
scan(dir);
|
|
} catch { /* skip unreadable root */ }
|
|
return count;
|
|
}
|
|
|
|
async function supabaseWizard(): Promise<string> {
|
|
try {
|
|
execSync('bunx supabase --version', { stdio: 'pipe' });
|
|
console.log('Supabase CLI detected.');
|
|
console.log('To auto-provision, run: bunx supabase login && bunx supabase projects create');
|
|
console.log('Then use: gbrain init --url <your-connection-string>');
|
|
} catch {
|
|
console.log('Supabase CLI not found.');
|
|
}
|
|
|
|
console.log('\nEnter your Supabase/Postgres connection URL:');
|
|
console.log(' Format: postgresql://postgres.[ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres');
|
|
console.log(' Find it: Supabase Dashboard > Connect (top bar) > Connection String > Session Pooler\n');
|
|
|
|
const url = await readLine('Connection URL: ');
|
|
if (!url) {
|
|
console.error('No URL provided.');
|
|
process.exit(1);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
function readLine(prompt: string): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
process.stdout.write(prompt);
|
|
let data = '';
|
|
process.stdin.setEncoding('utf-8');
|
|
process.stdin.once('data', (chunk) => {
|
|
data = chunk.toString().trim();
|
|
process.stdin.pause();
|
|
resolve(data);
|
|
});
|
|
process.stdin.resume();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Detect GStack installation across known host paths.
|
|
* Uses gstack-global-discover if available, falls back to path checking.
|
|
*/
|
|
export function detectGStack(): { found: boolean; path: string | null; host: string | null } {
|
|
// Try gstack's own discovery tool first (DRY: don't reimplement host detection)
|
|
try {
|
|
const result = execSync(
|
|
`${join(homedir(), '.claude', 'skills', 'gstack', 'bin', 'gstack-global-discover')} 2>/dev/null`,
|
|
{ encoding: 'utf-8', timeout: 5000 }
|
|
).trim();
|
|
if (result) {
|
|
return { found: true, path: result.split('\n')[0], host: 'auto-detected' };
|
|
}
|
|
} catch { /* binary not available */ }
|
|
|
|
// Fallback: check known host paths
|
|
const hostPaths = [
|
|
{ path: join(homedir(), '.claude', 'skills', 'gstack'), host: 'claude' },
|
|
{ path: join(homedir(), '.openclaw', 'skills', 'gstack'), host: 'openclaw' },
|
|
{ path: join(homedir(), '.codex', 'skills', 'gstack'), host: 'codex' },
|
|
{ path: join(homedir(), '.factory', 'skills', 'gstack'), host: 'factory' },
|
|
{ path: join(homedir(), '.kiro', 'skills', 'gstack'), host: 'kiro' },
|
|
];
|
|
|
|
for (const { path, host } of hostPaths) {
|
|
if (existsSync(join(path, 'SKILL.md')) || existsSync(join(path, 'setup'))) {
|
|
return { found: true, path, host };
|
|
}
|
|
}
|
|
|
|
return { found: false, path: null, host: null };
|
|
}
|
|
|
|
/**
|
|
* Install default identity templates (SOUL.md, USER.md, ACCESS_POLICY.md, HEARTBEAT.md)
|
|
* into the agent workspace. Uses minimal defaults, not the soul-audit interview.
|
|
*/
|
|
export function installDefaultTemplates(workspaceDir: string): string[] {
|
|
const gbrainRoot = dirname(dirname(__dirname)); // up from src/commands/ to repo root
|
|
const templatesDir = join(gbrainRoot, 'templates');
|
|
const installed: string[] = [];
|
|
|
|
const templates = [
|
|
{ src: 'SOUL.md.template', dest: 'SOUL.md' },
|
|
{ src: 'USER.md.template', dest: 'USER.md' },
|
|
{ src: 'ACCESS_POLICY.md.template', dest: 'ACCESS_POLICY.md' },
|
|
{ src: 'HEARTBEAT.md.template', dest: 'HEARTBEAT.md' },
|
|
];
|
|
|
|
for (const { src, dest } of templates) {
|
|
const srcPath = join(templatesDir, src);
|
|
const destPath = join(workspaceDir, dest);
|
|
if (existsSync(srcPath) && !existsSync(destPath)) {
|
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
copyFileSync(srcPath, destPath);
|
|
installed.push(dest);
|
|
}
|
|
}
|
|
|
|
return installed;
|
|
}
|
|
|
|
/**
|
|
* Report post-init status including GStack detection and skill count.
|
|
*/
|
|
export function reportModStatus(): void {
|
|
const gstack = detectGStack();
|
|
const gbrainRoot = dirname(dirname(__dirname));
|
|
const skillsDir = join(gbrainRoot, 'skills');
|
|
|
|
let skillCount = 0;
|
|
try {
|
|
const manifest = JSON.parse(
|
|
readFileSync(join(skillsDir, 'manifest.json'), 'utf-8')
|
|
);
|
|
skillCount = manifest.skills?.length || 0;
|
|
} catch { /* manifest not found */ }
|
|
|
|
console.log('');
|
|
console.log('--- GBrain Mod Status ---');
|
|
console.log(`Skills: ${skillCount} loaded`);
|
|
console.log(`GStack: ${gstack.found ? `found (${gstack.host})` : 'not found'}`);
|
|
if (!gstack.found) {
|
|
console.log(' Install GStack for coding skills:');
|
|
console.log(' git clone https://github.com/garrytan/gstack.git ~/.claude/skills/gstack');
|
|
console.log(' cd ~/.claude/skills/gstack && ./setup');
|
|
}
|
|
console.log('Resolver: skills/RESOLVER.md');
|
|
console.log('Soul audit: run `gbrain soul-audit` to customize agent identity');
|
|
console.log('');
|
|
}
|