feat(v0.18.2.fork.1): sources CLI manifest editing — runUpdate + --slug-prefix

Adds the CLI surface needed to populate config.slug_prefix_rules per
source so the Phase 2b manifest priority chain has data to act on.

- runAdd extension: --slug-prefix '<rule>,<rule>' flag at source-create
  time. Comma-separated, each rule validated via parseSlugPrefixFlag.
- runUpdate (NEW subcommand): replace manifest rules in-place on an
  existing source. --slug-prefix '' clears all rules. Preserves other
  config keys (federated, etc.).
- Prefix grammar validator (Issue #6 from /plan-eng-review): fail-fast
  at write time. Rejects underscores, uppercase, mid-string '*',
  multi-level '**', empty after split, whitespace, oversize. Accepts
  literal prefix, trailing single '*', '/'-separated segments, hyphens.
  A typo'd rule never silently lands in jsonb — surfaces as CLI error.
- runList output: human + JSON variants both surface slug_prefix_rules
  when present.
- printHelp: full grammar reference + examples.
- Dispatcher: case 'update' routes to runUpdate.

Tests:
- test/sources-update-slug-prefix.test.ts (new): runAdd --slug-prefix
  persistence, runUpdate replace + clear + preserve-other-keys,
  validator rejects (7 negative cases) + accepts (3 positive cases)

bun test: 2204 pass / 0 fail / 250 skip (1 flaky cycle.test.ts timeout
during full-sweep contention; 18/18 pass when run in isolation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 22:28:26 +08:00
parent 52092c64b1
commit 676d4283c7
2 changed files with 323 additions and 6 deletions

View File

@@ -40,6 +40,58 @@ function validateSourceId(id: string): void {
}
}
// v0.18.2.fork.1 — manifest prefix grammar.
//
// A slug-prefix rule is one of:
// - Literal prefix: 'memory-dashboard/' (most common)
// - Single-level glob: 'wedding-planning/*' (cosmetic; same as literal)
//
// Rules:
// - Allowed chars: lowercase a-z, 0-9, '-' (hyphen), '/' (slash)
// - Optional single trailing '*' (no other position)
// - Length 1..64 (excluding the trailing '*')
// - Reject underscores ('_'), uppercase, whitespace, mid-string '*',
// multi-level globs ('**'), any other punctuation
//
// Why fail-fast at write time: a typo'd rule (`memory_dashboard/`) writes
// successfully into config_jsonb but never matches anything at routing
// time — the put_page silently falls to brain-default. Catching at
// CLI-write moment surfaces "this is a typo" before bad data lands.
const SLUG_PREFIX_RULE_RE = /^[a-z0-9](?:[a-z0-9-/]{0,62}[a-z0-9-/])?\*?$/;
function validateSlugPrefix(rule: string): void {
if (!rule || rule.length === 0) {
throw new Error('Empty slug-prefix rule. Each --slug-prefix entry must be a non-empty string.');
}
if (rule.length > 64) {
throw new Error(`Slug-prefix rule too long (${rule.length} chars, max 64): "${rule}"`);
}
if (rule.includes('**')) {
throw new Error(`Multi-level glob ('**') not supported: "${rule}". Use a literal prefix or trailing single '*'.`);
}
// '*' permitted only as the final character.
const starIdx = rule.indexOf('*');
if (starIdx !== -1 && starIdx !== rule.length - 1) {
throw new Error(`'*' may only appear as the final character: "${rule}". For a literal prefix, drop the '*'.`);
}
if (!SLUG_PREFIX_RULE_RE.test(rule)) {
throw new Error(
`Invalid slug-prefix rule "${rule}". ` +
`Must be lowercase a-z, 0-9, '-', '/', optionally ending in '*'. ` +
`Reject: underscores, uppercase, whitespace, other punctuation.`,
);
}
}
function parseSlugPrefixFlag(value: string): string[] {
// Comma-separated list. Each item validated independently.
const parts = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
if (parts.length === 0) {
throw new Error('--slug-prefix value is empty after parsing. Provide one or more comma-separated rules.');
}
for (const p of parts) validateSlugPrefix(p);
return parts;
}
// ── Types ───────────────────────────────────────────────────
interface SourceRow {
@@ -59,6 +111,8 @@ interface SourceListEntry {
federated: boolean;
page_count: number;
last_sync_at: string | null;
/** v0.18.2.fork.1 — surfaces config.slug_prefix_rules when set. */
slug_prefix_rules?: string[];
}
// ── Helpers ─────────────────────────────────────────────────
@@ -98,7 +152,7 @@ async function countPages(engine: BrainEngine, sourceId: string): Promise<number
async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error('Usage: gbrain sources add <id> --path <path> [--name <display>] [--federated|--no-federated]');
console.error('Usage: gbrain sources add <id> [--path <path>] [--name <display>] [--federated|--no-federated] [--slug-prefix \'<rule>,<rule>\']');
process.exit(2);
}
validateSourceId(id);
@@ -106,6 +160,7 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
let localPath: string | null = null;
let displayName = id;
let federated: boolean | null = null; // null = default (false for new, opt-in via --federated)
let slugPrefixRules: string[] | null = null;
for (let i = 1; i < args.length; i++) {
const a = args[i];
@@ -113,6 +168,7 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
if (a === '--name') { displayName = args[++i]; continue; }
if (a === '--federated') { federated = true; continue; }
if (a === '--no-federated') { federated = false; continue; }
if (a === '--slug-prefix') { slugPrefixRules = parseSlugPrefixFlag(args[++i]); continue; }
console.error(`Unknown flag: ${a}`);
process.exit(2);
}
@@ -138,7 +194,10 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
}
}
const config = federated === null ? {} : { federated };
const config: Record<string, unknown> = {};
if (federated !== null) config.federated = federated;
if (slugPrefixRules) config.slug_prefix_rules = slugPrefixRules;
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config)
VALUES ($1, $2, $3, $4::jsonb)
@@ -154,6 +213,9 @@ async function runAdd(engine: BrainEngine, args: string[]): Promise<void> {
const fed = isFederated(created.config);
console.log(`Created source "${id}"${displayName !== id ? ` (name: ${displayName})` : ''}${localPath ? `${localPath}` : ''}`);
console.log(` federated: ${fed}${fed ? ' — appears in cross-source default search' : ' — only searched when explicitly named via --source'}`);
if (slugPrefixRules) {
console.log(` slug_prefix_rules: ${slugPrefixRules.join(', ')}`);
}
}
// ── Subcommand: list ────────────────────────────────────────
@@ -169,6 +231,10 @@ async function runList(engine: BrainEngine, args: string[]): Promise<void> {
const entries: SourceListEntry[] = [];
for (const r of rows) {
const pageCount = await countPages(engine, r.id);
const cfg = parseConfig(r.config);
const rules = Array.isArray(cfg.slug_prefix_rules)
? (cfg.slug_prefix_rules as unknown[]).filter(x => typeof x === 'string') as string[]
: undefined;
entries.push({
id: r.id,
name: r.name,
@@ -176,6 +242,7 @@ async function runList(engine: BrainEngine, args: string[]): Promise<void> {
federated: isFederated(r.config),
page_count: pageCount,
last_sync_at: r.last_sync_at ? new Date(r.last_sync_at).toISOString() : null,
...(rules && rules.length > 0 ? { slug_prefix_rules: rules } : {}),
});
}
@@ -193,6 +260,9 @@ async function runList(engine: BrainEngine, args: string[]): Promise<void> {
const sync = e.last_sync_at ? `last sync ${e.last_sync_at}` : 'never synced';
console.log(` ${e.id.padEnd(20)} ${fedMark.padEnd(10)} ${String(e.page_count).padStart(6)} pages ${sync}`);
if (e.local_path) console.log(` ${' '.repeat(22)}${pathStr}`);
if (e.slug_prefix_rules && e.slug_prefix_rules.length > 0) {
console.log(` ${' '.repeat(22)}slug-prefix: ${e.slug_prefix_rules.join(', ')}`);
}
}
if (entries.length === 0) console.log(' (no sources registered)');
}
@@ -324,6 +394,80 @@ async function runFederate(engine: BrainEngine, args: string[], value: boolean):
console.log(`Source "${id}" is now ${value ? 'federated (appears in cross-source default search)' : 'isolated (only searched when explicitly named)'}.`);
}
// ── Subcommand: update (v0.18.2.fork.1) ─────────────────────
//
// Mutates fields on an existing source in-place. Currently supports:
// --slug-prefix '<rule>,<rule>' Replace config.slug_prefix_rules
// --slug-prefix '' Clear all prefix rules
//
// Future flags can be slotted in (e.g. --read-boost / --preprocessor)
// once the manifest projection model is implemented (PR #2+ per design
// doc Open Q #4). Update is intentionally additive on the config_jsonb
// blob, not destructive on other keys (federated, etc. remain).
async function runUpdate(engine: BrainEngine, args: string[]): Promise<void> {
const id = args[0];
if (!id) {
console.error("Usage: gbrain sources update <id> --slug-prefix '<rule>,<rule>'");
process.exit(2);
}
validateSourceId(id);
const existing = await fetchSource(engine, id);
if (!existing) {
console.error(`Source "${id}" does not exist. Run 'gbrain sources add ${id}' first.`);
process.exit(3);
}
let slugPrefixRules: string[] | null = null;
let slugPrefixSet = false;
for (let i = 1; i < args.length; i++) {
const a = args[i];
if (a === '--slug-prefix') {
const value = args[++i];
if (value === '') {
// Explicit clear.
slugPrefixRules = [];
} else {
slugPrefixRules = parseSlugPrefixFlag(value);
}
slugPrefixSet = true;
continue;
}
console.error(`Unknown flag: ${a}`);
process.exit(2);
}
if (!slugPrefixSet) {
console.error('Nothing to update. Pass --slug-prefix to set/clear manifest rules.');
process.exit(2);
}
const config = parseConfig(existing.config);
if (slugPrefixRules && slugPrefixRules.length > 0) {
config.slug_prefix_rules = slugPrefixRules;
} else {
delete config.slug_prefix_rules;
}
await engine.executeRaw(
`UPDATE sources SET config = $1::jsonb WHERE id = $2`,
[JSON.stringify(config), id],
);
if (slugPrefixRules && slugPrefixRules.length > 0) {
console.log(`Updated source "${id}": slug_prefix_rules = ${slugPrefixRules.join(', ')}`);
} else {
console.log(`Updated source "${id}": slug_prefix_rules cleared`);
}
console.log(
' Note: cache TTL ~60s — other gbrain processes (MCP container, sync cron) ' +
'see the new rules within 60s. Restart with `docker compose restart gbrain-mcp` ' +
'for immediate effect.',
);
}
// ── Dispatcher ──────────────────────────────────────────────
export async function runSources(engine: BrainEngine, args: string[]): Promise<void> {
@@ -332,6 +476,7 @@ export async function runSources(engine: BrainEngine, args: string[]): Promise<v
switch (sub) {
case 'add': return runAdd(engine, rest);
case 'update': return runUpdate(engine, rest);
case 'list': return runList(engine, rest);
case 'remove': return runRemove(engine, rest);
case 'rename': return runRename(engine, rest);
@@ -353,12 +498,23 @@ export async function runSources(engine: BrainEngine, args: string[]): Promise<v
}
function printHelp(): void {
console.log(`gbrain sources — manage multi-source brain configuration (v0.18.0)
console.log(`gbrain sources — manage multi-source brain configuration (v0.18.0 + v0.18.2.fork.1 manifest)
Subcommands:
add <id> --path <p> [--name <n>] [--federated|--no-federated]
Register a new source.
list [--json] List registered sources with page counts.
add <id> [--path <p>] [--name <n>] [--federated|--no-federated]
[--slug-prefix '<rule>,<rule>']
Register a new source. --slug-prefix
enables manifest auto-routing: a put_page
whose slug starts with one of these rules
routes here automatically (priority 5 in
the resolveSourceId chain).
update <id> --slug-prefix '<rule>,<rule>'
Replace manifest rules on an existing
source. Pass empty string to clear.
Other gbrain processes pick up the change
within ~60s (cache TTL).
list [--json] List registered sources with page counts
and slug_prefix_rules.
remove <id> [--yes] [--dry-run] Cascade-delete a source and its pages.
rename <id> <new-name> Rename display name (id is immutable).
default <id> Set the brain-level default source.
@@ -368,5 +524,12 @@ Subcommands:
unfederate <id> Isolate source from default search.
Source id: [a-z0-9-]{1,32}. Immutable citation key.
Slug-prefix rule grammar:
- lowercase a-z, 0-9, '-', '/' allowed
- optional single trailing '*'
- max 64 chars
- reject: underscores, uppercase, whitespace, mid-string '*', '**'
examples: memory-dashboard/ projects/ wedding-planning/*
`);
}

View File

@@ -0,0 +1,154 @@
/**
* v0.18.2.fork.1 — `gbrain sources add --slug-prefix` + new `update` subcommand.
*
* Verifies:
* - runAdd accepts --slug-prefix '<rule>,<rule>' and writes config.slug_prefix_rules
* - runUpdate replaces config.slug_prefix_rules in-place
* - runUpdate '' clears the rules
* - runUpdate on missing source errors out (exit 3)
* - prefix validator rejects: underscores, uppercase, mid-string '*',
* multi-level glob '**', empty, whitespace, oversize
* - validator accepts: literal prefix, trailing '*', '/'-separated paths
* - runUpdate preserves other config keys (federated stays put)
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runSources } from '../src/commands/sources.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ type: 'pglite' } as never);
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
// Reset fixture sources between tests so write-then-update doesn't leak across cases.
await engine.executeRaw(
`DELETE FROM sources WHERE id LIKE 'test-%'`,
);
});
async function readConfig(id: string): Promise<Record<string, unknown>> {
const rows = await engine.executeRaw<{ config: string | Record<string, unknown> }>(
`SELECT config FROM sources WHERE id = $1`,
[id],
);
if (rows.length === 0) return {};
const cfg = rows[0].config;
return typeof cfg === 'string' ? JSON.parse(cfg) : cfg;
}
describe('runAdd --slug-prefix', () => {
test('accepts comma-separated rules and persists to config.slug_prefix_rules', async () => {
await runSources(engine, ['add', 'test-md', '--slug-prefix', 'memory-dashboard/,builder-journey']);
const cfg = await readConfig('test-md');
expect(cfg.slug_prefix_rules).toEqual(['memory-dashboard/', 'builder-journey']);
});
test('accepts trailing-glob form', async () => {
await runSources(engine, ['add', 'test-glob', '--slug-prefix', 'wedding-planning/*']);
const cfg = await readConfig('test-glob');
expect(cfg.slug_prefix_rules).toEqual(['wedding-planning/*']);
});
test('add without --slug-prefix leaves config without the key', async () => {
await runSources(engine, ['add', 'test-bare']);
const cfg = await readConfig('test-bare');
expect(cfg.slug_prefix_rules).toBeUndefined();
});
});
describe('runUpdate --slug-prefix', () => {
test('replaces rules in-place on existing source', async () => {
await runSources(engine, ['add', 'test-update', '--slug-prefix', 'old-prefix/']);
await runSources(engine, ['update', 'test-update', '--slug-prefix', 'new-a/,new-b/']);
const cfg = await readConfig('test-update');
expect(cfg.slug_prefix_rules).toEqual(['new-a/', 'new-b/']);
});
test("update --slug-prefix '' clears rules entirely", async () => {
await runSources(engine, ['add', 'test-clear', '--slug-prefix', 'foo/']);
await runSources(engine, ['update', 'test-clear', '--slug-prefix', '']);
const cfg = await readConfig('test-clear');
expect(cfg.slug_prefix_rules).toBeUndefined();
});
test('preserves other config keys (federated)', async () => {
await runSources(engine, ['add', 'test-fed', '--federated', '--slug-prefix', 'a/']);
await runSources(engine, ['update', 'test-fed', '--slug-prefix', 'b/']);
const cfg = await readConfig('test-fed');
expect(cfg.federated).toBe(true);
expect(cfg.slug_prefix_rules).toEqual(['b/']);
});
});
describe('Prefix grammar validator (Issue #6 — fail-fast at write time)', () => {
// Validator runs both at runAdd write time and runUpdate write time.
// We test via runAdd since it's the canonical surface; same code path.
const expectReject = async (rule: string, hint: string) => {
let threw = false;
let msg = '';
try {
await runSources(engine, ['add', `test-reject-${Math.random().toString(36).slice(2, 8)}`, '--slug-prefix', rule]);
} catch (e) {
threw = true;
msg = e instanceof Error ? e.message : String(e);
}
expect(threw).toBe(true);
if (hint) expect(msg).toContain(hint);
};
test('reject underscore', async () => {
await expectReject('memory_dashboard/', 'Invalid slug-prefix');
});
test('reject uppercase', async () => {
await expectReject('MemoryDashboard/', 'Invalid slug-prefix');
});
test('reject mid-string glob', async () => {
await expectReject('foo*bar/', "'*' may only appear as the final character");
});
test('reject multi-level glob', async () => {
await expectReject('a/**', "Multi-level glob");
});
test('reject empty after split', async () => {
let threw = false;
try {
await runSources(engine, ['add', 'test-reject-empty', '--slug-prefix', ',,,']);
} catch (e) {
threw = true;
const msg = e instanceof Error ? e.message : String(e);
expect(msg).toContain('--slug-prefix value is empty');
}
expect(threw).toBe(true);
});
test('reject whitespace inside rule', async () => {
await expectReject('foo bar/', 'Invalid slug-prefix');
});
test('reject oversized rule', async () => {
await expectReject('a'.repeat(65) + '/', 'too long');
});
test('accept literal prefix', async () => {
await runSources(engine, ['add', 'test-accept-lit', '--slug-prefix', 'memory-dashboard/']);
const cfg = await readConfig('test-accept-lit');
expect(cfg.slug_prefix_rules).toEqual(['memory-dashboard/']);
});
test('accept trailing star', async () => {
await runSources(engine, ['add', 'test-accept-star', '--slug-prefix', 'projects/*']);
const cfg = await readConfig('test-accept-star');
expect(cfg.slug_prefix_rules).toEqual(['projects/*']);
});
test('accept hyphen-segments and slashes', async () => {
await runSources(engine, ['add', 'test-accept-segments', '--slug-prefix', 'design/memory-dashboard/internal/']);
const cfg = await readConfig('test-accept-segments');
expect(cfg.slug_prefix_rules).toEqual(['design/memory-dashboard/internal/']);
});
});