mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8480c69cc5 |
+20
-11
@@ -128,11 +128,9 @@ function writeBody(path: string, body: string): void {
|
||||
// --- Subcommands ---
|
||||
|
||||
async function cmdList(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const slug = args[0];
|
||||
if (!slug) {
|
||||
console.error('Usage: gbrain takes <slug> [--json]');
|
||||
process.exit(1);
|
||||
}
|
||||
// #2079: `gbrain takes list` (no slug) lists all active takes brain-wide —
|
||||
// CLI parity with the takes_list op. A leading flag also means "no slug".
|
||||
const slug = args[0] && !args[0].startsWith('--') ? args[0] : undefined;
|
||||
const json = flagPresent(args, '--json');
|
||||
const holder = flagValue(args, '--who');
|
||||
const kind = flagValue(args, '--kind') as string | undefined;
|
||||
@@ -153,16 +151,17 @@ async function cmdList(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (takes.length === 0) {
|
||||
console.log(`No takes on ${slug}.`);
|
||||
console.log(slug ? `No takes on ${slug}.` : 'No takes in brain.');
|
||||
return;
|
||||
}
|
||||
console.log(`# Takes on ${slug}\n`);
|
||||
console.log(slug ? `# Takes on ${slug}\n` : '# All takes\n');
|
||||
for (const t of takes) {
|
||||
const tag = t.active ? '' : ' [superseded]';
|
||||
const w = Number(t.weight).toFixed(2);
|
||||
const since = t.since_date ?? '';
|
||||
const src = t.source ? ` — ${t.source}` : '';
|
||||
console.log(`#${t.row_num} [${t.kind} • ${t.holder} • w=${w}${since ? ` • ${since}` : ''}]${tag}\n ${t.claim}${src}\n`);
|
||||
const ref = slug ? `#${t.row_num}` : `${t.page_slug}#${t.row_num}`;
|
||||
console.log(`${ref} [${t.kind} • ${t.holder} • w=${w}${since ? ` • ${since}` : ''}]${tag}\n ${t.claim}${src}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,11 +289,17 @@ async function cmdSupersede(engine: BrainEngine, args: string[], sourceId?: stri
|
||||
await withPageLock(slug, async () => {
|
||||
const pageId = await getPageId(engine, slug, sourceId);
|
||||
|
||||
// Read existing row to inherit kind/holder unless overridden
|
||||
const existing = await engine.listTakes({ page_id: pageId, active: false, limit: 500 });
|
||||
// Read existing row to inherit kind/holder unless overridden.
|
||||
// #2078: the supersede target is an ACTIVE take — active:false could
|
||||
// only ever find already-superseded rows, so every real supersede failed.
|
||||
const existing = await engine.listTakes({ page_id: pageId, active: true, limit: 500 });
|
||||
const target = existing.find(t => t.row_num === rowNum);
|
||||
if (!target) {
|
||||
console.error(`Row #${rowNum} not found on ${slug}.`);
|
||||
const superseded = await engine.listTakes({ page_id: pageId, active: false, limit: 500 });
|
||||
const stale = superseded.find(t => t.row_num === rowNum);
|
||||
console.error(stale
|
||||
? `Row #${rowNum} on ${slug} is already superseded.`
|
||||
: `Row #${rowNum} not found on ${slug}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const kind = ensureKind(flagValue(args, '--kind') ?? target.kind);
|
||||
@@ -554,6 +559,7 @@ export async function runTakes(engine: BrainEngine, args: string[]): Promise<voi
|
||||
Subcommands:
|
||||
takes <slug> [--json] [--who h] [--kind k] [--sort weight|since_date|created_at] [--expired]
|
||||
List takes for a page
|
||||
takes list [<slug>] [same flags] List all active takes (or one page's)
|
||||
takes search "<query>" [--limit N] [--json]
|
||||
Keyword search across all takes
|
||||
takes add <slug> --claim "..." --kind <fact|take|bet|hunch> --who <holder>
|
||||
@@ -583,6 +589,9 @@ Common flags:
|
||||
const rest = args.slice(1);
|
||||
|
||||
switch (sub) {
|
||||
// #2079: reserved word — never a page slug. Bare `takes list` lists all
|
||||
// active takes; `takes list <slug>` behaves like `takes <slug>`.
|
||||
case 'list': return cmdList(engine, rest);
|
||||
case 'search': return cmdSearch(engine, rest);
|
||||
case 'add': return cmdAdd(engine, rest, await resolveTakesSourceId(engine));
|
||||
case 'update': return cmdUpdate(engine, rest, await resolveTakesSourceId(engine));
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// configured it returns zero events (auto-emit is a no-op, never an error).
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { computeContentHash } from '../ingestion/types.ts';
|
||||
import { isHardExcludedSlug, resolveDeriveExcludes } from '../search/source-boost.ts';
|
||||
|
||||
export interface ChronicleEventProposal {
|
||||
when: string; // ISO datetime or YYYY-MM-DD
|
||||
@@ -112,6 +113,12 @@ export async function runChronicleExtract(
|
||||
): Promise<ChronicleExtractResult> {
|
||||
const sourceId = opts.sourceId ?? 'default';
|
||||
const tz = opts.tz ?? 'UTC';
|
||||
// #2780 (privacy): never derive events from excluded-prefix pages — the
|
||||
// event page lands under life/events/ (outside the exclusion) and quotes
|
||||
// the excluded content in searchable form.
|
||||
if (isHardExcludedSlug(opts.slug, resolveDeriveExcludes())) {
|
||||
return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'excluded_prefix' };
|
||||
}
|
||||
const page = await engine.getPage(opts.slug, { sourceId });
|
||||
if (!page) return { slug: opts.slug, status: 'skipped', events_written: 0, reason: 'page_not_found' };
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ import { writeReceipt } from '../extract/receipt-writer.ts';
|
||||
import { upsertExtractRollup } from '../extract/rollup-writer.ts';
|
||||
import { createHash } from 'crypto';
|
||||
import { slugifySegment } from '../sync.ts';
|
||||
import { isHardExcludedSlug, resolveDeriveExcludes } from '../search/source-boost.ts';
|
||||
|
||||
const DEFAULT_BUDGET_USD = 0.3;
|
||||
|
||||
@@ -461,7 +462,11 @@ export async function runPhaseExtractAtoms(
|
||||
seenHashes.add(t.contentHash);
|
||||
work.push({ kind: 'transcript', ...t });
|
||||
}
|
||||
// #2780 (privacy): never derive atoms from excluded-prefix pages — the
|
||||
// atom lands under atoms/ (outside the exclusion) and becomes searchable.
|
||||
const hardExcludes = resolveDeriveExcludes();
|
||||
for (const p of pages) {
|
||||
if (isHardExcludedSlug(p.slug, hardExcludes)) continue;
|
||||
if (seenHashes.has(p.contentHash)) { duplicatesSkipped++; continue; }
|
||||
seenHashes.add(p.contentHash);
|
||||
work.push({ kind: 'page', ...p });
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from './phantom-redirect.ts';
|
||||
import { embed, isAvailable } from '../ai/gateway.ts';
|
||||
import { isAborted } from '../abort-check.ts';
|
||||
import { isHardExcludedSlug, resolveDeriveExcludes } from '../search/source-boost.ts';
|
||||
|
||||
interface ExistingPageFact {
|
||||
fact: string;
|
||||
@@ -247,11 +248,15 @@ export async function runExtractFacts(
|
||||
}
|
||||
|
||||
// ── Reconcile each page ───────────────────────────────────────
|
||||
// #2780 (privacy): never derive from excluded-prefix pages — the facts
|
||||
// rows would be searchable outside the exclusion.
|
||||
const hardExcludes = resolveDeriveExcludes();
|
||||
for (const slug of slugs) {
|
||||
// #1972: bail at the top of the per-page loop on abort. Each page is an
|
||||
// independent delete-then-insert commit, so breaking leaves a consistent
|
||||
// partial state; the receipt/rollup below still runs with partial counts.
|
||||
if (isAborted(opts.signal)) break;
|
||||
if (isHardExcludedSlug(slug, hardExcludes)) continue;
|
||||
result.pagesScanned += 1;
|
||||
|
||||
const page = await engine.getPage(slug, { sourceId });
|
||||
|
||||
@@ -24,6 +24,7 @@ import { join, relative, sep } from 'node:path';
|
||||
import type { BrainEngine, TakeBatchInput } from '../engine.ts';
|
||||
import { parseTakesFence, type ParsedTake } from '../takes-fence.ts';
|
||||
import { walkMarkdownFiles } from '../../commands/extract.ts';
|
||||
import { isHardExcludedSlug, resolveDeriveExcludes } from '../search/source-boost.ts';
|
||||
|
||||
export interface ExtractTakesOpts {
|
||||
/** Brain repo root. Required for source='fs'. */
|
||||
@@ -127,10 +128,13 @@ export async function extractTakesFromFs(
|
||||
|
||||
const files = walkMarkdownFiles(opts.repoPath);
|
||||
const buffer: TakeBatchInput[] = [];
|
||||
// #2780 (privacy): never derive takes from excluded-prefix pages.
|
||||
const hardExcludes = resolveDeriveExcludes();
|
||||
|
||||
for (const { path, relPath } of files) {
|
||||
const slug = relPath.replace(/\.md$/, '').split(sep).join('/');
|
||||
if (slugFilter && !slugFilter.has(slug)) continue;
|
||||
if (isHardExcludedSlug(slug, hardExcludes)) continue;
|
||||
result.pagesScanned++;
|
||||
|
||||
let body: string;
|
||||
@@ -198,8 +202,11 @@ export async function extractTakesFromDb(
|
||||
? opts.slugs.map(slug => ({ slug, source_id: 'default' }))
|
||||
: await engine.listAllPageRefs();
|
||||
const buffer: TakeBatchInput[] = [];
|
||||
// #2780 (privacy): never derive takes from excluded-prefix pages.
|
||||
const hardExcludes = resolveDeriveExcludes();
|
||||
|
||||
for (const { slug, source_id } of refs) {
|
||||
if (isHardExcludedSlug(slug, hardExcludes)) continue;
|
||||
result.pagesScanned++;
|
||||
const page = await engine.getPage(slug, { sourceId: source_id });
|
||||
if (!page) continue;
|
||||
|
||||
@@ -721,6 +721,15 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
? (permissions as Record<string, unknown>).source_id
|
||||
: undefined;
|
||||
const { sourceId, allowedSources } = parseLegacyTokenScope(sourceGrant);
|
||||
// #2529: honor permissions.takes_holders on the OAuth transport,
|
||||
// mirroring src/mcp/http-transport.ts. Fail-closed default ['world']
|
||||
// — a token with no grant sees public claims only.
|
||||
const takesHoldersRaw = permissions && typeof permissions === 'object'
|
||||
? (permissions as Record<string, unknown>).takes_holders
|
||||
: undefined;
|
||||
const takesHoldersAllowList = Array.isArray(takesHoldersRaw)
|
||||
? takesHoldersRaw.filter((h): h is string => typeof h === 'string')
|
||||
: ['world'];
|
||||
return {
|
||||
token,
|
||||
clientId: name,
|
||||
@@ -732,6 +741,7 @@ export class GBrainOAuthProvider implements OAuthServerProvider {
|
||||
// allowedSources for federated reads, matching legacy HTTP transport.
|
||||
sourceId,
|
||||
allowedSources,
|
||||
takesHoldersAllowList,
|
||||
} as CoreAuthInfo as SdkAuthInfo;
|
||||
}
|
||||
|
||||
|
||||
@@ -305,6 +305,15 @@ export interface AuthInfo {
|
||||
* case (back-compat).
|
||||
*/
|
||||
allowedSources?: string[];
|
||||
/**
|
||||
* #2529: per-token takes-holder allow-list from
|
||||
* `access_tokens.permissions.takes_holders`, resolved at
|
||||
* token-verification time so `serve --http` bearer tokens honor
|
||||
* `gbrain auth set-takes-holders`. Legacy tokens without a grant get
|
||||
* `['world']` (fail-closed); OAuth clients leave it undefined and the
|
||||
* transport defaults to `['world']`.
|
||||
*/
|
||||
takesHoldersAllowList?: string[];
|
||||
}
|
||||
|
||||
export interface OperationContext {
|
||||
|
||||
@@ -132,3 +132,30 @@ export function resolveHardExcludes(
|
||||
}
|
||||
return Array.from(union);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2780 (privacy): true when a slug sits under any hard-excluded prefix
|
||||
* (defaults ∪ GBRAIN_SEARCH_EXCLUDE). Derive phases (chronicle events,
|
||||
* facts, atoms, takes) MUST skip such source pages — otherwise excluded
|
||||
* content gets re-materialized as searchable rows/pages OUTSIDE the
|
||||
* excluded prefix, silently defeating the exclusion.
|
||||
*/
|
||||
export function isHardExcludedSlug(
|
||||
slug: string,
|
||||
prefixes: string[] = resolveHardExcludes(),
|
||||
): boolean {
|
||||
return prefixes.some(p => slug.startsWith(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* #2780: prefixes derive phases must never read from. Deliberately the
|
||||
* env-configured (privacy) excludes ONLY — the DEFAULT_HARD_EXCLUDES
|
||||
* noise prefixes (test/, attachments/, .raw/) stay derivable: they are a
|
||||
* search-ranking concern, not a privacy boundary, and extraction from
|
||||
* test/ pages is pinned by existing suites.
|
||||
*/
|
||||
export function resolveDeriveExcludes(
|
||||
envValue: string | undefined = process.env.GBRAIN_SEARCH_EXCLUDE,
|
||||
): string[] {
|
||||
return parseHardExcludesEnv(envValue);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// #2780 (privacy) — derive phases (chronicle events, facts, takes) must
|
||||
// skip pages under hard-excluded prefixes (defaults ∪ GBRAIN_SEARCH_EXCLUDE).
|
||||
// Pre-fix, excluded-prefix content was re-materialized as searchable
|
||||
// rows/pages outside the excluded prefix, silently defeating the exclusion.
|
||||
//
|
||||
// Hermetic: mock engines, env via withEnv. The atoms-phase gate is pinned
|
||||
// in test/extract-atoms-page-discovery.test.ts (PGLite fixture lives there).
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { isHardExcludedSlug, resolveDeriveExcludes } from '../src/core/search/source-boost.ts';
|
||||
import { runExtractFacts } from '../src/core/cycle/extract-facts.ts';
|
||||
import { extractTakesFromDb } from '../src/core/cycle/extract-takes.ts';
|
||||
import { runChronicleExtract } from '../src/core/chronicle/extract-events.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
describe('isHardExcludedSlug', () => {
|
||||
test('matches env prefixes and defaults, prefix-anchored', () => {
|
||||
expect(isHardExcludedSlug('private/journal', ['private/'])).toBe(true);
|
||||
expect(isHardExcludedSlug('people/private', ['private/'])).toBe(false);
|
||||
// Defaults apply when no explicit list is passed.
|
||||
expect(isHardExcludedSlug('attachments/x')).toBe(true);
|
||||
expect(isHardExcludedSlug('people/alice-example')).toBe(false);
|
||||
});
|
||||
|
||||
test('reads GBRAIN_SEARCH_EXCLUDE by default', async () => {
|
||||
await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'journal/' }, () => {
|
||||
expect(isHardExcludedSlug('journal/2026-01-01')).toBe(true);
|
||||
});
|
||||
await withEnv({ GBRAIN_SEARCH_EXCLUDE: undefined }, () => {
|
||||
expect(isHardExcludedSlug('journal/2026-01-01')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDeriveExcludes', () => {
|
||||
test('env privacy prefixes only — NOT the search-noise defaults', async () => {
|
||||
await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/,journal/' }, () => {
|
||||
expect(resolveDeriveExcludes()).toEqual(['private/', 'journal/']);
|
||||
});
|
||||
// test/ etc. stay derivable (pinned by the extract-takes holder suites).
|
||||
await withEnv({ GBRAIN_SEARCH_EXCLUDE: undefined }, () => {
|
||||
expect(resolveDeriveExcludes()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extract_facts skips excluded-prefix pages (#2780)', () => {
|
||||
test('excluded slug never reaches getPage', async () => {
|
||||
const fetched: string[] = [];
|
||||
const engine = {
|
||||
executeRaw: async (sql: string) => {
|
||||
if (sql.includes('row_num IS NULL')) return [{ n: '0' }];
|
||||
return [];
|
||||
},
|
||||
getPage: async (slug: string) => { fetched.push(slug); return null; },
|
||||
} as unknown as BrainEngine;
|
||||
|
||||
const result = await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/' }, () =>
|
||||
runExtractFacts(engine, { slugs: ['private/journal', 'people/alice-example'], dryRun: true }),
|
||||
);
|
||||
expect(fetched).toEqual(['people/alice-example']);
|
||||
expect(result.pagesScanned).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extract_takes skips excluded-prefix pages (#2780)', () => {
|
||||
test('db path: excluded slug never reaches getPage', async () => {
|
||||
const fetched: string[] = [];
|
||||
const engine = {
|
||||
getPage: async (slug: string) => { fetched.push(slug); return null; },
|
||||
} as unknown as BrainEngine;
|
||||
|
||||
const result = await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/' }, () =>
|
||||
extractTakesFromDb(engine, { slugs: ['private/journal', 'people/alice-example'], dryRun: true }),
|
||||
);
|
||||
expect(fetched).toEqual(['people/alice-example']);
|
||||
expect(result.pagesScanned).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chronicle extract skips excluded-prefix pages (#2780)', () => {
|
||||
test('excluded slug returns skipped/excluded_prefix without reading the page', async () => {
|
||||
let getPageCalled = false;
|
||||
const engine = {
|
||||
getPage: async () => { getPageCalled = true; return null; },
|
||||
} as unknown as BrainEngine;
|
||||
|
||||
const result = await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/' }, () =>
|
||||
runChronicleExtract(engine, { slug: 'private/journal' }),
|
||||
);
|
||||
expect(result.status).toBe('skipped');
|
||||
expect(result.reason).toBe('excluded_prefix');
|
||||
expect(getPageCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
discoverExtractablePages,
|
||||
} from '../src/core/cycle/extract-atoms.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import type { ChatOpts, ChatResult } from '../src/core/ai/gateway.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
@@ -254,6 +255,25 @@ describe('v0.41.2.1: runPhaseExtractAtoms — dual-source merge + idempotency',
|
||||
expect(result.details?.pages_processed).toBe(1);
|
||||
});
|
||||
|
||||
test('#2780: excluded-prefix pages are never atomized (privacy)', async () => {
|
||||
const chat = stubChatUnique();
|
||||
const result = await withEnv({ GBRAIN_SEARCH_EXCLUDE: 'private/' }, () =>
|
||||
runPhaseExtractAtoms(engine, {
|
||||
_transcripts: [],
|
||||
_pages: [
|
||||
{ slug: 'private/journal', content: 'secret content', contentHash: 'privhash123456789a' },
|
||||
{ slug: 'meeting/pub', content: 'public content', contentHash: 'pubhash1234567890b' },
|
||||
],
|
||||
_chat: chat,
|
||||
}));
|
||||
expect(result.details?.pages_processed).toBe(1);
|
||||
const rows = await engine.executeRaw<{ frontmatter: Record<string, unknown> }>(
|
||||
`SELECT frontmatter FROM pages WHERE type = 'atom'`,
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0].frontmatter.source_slug).toBe('meeting/pub');
|
||||
});
|
||||
|
||||
test('atom frontmatter: page-origin uses source_slug, transcript-origin uses source_path', async () => {
|
||||
// Use stubChatUnique so the two work-items write to distinct slugs;
|
||||
// a constant title would upsert into one slug and mask one origin.
|
||||
|
||||
@@ -363,6 +363,45 @@ describe('verifyAccessToken', () => {
|
||||
expect(authInfo.sourceId).toBe('default');
|
||||
expect(authInfo.allowedSources).toEqual(['default', 'src-a', 'src-b']);
|
||||
});
|
||||
|
||||
test('legacy access_tokens fallback honors permissions.takes_holders (#2529)', async () => {
|
||||
await sql`
|
||||
ALTER TABLE access_tokens
|
||||
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb
|
||||
`;
|
||||
|
||||
const legacyToken = generateToken('gbrain_');
|
||||
const hash = hashToken(legacyToken);
|
||||
await sql`
|
||||
INSERT INTO access_tokens (id, name, token_hash, permissions)
|
||||
VALUES (
|
||||
${crypto.randomUUID()},
|
||||
${'legacy-takes-agent'},
|
||||
${hash},
|
||||
${JSON.stringify({ takes_holders: ['self', 'world'] })}::jsonb
|
||||
)
|
||||
`;
|
||||
|
||||
const authInfo = await provider.verifyAccessToken(legacyToken) as CoreAuthInfo;
|
||||
expect(authInfo.takesHoldersAllowList).toEqual(['self', 'world']);
|
||||
});
|
||||
|
||||
test('legacy access_tokens without a takes_holders grant default to world (#2529)', async () => {
|
||||
await sql`
|
||||
ALTER TABLE access_tokens
|
||||
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '{"takes_holders":["world"]}'::jsonb
|
||||
`;
|
||||
|
||||
const legacyToken = generateToken('gbrain_');
|
||||
const hash = hashToken(legacyToken);
|
||||
await sql`
|
||||
INSERT INTO access_tokens (id, name, token_hash, permissions)
|
||||
VALUES (${crypto.randomUUID()}, ${'legacy-nogrant-agent'}, ${hash}, ${'{}'}::jsonb)
|
||||
`;
|
||||
|
||||
const authInfo = await provider.verifyAccessToken(legacyToken) as CoreAuthInfo;
|
||||
expect(authInfo.takesHoldersAllowList).toEqual(['world']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// #2078 + #2079 — `gbrain takes` CLI regressions:
|
||||
// #2078: cmdSupersede looked up its target with active:false, so
|
||||
// superseding any ACTIVE take failed "Row #N not found".
|
||||
// #2079: `gbrain takes list` parsed "list" as a page slug and printed
|
||||
// "No takes on list." — now a reserved subcommand.
|
||||
//
|
||||
// Hermetic: mock engine, no DB. Env-mutating (GBRAIN_HOME) via withEnv.
|
||||
|
||||
import { afterEach, describe, expect, test, spyOn } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runTakes } from '../src/commands/takes.ts';
|
||||
import type { BrainEngine, Take, TakesListOpts } from '../src/core/engine.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
const tmpRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of tmpRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function fakeTake(overrides: Partial<Take> = {}): Take {
|
||||
return {
|
||||
id: 1, page_id: 11, page_slug: 'ideas/widget-co', row_num: 1,
|
||||
claim: 'widget-co will reach PMF', kind: 'bet', holder: 'self',
|
||||
weight: 0.6, since_date: null, until_date: null, source: null,
|
||||
superseded_by: null, active: true, resolved_at: null,
|
||||
resolved_outcome: null, resolved_quality: null, resolved_value: null,
|
||||
resolved_unit: null, resolved_source: null, resolved_by: null,
|
||||
} as Take;
|
||||
}
|
||||
|
||||
function makeEngine() {
|
||||
const listCalls: TakesListOpts[] = [];
|
||||
const supersedeCalls: unknown[][] = [];
|
||||
const engine = {
|
||||
getConfig: async () => null,
|
||||
executeRaw: async (sql: string, params: unknown[] = []) => {
|
||||
if (sql.includes('FROM sources WHERE id = $1')) return [{ id: params[0] as string }];
|
||||
if (sql.includes('FROM sources')) return [];
|
||||
if (sql.includes('FROM pages WHERE slug = $1 AND source_id = $2')) return [{ id: 11 }];
|
||||
if (sql.includes('FROM pages WHERE slug = $1 LIMIT 1')) return [{ id: 11 }];
|
||||
return [];
|
||||
},
|
||||
listTakes: async (opts: TakesListOpts = {}) => {
|
||||
listCalls.push(opts);
|
||||
// Only the ACTIVE row #1 exists (the #2078 scenario).
|
||||
return opts.active === true ? [fakeTake()] : [];
|
||||
},
|
||||
supersedeTake: async (...args: unknown[]) => {
|
||||
supersedeCalls.push(args);
|
||||
return { oldRow: 1, newRow: 2 };
|
||||
},
|
||||
} as unknown as BrainEngine;
|
||||
return { engine, listCalls, supersedeCalls };
|
||||
}
|
||||
|
||||
describe('takes supersede targets ACTIVE rows (#2078)', () => {
|
||||
test('superseding an active take succeeds instead of "Row #N not found"', async () => {
|
||||
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-takes-sup-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'gbrain-takes-sup-home-'));
|
||||
tmpRoots.push(brainDir, home);
|
||||
const { engine, listCalls, supersedeCalls } = makeEngine();
|
||||
const exitSpy = spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('EXIT');
|
||||
}) as never);
|
||||
try {
|
||||
await withEnv({ GBRAIN_HOME: home, GBRAIN_SOURCE: undefined }, async () => {
|
||||
await runTakes(engine, [
|
||||
'supersede', 'ideas/widget-co', '--row', '1',
|
||||
'--claim', 'widget-co pivoted', '--dir', brainDir,
|
||||
]);
|
||||
});
|
||||
// Target lookup must ask for ACTIVE takes.
|
||||
expect(listCalls[0]?.active).toBe(true);
|
||||
expect(supersedeCalls.length).toBe(1);
|
||||
expect(supersedeCalls[0]?.[0]).toBe(11); // pageId
|
||||
expect(supersedeCalls[0]?.[1]).toBe(1); // rowNum
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('already-superseded row yields a clear error, not "not found"', async () => {
|
||||
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-takes-sup2-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'gbrain-takes-sup2-home-'));
|
||||
tmpRoots.push(brainDir, home);
|
||||
const listCalls: TakesListOpts[] = [];
|
||||
const engine = {
|
||||
getConfig: async () => null,
|
||||
executeRaw: async (sql: string, params: unknown[] = []) => {
|
||||
if (sql.includes('FROM sources WHERE id = $1')) return [{ id: params[0] as string }];
|
||||
if (sql.includes('FROM sources')) return [];
|
||||
if (sql.includes('FROM pages WHERE slug = $1')) return [{ id: 11 }];
|
||||
return [];
|
||||
},
|
||||
listTakes: async (opts: TakesListOpts = {}) => {
|
||||
listCalls.push(opts);
|
||||
// Row #1 exists only in the superseded set.
|
||||
return opts.active === false ? [fakeTake({ active: false })] : [];
|
||||
},
|
||||
supersedeTake: async () => { throw new Error('must not be called'); },
|
||||
} as unknown as BrainEngine;
|
||||
const exitSpy = spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('EXIT');
|
||||
}) as never);
|
||||
const errSpy = spyOn(console, 'error');
|
||||
try {
|
||||
await expect(withEnv({ GBRAIN_HOME: home, GBRAIN_SOURCE: undefined }, async () => {
|
||||
await runTakes(engine, [
|
||||
'supersede', 'ideas/widget-co', '--row', '1',
|
||||
'--claim', 'newer claim', '--dir', brainDir,
|
||||
]);
|
||||
})).rejects.toThrow('EXIT');
|
||||
const messages = errSpy.mock.calls.map(c => String(c[0]));
|
||||
expect(messages.some(m => m.includes('already superseded'))).toBe(true);
|
||||
} finally {
|
||||
errSpy.mockRestore();
|
||||
exitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('takes list is a reserved subcommand (#2079)', () => {
|
||||
test('`takes list` lists brain-wide, not a page named "list"', async () => {
|
||||
const { engine, listCalls } = makeEngine();
|
||||
await runTakes(engine, ['list', '--json']);
|
||||
expect(listCalls.length).toBe(1);
|
||||
expect(listCalls[0]?.page_slug).toBeUndefined();
|
||||
expect(listCalls[0]?.active).toBe(true);
|
||||
});
|
||||
|
||||
test('`takes list <slug>` scopes to the page', async () => {
|
||||
const { engine, listCalls } = makeEngine();
|
||||
await runTakes(engine, ['list', 'ideas/widget-co', '--json']);
|
||||
expect(listCalls[0]?.page_slug).toBe('ideas/widget-co');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user