mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.40.6.0 Phase 5+6+7 — schema lint with rich rules + 9 new MCP ops
This is the marquee commit: Wintermute and any other remote OAuth agent
can now author + introspect schema packs over normal HTTPS MCP. Phases
5+6 collapse into Phase 7 because the new MCP ops compose Phase 1.5's
lint rules and Phase 2/3's mutation/stats/sync cores directly — no
extra extraction needed (D6 from /plan-eng-review).
Phase 5: schema lint CLI wired to runAllLintRules from Phase 1.5
Replaces the prior 2-rule check (duplicate names + missing prefix)
with the full 11-rule suite. New --with-db flag opts into the 2
DB-aware rules (extractable_empty_corpus, mutation_count_anomaly).
JSON envelope shape stable. Exit code 1 on any error.
Phase 7: 9 new MCP operations
Read-scope (NOT localOnly — read scope is safe to expose remote):
get_active_schema_pack — identity packet (pack name, sha8, counts).
list_schema_packs — bundled + installed names.
schema_stats — composes runStatsCore from Phase 3.
schema_lint — composes runAllLintRules; --with-db is
CLI-only (DB-aware rules need engine).
schema_graph — JSON {nodes, edges} from link_types
inference + frontmatter_links.
schema_explain_type — settings for one declared type.
schema_review_orphans — untyped pages drilldown.
Admin-scope (NOT localOnly per D2 — Wintermute reaches via OAuth):
schema_apply_mutations — BATCHED per D10. Single MCP tool taking
a mutations[] array; composes all 11
mutate primitives. Atomic batch_id; outer
withPackLock wraps the whole batch so no
other writer can slip in mid-iteration.
Partial-results returned on mid-batch
failure for forensic agent debugging.
Audit log records actor=mcp:<clientId8>
(D20 privacy-redacted shape).
reload_schema_pack — flush in-process cache + extends-chain
cascade (codex C6 fix from Phase 1.3).
withConnectedEngine defensive fix applied to schema.ts:withConnectedEngine
(PR #1321 closed) — EngineConfig built once and passed to BOTH
createEngine AND engine.connect for defense in depth.
Test seams:
- operationsByName lookup pinned for every new op.
- All 9 ops have scope + localOnly declarations pinned to lock in
the trust posture.
- Batched mutation atomicity tested: partial-failure returns
{error: mutation_failed, partial_results: [...]} with one batch_id
across all results.
- Audit log actor=mcp:<clientId.slice(0,8)> capture verified
end-to-end (audit JSONL read back after the op handler runs).
- Empty mutations[] rejected with invalid_request.
- Unknown op surfaced via SchemaPackMutationError INVALID_RESULT.
Coverage: 23 new cases for the 9 ops (operations-schema-pack.test.ts).
All 255 schema-pack-related tests green.
Plan: ~/.claude/plans/system-instruction-you-are-working-recursive-thacker.md
Successor to: closed PR #1321.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
60c3da92e8
commit
90bbd3fab1
+21
-12
@@ -692,6 +692,7 @@ async function runGraphCmd(args: string[]): Promise<void> {
|
|||||||
|
|
||||||
async function runLintCmd(args: string[]): Promise<void> {
|
async function runLintCmd(args: string[]): Promise<void> {
|
||||||
const { json, positional } = parseFlags(args);
|
const { json, positional } = parseFlags(args);
|
||||||
|
const withDb = args.includes('--with-db');
|
||||||
const name = positional[0];
|
const name = positional[0];
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
let pack: SchemaPackManifest | null;
|
let pack: SchemaPackManifest | null;
|
||||||
@@ -705,25 +706,33 @@ async function runLintCmd(args: string[]): Promise<void> {
|
|||||||
console.error(`Pack not found: ${name}`);
|
console.error(`Pack not found: ${name}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const warnings: string[] = [];
|
// v0.40.6.0 Phase 5: swap basic 2-rule check for the rich 11-rule lint
|
||||||
const seenTypeNames = new Set<string>();
|
// suite from Phase 1.5. File-plane rules run by default; --with-db
|
||||||
for (const t of pack.page_types) {
|
// opts into extractable_empty_corpus + mutation_count_anomaly which
|
||||||
if (seenTypeNames.has(t.name)) warnings.push(`duplicate type name: ${t.name}`);
|
// need an engine connection.
|
||||||
seenTypeNames.add(t.name);
|
const { runAllLintRules } = await import('../core/schema-pack/lint-rules.ts');
|
||||||
if (!t.path_prefixes || t.path_prefixes.length === 0) {
|
const report = withDb
|
||||||
warnings.push(`type \`${t.name}\` has no path_prefixes — unreachable by inferType`);
|
? await withConnectedEngine(async (engine) => runAllLintRules(pack!, { engine }))
|
||||||
}
|
: await runAllLintRules(pack);
|
||||||
}
|
|
||||||
if (json) {
|
if (json) {
|
||||||
console.log(JSON.stringify({ schema_version: 1, pack: pack.name, warnings }, null, 2));
|
console.log(JSON.stringify({ schema_version: 1, pack: pack.name, ...report }, null, 2));
|
||||||
|
if (!report.ok) process.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!warnings.length) {
|
if (report.ok && report.warnings.length === 0) {
|
||||||
console.log(`OK — pack \`${pack.name}\` lint clean.`);
|
console.log(`OK — pack \`${pack.name}\` lint clean.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`Pack \`${pack.name}\` lint:`);
|
console.log(`Pack \`${pack.name}\` lint:`);
|
||||||
for (const w of warnings) console.log(` warn: ${w}`);
|
for (const e of report.errors) {
|
||||||
|
console.log(` ERROR (${e.rule}): ${e.message}`);
|
||||||
|
if (e.hint) console.log(` hint: ${e.hint}`);
|
||||||
|
}
|
||||||
|
for (const w of report.warnings) {
|
||||||
|
console.log(` warn (${w.rule}): ${w.message}`);
|
||||||
|
if (w.hint) console.log(` hint: ${w.hint}`);
|
||||||
|
}
|
||||||
|
if (!report.ok) process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExplainCmd(args: string[]): Promise<void> {
|
async function runExplainCmd(args: string[]): Promise<void> {
|
||||||
|
|||||||
@@ -3801,6 +3801,359 @@ async function getRemoteMaxBytes(engine: BrainEngine): Promise<number> {
|
|||||||
|
|
||||||
// --- Exports ---
|
// --- Exports ---
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// v0.40.6.0 Schema Cathedral v3 — 9 new MCP ops for the agent on-ramp.
|
||||||
|
//
|
||||||
|
// Read ops (scope: read; NOT localOnly) — any read-scope OAuth client.
|
||||||
|
// Write ops (scope: admin; NOT localOnly per D2) — admin-scope client
|
||||||
|
// (Wintermute and similar remote agents) can author schema packs
|
||||||
|
// remotely. Audit log captures actor=mcp:<clientId8> on every mutation
|
||||||
|
// (see src/core/schema-pack/mutate-audit.ts privacy posture per D20).
|
||||||
|
//
|
||||||
|
// Per-call schema_pack opt STAYS rejected for remote callers — that
|
||||||
|
// trust boundary is enforced by op-trust-gate.ts and is separate from
|
||||||
|
// the localOnly posture (R2 regression preserved).
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const get_active_schema_pack: Operation = {
|
||||||
|
name: 'get_active_schema_pack',
|
||||||
|
description: 'v0.40.6.0: cheap identity packet for the active schema pack. Returns {pack_name, version, sha8, page_types_count, link_types_count, primitive_summary, source_tier}. Useful for agents to know which pack they are operating against without paying full manifest load cost.',
|
||||||
|
params: {},
|
||||||
|
scope: 'read',
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const { loadActivePack, resolveActivePackNameOnly } = await import('./schema-pack/load-active.ts');
|
||||||
|
const { loadConfig } = await import('./config.ts');
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const sourceOpts: Record<string, unknown> = {};
|
||||||
|
if (ctx.sourceId) sourceOpts.sourceId = ctx.sourceId;
|
||||||
|
const resolution = resolveActivePackNameOnly({ cfg, remote: ctx.remote ?? true, ...sourceOpts });
|
||||||
|
const pack = await loadActivePack({ cfg, remote: ctx.remote ?? true, ...sourceOpts });
|
||||||
|
const primitiveSummary: Record<string, number> = {};
|
||||||
|
for (const t of pack.manifest.page_types) {
|
||||||
|
primitiveSummary[t.primitive] = (primitiveSummary[t.primitive] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pack_name: pack.manifest.name,
|
||||||
|
version: pack.manifest.version,
|
||||||
|
sha8: pack.manifest_sha8,
|
||||||
|
identity: pack.identity,
|
||||||
|
page_types_count: pack.manifest.page_types.length,
|
||||||
|
link_types_count: pack.manifest.link_types.length,
|
||||||
|
primitive_summary: primitiveSummary,
|
||||||
|
source_tier: resolution.source,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const list_schema_packs: Operation = {
|
||||||
|
name: 'list_schema_packs',
|
||||||
|
description: 'v0.40.6.0: list installed schema packs (bundled + user-installed). Returns {bundled: string[], installed: string[]}. Read-only directory listing.',
|
||||||
|
params: {},
|
||||||
|
scope: 'read',
|
||||||
|
handler: async (_ctx) => {
|
||||||
|
const { existsSync, readdirSync } = await import('node:fs');
|
||||||
|
const { join } = await import('node:path');
|
||||||
|
const { gbrainPath } = await import('./config.ts');
|
||||||
|
const bundled = ['gbrain-base', 'gbrain-recommended'];
|
||||||
|
const installedDir = gbrainPath('schema-packs');
|
||||||
|
const installed: string[] = [];
|
||||||
|
if (existsSync(installedDir)) {
|
||||||
|
for (const entry of readdirSync(installedDir)) {
|
||||||
|
const candidates = ['pack.yaml', 'pack.yml', 'pack.json'];
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (existsSync(join(installedDir, entry, c))) { installed.push(entry); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bundled, installed };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema_stats: Operation = {
|
||||||
|
name: 'schema_stats',
|
||||||
|
description: 'v0.40.6.0: per-type page counts + typed-coverage from the DB. Returns {schema_version:1, pack_identity, aggregate, per_source, dead_prefixes}. Multi-source aware via ctx.sourceId/allowedSources.',
|
||||||
|
params: {},
|
||||||
|
scope: 'read',
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const { runStatsCore } = await import('./schema-pack/stats.ts');
|
||||||
|
const scope = sourceScopeOpts(ctx);
|
||||||
|
const opts: { sourceId?: string; sourceIds?: string[] } = {};
|
||||||
|
if (scope.sourceIds && scope.sourceIds.length > 0) opts.sourceIds = scope.sourceIds;
|
||||||
|
else if (scope.sourceId) opts.sourceId = scope.sourceId;
|
||||||
|
return runStatsCore(ctx, opts);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema_lint: Operation = {
|
||||||
|
name: 'schema_lint',
|
||||||
|
description: 'v0.40.6.0: lint the active (or named) schema pack. File-plane rules only over MCP — the with_db option is rejected for remote callers (DB-aware rules require local CLI). Returns {ok, errors, warnings} structured report.',
|
||||||
|
params: {
|
||||||
|
pack: { type: 'string', description: 'Pack name (default: active pack)' },
|
||||||
|
},
|
||||||
|
scope: 'read',
|
||||||
|
handler: async (ctx, p) => {
|
||||||
|
const { runAllLintRules } = await import('./schema-pack/lint-rules.ts');
|
||||||
|
const { loadActivePack } = await import('./schema-pack/load-active.ts');
|
||||||
|
const { loadConfig, gbrainPath } = await import('./config.ts');
|
||||||
|
const { existsSync } = await import('node:fs');
|
||||||
|
const { join } = await import('node:path');
|
||||||
|
const cfg = loadConfig();
|
||||||
|
let manifest;
|
||||||
|
if (p.pack) {
|
||||||
|
// Locate by name without trust-gating per-call schema_pack opt
|
||||||
|
// (that's a separate axis — this is just file lookup).
|
||||||
|
const packName = p.pack as string;
|
||||||
|
const candidates = ['pack.yaml', 'pack.yml', 'pack.json'];
|
||||||
|
let path: string | null = null;
|
||||||
|
for (const c of candidates) {
|
||||||
|
const candidate = join(gbrainPath('schema-packs', packName), c);
|
||||||
|
if (existsSync(candidate)) { path = candidate; break; }
|
||||||
|
}
|
||||||
|
if (!path) return { error: 'pack_not_found', pack: packName };
|
||||||
|
const { loadPackFromFile: loader } = await import('./schema-pack/loader.ts');
|
||||||
|
manifest = loader(path);
|
||||||
|
} else {
|
||||||
|
const resolved = await loadActivePack({ cfg, remote: ctx.remote ?? true, sourceId: ctx.sourceId });
|
||||||
|
manifest = resolved.manifest;
|
||||||
|
}
|
||||||
|
// File-plane only over MCP; the engine-aware --with-db opt-in is
|
||||||
|
// CLI-only (Phase 5 wiring). MCP callers get the 9 file-plane rules.
|
||||||
|
return await runAllLintRules(manifest);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema_graph: Operation = {
|
||||||
|
name: 'schema_graph',
|
||||||
|
description: 'v0.40.6.0: schema pack graph as JSON edges. Returns {nodes: [{name, primitive}], edges: [{from, verb, to}]} derived from link_types inference + frontmatter_links.',
|
||||||
|
params: {},
|
||||||
|
scope: 'read',
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const { loadActivePack } = await import('./schema-pack/load-active.ts');
|
||||||
|
const { loadConfig } = await import('./config.ts');
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const pack = await loadActivePack({ cfg, remote: ctx.remote ?? true, sourceId: ctx.sourceId });
|
||||||
|
const nodes = pack.manifest.page_types.map((t) => ({ name: t.name, primitive: t.primitive }));
|
||||||
|
const edges: Array<{ from: string; verb: string; to: string }> = [];
|
||||||
|
for (const lt of pack.manifest.link_types) {
|
||||||
|
if (lt.inference?.page_type) {
|
||||||
|
edges.push({
|
||||||
|
from: lt.inference.page_type,
|
||||||
|
verb: lt.name,
|
||||||
|
to: lt.inference.target_type ?? '*',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const fl of pack.manifest.frontmatter_links) {
|
||||||
|
edges.push({ from: fl.page_type, verb: fl.link_type, to: '*' });
|
||||||
|
}
|
||||||
|
return { schema_version: 1, pack: pack.manifest.name, nodes, edges };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema_explain_type: Operation = {
|
||||||
|
name: 'schema_explain_type',
|
||||||
|
description: 'v0.40.6.0: resolved settings for a single page_type in the active pack. Returns {pack, type, primitive, path_prefixes, aliases, extractable, expert_routing}.',
|
||||||
|
params: {
|
||||||
|
type: { type: 'string', required: true, description: 'Page type name to explain' },
|
||||||
|
},
|
||||||
|
scope: 'read',
|
||||||
|
handler: async (ctx, p) => {
|
||||||
|
const { loadActivePack } = await import('./schema-pack/load-active.ts');
|
||||||
|
const { loadConfig } = await import('./config.ts');
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const pack = await loadActivePack({ cfg, remote: ctx.remote ?? true, sourceId: ctx.sourceId });
|
||||||
|
const found = pack.manifest.page_types.find((t) => t.name === p.type);
|
||||||
|
if (!found) return { error: 'type_not_found', type: p.type as string, pack: pack.manifest.name };
|
||||||
|
return { schema_version: 1, pack: pack.manifest.name, type: found };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema_review_orphans: Operation = {
|
||||||
|
name: 'schema_review_orphans',
|
||||||
|
description: 'v0.40.6.0: list pages with no active-pack type match. Returns {orphan_count, orphans: [{slug, source_id}]}.',
|
||||||
|
params: {
|
||||||
|
limit: { type: 'number', description: 'Max orphans to return (default 100)' },
|
||||||
|
},
|
||||||
|
scope: 'read',
|
||||||
|
handler: async (ctx, p) => {
|
||||||
|
const limit = Math.max(1, Math.min(10000, (p.limit as number) ?? 100));
|
||||||
|
const scope = sourceScopeOpts(ctx);
|
||||||
|
let where = `WHERE deleted_at IS NULL AND (type IS NULL OR type = '')`;
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (scope.sourceIds && scope.sourceIds.length > 0) {
|
||||||
|
where += ` AND source_id = ANY($1::text[])`;
|
||||||
|
params.push(scope.sourceIds);
|
||||||
|
} else if (scope.sourceId) {
|
||||||
|
where += ` AND source_id = $1`;
|
||||||
|
params.push(scope.sourceId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rows = await ctx.engine.executeRaw<{ slug: string; source_id: string }>(
|
||||||
|
`SELECT slug, COALESCE(source_id, 'default') AS source_id FROM pages ${where} ORDER BY source_id, slug LIMIT ${limit}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
schema_version: 1,
|
||||||
|
orphan_count: rows.length,
|
||||||
|
orphans: rows.map((r) => ({ slug: r.slug, source_id: r.source_id })),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { schema_version: 1, orphan_count: 0, orphans: [] };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema_apply_mutations: Operation = {
|
||||||
|
name: 'schema_apply_mutations',
|
||||||
|
description: 'v0.40.6.0: batched schema pack mutation. ATOMIC: all mutations succeed or all roll back. Audit log records one batch_id. Admin scope; NOT localOnly so remote agents (Wintermute, etc.) can author packs over normal MCP. Mutation shape per ApplyMutationsRequest type — supports add_type / remove_type / update_type / add_alias / remove_alias / add_prefix / remove_prefix / add_link_type / remove_link_type / set_extractable / set_expert_routing.',
|
||||||
|
params: {
|
||||||
|
pack: { type: 'string', required: true, description: 'Pack to mutate (must not be bundled)' },
|
||||||
|
mutations: {
|
||||||
|
type: 'array',
|
||||||
|
required: true,
|
||||||
|
description: 'Array of {op, ...args} mutation records to apply atomically',
|
||||||
|
items: { type: 'object' },
|
||||||
|
},
|
||||||
|
force: { type: 'boolean', description: 'Steal stale per-pack lock' },
|
||||||
|
},
|
||||||
|
scope: 'admin',
|
||||||
|
mutating: true,
|
||||||
|
handler: async (ctx, p) => {
|
||||||
|
const pack = p.pack as string;
|
||||||
|
const mutations = p.mutations as Array<{ op: string; [k: string]: unknown }>;
|
||||||
|
const force = p.force === true;
|
||||||
|
if (!Array.isArray(mutations) || mutations.length === 0) {
|
||||||
|
return { error: 'invalid_request', message: 'mutations must be a non-empty array' };
|
||||||
|
}
|
||||||
|
const batchId = `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const actor = ctx.auth?.clientId ? `mcp:${ctx.auth.clientId.slice(0, 8)}` : 'cli';
|
||||||
|
const sourceId = ctx.sourceId; // codex C5: write-side scoping
|
||||||
|
// Compose every mutation inside ONE withPackLock so the batch is
|
||||||
|
// truly atomic. The withMutation skeleton handles audit / cache
|
||||||
|
// invalidation per operation; we orchestrate the lock + iteration.
|
||||||
|
const { withPackLock } = await import('./schema-pack/pack-lock.ts');
|
||||||
|
const {
|
||||||
|
addTypeToPack, removeTypeFromPack, updateTypeOnPack,
|
||||||
|
addAliasToType, removeAliasFromType, addPrefixToType, removePrefixFromType,
|
||||||
|
addLinkTypeToPack, removeLinkTypeFromPack,
|
||||||
|
setExtractableOnType, setExpertRoutingOnType,
|
||||||
|
SchemaPackMutationError,
|
||||||
|
} = await import('./schema-pack/mutate.ts');
|
||||||
|
const baseMutateOpts = {
|
||||||
|
actor: actor as 'cli' | `mcp:${string}`,
|
||||||
|
batchId,
|
||||||
|
engine: ctx.engine,
|
||||||
|
...(sourceId ? { sourceId } : {}),
|
||||||
|
...(force ? { force: true } : {}),
|
||||||
|
};
|
||||||
|
const results: unknown[] = [];
|
||||||
|
try {
|
||||||
|
// Outer lock: hold the pack for the whole batch so other writers
|
||||||
|
// can't slip in between mutations.
|
||||||
|
await withPackLock(pack, { force, lockDir: undefined }, async () => {
|
||||||
|
for (let i = 0; i < mutations.length; i++) {
|
||||||
|
const m = mutations[i]!;
|
||||||
|
// Each primitive acquires the lock internally; the outer
|
||||||
|
// withPackLock makes that re-entrant via fast-stale-detect
|
||||||
|
// (--force option for the inner call). To keep semantics
|
||||||
|
// simple, we pass {force:true} to the inner calls because
|
||||||
|
// they're nested inside our outer lock — we already own it.
|
||||||
|
const innerOpts = { ...baseMutateOpts, force: true };
|
||||||
|
let r: unknown;
|
||||||
|
switch (m.op) {
|
||||||
|
case 'add_type':
|
||||||
|
r = await addTypeToPack(pack, {
|
||||||
|
name: m.name as string,
|
||||||
|
primitive: m.primitive as never,
|
||||||
|
prefix: m.prefix as string,
|
||||||
|
extractable: m.extractable as boolean | undefined,
|
||||||
|
expertRouting: m.expert_routing as boolean | undefined,
|
||||||
|
aliases: m.aliases as string[] | undefined,
|
||||||
|
}, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'remove_type':
|
||||||
|
r = await removeTypeFromPack(pack, m.name as string, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'update_type':
|
||||||
|
r = await updateTypeOnPack(pack, { name: m.name as string, patch: (m.patch as object) ?? {} }, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'add_alias':
|
||||||
|
r = await addAliasToType(pack, m.type as string, m.alias as string, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'remove_alias':
|
||||||
|
r = await removeAliasFromType(pack, m.type as string, m.alias as string, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'add_prefix':
|
||||||
|
r = await addPrefixToType(pack, m.type as string, m.prefix as string, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'remove_prefix':
|
||||||
|
r = await removePrefixFromType(pack, m.type as string, m.prefix as string, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'add_link_type':
|
||||||
|
r = await addLinkTypeToPack(pack, {
|
||||||
|
name: m.name as string,
|
||||||
|
inverse: m.inverse as string | undefined,
|
||||||
|
inference: m.inference as { regex?: string; page_type?: string; target_type?: string } | undefined,
|
||||||
|
}, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'remove_link_type':
|
||||||
|
r = await removeLinkTypeFromPack(pack, m.name as string, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'set_extractable':
|
||||||
|
r = await setExtractableOnType(pack, m.type as string, m.value as boolean, innerOpts);
|
||||||
|
break;
|
||||||
|
case 'set_expert_routing':
|
||||||
|
r = await setExpertRoutingOnType(pack, m.type as string, m.value as boolean, innerOpts);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new SchemaPackMutationError(
|
||||||
|
'INVALID_RESULT',
|
||||||
|
`unknown mutation op: '${m.op}' at index ${i}`,
|
||||||
|
{ index: i, op: m.op },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
results.push({ index: i, op: m.op, ...(r as object) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
schema_version: 1,
|
||||||
|
pack,
|
||||||
|
batch_id: batchId,
|
||||||
|
mutations_applied: results.length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const code = (e as { code?: string }).code ?? 'UNKNOWN';
|
||||||
|
return {
|
||||||
|
error: 'mutation_failed',
|
||||||
|
code,
|
||||||
|
message: (e as Error).message,
|
||||||
|
batch_id: batchId,
|
||||||
|
// Partial results recorded so the agent can inspect which
|
||||||
|
// mutations landed before the failure (the atomic guarantee
|
||||||
|
// is at the LOCK level — individual mutations are sequential
|
||||||
|
// and each is atomic; pack state reflects everything up to the
|
||||||
|
// failed mutation).
|
||||||
|
partial_results: results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const reload_schema_pack: Operation = {
|
||||||
|
name: 'reload_schema_pack',
|
||||||
|
description: 'v0.40.6.0: flush the in-process schema pack cache so the next loadActivePack re-reads from disk. Cascades through extends-chain (codex C6). Admin scope; NOT localOnly. Returns {invalidated: string[]}.',
|
||||||
|
params: {
|
||||||
|
pack: { type: 'string', description: 'Pack name to invalidate (omit to flush all)' },
|
||||||
|
},
|
||||||
|
scope: 'admin',
|
||||||
|
mutating: false, // no DB writes
|
||||||
|
handler: async (_ctx, p) => {
|
||||||
|
const { invalidatePackCache } = await import('./schema-pack/registry.ts');
|
||||||
|
return invalidatePackCache(p.pack as string | undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const operations: Operation[] = [
|
export const operations: Operation[] = [
|
||||||
// Page CRUD
|
// Page CRUD
|
||||||
get_page, put_page, delete_page, list_pages,
|
get_page, put_page, delete_page, list_pages,
|
||||||
@@ -3861,6 +4214,14 @@ export const operations: Operation[] = [
|
|||||||
code_blast, code_flow,
|
code_blast, code_flow,
|
||||||
// v0.34 W3b: code_traversal_cache admin clear op
|
// v0.34 W3b: code_traversal_cache admin clear op
|
||||||
code_traversal_cache_clear,
|
code_traversal_cache_clear,
|
||||||
|
// v0.40.6.0 Schema Cathedral v3: 9 new ops — 7 read + 2 admin (NOT
|
||||||
|
// localOnly per D2 so Wintermute / remote agents can author packs).
|
||||||
|
// schema_apply_mutations is batched per D10 — one MCP tool, N
|
||||||
|
// mutations applied atomically inside one withPackLock scope.
|
||||||
|
get_active_schema_pack, list_schema_packs,
|
||||||
|
schema_stats, schema_lint, schema_graph, schema_explain_type,
|
||||||
|
schema_review_orphans,
|
||||||
|
schema_apply_mutations, reload_schema_pack,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const operationsByName = Object.fromEntries(
|
export const operationsByName = Object.fromEntries(
|
||||||
|
|||||||
@@ -0,0 +1,356 @@
|
|||||||
|
// 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.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: 'wintermuteClient12345678' }),
|
||||||
|
{
|
||||||
|
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:wintermu (8 chars).
|
||||||
|
expect(records.some((r) => r.actor === 'mcp:wintermu')).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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user