import { readdirSync, lstatSync, existsSync, writeFileSync, readFileSync, unlinkSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, relative } from 'path'; import { cpus, totalmem } from 'os'; import type { BrainEngine } from '../core/engine.ts'; import { importFile, importImageFile, isImageFilePath } from '../core/import-file.ts'; import { loadConfig, gbrainPath } from '../core/config.ts'; import { createProgress } from '../core/progress.ts'; import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts'; function defaultWorkers(): number { const cpuCount = cpus().length; const memGB = totalmem() / (1024 ** 3); // Network-bound, so we can go higher than CPU count. // Cap by: DB pool (leave 2 for other queries), CPU, memory. const byPool = 8; const byCpu = Math.max(2, cpuCount); const byMem = Math.floor(memGB * 2); return Math.min(byPool, byCpu, byMem); } /** Bug 9 — surface per-file failures so callers (performFullSync) can gate state advances. */ export interface RunImportResult { imported: number; skipped: number; errors: number; chunksCreated: number; failures: Array<{ path: string; error: string }>; } export async function runImport(engine: BrainEngine, args: string[], opts: { commit?: string } = {}): Promise { const noEmbed = args.includes('--no-embed'); const fresh = args.includes('--fresh'); const jsonOutput = args.includes('--json'); const workersIdx = args.indexOf('--workers'); const workersArg = workersIdx !== -1 ? args[workersIdx + 1] : null; // v0.22.13 (PR #490 Q2): shared parseWorkers helper rejects bad input // (--workers 0, -3, "foo") with a loud error instead of silently falling // through to 1. Mirrors sync.ts's flag handling. const { parseWorkers } = await import('../core/sync-concurrency.ts'); let workerCount: number; try { workerCount = parseWorkers(workersArg ?? undefined) ?? 1; } catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); } // Find dir: first non-flag arg that isn't a value for --workers const flagValues = new Set(); if (workersIdx !== -1) flagValues.add(workersIdx + 1); const dirArg = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i)); if (!dirArg) { console.error('Usage: gbrain import [--no-embed] [--workers N] [--fresh] [--json]'); process.exit(1); } const dir: string = dirArg; // narrowed; survives closure capture // Collect all .md files const allFiles = collectMarkdownFiles(dir); console.log(`Found ${allFiles.length} markdown files`); // Resume from checkpoint if available const checkpointPath = gbrainPath('import-checkpoint.json'); let files = allFiles; let resumeIndex = 0; if (!fresh && existsSync(checkpointPath)) { try { const cp = JSON.parse(readFileSync(checkpointPath, 'utf-8')); if (cp.dir === dir && cp.totalFiles === allFiles.length) { resumeIndex = cp.processedIndex; files = allFiles.slice(resumeIndex); console.log(`Resuming from checkpoint: skipping ${resumeIndex} already-processed files`); } } catch { // Invalid checkpoint, start fresh } } // Determine actual worker count const actualWorkers = workerCount > 1 ? workerCount : 1; if (actualWorkers > 1) { console.log(`Using ${actualWorkers} parallel workers`); } let imported = 0; let skipped = 0; let errors = 0; let processed = 0; let chunksCreated = 0; const importedSlugs: string[] = []; const errorCounts: Record = {}; const failures: Array<{ path: string; error: string }> = []; // Bug 9 const startTime = Date.now(); // Progress on stderr so stdout stays clean for the final summary / --json payload. const progress = createProgress(cliOptsToProgressOptions(getCliOptions())); progress.start('import.files', files.length); function tickProgress() { progress.tick(1, `imported=${imported} skipped=${skipped} errors=${errors}`); } async function processFile(eng: BrainEngine, filePath: string) { const relativePath = relative(dir, filePath); try { // v0.27.1 (F2): dispatch image extensions to importImageFile when // multimodal is enabled. The walker (collectMarkdownFiles) only picks // up images when GBRAIN_EMBEDDING_MULTIMODAL=true so this branch is // unreachable when the gate is off; defense-in-depth check anyway. const result = isImageFilePath(relativePath) && process.env.GBRAIN_EMBEDDING_MULTIMODAL === 'true' ? await importImageFile(eng, filePath, relativePath, { noEmbed }) : await importFile(eng, filePath, relativePath, { noEmbed }); if (result.status === 'imported') { imported++; chunksCreated += result.chunks; importedSlugs.push(result.slug); } else { skipped++; if (result.error && result.error !== 'unchanged') { console.error(` Skipped ${relativePath}: ${result.error}`); // Bug 9 — non-"unchanged" skips carry a real error reason. failures.push({ path: relativePath, error: result.error }); } } } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); const errorKey = msg.replace(/"[^"]*"/g, '""'); errorCounts[errorKey] = (errorCounts[errorKey] || 0) + 1; if (errorCounts[errorKey] <= 5) { console.error(` Warning: skipped ${relativePath}: ${msg}`); } else if (errorCounts[errorKey] === 6) { console.error(` (suppressing further "${errorKey.slice(0, 60)}..." errors)`); } errors++; skipped++; failures.push({ path: relativePath, error: msg }); } processed++; tickProgress(); if (processed % 100 === 0 || processed === files.length) { // Save checkpoint every 100 files — track completed file set, not just a counter if (processed % 100 === 0) { try { const cpDir = gbrainPath(); if (!existsSync(cpDir)) { const { mkdirSync } = await import('fs'); mkdirSync(cpDir, { recursive: true }); } writeFileSync(checkpointPath, JSON.stringify({ dir, totalFiles: allFiles.length, processedIndex: resumeIndex + processed, completedFiles: importedSlugs.length + skipped, timestamp: new Date().toISOString(), })); } catch { /* non-fatal */ } } } } if (actualWorkers > 1) { // v0.22.13 (PR #490 A1 + Q3): use engine.kind discriminator (not config.engine // string sniff) and fall back to serial when database_url is unset. Both // checks belt-and-suspenders so we never crash on a null assertion. const config = loadConfig(); if (engine.kind === 'pglite' || !config?.database_url) { for (const file of files) { await processFile(engine, file); } } else { const { PostgresEngine } = await import('../core/postgres-engine.ts'); const { resolvePoolSize } = await import('../core/db.ts'); // Default per-worker pool is 2 (small, parallel import case). Users on // constrained poolers (e.g. Supabase port 6543) can cap below this via // GBRAIN_POOL_SIZE=1. const workerPoolSize = Math.min(2, resolvePoolSize(2)); const databaseUrl = config.database_url; // v0.22.13 (PR #490 A2): connect workers serially so a partial failure // leaves us with the connected ones already pushed onto workerEngines // for the finally-block cleanup. The prior Promise.all could leak any // engine that connected before another's connect() rejected. const workerEngines: InstanceType[] = []; try { for (let i = 0; i < actualWorkers; i++) { const eng = new PostgresEngine(); await eng.connect({ database_url: databaseUrl, poolSize: workerPoolSize }); workerEngines.push(eng); } // Thread-safe queue: atomic index counter (JS is single-threaded; the // read-then-increment happens between awaits so no lock is needed). let queueIndex = 0; await Promise.all(workerEngines.map(async (eng) => { while (true) { const idx = queueIndex++; if (idx >= files.length) break; await processFile(eng, files[idx]); } })); } finally { // v0.22.13 (PR #490 A2): try/finally guarantees cleanup even when the // worker loop throws. Each disconnect is best-effort — one failing // disconnect must not strand the others. await Promise.all( workerEngines.map(e => e.disconnect().catch((err: unknown) => console.error(` worker disconnect failed: ${err instanceof Error ? err.message : String(err)}`), ), ), ); } } // end else (postgres parallel) } else { // Sequential: use the provided engine for (const filePath of files) { await processFile(engine, filePath); } } progress.finish(); // Error summary for (const [err, count] of Object.entries(errorCounts)) { if (count > 5) { console.error(` ${count} files failed: ${err.slice(0, 100)}`); } } // Clear checkpoint only on successful completion (no errors) if (errors === 0 && existsSync(checkpointPath)) { try { unlinkSync(checkpointPath); } catch { /* non-fatal */ } } else if (errors > 0 && existsSync(checkpointPath)) { console.log(` Checkpoint preserved (${errors} errors). Run again to retry failed files.`); } const totalTime = ((Date.now() - startTime) / 1000).toFixed(1); if (jsonOutput) { console.log(JSON.stringify({ status: 'success', duration_s: parseFloat(totalTime), imported, skipped, errors, chunks: chunksCreated, total_files: allFiles.length, })); } else { console.log(`\nImport complete (${totalTime}s):`); console.log(` ${imported} pages imported`); console.log(` ${skipped} pages skipped (${skipped - errors} unchanged, ${errors} errors)`); console.log(` ${chunksCreated} chunks created`); } // Log the ingest await engine.logIngest({ source_type: 'directory', source_ref: dir, pages_updated: importedSlugs, summary: `Imported ${imported} pages, ${skipped} skipped, ${chunksCreated} chunks`, }); // Import → sync continuity: write sync checkpoint if this is a git repo. // Bug 9 — gate last_commit on "no failures" so import doesn't silently // stomp on the sync bookmark when parsing broke. We still write // last_run + repo_path either way (those are progress indicators). let gitHead: string | null = null; try { if (existsSync(join(dir, '.git'))) { gitHead = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim(); } } catch { // Not a git repo or git not available } if (gitHead) { // Record failures into the central JSONL so doctor can surface them. // Use gitHead as the commit so a later sync can tell "same broken // state as last time" from "new broken state." if (failures.length > 0) { const { recordSyncFailures } = await import('../core/sync.ts'); recordSyncFailures(failures, gitHead); } if (failures.length === 0) { await engine.setConfig('sync.last_commit', gitHead); } else { console.error( `\nImport completed with ${failures.length} failure(s). ` + `sync.last_commit NOT advanced — re-run 'gbrain sync' to retry, or ` + `'gbrain sync --skip-failed' to acknowledge and move past them.`, ); } await engine.setConfig('sync.last_run', new Date().toISOString()); await engine.setConfig('sync.repo_path', dir); } return { imported, skipped, errors, chunksCreated, failures }; } export function collectMarkdownFiles(dir: string): string[] { const files: string[] = []; function walk(d: string) { for (const entry of readdirSync(d)) { // Skip hidden dirs and .raw dirs if (entry.startsWith('.')) continue; // Skip node_modules if (entry === 'node_modules') continue; const full = join(d, entry); let stat; try { // lstatSync, not statSync: we must NOT follow symlinks. A symlink // inside the brain directory can point to any file the importing // user can read, so a contributor to a shared brain could plant // notes/innocent.md as a symlink to ~/.gbrain/config.json, /etc/passwd, // or another sensitive file outside the brain root — and on the // next `gbrain import` it would be read, chunked, embedded, and // indexed, at which point a bearer-token holder could exfiltrate // it via search/get_page. See L002 in report/findings.md. stat = lstatSync(full); } catch { // Broken symlink or permission error — skip console.warn(`[gbrain import] Skipping unreadable path: ${full}`); continue; } // Skip symlinks (both file and directory targets). This also blocks // circular symlink DoS since we refuse to descend into linked dirs. if (stat.isSymbolicLink()) { console.warn(`[gbrain import] Skipping symlink: ${full}`); continue; } if (stat.isDirectory()) { walk(full); } else if (entry.endsWith('.md') || entry.endsWith('.mdx')) { files.push(full); } else if (multimodalEnabled && isImageFilePath(entry)) { // v0.27.1 (F2): images join the walker only when multimodal is on. // Pre-v0.27.1 brains keep their existing markdown-only walk. files.push(full); } } } const multimodalEnabled = process.env.GBRAIN_EMBEDDING_MULTIMODAL === 'true'; walk(dir); return files.sort(); }