mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* test: parallel unit-test wrapper + failure-first logging (commit 1/8) Lay foundation for v0.26.4 parallel test loop: - scripts/run-unit-parallel.sh: spawns N shards (default min(8, cpu_count)) via run-unit-shard.sh, captures per-shard logs, post-shard single-writer failure-log aggregation at .context/test-failures.log, 10s heartbeat to stderr, per-shard 600s timeout (gtimeout/timeout/bg-pid fallback chain), loud final banner with absolute path + tail-30 of failures, summary file for at-a-glance status. Single writer eliminates concurrent-write hazards on the failure log. - scripts/run-serial-tests.sh: discovers *.serial.test.ts files (concurrency- unsafe by design), runs them with --max-concurrency=1. Invoked after the parallel pass. - scripts/run-unit-shard.sh: now accepts --max-concurrency=N (forwarded to bun test); --dry-run-list moved into argv parsing alongside; excludes *.serial.test.ts in addition to *.slow.test.ts. - bunfig.toml: trim stale comment about typecheck-chained timeout. - .gitignore: add .context/ (Conductor workspace artifacts directory; the failure log + summary + per-shard logs all live here). No package.json changes yet (commit 2). No test reorganization yet (commits 4-7). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: split package.json scripts; bun run test = parallel fast loop (commit 2/8) Per Codex Tension #4 (verify scope), distinguish three tiers cleanly: - `bun run test` = fast loop, file-level parallel fan-out via the new wrapper (scripts/run-unit-parallel.sh). No pre-checks, no typecheck, no wasm compile in the hot path. ~15s of pre-test gates removed. - `bun run verify` = CI's authoritative gate set: check:jsonb + check:progress + check:wasm + typecheck. Matches what .github/workflows/test.yml runs on shard 1, no scope drift. The 4 checks not in CI (privacy, no-legacy-getconnection, trailing-newline, exports-count) move to `bun run check:all` for opt-in local use. - `bun run test:full` = verify + parallel + slow + smart e2e (runs e2e only if DATABASE_URL is set; else loud skip notice to stderr per Open Item #7). The local equivalent of "everything CI runs." Adds `bun run test:serial` for the *.serial.test.ts subset (concurrency- unsafe files run with --max-concurrency=1). Bumps VERSION + package.json to 0.26.4. Both move together per the CI version-gate contract in CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: fix-wave for parallel wrapper + tighten privacy gate (commit 3/5) Wave: makes the new wrapper actually green and tightens the CI gate it exposed. Wrapper bug fixes (scripts/run-unit-parallel.sh): - grep_count helper: avoids the `grep -c | echo 0` double-output bug where 0 matches yields a 2-line "0\n0" string and breaks arithmetic. - bun_summary_count helper: parses Bun's actual end-of-shard summary format (`N pass` / `N fail` / `N skip`), not the per-test markers (which are `✓` / `(fail)`, never `(pass)` / `(skip)`). - Heartbeat now reads `^\s+✓` (Bun's per-test pass marker) for live progress mid-run; final summary still uses the summary-line counts for accuracy. Privacy gate tightening: - Move scripts/check-privacy.sh into `bun run verify` (was previously only in the now-removed `bun run test` chain). Without this, after commit 2 the privacy check ran in nothing automatic. - .github/workflows/test.yml now calls `bun run verify` instead of inlining the gate list. Single source of truth for "what's the ship gate." This is what verify == CI was supposed to mean per Codex T#4. - Pre-existing `Wintermute` references in src/core/mounts-cache.ts:6 and :324 caught by the now-running gate; replaced with `your OpenClaw` per CLAUDE.md privacy rule (verify gate now passes on master HEAD). - test/privacy-script-wired.test.ts updated: regression guard now asserts verify includes check:privacy AND that test.yml runs `bun run verify`, replacing the obsolete "test script includes check-privacy.sh" assertion. Quarantine 2 cross-file-contention flakes: - test/brain-registry.test.ts: 28 tests pass alone (41ms); 1 test ("empty/null/undefined id routes to host") fails when run alongside other files in the same shard. Renamed → *.serial.test.ts so it runs in scripts/run-serial-tests.sh's serial pass after the parallel pass completes. - test/reconcile-links.test.ts: 6 tests pass alone (1s); a beforeEach hook times out (~896s) under cross-file contention. Same treatment. Both flakes are bun-process-level shared-state leaks (PGLite singletons or top-level imports). Fixing them properly is the v0.27.0+ intra-file parallelism project (TODO P0 — see commit 5). Measurement after this commit: bun run test = 94s (was 18 min sequential) 3639 pass, 0 fail, 0 skip across 8 parallel shards + 34 serial tests Failure-log + heartbeat + summary all working Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: regression tests for parallel wrapper + serial-test contracts (commit 4/5) Three regression suites pin the v0.26.4 contracts. Without these, future refactors of the wrapper or shard scripts could silently regress the work in commits 1-3. test/scripts/run-unit-shard.test.ts (4 cases — gap b): - Asserts the unit-shard `--dry-run-list` output excludes every *.slow.test.ts and *.serial.test.ts file, plus the test/e2e/ subtree. - Catches a future `find` expression that drops one of the `-not -name` clauses and silently un-quarantines slow/serial files into the parallel pass. test/scripts/serial-files.test.ts (3 cases — gap e): - Every checked-in *.serial.test.ts (via `git ls-files`) is listed by scripts/run-serial-tests.sh's `--dry-run-list`. - The script's source contains `bun test --max-concurrency=1` (the serial-pass guarantee that quarantined files don't run intra-file concurrent and reintroduce the contention they were quarantined for). - Disjoint set: a file is never in both the unit-shard list AND the serial list — pins the carve-out contract. test/scripts/run-unit-parallel.test.ts (6 cases — gaps a + d): - Exit-code propagation (a): wrapper exits non-zero when ANY shard has a failing test; exits zero when all pass. The hardest contract to silently break in a fan-out wrapper (`for ... &; wait` returns the LAST child's status, not any failure's). - Failure-log contract (d): on failure, .context/test-failures.log exists, is non-empty, contains the `--- shard N:` prefix and the failing test's describe text. Stderr banner contains the absolute log path. On success, the log is cleared (no stale content). - Summary file format: `shard N/M: pass=X fail=Y skip=Z rc=W` per shard, machine-parseable for future tooling. The wrapper test runs against a 4-file tempdir (3 pass + 1 fail) so it executes in ~500ms; spawning the wrapper against the real test suite would take ~90s and isn't worth the cost in a regression suite. All 13 cases pass on first run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(v0.26.4): testing tier docs + CHANGELOG + intra-file P0 TODO (commit 5/5) Closes the v0.26.4 ship. CLAUDE.md Testing section rewritten: - New tier table: test (fast loop, 85s) / verify (CI gates, 12s) / test:full (everything local) / test:slow / test:serial / test:e2e / check:all. Each row names its scope, wallclock, and when to use. - Intentional CI vs local divergence section: CI matrix (test-shard.sh, hash-bucketed, includes slow) vs local fast loop (run-unit-shard.sh, round-robin, excludes slow + serial). Codex correctly flagged that a parity test would always fail by design — this is the documentation that explains why. - Failure-first logging contract: .context/test-failures.log format, stderr banner, summary file, wedge handling. - File taxonomy: *.test.ts / *.slow.test.ts / *.serial.test.ts / test/e2e/. Names the two currently-quarantined files and points at the intra-file P0 TODO for the proper fix. CHANGELOG.md `## [0.26.4]` entry per voice rules: - Two-line headline: "bun run test finishes in 85 seconds. Was 18 minutes." + failure-log directive. - Lead paragraph names what shipped and why. - Numbers-that-matter table: BEFORE / AFTER / Δ for wallclock, pre-test gates, failure visibility, shards, pipe-survival. - "What this means for you" closing tied to the inner-loop user. - "To take advantage of v0.26.4" block per the v0.13+ self-repair template (gbrain upgrade + contributor steps). - Itemized changes by area (new scripts, script extensions, package.json tier split, CI tightening, failure-first logging, quarantine, regression tests, bunfig). - "What did NOT ship" section names the intra-file project + E2E template-DB project as P0/P1 follow-ups with concrete acceptance criteria. - Process section names the codex review + scope-correction loop honestly: "snapped back to ship today once empirical measurement showed Bun's --max-concurrency does nothing on tests not marked test.concurrent()." - For-contributors note on portability + single-writer + fallback paths. TODOS.md adds two P-rated entries: - P0: intra-file parallelism via --concurrent flag. Sweep ~58 PGLite sites + ~40 env mutations + 2 mock.module sites. Target: bun run test < 30s. ~1-2 weeks. Detailed acceptance criteria. References Codex findings and plan-file rationale. - P1: E2E parallelism via Postgres template databases. CREATE DATABASE TEMPLATE gbrain_template per test file. ~1-2 days. llms.txt + llms-full.txt regenerated via `bun run build:llms` to absorb the CLAUDE.md changes (per CLAUDE.md's "After any release ship that touches the Key Files annotations in CLAUDE.md, run bun run build:llms" rule). The build-llms regression test was firing in shard 7 of the parallel pass — caught the drift, regeneration cleared it. Final measurement after fix: 94s wallclock, 3652 pass, 0 fail across 8 parallel shards + 34 serial tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
/**
|
|
* Mounts-cache composition + publishing (v0.19.0, PR 0).
|
|
*
|
|
* The runtime ownership seam (Codex finding #3 from plan-eng-review):
|
|
* `check-resolvable.ts` VALIDATES RESOLVER.md; it does not DISPATCH skills.
|
|
* Host agents (your OpenClaw / any Claude Code install) read
|
|
* `skills/RESOLVER.md` directly to route a user request to a skill.
|
|
*
|
|
* For mounted team brains to participate in routing without editing the
|
|
* host's checked-in `skills/RESOLVER.md`, gbrain publishes an aggregated
|
|
* `~/.gbrain/mounts-cache/RESOLVER.md` + `manifest.json` whenever mounts
|
|
* change. Host agents are taught (via AGENTS.md / CLAUDE.md install-path
|
|
* docs) to prefer the aggregated file when it exists.
|
|
*
|
|
* This file exposes:
|
|
* - composeResolvers(...) — pure: merge host + mount RESOLVER entries
|
|
* - composeManifests(...) — pure: merge host + mount manifest.json entries
|
|
* - writeMountsCache(...) — writes both aggregated files to disk
|
|
* - clearMountsCache(...) — removes the cache (used on `mounts remove`
|
|
* and test teardown)
|
|
*
|
|
* Compose functions are PURE: they take inputs, return structured output,
|
|
* no filesystem writes. Tests can exercise every branch without a tempdir.
|
|
* Only writeMountsCache touches disk.
|
|
*/
|
|
|
|
import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { homedir } from 'os';
|
|
import { parseResolverEntries } from './check-resolvable.ts';
|
|
import { HOST_BRAIN_ID, type MountEntry } from './brain-registry.ts';
|
|
|
|
/** Default location of the aggregated cache directory. */
|
|
function getMountsCacheDir(): string {
|
|
return join(homedir(), '.gbrain', 'mounts-cache');
|
|
}
|
|
|
|
/** A composed resolver entry with full provenance for audit + dispatch. */
|
|
export interface ComposedResolverEntry {
|
|
/** The user-facing trigger phrase from the RESOLVER.md table. */
|
|
trigger: string;
|
|
/**
|
|
* Namespace-qualified skill name. Format:
|
|
* - 'query' for host skills (no prefix)
|
|
* - 'yc-media::query' for mount skills
|
|
* Stable across mount-path changes — only depends on mount id.
|
|
*/
|
|
qualifiedName: string;
|
|
/**
|
|
* Absolute filesystem path to the SKILL.md. Host agents read this
|
|
* directly. For host skills, resolved against the host skills dir;
|
|
* for mount skills, resolved against the mount's clone path.
|
|
*/
|
|
absolutePath: string;
|
|
/** Mount id ('host' for host skills, else the mount id). */
|
|
brainId: string;
|
|
/** Section header from RESOLVER.md ('Brain operations', etc.) */
|
|
section: string;
|
|
/**
|
|
* True when this entry represents an external pointer (e.g.
|
|
* 'GStack: /review', 'Check CLAUDE.md'). Not a filesystem path.
|
|
*/
|
|
isExternal: boolean;
|
|
}
|
|
|
|
/** Information about a skill shadowed by a host skill of the same name. */
|
|
export interface ShadowInfo {
|
|
skillName: string;
|
|
hostEntry: ComposedResolverEntry;
|
|
shadowedMounts: Array<{ mountId: string; absolutePath: string }>;
|
|
}
|
|
|
|
/** Information about a bare name that resolves to two or more mounts. */
|
|
export interface AmbiguityInfo {
|
|
/** The short skill name (filename without .SKILL.md) users would type. */
|
|
skillName: string;
|
|
/** The mount ids that ALL claim this name (host excluded — host wins). */
|
|
mountIds: string[];
|
|
}
|
|
|
|
/** Output of composeResolvers — pure, no side effects. */
|
|
export interface ComposedResolver {
|
|
entries: ComposedResolverEntry[];
|
|
shadows: ShadowInfo[];
|
|
ambiguities: AmbiguityInfo[];
|
|
}
|
|
|
|
/** A single skill declaration in a manifest.json. */
|
|
export interface ManifestEntry {
|
|
/** Namespace-qualified name. 'query' or 'yc-media::query'. */
|
|
name: string;
|
|
/** Absolute filesystem path to the SKILL.md. */
|
|
absolutePath: string;
|
|
/** Mount id ('host' for host skills). */
|
|
brainId: string;
|
|
}
|
|
|
|
/** Output of composeManifests. */
|
|
export interface ComposedManifest {
|
|
entries: ManifestEntry[];
|
|
}
|
|
|
|
/** Default skills-subdir within a mount's clone. */
|
|
const DEFAULT_SKILLS_SUBDIR = 'skills';
|
|
|
|
/** Extract the filename-part of a skills-relative path (e.g. 'query/SKILL.md' → 'query'). */
|
|
function skillNameFromRelPath(relPath: string): string {
|
|
// 'skills/query/SKILL.md' → 'query'; 'query/SKILL.md' → 'query'
|
|
const parts = relPath.split('/');
|
|
const skillIdx = parts.indexOf('skills');
|
|
const start = skillIdx >= 0 ? skillIdx + 1 : 0;
|
|
if (start >= parts.length) return '';
|
|
return parts[start];
|
|
}
|
|
|
|
/**
|
|
* Compose host + mount RESOLVER.md entries into a single aggregated list
|
|
* with shadow + ambiguity detection.
|
|
*
|
|
* Host skills always win over any mount skill of the same name (locked
|
|
* decision 1 from plan). When two mounts ship the same skill name and the
|
|
* host does NOT define it, both entries are emitted with namespace prefixes
|
|
* and the name is reported in `ambiguities` so doctor can warn.
|
|
*/
|
|
export function composeResolvers(
|
|
hostSkillsDir: string,
|
|
mounts: MountEntry[],
|
|
opts: { readFile?: (path: string) => string | null } = {},
|
|
): ComposedResolver {
|
|
const readFile = opts.readFile ?? ((p: string) => {
|
|
try { return existsSync(p) ? readFileSync(p, 'utf-8') : null; } catch { return null; }
|
|
});
|
|
|
|
const hostResolverPath = join(hostSkillsDir, 'RESOLVER.md');
|
|
const hostContent = readFile(hostResolverPath);
|
|
const hostRawEntries = hostContent ? parseResolverEntries(hostContent) : [];
|
|
|
|
// Host entries: fully qualified against the host skills dir.
|
|
const hostEntries: ComposedResolverEntry[] = hostRawEntries.map(e => {
|
|
const isExternal = e.isGStack;
|
|
const abs = isExternal
|
|
? e.skillPath
|
|
: join(hostSkillsDir, e.skillPath.replace(/^skills\//, ''));
|
|
const name = isExternal ? e.skillPath : skillNameFromRelPath(e.skillPath);
|
|
return {
|
|
trigger: e.trigger,
|
|
qualifiedName: name,
|
|
absolutePath: abs,
|
|
brainId: HOST_BRAIN_ID,
|
|
section: e.section,
|
|
isExternal,
|
|
};
|
|
});
|
|
|
|
// Build fast lookup of host skill names (the shadow set).
|
|
const hostSkillNames = new Set<string>();
|
|
for (const e of hostEntries) {
|
|
if (!e.isExternal) hostSkillNames.add(e.qualifiedName);
|
|
}
|
|
|
|
// Track which mount a skill-name came from so we can detect ambiguity.
|
|
const byNameMountIds = new Map<string, Set<string>>(); // short name → {mount ids}
|
|
const mountEntriesByMount: Array<{ mount: MountEntry; entries: ComposedResolverEntry[] }> = [];
|
|
const shadowedByName = new Map<string, Array<{ mountId: string; absolutePath: string }>>();
|
|
|
|
for (const mount of mounts) {
|
|
if (mount.enabled === false) continue;
|
|
const mountSkillsDir = join(mount.path, DEFAULT_SKILLS_SUBDIR);
|
|
const resolverPath = join(mountSkillsDir, 'RESOLVER.md');
|
|
const content = readFile(resolverPath);
|
|
if (!content) continue; // Mount without a RESOLVER.md contributes no routing entries. Not an error.
|
|
const rawEntries = parseResolverEntries(content);
|
|
const composed: ComposedResolverEntry[] = rawEntries.map(e => {
|
|
const isExternal = e.isGStack;
|
|
const shortName = isExternal ? e.skillPath : skillNameFromRelPath(e.skillPath);
|
|
const qualifiedName = isExternal ? shortName : `${mount.id}::${shortName}`;
|
|
const abs = isExternal
|
|
? e.skillPath
|
|
: join(mountSkillsDir, e.skillPath.replace(/^skills\//, ''));
|
|
return {
|
|
trigger: e.trigger,
|
|
qualifiedName,
|
|
absolutePath: abs,
|
|
brainId: mount.id,
|
|
section: e.section,
|
|
isExternal,
|
|
};
|
|
});
|
|
mountEntriesByMount.push({ mount, entries: composed });
|
|
|
|
for (const entry of composed) {
|
|
if (entry.isExternal) continue;
|
|
const shortName = entry.qualifiedName.split('::').pop() ?? '';
|
|
if (!shortName) continue;
|
|
if (hostSkillNames.has(shortName)) {
|
|
// Shadow: host wins. Record so doctor can emit a warning.
|
|
const list = shadowedByName.get(shortName) ?? [];
|
|
list.push({ mountId: mount.id, absolutePath: entry.absolutePath });
|
|
shadowedByName.set(shortName, list);
|
|
continue;
|
|
}
|
|
const set = byNameMountIds.get(shortName) ?? new Set();
|
|
set.add(mount.id);
|
|
byNameMountIds.set(shortName, set);
|
|
}
|
|
}
|
|
|
|
// Ambiguities: any bare name with 2+ mounts (host excluded).
|
|
const ambiguities: AmbiguityInfo[] = [];
|
|
for (const [name, ids] of byNameMountIds.entries()) {
|
|
if (ids.size >= 2) {
|
|
ambiguities.push({ skillName: name, mountIds: Array.from(ids).sort() });
|
|
}
|
|
}
|
|
ambiguities.sort((a, b) => a.skillName.localeCompare(b.skillName));
|
|
|
|
// Shadows: group by host entry.
|
|
const shadows: ShadowInfo[] = [];
|
|
for (const [name, shadowedMounts] of shadowedByName.entries()) {
|
|
const hostEntry = hostEntries.find(h => h.qualifiedName === name && !h.isExternal);
|
|
if (!hostEntry) continue;
|
|
shadows.push({
|
|
skillName: name,
|
|
hostEntry,
|
|
shadowedMounts: shadowedMounts.sort((a, b) => a.mountId.localeCompare(b.mountId)),
|
|
});
|
|
}
|
|
shadows.sort((a, b) => a.skillName.localeCompare(b.skillName));
|
|
|
|
// Final entry list: host first, then all mount entries in mount-id order.
|
|
// Shadow detection applies to BARE-NAME routing only (the host entry wins
|
|
// when the agent types `ingest`), NOT to namespace-qualified routing
|
|
// (`yc-media::ingest` must always resolve to the mount, even when shadowed
|
|
// by a host skill of the same short name). Codex review 2026-04-23 caught
|
|
// the earlier version that dropped shadowed mount entries — that broke
|
|
// the entire namespace-disambiguation model.
|
|
const mountEntries: ComposedResolverEntry[] = [];
|
|
mountEntriesByMount.sort((a, b) => a.mount.id.localeCompare(b.mount.id));
|
|
for (const { entries } of mountEntriesByMount) {
|
|
for (const e of entries) mountEntries.push(e);
|
|
}
|
|
|
|
return {
|
|
entries: [...hostEntries, ...mountEntries],
|
|
shadows,
|
|
ambiguities,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read a manifest.json and return its skills[] array. Returns [] when the
|
|
* file is absent or malformed (propagating the same forgiving semantics as
|
|
* check-resolvable's loadManifest).
|
|
*/
|
|
function readManifestSkills(skillsDir: string, readFile?: (p: string) => string | null): Array<{ name: string; path: string }> {
|
|
const path = join(skillsDir, 'manifest.json');
|
|
const reader = readFile ?? ((p: string) => {
|
|
try { return existsSync(p) ? readFileSync(p, 'utf-8') : null; } catch { return null; }
|
|
});
|
|
const content = reader(path);
|
|
if (!content) return [];
|
|
try {
|
|
const parsed = JSON.parse(content);
|
|
return Array.isArray(parsed.skills) ? parsed.skills : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compose host + mount manifest.json entries. Host wins on name
|
|
* collisions (shadow). Mount entries get namespace-prefixed names.
|
|
* Codex finding #8: without this, remote skills break doctor conformance.
|
|
*/
|
|
export function composeManifests(
|
|
hostSkillsDir: string,
|
|
mounts: MountEntry[],
|
|
opts: { readFile?: (path: string) => string | null } = {},
|
|
): ComposedManifest {
|
|
const hostSkills = readManifestSkills(hostSkillsDir, opts.readFile);
|
|
const hostEntries: ManifestEntry[] = hostSkills.map(s => ({
|
|
name: s.name,
|
|
absolutePath: join(hostSkillsDir, s.path),
|
|
brainId: HOST_BRAIN_ID,
|
|
}));
|
|
const hostNames = new Set(hostEntries.map(e => e.name));
|
|
|
|
// Mount entries always get their namespace-qualified name regardless of
|
|
// shadow by a host skill. The namespace form `yc-media::ingest` must stay
|
|
// routable even when host defines `ingest` (bare-name host-wins only
|
|
// governs un-namespaced resolution). Codex review caught the earlier
|
|
// version that silently dropped shadowed mount entries from the manifest,
|
|
// making the aggregated cache inconsistent with composeResolvers' output.
|
|
const mountEntries: ManifestEntry[] = [];
|
|
const seenMounts = [...mounts].sort((a, b) => a.id.localeCompare(b.id));
|
|
for (const mount of seenMounts) {
|
|
if (mount.enabled === false) continue;
|
|
const mountSkillsDir = join(mount.path, DEFAULT_SKILLS_SUBDIR);
|
|
const skills = readManifestSkills(mountSkillsDir, opts.readFile);
|
|
for (const s of skills) {
|
|
mountEntries.push({
|
|
name: `${mount.id}::${s.name}`,
|
|
absolutePath: join(mountSkillsDir, s.path),
|
|
brainId: mount.id,
|
|
});
|
|
}
|
|
}
|
|
void hostNames; // retained for future shadow metadata (PR 1 doctor warning)
|
|
|
|
return { entries: [...hostEntries, ...mountEntries] };
|
|
}
|
|
|
|
/**
|
|
* Render the composed resolver as markdown matching the existing
|
|
* skills/RESOLVER.md format. The table groups by section (preserving
|
|
* host section headings) with mount entries appended under a new
|
|
* "Mounted brains" section.
|
|
*/
|
|
export function renderResolverMarkdown(composed: ComposedResolver): string {
|
|
const lines: string[] = [];
|
|
lines.push('# GBrain Skill Resolver (aggregated)');
|
|
lines.push('');
|
|
lines.push('Auto-generated by `gbrain mounts add|remove|sync`. Do not edit by hand.');
|
|
lines.push('Host agents (your OpenClaw / Claude Code install) should prefer this file over');
|
|
lines.push('the repo-checked-in `skills/RESOLVER.md` when it exists.');
|
|
lines.push('');
|
|
lines.push('See `docs/architecture/brains-and-sources.md` for the mental model.');
|
|
lines.push('');
|
|
|
|
// Group entries by section in insertion order.
|
|
const bySection = new Map<string, ComposedResolverEntry[]>();
|
|
for (const e of composed.entries) {
|
|
const key = e.section || '(uncategorized)';
|
|
const bucket = bySection.get(key) ?? [];
|
|
bucket.push(e);
|
|
bySection.set(key, bucket);
|
|
}
|
|
|
|
for (const [section, entries] of bySection.entries()) {
|
|
lines.push(`## ${section}`);
|
|
lines.push('');
|
|
lines.push('| Trigger | Skill | Brain |');
|
|
lines.push('|---------|-------|-------|');
|
|
for (const e of entries) {
|
|
const skillCol = e.isExternal ? e.absolutePath : `\`${e.absolutePath}\``;
|
|
lines.push(`| ${e.trigger} | ${skillCol} | ${e.brainId} |`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
if (composed.shadows.length > 0) {
|
|
lines.push('## Shadows');
|
|
lines.push('');
|
|
lines.push('Host skills that shadow mount skills of the same name:');
|
|
lines.push('');
|
|
for (const s of composed.shadows) {
|
|
lines.push(`- \`${s.skillName}\` (host) shadows ${s.shadowedMounts.map(m => `\`${m.mountId}::${s.skillName}\``).join(', ')}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
if (composed.ambiguities.length > 0) {
|
|
lines.push('## Ambiguous bare names');
|
|
lines.push('');
|
|
lines.push('These skill names are defined in multiple mounts. Use the explicit');
|
|
lines.push('namespace form (e.g. `yc-media::ingest`) to disambiguate.');
|
|
lines.push('');
|
|
for (const a of composed.ambiguities) {
|
|
lines.push(`- \`${a.skillName}\` → ${a.mountIds.map(m => `\`${m}::${a.skillName}\``).join(', ')}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/** Render the composed manifest as manifest.json bytes. */
|
|
export function renderManifestJson(composed: ComposedManifest): string {
|
|
return JSON.stringify(
|
|
{
|
|
generated_by: 'gbrain mounts',
|
|
generated_at: new Date().toISOString(),
|
|
skills: composed.entries.map(e => ({
|
|
name: e.name,
|
|
path: e.absolutePath,
|
|
brain: e.brainId,
|
|
})),
|
|
},
|
|
null,
|
|
2,
|
|
) + '\n';
|
|
}
|
|
|
|
/**
|
|
* Write the aggregated cache to ~/.gbrain/mounts-cache/. Safe to call
|
|
* repeatedly. The directory is created if missing. Both files are
|
|
* rewritten atomically (write-then-rename via a .tmp sibling).
|
|
*/
|
|
export function writeMountsCache(
|
|
hostSkillsDir: string,
|
|
mounts: MountEntry[],
|
|
opts: { cacheDir?: string } = {},
|
|
): { resolverPath: string; manifestPath: string } {
|
|
const cacheDir = opts.cacheDir ?? getMountsCacheDir();
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
|
|
const resolver = composeResolvers(hostSkillsDir, mounts);
|
|
const manifest = composeManifests(hostSkillsDir, mounts);
|
|
|
|
const resolverPath = join(cacheDir, 'RESOLVER.md');
|
|
const manifestPath = join(cacheDir, 'manifest.json');
|
|
|
|
// Unique tmp names per call so concurrent `gbrain mounts add|remove`
|
|
// invocations don't clobber each other's .tmp file. The two-file swap
|
|
// (RESOLVER.md + manifest.json) is not itself atomic across files —
|
|
// readers may briefly observe RESOLVER.md(new) + manifest.json(old).
|
|
// That's acceptable: the aggregated cache is recomputable from
|
|
// mounts.json at any time, and doctor will flag divergence. A true
|
|
// generation-swap (cacheDir/current → new-gen dir) is deferred to PR 1.
|
|
const suffix = `${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
|
|
const resolverTmp = `${resolverPath}.tmp.${suffix}`;
|
|
const manifestTmp = `${manifestPath}.tmp.${suffix}`;
|
|
|
|
writeFileSync(resolverTmp, renderResolverMarkdown(resolver), { mode: 0o644 });
|
|
writeFileSync(manifestTmp, renderManifestJson(manifest), { mode: 0o644 });
|
|
|
|
// Atomic swap via rename on each file.
|
|
renameSync(resolverTmp, resolverPath);
|
|
renameSync(manifestTmp, manifestPath);
|
|
|
|
return { resolverPath, manifestPath };
|
|
}
|
|
|
|
/** Remove the aggregated cache dir. Called by `gbrain mounts remove` and tests. */
|
|
export function clearMountsCache(opts: { cacheDir?: string } = {}): void {
|
|
const cacheDir = opts.cacheDir ?? getMountsCacheDir();
|
|
if (!existsSync(cacheDir)) return;
|
|
rmSync(cacheDir, { recursive: true, force: true });
|
|
}
|
|
|
|
/** Exposed for tests. */
|
|
export const __testing = {
|
|
getMountsCacheDir,
|
|
skillNameFromRelPath,
|
|
DEFAULT_SKILLS_SUBDIR,
|
|
};
|