mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 09:52:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd7a85a56d |
@@ -5,8 +5,8 @@
|
||||
* checks if back-links exist, and optionally creates them.
|
||||
*
|
||||
* Usage:
|
||||
* gbrain check-backlinks check [--dir <brain-dir>] # report missing back-links
|
||||
* gbrain check-backlinks fix [--dir <brain-dir>] # create missing back-links
|
||||
* gbrain check-backlinks check [dir] [--dir <brain-dir>] # report missing back-links
|
||||
* gbrain check-backlinks fix [dir] [--dir <brain-dir>] # create missing back-links
|
||||
* gbrain check-backlinks fix --dry-run # preview fixes
|
||||
*/
|
||||
|
||||
@@ -201,6 +201,40 @@ export interface BacklinksResult {
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export interface ParsedBacklinksArgs {
|
||||
subcommand: string | undefined;
|
||||
brainDir: string;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export function parseBacklinksArgs(args: string[]): ParsedBacklinksArgs {
|
||||
const subcommand = args[0];
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const dirIdx = args.indexOf('--dir');
|
||||
const flagDir = dirIdx >= 0 && args[dirIdx + 1] && !args[dirIdx + 1].startsWith('--')
|
||||
? args[dirIdx + 1]
|
||||
: undefined;
|
||||
|
||||
let positionalDir: string | undefined;
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--dir') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--dry-run') continue;
|
||||
if (arg.startsWith('--')) continue;
|
||||
positionalDir = arg;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
subcommand,
|
||||
brainDir: flagDir ?? positionalDir ?? '.',
|
||||
dryRun,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Library-level backlinks check/fix. Throws on validation errors; returns a
|
||||
* structured result so Minions handlers + autopilot-cycle can surface counts.
|
||||
@@ -236,16 +270,14 @@ export async function runBacklinksCore(opts: BacklinksOpts): Promise<BacklinksRe
|
||||
}
|
||||
|
||||
export async function runBacklinks(args: string[]) {
|
||||
const subcommand = args[0];
|
||||
const dirIdx = args.indexOf('--dir');
|
||||
const brainDir = dirIdx >= 0 ? args[dirIdx + 1] : '.';
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const { subcommand, brainDir, dryRun } = parseBacklinksArgs(args);
|
||||
|
||||
if (!subcommand || !['check', 'fix'].includes(subcommand)) {
|
||||
console.error('Usage: gbrain check-backlinks <check|fix> [--dir <brain-dir>] [--dry-run]');
|
||||
console.error('Usage: gbrain check-backlinks <check|fix> [dir] [--dir <brain-dir>] [--dry-run]');
|
||||
console.error(' check Report missing back-links');
|
||||
console.error(' fix Create missing back-links (appends to Timeline)');
|
||||
console.error(' --dir Brain directory (default: current directory)');
|
||||
console.error(' dir Brain directory (default: current directory)');
|
||||
console.error(' --dir Brain directory override');
|
||||
console.error(' --dry-run Preview fixes without writing');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ export interface SyncResult {
|
||||
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
|
||||
embedded: number;
|
||||
pagesAffected: string[];
|
||||
failedFiles?: number; // count of per-file import/sync failures (Bug 9)
|
||||
failedFiles?: number; // count of parse failures (Bug 9)
|
||||
/**
|
||||
* v0.41.13.0 partial-sync fields (only set when status === 'partial').
|
||||
*
|
||||
@@ -3183,7 +3183,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
await clearOpCheckpoint(engine, ckpt.target);
|
||||
};
|
||||
|
||||
// issue #1939 adversarial finding #1: a file that failed to import (open ledger
|
||||
// issue #1939 adversarial finding #1: a file that failed to parse (open ledger
|
||||
// row) and is then deleted/renamed-away never re-enters failedFiles and never
|
||||
// imports, so its row would never clear and would age doctor to a permanent
|
||||
// FAIL. Treat removed paths as resolved so the ledger self-heals.
|
||||
@@ -3215,9 +3215,9 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
} else {
|
||||
const fileFailCount = failedFiles.filter(f => isSkippablePath(f.path)).length;
|
||||
serr(
|
||||
`\nSync blocked: ${fileFailCount} file(s) failed to import:\n` +
|
||||
`\nSync blocked: ${fileFailCount} file(s) failed to parse:\n` +
|
||||
`${codeBreakdown}\n\n` +
|
||||
`Fix the listed file errors and re-run, or use 'gbrain sync --skip-failed' to ` +
|
||||
`Fix the frontmatter and re-run, or use 'gbrain sync --skip-failed' to ` +
|
||||
`acknowledge and move on. A file that keeps failing auto-skips after ` +
|
||||
`${resolveAutoSkipThreshold()} consecutive syncs.`,
|
||||
);
|
||||
@@ -5355,7 +5355,7 @@ function printSyncResult(result: SyncResult, sink: NodeJS.WriteStream = process.
|
||||
case 'dry_run':
|
||||
break; // already printed in performSync
|
||||
case 'blocked_by_failures':
|
||||
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to import.`);
|
||||
write(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
|
||||
write(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
|
||||
write(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
|
||||
break;
|
||||
|
||||
@@ -1058,16 +1058,6 @@ export class PGLiteEngine implements BrainEngine {
|
||||
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at`,
|
||||
[sourceId, slug, page.type, pageKind, page.title, page.compiled_truth, page.timeline || '', JSON.stringify(frontmatter), hash, effectiveDate, effectiveDateSource, importFilename, chunkerVersion, sourcePath, sourceKind, sourceUri, ingestedVia, ingestedAt]
|
||||
);
|
||||
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
|
||||
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
|
||||
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
|
||||
// Throw a diagnosable error naming the row instead. Mirrors postgres-engine.ts.
|
||||
if (!rows[0]) {
|
||||
throw new Error(
|
||||
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
|
||||
`A trigger or rule on the pages table may be suppressing the write.`
|
||||
);
|
||||
}
|
||||
return rowToPage(rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
|
||||
@@ -1119,16 +1119,6 @@ export class PostgresEngine implements BrainEngine {
|
||||
ingested_at = COALESCE(EXCLUDED.ingested_at, pages.ingested_at)
|
||||
RETURNING id, source_id, slug, type, title, compiled_truth, timeline, frontmatter, content_hash, created_at, updated_at, effective_date, effective_date_source, import_filename, source_kind, source_uri, ingested_via, ingested_at
|
||||
`;
|
||||
// #2189: an INSERT … ON CONFLICT DO UPDATE … RETURNING that yields 0 rows
|
||||
// (e.g. a BEFORE trigger suppressing the write) previously crashed in
|
||||
// rowToPage with an opaque "undefined is not an object (row.deleted_at)".
|
||||
// Throw a diagnosable error naming the row instead. Mirrors pglite-engine.ts.
|
||||
if (!rows[0]) {
|
||||
throw new Error(
|
||||
`putPage: INSERT … RETURNING produced no row for slug='${slug}' source_id='${sourceId}'. ` +
|
||||
`A trigger or rule on the pages table may be suppressing the write.`
|
||||
);
|
||||
}
|
||||
return rowToPage(rows[0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
extractPageTitle,
|
||||
hasBacklink,
|
||||
buildBacklinkEntry,
|
||||
parseBacklinksArgs,
|
||||
} from '../src/commands/backlinks.ts';
|
||||
|
||||
describe('extractEntityRefs', () => {
|
||||
@@ -104,3 +105,26 @@ describe('findBacklinkGaps dedupe (v0.36.x #967 regression)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBacklinksArgs', () => {
|
||||
test('uses positional dir for check and fix subcommands', () => {
|
||||
expect(parseBacklinksArgs(['check', '/tmp/brain']).brainDir).toBe('/tmp/brain');
|
||||
expect(parseBacklinksArgs(['fix', '/tmp/brain']).brainDir).toBe('/tmp/brain');
|
||||
});
|
||||
|
||||
test('defaults to cwd when no dir given', () => {
|
||||
expect(parseBacklinksArgs(['check']).brainDir).toBe('.');
|
||||
});
|
||||
|
||||
test('--dir overrides positional dir and preserves dry-run', () => {
|
||||
const parsed = parseBacklinksArgs(['fix', '/tmp/ignored', '--dir', '/tmp/brain', '--dry-run']);
|
||||
expect(parsed.subcommand).toBe('fix');
|
||||
expect(parsed.brainDir).toBe('/tmp/brain');
|
||||
expect(parsed.dryRun).toBe(true);
|
||||
});
|
||||
|
||||
test('--dir missing its value falls back to positional dir', () => {
|
||||
expect(parseBacklinksArgs(['check', '/tmp/brain', '--dir']).brainDir).toBe('/tmp/brain');
|
||||
expect(parseBacklinksArgs(['check', '--dir', '--dry-run']).brainDir).toBe('.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
// #2189 regression guard: putPage's INSERT … ON CONFLICT DO UPDATE … RETURNING
|
||||
// can yield 0 rows when brain-local DB state (e.g. a BEFORE INSERT trigger)
|
||||
// suppresses the write. Pre-fix, rowToPage(rows[0]) crashed with the opaque
|
||||
// "undefined is not an object (evaluating 'row.deleted_at')" that failed
|
||||
// ~all files of a code sync. Post-fix, putPage throws a descriptive error
|
||||
// naming the slug + source_id so the failure is diagnosable per-file.
|
||||
//
|
||||
// Same guard lands in postgres-engine.ts (engine-parity invariant); this test
|
||||
// exercises the PGLite side, where the issue was reported.
|
||||
|
||||
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
// Simulate the reporter's state-dependent failure: a trigger that
|
||||
// suppresses inserts for one slug, making RETURNING produce no row.
|
||||
await engine.executeRaw(`
|
||||
CREATE OR REPLACE FUNCTION suppress_pages_insert() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.slug = 'suppressed-page' THEN RETURN NULL; END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`);
|
||||
await engine.executeRaw(`
|
||||
CREATE TRIGGER suppress_pages_insert_trg
|
||||
BEFORE INSERT ON pages
|
||||
FOR EACH ROW EXECUTE FUNCTION suppress_pages_insert();
|
||||
`);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.executeRaw('DROP TRIGGER IF EXISTS suppress_pages_insert_trg ON pages');
|
||||
await engine.executeRaw('DROP FUNCTION IF EXISTS suppress_pages_insert');
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
describe('putPage RETURNING guard (#2189)', () => {
|
||||
test('0-row RETURNING throws a descriptive error, not row.deleted_at TypeError', async () => {
|
||||
let err: Error | undefined;
|
||||
try {
|
||||
await engine.putPage('suppressed-page', {
|
||||
type: 'code',
|
||||
title: 'Suppressed',
|
||||
compiled_truth: 'x',
|
||||
timeline: '',
|
||||
});
|
||||
} catch (e) {
|
||||
err = e as Error;
|
||||
}
|
||||
expect(err).toBeDefined();
|
||||
expect(err!.message).toContain('putPage');
|
||||
expect(err!.message).toContain("slug='suppressed-page'");
|
||||
expect(err!.message).toContain("source_id='default'");
|
||||
// The pre-fix crash signature must be gone.
|
||||
expect(err!.message).not.toContain('deleted_at');
|
||||
});
|
||||
|
||||
test('unsuppressed slugs still upsert normally with the trigger installed', async () => {
|
||||
const page = await engine.putPage('normal-page', {
|
||||
type: 'concept',
|
||||
title: 'Normal',
|
||||
compiled_truth: 'y',
|
||||
timeline: '',
|
||||
});
|
||||
expect(page.slug).toBe('normal-page');
|
||||
expect(page.source_id).toBe('default');
|
||||
});
|
||||
});
|
||||
@@ -375,31 +375,6 @@ describe('performSync dry-run never writes', () => {
|
||||
expect(messages.some(m => m.includes('git pull failed'))).toBe(false);
|
||||
});
|
||||
|
||||
test('first PGLite code sync imports code files without runtime failures', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
mkdirSync(join(repoPath, 'src'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(repoPath, 'src/example.ts'),
|
||||
'export function add(left: number, right: number) { return left + right; }\n',
|
||||
);
|
||||
execSync('git add -A && git commit -m "add code file"', { cwd: repoPath, stdio: 'pipe' });
|
||||
|
||||
const result = await performSync(engine, {
|
||||
repoPath,
|
||||
noPull: true,
|
||||
noEmbed: true,
|
||||
noExtract: true,
|
||||
strategy: 'code',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('first_sync');
|
||||
expect(result.added).toBe(1);
|
||||
expect(result.failedFiles ?? 0).toBe(0);
|
||||
const page = await engine.getPage('src-example-ts');
|
||||
expect(page?.type).toBe('code');
|
||||
expect(page?.frontmatter).toMatchObject({ file: 'src/example.ts', language: 'typescript' });
|
||||
});
|
||||
|
||||
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
// First do a real sync to seed the bookmark.
|
||||
|
||||
Reference in New Issue
Block a user