mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
* fix(schema): make bundled pack inspection truthful (#2029) Two live bugs: - parseYamlMini had no block-scalar support, so a 'description: |' swallowed every following top-level key — the active gbrain-recommended pack loaded with 0 page types. Add parseBlockScalar for |/|-/|+ and >/>-/>+ in both mapping and sequence-sibling positions. - The bundled-pack list was hand-copied in three places (operations.ts had 2 names, mutate.ts had 3, load-active.ts had 7). New single registry src/core/schema-pack/bundled.ts carries all 7 shipped packs; every consumer derives from it. Takeover of #2029, rebased onto master (schema.ts hunks already landed). Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(minions): subagent default client resolves config-stored Anthropic key (#2048) The legacy subagent path constructed a bare new Anthropic() (env-only), so launchd/MCP workers whose key lives in gbrain config (anthropic_api_key) failed auth. anthropic-key.ts now exports resolveAnthropicKey() (env first, then config; hasAnthropicKey delegates) and makeSubagentHandler passes it as apiKey. Partial takeover of #2048 — only the auth patch; the path patches were superseded by the outputRoot mechanism (#2415). Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): point bundled-registry test at bundled.ts source of truth The bundled pack list moved from load-active.ts to bundled.ts in the truthful-inspection refactor; the T4 registry test still grepped load-active.ts source. Assert BUNDLED_PACK_NAMES directly instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(schema): block scalars keep '#' as literal content Inside a YAML block scalar '#' is content, not a comment; parseBlockScalar was routing lines through stripComment/isBlank, truncating descriptions like 'see issue #2029' and blanking comment-looking lines. Use the raw line inside the scalar. Adds a pinning test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
// v0.40.6.0 — MCP op contract tests for the 9 new schema-pack operations.
|
|
//
|
|
// Verifies: scope declarations, ctx routing, source-scoping, atomic
|
|
// batched mutations via schema_apply_mutations (D10 + codex F2), and
|
|
// the audit-log actor capture for MCP clients (D2 + D20).
|
|
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { operationsByName } from '../src/core/operations.ts';
|
|
import {
|
|
__setPackLocatorForTests,
|
|
_resetPackLocatorForTests,
|
|
} from '../src/core/schema-pack/load-active.ts';
|
|
import { _resetPackCacheForTests } from '../src/core/schema-pack/registry.ts';
|
|
import type { OperationContext } from '../src/core/operations.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpDir: string;
|
|
let auditDir: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
_resetPackCacheForTests();
|
|
_resetPackLocatorForTests();
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-ops-schema-test-'));
|
|
auditDir = mkdtempSync(join(tmpdir(), 'gbrain-ops-schema-audit-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const d of [tmpDir, auditDir]) {
|
|
try { rmSync(d, { recursive: true, force: true }); } catch { /* swallow */ }
|
|
}
|
|
});
|
|
|
|
function ctxOf(opts: { remote?: boolean; clientId?: string; sourceId?: string } = {}): OperationContext {
|
|
return {
|
|
engine,
|
|
config: {},
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote: opts.remote ?? true,
|
|
sourceId: opts.sourceId,
|
|
auth: opts.clientId ? { clientId: opts.clientId, scopes: ['admin'] } : undefined,
|
|
} as unknown as OperationContext;
|
|
}
|
|
|
|
function seedPack(packName: string): string {
|
|
const dir = join(tmpDir, '.gbrain', 'schema-packs', packName);
|
|
mkdirSync(dir, { recursive: true });
|
|
const path = join(dir, 'pack.yaml');
|
|
writeFileSync(path, `api_version: gbrain-schema-pack-v1
|
|
name: ${packName}
|
|
version: 1.0.0
|
|
description: ""
|
|
gbrain_min_version: 0.38.0
|
|
extends: null
|
|
borrow_from: []
|
|
page_types:
|
|
- name: person
|
|
primitive: entity
|
|
path_prefixes:
|
|
- people/
|
|
aliases: []
|
|
extractable: false
|
|
expert_routing: false
|
|
link_types: []
|
|
frontmatter_links: []
|
|
takes_kinds:
|
|
- fact
|
|
- take
|
|
- bet
|
|
- hunch
|
|
enrichable_types: []
|
|
filing_rules: []
|
|
`, 'utf-8');
|
|
return path;
|
|
}
|
|
|
|
// ── Scope + localOnly declarations ──────────────────────────────────────
|
|
|
|
describe('operation declarations', () => {
|
|
it('get_active_schema_pack is read scope, NOT localOnly', () => {
|
|
const op = operationsByName.get_active_schema_pack!;
|
|
expect(op.scope).toBe('read');
|
|
expect(op.localOnly).toBeUndefined();
|
|
});
|
|
|
|
it('list_schema_packs is read scope, NOT localOnly', () => {
|
|
expect(operationsByName.list_schema_packs!.scope).toBe('read');
|
|
expect(operationsByName.list_schema_packs!.localOnly).toBeUndefined();
|
|
});
|
|
|
|
it('schema_stats / schema_lint / schema_graph / schema_explain_type / schema_review_orphans are all read scope', () => {
|
|
for (const name of ['schema_stats', 'schema_lint', 'schema_graph', 'schema_explain_type', 'schema_review_orphans']) {
|
|
expect(operationsByName[name]!.scope).toBe('read');
|
|
expect(operationsByName[name]!.localOnly).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('schema_apply_mutations is admin scope, NOT localOnly (D2)', () => {
|
|
const op = operationsByName.schema_apply_mutations!;
|
|
expect(op.scope).toBe('admin');
|
|
expect(op.localOnly).toBeUndefined();
|
|
expect(op.mutating).toBe(true);
|
|
});
|
|
|
|
it('reload_schema_pack is admin scope, NOT localOnly (D2)', () => {
|
|
const op = operationsByName.reload_schema_pack!;
|
|
expect(op.scope).toBe('admin');
|
|
expect(op.localOnly).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── get_active_schema_pack ──────────────────────────────────────────────
|
|
|
|
describe('get_active_schema_pack', () => {
|
|
it('returns identity packet for the bundled gbrain-base pack', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
const result = await operationsByName.get_active_schema_pack!.handler(ctxOf(), {}) as Record<string, unknown>;
|
|
expect(result.pack_name).toBe('gbrain-base');
|
|
expect(result.page_types_count).toBeGreaterThan(0);
|
|
expect(typeof result.sha8).toBe('string');
|
|
expect(typeof result.source_tier).toBe('string');
|
|
expect(typeof result.primitive_summary).toBe('object');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── list_schema_packs ──────────────────────────────────────────────────
|
|
|
|
describe('list_schema_packs', () => {
|
|
it('returns bundled + installed', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir }, async () => {
|
|
seedPack('mine');
|
|
const result = await operationsByName.list_schema_packs!.handler(ctxOf(), {}) as { bundled: string[]; installed: string[] };
|
|
expect(result.bundled).toContain('gbrain-base');
|
|
expect(result.bundled).toContain('gbrain-recommended');
|
|
expect(result.bundled).toContain('gbrain-base-v2');
|
|
expect(result.bundled).toContain('gbrain-investor');
|
|
expect(result.installed).toContain('mine');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── schema_stats ───────────────────────────────────────────────────────
|
|
|
|
describe('schema_stats', () => {
|
|
it('returns coverage + per-source breakdown', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, source_path, type, title, compiled_truth, timeline, content_hash)
|
|
VALUES ('a', 'default', 'people/alice.md', 'person', 'a', '', '', '')`,
|
|
);
|
|
const result = await operationsByName.schema_stats!.handler(ctxOf(), {}) as Record<string, unknown>;
|
|
expect((result.aggregate as { total_pages: number }).total_pages).toBe(1);
|
|
expect(result.schema_version).toBe(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── schema_lint ─────────────────────────────────────────────────────────
|
|
|
|
describe('schema_lint', () => {
|
|
it('lints the active pack and returns ok=true for clean pack', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
const result = await operationsByName.schema_lint!.handler(ctxOf(), {}) as Record<string, unknown>;
|
|
expect(result.ok).toBe(true);
|
|
expect(Array.isArray(result.errors)).toBe(true);
|
|
expect(Array.isArray(result.warnings)).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('returns pack_not_found for unknown pack', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir }, async () => {
|
|
const result = await operationsByName.schema_lint!.handler(ctxOf(), { pack: 'nonexistent' }) as Record<string, unknown>;
|
|
expect(result.error).toBe('pack_not_found');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── schema_graph ──────────────────────────────────────────────────────
|
|
|
|
describe('schema_graph', () => {
|
|
it('returns nodes + edges from link_types', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
const result = await operationsByName.schema_graph!.handler(ctxOf(), {}) as Record<string, unknown>;
|
|
expect(Array.isArray(result.nodes)).toBe(true);
|
|
expect(Array.isArray(result.edges)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── schema_explain_type ────────────────────────────────────────────────
|
|
|
|
describe('schema_explain_type', () => {
|
|
it('returns settings for a known type', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
const result = await operationsByName.schema_explain_type!.handler(ctxOf(), { type: 'person' }) as Record<string, unknown>;
|
|
expect((result.type as { name?: string }).name).toBe('person');
|
|
});
|
|
});
|
|
|
|
it('returns type_not_found for unknown type', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_SCHEMA_PACK: undefined }, async () => {
|
|
const result = await operationsByName.schema_explain_type!.handler(ctxOf(), { type: 'ghost' }) as Record<string, unknown>;
|
|
expect(result.error).toBe('type_not_found');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── schema_review_orphans ──────────────────────────────────────────────
|
|
|
|
describe('schema_review_orphans', () => {
|
|
it('returns untyped pages from the DB', async () => {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, source_path, type, title, compiled_truth, timeline, content_hash)
|
|
VALUES ('orphan-1', 'default', 'unknown/page.md', '', 'o', '', '', '')`,
|
|
);
|
|
const result = await operationsByName.schema_review_orphans!.handler(ctxOf(), {}) as Record<string, unknown>;
|
|
expect(result.orphan_count).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('respects limit param', async () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
await engine.executeRaw(
|
|
`INSERT INTO pages (slug, source_id, source_path, type, title, compiled_truth, timeline, content_hash)
|
|
VALUES ($1, 'default', $2, '', $1, '', '', '')`,
|
|
[`o${i}`, `unknown/o${i}.md`],
|
|
);
|
|
}
|
|
const result = await operationsByName.schema_review_orphans!.handler(ctxOf(), { limit: 2 }) as Record<string, unknown>;
|
|
expect(result.orphan_count).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ── schema_apply_mutations — batched + atomic ──────────────────────────
|
|
|
|
describe('schema_apply_mutations', () => {
|
|
it('applies a single add_type mutation', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => {
|
|
seedPack('mine');
|
|
const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), {
|
|
pack: 'mine',
|
|
mutations: [
|
|
{ op: 'add_type', name: 'researcher', primitive: 'entity', prefix: 'people/researchers/', extractable: true },
|
|
],
|
|
}) as Record<string, unknown>;
|
|
expect(result.mutations_applied).toBe(1);
|
|
expect(result.batch_id).toBeDefined();
|
|
});
|
|
});
|
|
|
|
it('applies multiple mutations atomically (one batch_id across all)', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => {
|
|
seedPack('mine');
|
|
const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), {
|
|
pack: 'mine',
|
|
mutations: [
|
|
{ op: 'add_type', name: 'researcher', primitive: 'entity', prefix: 'people/researchers/' },
|
|
{ op: 'add_type', name: 'company', primitive: 'entity', prefix: 'companies/' },
|
|
{ op: 'add_link_type', name: 'works_at', inference: { page_type: 'researcher', target_type: 'company' } },
|
|
],
|
|
}) as Record<string, unknown>;
|
|
expect(result.mutations_applied).toBe(3);
|
|
const results = result.results as Array<{ index: number }>;
|
|
expect(results.length).toBe(3);
|
|
expect(results.map((r) => r.index)).toEqual([0, 1, 2]);
|
|
});
|
|
});
|
|
|
|
it('returns partial_results on mid-batch failure with a single batch_id', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => {
|
|
seedPack('mine');
|
|
const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), {
|
|
pack: 'mine',
|
|
mutations: [
|
|
{ op: 'add_type', name: 'company', primitive: 'entity', prefix: 'companies/' },
|
|
{ op: 'add_type', name: 'person', primitive: 'entity', prefix: 'people/' }, // collides with seed
|
|
],
|
|
}) as Record<string, unknown>;
|
|
expect(result.error).toBe('mutation_failed');
|
|
const partial = result.partial_results as Array<unknown>;
|
|
expect(partial.length).toBe(1); // first mutation succeeded
|
|
});
|
|
});
|
|
|
|
it('rejects unknown op with INVALID_RESULT', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => {
|
|
seedPack('mine');
|
|
const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), {
|
|
pack: 'mine',
|
|
mutations: [{ op: 'nonexistent_op', name: 'x' }],
|
|
}) as Record<string, unknown>;
|
|
expect(result.error).toBe('mutation_failed');
|
|
});
|
|
});
|
|
|
|
it('rejects empty mutations array', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => {
|
|
seedPack('mine');
|
|
const result = await operationsByName.schema_apply_mutations!.handler(ctxOf(), {
|
|
pack: 'mine',
|
|
mutations: [],
|
|
}) as Record<string, unknown>;
|
|
expect(result.error).toBe('invalid_request');
|
|
});
|
|
});
|
|
|
|
it('captures MCP client_id in audit log actor field (D2 + D20)', async () => {
|
|
await withEnv({ GBRAIN_HOME: tmpDir, GBRAIN_AUDIT_DIR: auditDir }, async () => {
|
|
seedPack('mine');
|
|
await operationsByName.schema_apply_mutations!.handler(
|
|
ctxOf({ clientId: 'remoteAgentClient12345678' }),
|
|
{
|
|
pack: 'mine',
|
|
mutations: [{ op: 'add_type', name: 'researcher', primitive: 'entity', prefix: 'r/' }],
|
|
},
|
|
);
|
|
// Audit log filename pattern: schema-mutations-YYYY-Www.jsonl.
|
|
const { readdirSync } = await import('node:fs');
|
|
const auditFiles = readdirSync(auditDir).filter((f) => f.startsWith('schema-mutations-'));
|
|
expect(auditFiles.length).toBeGreaterThan(0);
|
|
const auditPath = join(auditDir, auditFiles[0]!);
|
|
const lines = readFileSync(auditPath, 'utf-8').trim().split('\n');
|
|
const records = lines.map((l) => JSON.parse(l));
|
|
// Actor is mcp:<first 8 chars of clientId> = mcp:remoteAg (8 chars).
|
|
expect(records.some((r) => r.actor === 'mcp:remoteAg')).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── reload_schema_pack ────────────────────────────────────────────────
|
|
|
|
describe('reload_schema_pack', () => {
|
|
it('returns invalidated list', async () => {
|
|
const result = await operationsByName.reload_schema_pack!.handler(ctxOf(), {}) as { invalidated: string[] };
|
|
expect(Array.isArray(result.invalidated)).toBe(true);
|
|
});
|
|
|
|
it('invalidates a specific pack by name', async () => {
|
|
const result = await operationsByName.reload_schema_pack!.handler(ctxOf(), { pack: 'foo' }) as { invalidated: string[] };
|
|
expect(result.invalidated).toContain('foo');
|
|
});
|
|
});
|