mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a5e7ac1e |
+19
-1
@@ -19,7 +19,7 @@ for (const op of operations) {
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'apply-migrations', 'skillpack-check', 'resolvers', 'integrity', 'repair-jsonb', 'orphans']);
|
||||
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'apply-migrations', 'skillpack-check', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'dream']);
|
||||
|
||||
async function main() {
|
||||
// Parse global flags (--quiet / --progress-json / --progress-interval)
|
||||
@@ -358,6 +358,20 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'dream') {
|
||||
const { runDream } = await import('./commands/dream.ts');
|
||||
// Dream runs filesystem phases first, DB phases only if available
|
||||
let eng: BrainEngine | null = null;
|
||||
try {
|
||||
eng = await connectEngine();
|
||||
} catch {
|
||||
// DB unavailable — still run filesystem phases
|
||||
}
|
||||
await runDream(eng, args);
|
||||
if (eng) await eng.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// All remaining CLI-only commands need a DB connection
|
||||
const engine = await connectEngine();
|
||||
try {
|
||||
@@ -443,6 +457,7 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runOrphans(engine, args);
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
} finally {
|
||||
if (command !== 'serve') await engine.disconnect();
|
||||
@@ -552,6 +567,9 @@ TOOLS
|
||||
check-backlinks <check|fix> [dir] Find/fix missing back-links across brain
|
||||
lint <dir|file> [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter
|
||||
orphans [--json] [--count] Find pages with no inbound wikilinks
|
||||
dream [--json] [--dry-run] Nightly dream cycle: lint, backlinks, orphans, embed, sync
|
||||
[--phase <name>] Run single phase (lint|backlinks|orphans|embed|sync)
|
||||
[--skip-embed] [--skip-sync] Skip slow phases
|
||||
report --type <name> --content ... Save timestamped report to brain/reports/
|
||||
|
||||
JOBS (Minions)
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* gbrain dream — Nightly dream cycle orchestrator.
|
||||
*
|
||||
* Runs while you sleep. Ties together lint, backlinks, orphan detection,
|
||||
* embedding, and sync into a single command that keeps the brain healthy
|
||||
* and compounding overnight.
|
||||
*
|
||||
* Phases:
|
||||
* 1. Lint & Fix — auto-fix LLM artifacts, placeholder dates, broken citations
|
||||
* 2. Backlinks — detect and create missing back-links between pages
|
||||
* 3. Orphan Sweep — surface pages with no inbound links (thin/disconnected)
|
||||
* 4. Embed — re-embed stale content so search stays fresh
|
||||
* 5. Sync — sync repo changes to the database index
|
||||
*
|
||||
* Usage:
|
||||
* gbrain dream # full dream cycle
|
||||
* gbrain dream --dry-run # preview all fixes without writing
|
||||
* gbrain dream --json # structured JSON report
|
||||
* gbrain dream --phase lint # run only one phase
|
||||
* gbrain dream --phase backlinks
|
||||
* gbrain dream --phase orphans
|
||||
* gbrain dream --phase embed
|
||||
* gbrain dream --phase sync
|
||||
* gbrain dream --skip-embed # skip embedding (faster, for testing)
|
||||
* gbrain dream --skip-sync # skip sync phase
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { createProgress, startHeartbeat, type ProgressReporter } from '../core/progress.ts';
|
||||
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PhaseResult {
|
||||
phase: string;
|
||||
status: 'ok' | 'warn' | 'fail' | 'skipped';
|
||||
duration_ms: number;
|
||||
summary: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DreamReport {
|
||||
timestamp: string;
|
||||
duration_ms: number;
|
||||
phases: PhaseResult[];
|
||||
brain_dir: string | null;
|
||||
totals: {
|
||||
lint_fixes: number;
|
||||
backlinks_added: number;
|
||||
orphans_found: number;
|
||||
pages_embedded: number;
|
||||
pages_synced: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function findRepoRoot(): string | null {
|
||||
// Walk up from cwd looking for a .git directory
|
||||
let dir = process.cwd();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (existsSync(join(dir, '.git'))) return dir;
|
||||
const parent = join(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
// Check common locations
|
||||
for (const candidate of ['/data/brain', './brain']) {
|
||||
if (existsSync(candidate) && existsSync(join(candidate, '.git'))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]) {
|
||||
return {
|
||||
json: args.includes('--json'),
|
||||
dryRun: args.includes('--dry-run'),
|
||||
skipEmbed: args.includes('--skip-embed'),
|
||||
skipSync: args.includes('--skip-sync'),
|
||||
phase: (() => {
|
||||
const idx = args.indexOf('--phase');
|
||||
return idx !== -1 ? args[idx + 1] : null;
|
||||
})(),
|
||||
dir: (() => {
|
||||
const idx = args.indexOf('--dir');
|
||||
return idx !== -1 ? args[idx + 1] : null;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
async function timePhase<T>(
|
||||
name: string,
|
||||
fn: () => Promise<T>,
|
||||
progress: ProgressReporter,
|
||||
): Promise<{ result: T; duration_ms: number }> {
|
||||
progress.start(name);
|
||||
const start = performance.now();
|
||||
const result = await fn();
|
||||
const duration_ms = Math.round(performance.now() - start);
|
||||
progress.finish(`${name} done (${(duration_ms / 1000).toFixed(1)}s)`);
|
||||
return { result, duration_ms };
|
||||
}
|
||||
|
||||
// ── Phase Runners ───────────────────────────────────────────────────
|
||||
|
||||
async function runLintPhase(brainDir: string, dryRun: boolean): Promise<PhaseResult> {
|
||||
try {
|
||||
// Use the library-level lint function
|
||||
const { runLintCore } = await import('./lint.ts');
|
||||
const result = await runLintCore({
|
||||
target: brainDir,
|
||||
fix: !dryRun,
|
||||
dryRun,
|
||||
});
|
||||
const fixed = result.total_fixed ?? 0;
|
||||
const issues = result.total_issues ?? 0;
|
||||
return {
|
||||
phase: 'lint',
|
||||
status: issues > 0 ? 'warn' : 'ok',
|
||||
duration_ms: 0,
|
||||
summary: dryRun
|
||||
? `${issues} issues found (dry run, no fixes applied)`
|
||||
: `${fixed} fixes applied, ${Math.max(0, issues - fixed)} remaining`,
|
||||
details: { issues, fixed, pages_scanned: result.pages_scanned },
|
||||
};
|
||||
} catch {
|
||||
// Fallback: shell out to the lint CLI
|
||||
const { execSync } = await import('child_process');
|
||||
try {
|
||||
const fixFlag = dryRun ? '--fix --dry-run' : '--fix';
|
||||
const output = execSync(
|
||||
`bun run ${join(import.meta.dir, '..', 'cli.ts')} lint "${brainDir}" ${fixFlag} --json`,
|
||||
{ encoding: 'utf-8', timeout: 120_000 },
|
||||
);
|
||||
const data = JSON.parse(output);
|
||||
const issues = data.totalIssues ?? data.issues?.length ?? 0;
|
||||
const fixed = data.totalFixed ?? data.fixed ?? 0;
|
||||
return {
|
||||
phase: 'lint',
|
||||
status: issues > 0 ? 'warn' : 'ok',
|
||||
duration_ms: 0,
|
||||
summary: dryRun
|
||||
? `${issues} issues found (dry run)`
|
||||
: `${fixed} fixes applied, ${Math.max(0, issues - fixed)} remaining`,
|
||||
details: { issues, fixed },
|
||||
};
|
||||
} catch (e: any) {
|
||||
// lint exits non-zero when issues found — parse stdout
|
||||
const stdout = e.stdout || '';
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
const issues = data.totalIssues ?? data.issues?.length ?? 0;
|
||||
const fixed = data.totalFixed ?? data.fixed ?? 0;
|
||||
return {
|
||||
phase: 'lint',
|
||||
status: 'warn',
|
||||
duration_ms: 0,
|
||||
summary: `${fixed} fixes, ${Math.max(0, issues - fixed)} remaining`,
|
||||
details: { issues, fixed },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
phase: 'lint',
|
||||
status: 'fail',
|
||||
duration_ms: 0,
|
||||
summary: `Lint failed: ${e.message?.slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runBacklinksPhase(brainDir: string, dryRun: boolean): Promise<PhaseResult> {
|
||||
const { execSync } = await import('child_process');
|
||||
const subcmd = dryRun ? 'fix --dry-run' : 'fix';
|
||||
try {
|
||||
const output = execSync(
|
||||
`bun run ${join(import.meta.dir, '..', 'cli.ts')} check-backlinks ${subcmd} --dir "${brainDir}" --json`,
|
||||
{ encoding: 'utf-8', timeout: 120_000 },
|
||||
);
|
||||
const data = JSON.parse(output);
|
||||
const added = data.fixed ?? data.created ?? data.added ?? 0;
|
||||
const gaps = data.gaps ?? data.total ?? 0;
|
||||
return {
|
||||
phase: 'backlinks',
|
||||
status: gaps > 0 ? 'warn' : 'ok',
|
||||
duration_ms: 0,
|
||||
summary: dryRun
|
||||
? `${gaps} missing back-links found (dry run)`
|
||||
: `${added} back-links created, ${Math.max(0, gaps - added)} remaining`,
|
||||
details: { gaps, added },
|
||||
};
|
||||
} catch (e: any) {
|
||||
const stdout = e.stdout || '';
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
const added = data.fixed ?? data.created ?? data.added ?? 0;
|
||||
const gaps = data.gaps ?? data.total ?? 0;
|
||||
return {
|
||||
phase: 'backlinks',
|
||||
status: 'warn',
|
||||
duration_ms: 0,
|
||||
summary: `${added} back-links created, ${Math.max(0, gaps - added)} gaps`,
|
||||
details: { gaps, added },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
phase: 'backlinks',
|
||||
status: 'fail',
|
||||
duration_ms: 0,
|
||||
summary: `Backlinks failed: ${(e.message || '').slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runOrphansPhase(): Promise<PhaseResult> {
|
||||
try {
|
||||
const { findOrphans } = await import('./orphans.ts');
|
||||
const result = await findOrphans(false);
|
||||
const count = result?.total_orphans ?? 0;
|
||||
// Group by domain
|
||||
const domains: Record<string, number> = {};
|
||||
for (const o of result?.orphans ?? []) {
|
||||
const d = o.domain || 'unknown';
|
||||
domains[d] = (domains[d] || 0) + 1;
|
||||
}
|
||||
return {
|
||||
phase: 'orphans',
|
||||
status: count > 20 ? 'warn' : 'ok',
|
||||
duration_ms: 0,
|
||||
summary: `${count} orphan pages (no inbound links)`,
|
||||
details: { count, by_domain: domains },
|
||||
};
|
||||
} catch (e: any) {
|
||||
// Fallback: shell out
|
||||
const { execSync } = await import('child_process');
|
||||
try {
|
||||
const output = execSync(
|
||||
`bun run ${join(import.meta.dir, '..', 'cli.ts')} orphans --json`,
|
||||
{ encoding: 'utf-8', timeout: 60_000 },
|
||||
);
|
||||
const data = JSON.parse(output);
|
||||
const count = data.total ?? data.orphans?.length ?? 0;
|
||||
return {
|
||||
phase: 'orphans',
|
||||
status: count > 20 ? 'warn' : 'ok',
|
||||
duration_ms: 0,
|
||||
summary: `${count} orphan pages`,
|
||||
details: { count },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
phase: 'orphans',
|
||||
status: 'fail',
|
||||
duration_ms: 0,
|
||||
summary: `Orphan check failed: ${(e.message || '').slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runEmbedPhase(engine: BrainEngine): Promise<PhaseResult> {
|
||||
const { execSync } = await import('child_process');
|
||||
try {
|
||||
const output = execSync(
|
||||
`bun run ${join(import.meta.dir, '..', 'cli.ts')} embed --stale --json`,
|
||||
{ encoding: 'utf-8', timeout: 300_000 },
|
||||
);
|
||||
const data = JSON.parse(output);
|
||||
const embedded = data.embedded ?? data.count ?? 0;
|
||||
return {
|
||||
phase: 'embed',
|
||||
status: 'ok',
|
||||
duration_ms: 0,
|
||||
summary: `${embedded} stale pages re-embedded`,
|
||||
details: { embedded },
|
||||
};
|
||||
} catch (e: any) {
|
||||
const stdout = e.stdout || '';
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
const embedded = data.embedded ?? data.count ?? 0;
|
||||
return {
|
||||
phase: 'embed',
|
||||
status: 'ok',
|
||||
duration_ms: 0,
|
||||
summary: `${embedded} pages re-embedded`,
|
||||
details: { embedded },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
phase: 'embed',
|
||||
status: 'fail',
|
||||
duration_ms: 0,
|
||||
summary: `Embed failed: ${(e.message || '').slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runSyncPhase(engine: BrainEngine, brainDir: string): Promise<PhaseResult> {
|
||||
const { execSync } = await import('child_process');
|
||||
try {
|
||||
const output = execSync(
|
||||
`bun run ${join(import.meta.dir, '..', 'cli.ts')} sync --repo "${brainDir}" --no-pull`,
|
||||
{ encoding: 'utf-8', timeout: 300_000 },
|
||||
);
|
||||
// Parse sync output for page count
|
||||
const match = output.match(/(\d+)\s+page/);
|
||||
const pages = match ? parseInt(match[1], 10) : 0;
|
||||
return {
|
||||
phase: 'sync',
|
||||
status: 'ok',
|
||||
duration_ms: 0,
|
||||
summary: `Synced${pages ? ` (${pages} pages)` : ''}`,
|
||||
details: { pages },
|
||||
};
|
||||
} catch (e: any) {
|
||||
return {
|
||||
phase: 'sync',
|
||||
status: 'fail',
|
||||
duration_ms: 0,
|
||||
summary: `Sync failed: ${(e.message || '').slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runDream(engine: BrainEngine | null, args: string[]) {
|
||||
const opts = parseArgs(args);
|
||||
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||||
const heartbeat = startHeartbeat(progress, 5_000);
|
||||
|
||||
const brainDir = opts.dir ?? findRepoRoot();
|
||||
const phases: PhaseResult[] = [];
|
||||
const start = performance.now();
|
||||
|
||||
if (!opts.json) {
|
||||
console.log('🌙 Dream cycle starting...\n');
|
||||
}
|
||||
|
||||
const shouldRun = (phase: string) => !opts.phase || opts.phase === phase;
|
||||
|
||||
try {
|
||||
// Phase 1: Lint & Fix
|
||||
if (shouldRun('lint') && brainDir) {
|
||||
const { result, duration_ms } = await timePhase('lint', () => runLintPhase(brainDir, opts.dryRun), progress);
|
||||
result.duration_ms = duration_ms;
|
||||
phases.push(result);
|
||||
if (!opts.json) {
|
||||
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
|
||||
console.log(`${icon} Lint: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Backlinks
|
||||
if (shouldRun('backlinks') && brainDir) {
|
||||
const { result, duration_ms } = await timePhase('backlinks', () => runBacklinksPhase(brainDir, opts.dryRun), progress);
|
||||
result.duration_ms = duration_ms;
|
||||
phases.push(result);
|
||||
if (!opts.json) {
|
||||
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
|
||||
console.log(`${icon} Backlinks: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Orphan Sweep (requires DB)
|
||||
if (shouldRun('orphans') && engine) {
|
||||
const { result, duration_ms } = await timePhase('orphans', () => runOrphansPhase(), progress);
|
||||
result.duration_ms = duration_ms;
|
||||
phases.push(result);
|
||||
if (!opts.json) {
|
||||
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
|
||||
console.log(`${icon} Orphans: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4: Embed stale content (requires DB)
|
||||
if (shouldRun('embed') && !opts.skipEmbed && engine) {
|
||||
const { result, duration_ms } = await timePhase('embed', () => runEmbedPhase(engine), progress);
|
||||
result.duration_ms = duration_ms;
|
||||
phases.push(result);
|
||||
if (!opts.json) {
|
||||
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
|
||||
console.log(`${icon} Embed: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
} else if (shouldRun('embed') && opts.skipEmbed) {
|
||||
phases.push({ phase: 'embed', status: 'skipped', duration_ms: 0, summary: 'Skipped (--skip-embed)' });
|
||||
}
|
||||
|
||||
// Phase 5: Sync
|
||||
if (shouldRun('sync') && !opts.skipSync && brainDir) {
|
||||
const { result, duration_ms } = await timePhase('sync', () => runSyncPhase(engine, brainDir), progress);
|
||||
result.duration_ms = duration_ms;
|
||||
phases.push(result);
|
||||
if (!opts.json) {
|
||||
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
|
||||
console.log(`${icon} Sync: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
} else if (shouldRun('sync') && opts.skipSync) {
|
||||
phases.push({ phase: 'sync', status: 'skipped', duration_ms: 0, summary: 'Skipped (--skip-sync)' });
|
||||
}
|
||||
|
||||
const totalMs = Math.round(performance.now() - start);
|
||||
|
||||
// Build report
|
||||
const report: DreamReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
duration_ms: totalMs,
|
||||
phases,
|
||||
brain_dir: brainDir,
|
||||
totals: {
|
||||
lint_fixes: (phases.find(p => p.phase === 'lint')?.details?.fixed as number) ?? 0,
|
||||
backlinks_added: (phases.find(p => p.phase === 'backlinks')?.details?.added as number) ?? 0,
|
||||
orphans_found: (phases.find(p => p.phase === 'orphans')?.details?.count as number) ?? 0,
|
||||
pages_embedded: (phases.find(p => p.phase === 'embed')?.details?.embedded as number) ?? 0,
|
||||
pages_synced: (phases.find(p => p.phase === 'sync')?.details?.pages as number) ?? 0,
|
||||
},
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
const failed = phases.filter(p => p.status === 'fail').length;
|
||||
const warned = phases.filter(p => p.status === 'warn').length;
|
||||
console.log(`\n🌙 Dream cycle complete in ${(totalMs / 1000).toFixed(1)}s`);
|
||||
if (failed > 0) {
|
||||
console.log(` ${failed} phase(s) failed — check output above`);
|
||||
} else if (warned > 0) {
|
||||
console.log(` ${warned} phase(s) have warnings — brain is getting healthier`);
|
||||
} else {
|
||||
console.log(' All phases clean — brain is healthy 🧠');
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { existsSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { join } from 'path';
|
||||
|
||||
const CLI = join(import.meta.dir, '..', 'src', 'cli.ts');
|
||||
const BUN = 'bun run';
|
||||
|
||||
// CI may not have a brain dir or DB — detect environment
|
||||
const HAS_BRAIN = existsSync('/data/brain/.git');
|
||||
const BRAIN_DIR = HAS_BRAIN ? '/data/brain' : null;
|
||||
|
||||
function gbrain(args: string, timeout = 30_000): string {
|
||||
try {
|
||||
return execSync(`${BUN} ${CLI} ${args} 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
env: { ...process.env, BUN_INSTALL: '/root/.bun', PATH: `/root/.bun/bin:${process.env.PATH}` },
|
||||
}).trim();
|
||||
} catch (e: any) {
|
||||
return (e.stdout || '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonOutput(output: string): any {
|
||||
try { return JSON.parse(output); } catch {}
|
||||
const lines = output.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith('{')) {
|
||||
try { return JSON.parse(lines.slice(i).join('\n')); } catch {}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Tests that always work (no brain or DB needed) ──
|
||||
|
||||
describe('Dream Command — CLI Registration', () => {
|
||||
test('dream appears in help text', () => {
|
||||
const help = gbrain('--help');
|
||||
expect(help).toContain('dream');
|
||||
expect(help).toContain('Nightly dream cycle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dream Command — Source File', () => {
|
||||
test('dream.ts exists', () => {
|
||||
expect(existsSync(join(import.meta.dir, '..', 'src', 'commands', 'dream.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
test('exports runDream function', async () => {
|
||||
const mod = await import('../src/commands/dream.ts');
|
||||
expect(typeof mod.runDream).toBe('function');
|
||||
});
|
||||
|
||||
test('DreamReport type structure is correct', async () => {
|
||||
const mod = await import('../src/commands/dream.ts');
|
||||
expect(mod.runDream).toBeTruthy();
|
||||
// Verify the module compiles without errors
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tests that require a brain directory ──
|
||||
|
||||
describe('Dream Command — Integration', () => {
|
||||
const skipReason = !HAS_BRAIN ? '(skip) no brain dir at /data/brain' : undefined;
|
||||
|
||||
test('dream command runs lint phase', () => {
|
||||
if (!HAS_BRAIN) return; // skip in CI
|
||||
const output = gbrain(`dream --phase lint --dry-run --json --dir ${BRAIN_DIR}`, 30_000);
|
||||
expect(output).not.toContain('Unknown command');
|
||||
const report = parseJsonOutput(output);
|
||||
expect(report).toBeTruthy();
|
||||
expect(report.phases.length).toBe(1);
|
||||
expect(report.phases[0].phase).toBe('lint');
|
||||
expect(report.timestamp).toBeTruthy();
|
||||
expect(report.duration_ms).toBeGreaterThanOrEqual(0);
|
||||
expect(typeof report.totals.lint_fixes).toBe('number');
|
||||
}, 60_000);
|
||||
|
||||
test('dream command runs backlinks phase', () => {
|
||||
if (!HAS_BRAIN) return;
|
||||
const output = gbrain(`dream --phase backlinks --dry-run --json --dir ${BRAIN_DIR}`, 30_000);
|
||||
const report = parseJsonOutput(output);
|
||||
expect(report).toBeTruthy();
|
||||
expect(report.phases.length).toBe(1);
|
||||
expect(report.phases[0].phase).toBe('backlinks');
|
||||
}, 60_000);
|
||||
|
||||
test('--json returns valid DreamReport', () => {
|
||||
if (!HAS_BRAIN) return;
|
||||
const output = gbrain(`dream --phase lint --dry-run --json --dir ${BRAIN_DIR}`, 30_000);
|
||||
const report = parseJsonOutput(output);
|
||||
expect(report).toBeTruthy();
|
||||
expect(report.timestamp).toBeTruthy();
|
||||
expect(report.duration_ms).toBeGreaterThanOrEqual(0);
|
||||
expect(Array.isArray(report.phases)).toBe(true);
|
||||
expect(report.totals).toBeTruthy();
|
||||
expect(report.brain_dir).toBeTruthy();
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in New Issue
Block a user