mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* docs(designs): agent-bootstrap plan + design docs (normative, review-absorbed) The scrubbed, in-repo sources of truth for the gbrain bootstrap wave: AGENT_BOOTSTRAP_DESIGN.md (product scope/sequencing) and AGENT_BOOTSTRAP_PLAN.md (implementation; all review-finding IDs inlined). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): format spec, question bank, identity templates, bundled assets agent.json manifest (format_version 1, initialized sentinel) + machine-local install receipt [CX2-1, CX2-12]; 12-question/6-required interview bank with consent keys and a persist:false sink for the optional provider key [CX2-13]; ten {{TOKEN}} identity templates (generic, adapted to gbrain ops — gates call recall/query/put_page, write-through-ops rule, keyless agent-authored facts, silence contract); assets embedded compiled-binary-safe via file-type imports [ENG-6]. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): runbook, README paste block, bootstrap guide, TODOS entries BOOTSTRAP_FOR_AGENTS.md (agent-driven install runbook: CLI phase list is the source of truth, never-invent rules, Codex approvals preflight, keyless posture, failure-modes table, version stamp for the skew check); README gains the full-agent paste block pinned to latest-stable inside the Claude Code/Codex quick start (memory-only tier stays); docs/guides/bootstrap.md carries the full install/security/consent/degradation/uninstall contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(designs): spike instrument for the bootstrap wave (build order 0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): interview + render engines Interview gate with read-back confirm-hash (any later answer change clears the confirmation — the hostile single-batch case is structurally impossible), per-answer provenance, caps + escaping at set time, config-sink routing for the provider key; renderer with hard-fail token sweep, subordinate fencing of principal input, never-clobber + backups, deterministic minimal mode for the template repo, scaled byte floors. 58 unit tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): private-repo lifecycle — repo create, attach, uninstall, run lock gh-gated private repo creation with API-verified privacy (rate-limit distinct from public), refuse-foreign-origin with attach as the sanctioned path, atomic bootstrap mutex (pid liveness + age + token), receipt-keyed uninstall that never wholesale-deletes the gbrain home and only offers --delete-brain for a brain it created; read-only PGLite lock probe (never opens the engine). 54 unit tests, injectable exec seam throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(release): latest-stable ref, template-repo publish job, bootstrap CI guards release.yml advances the latest-stable tag only after assets publish (the paste block's permanent ref — copies in the wild never rot) and gains a PAT-gated publish-template job verified against the vendored tree; two skip-graceful guards (sanctioned-ref + runbook stamp; template/token bijection + placeholder assertion + generator byte-diff) wired into verify; README + runbook re-admitted to the CI cache hash; vendored deterministic template tree generated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(context): IPC v2 turn_context + 8KB assembly + visibility resolver + session identity Discriminated-union IPC with handler map, protocol echo (stale-serve detection), shared-secret gate, server-side source binding, per-kind budgets; turn-context assembly (reflex pointers + volunteered pages + world-only hot facts) under a data-not-instructions envelope trimmed to the harness's 10KB hook-output cap; facts.default_visibility resolved through one helper at all four sites (explicit caller wins, typos fail closed); typed sessionId threads _meta.session_id into the hot-memory cache key. 50 new tests; 180 adjacent tests confirmed green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(persistence): secret-scan, gbrain sources push, durability unification Pattern secret scanner (own runtime allowlist, redacted previews, corpus-write redaction mode); sources push runs the whole scan→stage→commit→pull→push sequence under one cross-platform lock (mkdir-atomic, pid+age+token) with a deny-glob backstop, commit-first divergence-safe pull, refuse-unverifiable visibility, and push-status telemetry; gbrain-home choke point unifies GBRAIN_HOME semantics with config (0700); durability is parent-repo-aware and rotates its push log at 0600. 35 new tests; 200 existing green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sources): harden/pull gates accept sources inside a parent git repo The bootstrap workspace registers brain/ (a subdirectory) as the source; the durability core already resolves the repo root, so the command gates now check inside-a-repo rather than .git-right-here [CX2-3]. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(serve): resident maintenance sweep + keyless capability probe The lock-owning serve process now closes the persistence loop: startup (3s post-connect, best-effort, unref'd) and idle (10-min quiet intervals through the injectable timer seam) sweeps run facts-fence reconciliation, deterministic link/timeline extraction over recent workspace pages, and spend-gated corpus ingest (skipped keyless — agent-authored fences cover it). gbrain sweep --once is the trusted CLI seam bootstrap verify uses. Capability probe renders the honest keyless/keyed report. Full reuse of the cycle extractor + extract cores; 26 new tests, neighbors green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(hooks): engine-free gbrain hook command, settings writers, transcript parser Four hook events (session-start digest + crashed-session recovery push, user-prompt turn-context injection under an 800ms deadline and the 10KB cap, stop buffers, session-end corpus write with redaction/retention/dedup + best-effort push); structural JSON settings merger keyed by a _gbrain marker (foreign hooks and permissions survive); dated host-spec registry; Claude Code .jsonl parser as a spec-target with a scrubbed 7-shape fixture. Heartbeat is counters-only by construction. 59 tests; zero engine modules in the import graph. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(bootstrap): cross-link the full-agent path from the connection docs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): dispatcher, verify, status — the command assembled gbrain bootstrap {status,interview,render,repo,hooks,verify,uninstall,attach}: engine-free except verify (owns its engine, in-process sweep — no live-serve conflict); phase list is the TS source of truth with install.jsonl telemetry and the support blob; verify's fail-soft check suite covers the real write path (put_page → write-through file → sweep → graph floor → recall), passes keyless, persists snapshots, and ends with the first-run tour. cli.ts wired per the three-touchpoint rule; doctor gains the bootstrap check group (silent on machines with no bootstrap state). 28 new tests; 353 adjacent green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: KEY_FILES bootstrap cluster + CLAUDE.md dispatcher row (+ build:llms) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bootstrap): e2e pins — hook-under-live-serve, attach, degraded modes, compiled binary, Docker harness The permanent pins: a real serve holds the PGLite lock while the engine-free hook completes (and a direct engine open provably throws LiveServeLockError); stale-socket fail-open; machine-2 attach with marker-keyed hook repair; decline-everything installs verify green with every degradation named; the compiled binary renders bundled templates in an empty cwd. Offline Docker harness (networkless, read-only) gated into heavy-tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): register doctor check categories + system-of-record allow comments The six bootstrap doctor checks join OPS_CHECK_NAMES; the sweep's batch link/ timeline inserts carry the explicit extract-path allow comments (the sweep IS the extraction path for workspace pages). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): shard wedge cap tracks suite growth (1500s -> 1800s) At ~9000 tests a healthy shard finished at 1466s and two progressing shards were false-killed at the old cap; 1800s restores ~25% headroom over the slowest observed healthy shard. Real hangs still hit it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ci): cache-hash policy — README + runbook edits must invalidate [C2] The old deny-list assertion predates the paste block; README.md and BOOTSTRAP_FOR_AGENTS.md are policy-doc re-admissions now, so their edits must change the hash (a paste-block edit shipping under a cached green was the C2 hole). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): classify post-suite exit-hangs as warn-pass; file the leak forensics A shard killed by the wedge watchdog with every assigned file started and zero fail markers did all its work and leaked a handle at exit — pre-existing and master-reproducible (P1 TODO carries the full bisect forensics). Bun's per-test timeout turns a hung test into a (fail), so the classifier cannot mask one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): shard cap 2400s — the count-balanced heavy shard needs it under contention Observed: the heavy shard still progressing 22s before an 1800s kill while siblings finish at 1150-1550s (split balances file count, not weight). Filed the load-sensitive WAL-repair flake (pre-existing, master's v0.42.75.0 wave). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bootstrap): quarantine env-mutating suites to the serial lane check-test-isolation R1: six new files mutate GBRAIN_HOME/env at module scope — the serial lane (one process per file) is the guard's prescribed home for them. All 114 tests pass post-rename. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(readme): per-harness install sections — Codex, Claude Code, then OpenClaw/Hermes Each harness gets its own complete paste-block section (desktop app first, terminal noted — Claude Code CLI is the identical harness; Codex CLI works pull-based today); the OpenClaw/Hermes platform path keeps equal weight with its one-click deploys and INSTALL_FOR_AGENTS block intact; memory-only and remote-connect tiers consolidated under 'Lighter ways in'. Supersedes the review's D5 ordering by user direction; stale heading references updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(readme): Codex as the recommended first step; OpenClaw/Hermes framed as-intended, high-cost The install section now routes newcomers explicitly: Codex first (subscription-priced, nothing to deploy), OpenClaw/Hermes as GBrain used the way it was designed — always on, at real server + API cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: doc-audit code chasers — broken recovery hints, stale op description, auto_link key Four small code fixes surfaced by the markdown accuracy audit: - doctor's auto-RLS recovery hint pointed at `apply-migrations --force-retry 35`, which cannot work (--force-retry targets the vX.Y.Z orchestrator registry, not the numeric schema MIGRATIONS array). Hint now points at the recreate SQL in docs/guides/rls-and-you.md; test pins against regression. - v0_11_0 migration printed the same broken-mechanism class of hint (`config set minion_mode` writes DB config nothing reads); now names `apply-migrations --mode` + preferences.json, the real setter. - submit_job's op description hardcoded a stale handler list; now points at registerBuiltinHandlers as the source plus the --follow discovery trick. - `auto_link` added to KNOWN_CONFIG_KEYS: read by link-extraction, reconcile-links, and sweep, and documented as the off-switch in brain-ops/maintain, but the allowlist rejected `config set auto_link false`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: repo-wide accuracy + MECE reform from the 9-bucket markdown audit A code-grounded audit of every markdown file (root, architecture, guides, mcp, tutorials, docs-root, operations/eval/designs, skills, recipes) followed by a fix wave with per-bucket ownership. Four classes of change: Accuracy — every documented command/flag verified against src/ before writing: dead commands replaced with working ones (pages purge-deleted, jobs watch --follow, gbrain restore, import-based Obsidian flow, space-separated --scopes, real thin-client recipes, working isolation verification, real supervisor restart procedure, curl-based ngrok health check, real minion_mode setter); count drift fixed with rot-proof phrasing (100+ ops, 50+ bundled skills via skills/manifest.json, 140+ engine methods, KNOBS_HASH_VERSION pointer instead of hardcoded versions); stale claims corrected (search-mode defaults, RETRIEVAL pipeline order incl. autocut, sentinel rules, refusal-list mechanism, engine snapshot, shard cap 2400s + EXIT-HANG classifier in TESTING.md, latest-stable + publish-template documented in RELEASING.md as release.yml promises). MECE — one home per concept, pointers elsewhere: test isolation → TESTING.md; OAuth registration + --bind/--public-url lore → DEPLOY.md; mode bundles → guides/search-modes.md (the home the CLAUDE.md dispatcher always promised); merge contract → schema-packs.md; WAL ladder → ENGINES.md; quiet-hours → quiet-hours.md; capture taxonomy → entity-detection.md; person-page taxonomy → compiled-truth.md; brain-first protocol → brain-first-lookup.md; refresh semantics → refresh-algorithm.md; KEY_FILES.md deduplicated (58 extension entries merged, one entry per file); infra-layer.md rewritten as a pointer page. Privacy — placeholder sweep across guides, docs, skills, and recipes per the iron rule; per-release narration stripped from reference docs (current-state prose only). Bootstrap coverage — AGENTS.md pointer, RESOLVER routing row, INSTALL.md path, tutorial cross-links, keyless-mode sections in spend-controls/headless-install. skills.lock.json regenerated; llms.txt/llms-full.txt rebuilt. Gates: verify 36/36, typecheck clean, doctor 96/96, skills-integrity + resolver + build-llms + config-set + migrations all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(readme): refresh production-brain stats to current brain-repo counts 155,795 pages / 24,589 people / 5,340 companies, counted from the brain repo's current HEAD; the "100K-page brain" framing moves to 150K to match. Cron-fleet count unchanged (its store lives on the deployment host, not in the repos available for verification). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(scan,push): modern OpenAI/Voyage key patterns, scan staged blobs not disk - secret-scan matches sk-proj-/sk-svcacct-/sk-None- and pa- Voyage keys (the bare sk- pattern missed every current OpenAI key format). - workspacePush stages first, then scans the staged index blobs via git cat-file, closing the scan-then-stage TOCTOU where a file changed between snapshot and commit shipped unscanned. - shared binary-sniff helper, memoized glob regexes, atomic push-status write, and tests for pull_conflict + gitignored deny-match paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): regenerate flag registry for new commands, harden shard classifier + release token - cli-flag-registry.generated.ts regenerated: bootstrap/hook/sweep and sources push --message/--allow-unverified-remote were missing, so the strict #2185 validator rejected real invocations and skipped the new commands entirely. - EXIT-HANG shard classifier now requires every assigned file to have started before warn-passing a watchdog kill (was fail-open). - release.yml passes TEMPLATE_REPO_PAT via http.extraheader, off the argv. - compiled-binary e2e fails loud in CI instead of a silent permanent skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hook,sweep,ipc): non-blocking hook pushes, bounded sweep + cache, source-bound resolve - session-start/session-end no longer run synchronous git + inline push inside their self-deadline; a detached child does the push and the hook returns immediately (blocked Claude Code startup for minutes on a dirty tree before). - serve sweep drops the unbounded listAllPageRefs, resolves only candidate targets, claims corpus files atomically (no double-LLM-spend race), and caps the fence LIKE scan; heartbeat writes are O_APPEND with rare compaction. - hot-memory cache evicts expired entries and bounds entry count (the key is caller-controlled via _meta.session_id). - v1 resolve IPC honors boundSourceId like turn_context; turn-context runs its arms concurrently. doctor reads push/heartbeat thresholds from hook.ts. - new tests: doctor bootstrap checks, hook push-gate + deadline, concurrent sweep claims, cache eviction, bound-source resolve. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): origin-ownership gate, world visibility, collision-safe source id, consent + templates - repo adoption requires an exact receipt repo_url match or authed-owner check (undefined repo_url was a wildcard); create verifies privacy BEFORE the first push. - verify sets facts.default_visibility=world if unset, so agent-authored facts surface in per-turn context (they defaulted private before). - source_id derives a path-hash suffix when 'workspace' is taken by another checkout; every consumer reads manifest.source_id. - skipped HOOKS_CONSENT now declines (was falling through to default yes); --minimal refuses on an initialized manifest; tilde fences escaped. - MCP registration pins --surface full; status hard-fails a public origin (template door); receipt writers guard against newer/corrupt receipts; uninstall only claims brain-deleted after a real rm. - templates ship jobs disabled + provider-consent + support-relay lines; soul-audit re-runs over the shared interview bank. TODOS: 11 follow-ups. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): close adversarial-review findings — scan fails closed, whole-PEM redaction, bound repo push Cross-model adversarial pass (Claude + Codex) on the bootstrap wave: - secret scan fails CLOSED: an unreadable, oversized, or binary staged blob now blocks the push (blocked_unscannable, exit 5) instead of committing unscanned; only a confirmed staged deletion is skipped. This was the headline "block secrets before they leave the machine" property failing open. - private-key redaction spans the whole PEM block (header+body+footer), not just the header line — the base64 body no longer survives into the corpus the sweep sends to an extraction provider. - bootstrap repo commits the workspace (secret-scan-gated) before the first push and verifies the remote actually received it, so a push-fail retry can't adopt an empty remote as success. - privacy verify is re-bound to origin immediately before push (a concurrent origin rewrite between verify and push is refused). - session-end corpus write is atomic and clears the stale ingested/in-progress sidecars so a resumed session's appended transcript is re-ingested. - public-origin refusal enforced at render (not only status); MCP "already registered" is verified to target this workspace, not blessed blindly; verify probe cleanup scopes deletes to its own slugs, not a token substring; allowlist fingerprint floor raised 8→16 hex. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v0.45.0.0 feat(bootstrap): paste-in personal-agent install for Codex + Claude Code Turns a Codex or Claude Code session into a persistent personal agent: interview-rendered identity files, a local PGLite brain, per-turn context via serve IPC (Claude Code hooks / Codex pull protocol), session-triggered persistence, and a private GitHub repo as the agent's portable body. Keyless- first (the harness model is the LLM; one optional key adds embeddings + extraction). New `gbrain bootstrap` command family + `gbrain hook` + `gbrain sweep`; doctor bootstrap health checks; latest-stable distribution ref + template-repo publish job. Opt-in, additive — existing installs untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: regenerate flag registry for security-fix flags; drop fabricated gbrain capabilities doc ref CI caught two real failures under the merged state: - the flag registry lagged the blocked_unscannable/exit-5 flags the security round added, tripping the #2185 freshness guard. - headless-install.md described the keyless capability report as a `gbrain capabilities` command, which the #3502 doc-command resolver rejects — reworded to prose (the real surface is bootstrap verify's report). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: sync KEY_FILES + bootstrap plan to security-fix behavior Cross-referenced the security-fix round against the reference docs and corrected the drift those commits introduced: - workspace-push.ts entry: stage-FIRST-then-scan order (the TOCTOU fix), fail-closed blocked_unscannable, and the sources-push status -> exit-code map. - hooks.ts entry: MCP registration pins `serve --surface full`. - hook.ts entry: session-start/session-end pushes run in a detached child (non-blocking); atomic corpus write clears stale sidecars. - bootstrap.ts entry: render hard-refuses a public origin (template door). - verify.ts entry: source_id collision resolution (workspace-<path-hash>). - AGENT_BOOTSTRAP_PLAN as-shipped delta note for the scan/stage reorder. llms bundle unchanged (KEY_FILES is link-only); build:llms and test/build-llms.test.ts green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): silence SC2016 on the intentional askpass literal in release.yml The one-shot GIT_ASKPASS script must contain literal $1 and $TEMPLATE_REPO_PAT so they expand when /bin/sh runs it at git's credential prompt, not when the outer shell writes the file — single quotes are correct. Add a scoped shellcheck disable so actionlint passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): declare 'bootstrap my data' trigger in cold-start frontmatter The doc reform added 'bootstrap my data' to cold-start's RESOLVER.md row (to disambiguate data-bootstrap from agent-bootstrap) but not to the skill's own frontmatter triggers, tripping the RESOLVER↔frontmatter round-trip contract (resolver.test.ts). Declare it; regenerate skills.lock.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): default per-turn hooks + search mode ON without a prompt Installing gbrain for your coding agent IS the consent for the behaviors that make it work, so stop re-litigating them with install-time questions whose "no" defeats the product: - Per-turn hooks (Claude Code) install ON by default — no prompt. Off-ramps: `--no-hooks` at install, `GBRAIN_HOOKS=0` at runtime, `bootstrap uninstall`. The "hooks installed" line now surfaces the kill switch so default-on is never silent. A persisted HOOKS_CONSENT=no (interview --skip) still declines. - Search mode defaults to `balanced` silently (nobody knows the modes at install; `gbrain search modes` changes it any time). - MCP scope stays the ONE deliberate prompt — project vs user is a real cross-repo privacy choice, not friction. Marks the two consents `silent: true` in the question bank (new QuestionSpec field), rewrites the runbook phases so the agent no longer asks them, adds the `--no-hooks` flag (+ registry regen), and adds default-on / opt-out tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bootstrap): real end-to-end coverage — cross-session recall, per-turn content, Codex door, realistic corpus Closes the seven e2e gaps a coverage audit surfaced: the plumbing was well-unit-tested but the product claims ("Codex works, context shows up every turn with real content, it remembers across restarts, machine two recovers, Postgres works") were unproven end to end. Test-only wave — zero src changes. - Hermetic synthetic corpus (test/fixtures/bootstrap-corpus/ + a loader helper): 12 interlinked pages (52 edges, timelines), 12 world/private beliefs, 8 gold queries — curated from the gbrain-evals synthetic corpora, 100% placeholder names, so recall is asserted on a real multi-entity brain instead of a 2-node self-planted probe. - GAP1 magic moment: author a fact via the real write path, disconnect the engine, reopen against the same DB, recall it — a real session boundary, not verify.ts's same-connection SQL read-back. Plus a source-isolation assertion. - GAP2 per-turn content: hook-under-serve Pin 1 now seeds a known fact and asserts its text lands in the injected block AND private beliefs never do (was: empty brain, empty_block accepted as a pass). - GAP3 Codex door: assert the rendered AGENTS.md carries the Gate-3 brain-first pull protocol; make the fake codex shim implement `mcp get` so the [FIX7] target-verification can actually fail; the Docker cold-machine harness now exercises the hooks/MCP registration step instead of skipping it. - GAP4 corpus recall: turn-context + verify graph-floor/qrels run on the real multi-entity brain with real edges. - GAP5 attach: machine-two now re-ingests the cloned brain/ into a fresh DB and recalls a fact authored only on machine one — the multi-device payoff. - GAP6 keyed + Postgres (env-gated): real embeddings prove semantic recall a paraphrase query can reach but keyless BM25 cannot; bootstrap verify drives a real Postgres engine (skipIf DATABASE_URL/keys absent). - GAP7 persistence: session-end runs the REAL push (not the mocked seam) to a local bare remote and the remote receives the content; a planted secret is blocked at the gate; the 15-min cron installs and fires a scan-gated push. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bootstrap): real-agent e2e — drive the actual claude + codex binaries end to end Closes the audit's biggest gap ("no real harness ever drives a turn"). Adapts gstack's PTY/headless agent harness to prove the bootstrap install + smoke work against the REAL binaries, not PATH shims. Test/CI/docs only — zero src changes. - test/helpers/agent-harness.ts: hermetic clean-room child env (ported from gstack; drops CONDUCTOR_/CLAUDE_/GSTACK_/MCP_/GBRAIN_, promotes GSTACK_ANTHROPIC_API_KEY→ANTHROPIC_API_KEY), real-binary resolvers + auth probes, headless `claude -p --output-format stream-json` and `codex exec --json` turn runners, a gbrain stdio MCP-config writer, and a keyless brain seeder. + a fixture-parse unit test (no binary needed). - test/e2e/bootstrap-real-claude.serial.test.ts: real `gbrain bootstrap` install → REAL `claude mcp add` (verified via `claude mcp get`) → verify exit 0 → a real `claude -p --mcp-config --strict-mcp-config` turn that invokes mcp__gbrain__search and answers from the brain (proven: toolCalls include mcp__gbrain__search, final text carries the seeded fact). - test/e2e/bootstrap-real-codex.serial.test.ts: same install with REAL `codex mcp add` into a real ~/.codex/config.toml + Gate-3 pull-protocol assertion, then a real `codex exec --json` turn surfacing the fact (MCP or the pull- protocol shell path). Bounded retry absorbs codex's occasional MCP-call cancellation without softening the fact-requiring assertion. - Everything hermetic (temp HOME/CLAUDE_CONFIG_DIR/CODEX_HOME/GBRAIN_HOME; real ~/.codex auth copied read-only) and skipIf-gated so it self-skips cleanly where the binaries/auth are absent. - heavy-tests.yml: gated `real-agent-e2e` job (nightly/label, never the PR shard; no-op on a runner without authed binaries). - TODOS: compiled `gbrain` binary can't serve a PGLite brain (bun compile omits the WASM/extension payloads); harness falls back to `bun run` serve. Verified against live claude 4.6 + codex 0.147.0: 15 pass / 0 fail; verify 36/36; typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): real-agent-e2e job — bash array + --timeout (actionlint SC2086 + bun-test-timeout guard) The real-agent-e2e job's file loop used an unquoted $FILES (SC2086) and ran `bun test` without --timeout (check-bun-test-timeout guard). Switch to a bash array and add --timeout=600000 (real-agent turns are slow; the tests self-skip without authed binaries so it's a no-op elsewhere). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pglite): embed WASM + extension assets so the compiled binary can serve A `bun build --compile` gbrain binary could not `serve` a PGLite brain: the compile bundles JS but not PGLite's runtime payload (pglite.wasm, initdb.wasm, pglite.data, vector/pg_trgm tarballs), so `serve` on PGLite died with a bunfs/ENOENT. Now the assets ride inside the binary. - src/core/pglite-embedded-assets.ts: embeds the five assets via `import … with { type: 'file' }` (the ENG-6 idiom) and exposes getEmbeddedPgliteOptions() → { pgliteWasmModule, initdbWasmModule, fsBundle, extensions:{vector,pg_trgm} }. WASM/fsBundle are consumed as bytes; the two extension tarballs are materialized to a content-addressed temp file (atomic, size-verified reuse) because PGLite reads them via fs.createReadStream, which cannot read a /$bunfs path. Unconditional (works in bun-run and compiled), so no fragile mode branch. - src/core/pglite-engine.ts: static-import getEmbeddedPgliteOptions (engine path stays static per the engine-dynamic-import invariant); spread into both PGlite.create sites (initial + WAL-repair retry). The bunfs classifier stays as a backstop but no longer fires for a correct binary. - scripts/check-pglite-embedded.sh (+ smoketest): compiles a focused binary and asserts it boots PGLite, CREATE EXTENSION vector/pg_trgm, and round-trips a page — wired into `bun run verify` (now 37 checks), check:all, and check:pglite-embedded. Fail-soft only when compile is unavailable. - agent-harness.ts probeCompiledPglite now passes → the real-agent e2e uses the fast compiled MCP server. TODOS: the P2 "can't serve PGLite" item is closed. Verified: fresh compiled binary ran `search`/`query` against a PGLite brain and returned the seeded row (no bunfs/ENOENT); verify 37/37; pglite-engine 120/0 source-mode; typecheck clean; engine-dynamic-import + parity guards pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
429 lines
19 KiB
TypeScript
429 lines
19 KiB
TypeScript
/**
|
|
* `bootstrap uninstall` confinement tests [G2, S3#5, A2, CX2-12].
|
|
*
|
|
* Pins the ownership + confinement contract: receipt-keyed removal (exactly
|
|
* created_paths, containment-checked), foreign files + global config survive,
|
|
* refuse-without-receipt, the deleteBrain gate on brain_created_by_bootstrap,
|
|
* the GBRAIN_HOME guard, symlinked-home rejection (lstat), and the read-only
|
|
* live-serve lock probe (the engine is never opened).
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
probeLivePgliteHolder,
|
|
resolveBrainDataDir,
|
|
uninstallWorkspace,
|
|
} from '../src/core/bootstrap/uninstall.ts';
|
|
import { BootstrapError } from '../src/core/bootstrap/lock.ts';
|
|
import { readReceipt, writeReceipt, type InstallReceipt } from '../src/core/bootstrap/format.ts';
|
|
|
|
let ws: string;
|
|
let home: string;
|
|
let savedGbrainHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
ws = mkdtempSync(join(tmpdir(), 'gbrain-uninstall-ws-'));
|
|
home = mkdtempSync(join(tmpdir(), 'gbrain-uninstall-home-'));
|
|
savedGbrainHome = process.env.GBRAIN_HOME;
|
|
delete process.env.GBRAIN_HOME;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedGbrainHome === undefined) delete process.env.GBRAIN_HOME;
|
|
else process.env.GBRAIN_HOME = savedGbrainHome;
|
|
rmSync(ws, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
/** A realistic gbrain home: global config, a brain, clones, foreign files,
|
|
* bootstrap telemetry, and one bootstrap-created path (hook wiring). */
|
|
function seedHome(receiptOverrides: Partial<InstallReceipt> = {}): { hooksDir: string; receipt: InstallReceipt } {
|
|
writeFileSync(join(home, 'config.json'), JSON.stringify({ engine: 'pglite', database_path: join(home, 'brain.pglite') }), 'utf8');
|
|
mkdirSync(join(home, 'brain.pglite'), { recursive: true });
|
|
writeFileSync(join(home, 'brain.pglite', 'PG_VERSION'), '16', 'utf8');
|
|
mkdirSync(join(home, 'clones', 'other'), { recursive: true });
|
|
writeFileSync(join(home, 'clones', 'other', 'keep.txt'), 'foreign clone', 'utf8');
|
|
writeFileSync(join(home, 'foreign.txt'), 'not bootstrap-created', 'utf8');
|
|
mkdirSync(join(home, 'bootstrap'), { recursive: true });
|
|
writeFileSync(join(home, 'bootstrap', 'install.jsonl'), '{"phase":"render","outcome":"ok"}\n', 'utf8');
|
|
const hooksDir = join(home, 'integrations', 'hooks');
|
|
mkdirSync(hooksDir, { recursive: true });
|
|
writeFileSync(join(hooksDir, 'heartbeat.jsonl'), '{}\n', 'utf8');
|
|
const receipt: InstallReceipt = {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [hooksDir],
|
|
registrations: [{ host: 'claude-code', scope: 'project', detail: 'mcp gbrain' }],
|
|
...receiptOverrides,
|
|
};
|
|
writeReceipt(home, receipt);
|
|
return { hooksDir, receipt };
|
|
}
|
|
|
|
async function expectRefusal(p: Promise<unknown>, code: string): Promise<BootstrapError> {
|
|
try {
|
|
await p;
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(BootstrapError);
|
|
expect((e as BootstrapError).code).toBe(code as BootstrapError['code']);
|
|
return e as BootstrapError;
|
|
}
|
|
throw new Error(`expected ${code} refusal, got success`);
|
|
}
|
|
|
|
describe('uninstallWorkspace', () => {
|
|
test('removes exactly receipt.created_paths; config.json, clones, foreign files, and the brain survive', async () => {
|
|
const { hooksDir } = seedHome();
|
|
const result = await uninstallWorkspace(ws, { gbrainHomeDir: home });
|
|
|
|
expect(result.removed_paths).toEqual([hooksDir]);
|
|
expect(existsSync(hooksDir)).toBe(false);
|
|
// Everything bootstrap did NOT create survives [CX2-12].
|
|
expect(existsSync(join(home, 'config.json'))).toBe(true);
|
|
expect(existsSync(join(home, 'clones', 'other', 'keep.txt'))).toBe(true);
|
|
expect(existsSync(join(home, 'foreign.txt'))).toBe(true);
|
|
expect(existsSync(join(home, 'brain.pglite'))).toBe(true);
|
|
// Telemetry survives a plain uninstall; only the receipt is consumed.
|
|
expect(existsSync(join(home, 'bootstrap', 'install.jsonl'))).toBe(true);
|
|
expect(readReceipt(home)).toBeNull();
|
|
expect(result.receipt_removed).toBe(true);
|
|
expect(result.brain_deleted).toBe(false);
|
|
// Host registrations come back as structured removal requests.
|
|
expect(result.registration_removals).toEqual([{ host: 'claude-code', scope: 'project', detail: 'mcp gbrain' }]);
|
|
});
|
|
|
|
test('no receipt → NO_RECEIPT refusal ("nothing bootstrap-created on this machine")', async () => {
|
|
// Home exists but was never bootstrapped.
|
|
writeFileSync(join(home, 'config.json'), '{}', 'utf8');
|
|
const err = await expectRefusal(uninstallWorkspace(ws, { gbrainHomeDir: home }), 'NO_RECEIPT');
|
|
expect(err.message).toContain('nothing bootstrap-created');
|
|
});
|
|
|
|
test('missing home entirely → NO_RECEIPT refusal', async () => {
|
|
await expectRefusal(uninstallWorkspace(ws, { gbrainHomeDir: join(home, 'nope') }), 'NO_RECEIPT');
|
|
});
|
|
|
|
test('receipt for a different workspace → RECEIPT_MISMATCH, nothing removed', async () => {
|
|
const { hooksDir } = seedHome({ workspace_dir: join(tmpdir(), 'some-other-ws') });
|
|
await expectRefusal(uninstallWorkspace(ws, { gbrainHomeDir: home }), 'RECEIPT_MISMATCH');
|
|
expect(existsSync(hooksDir)).toBe(true);
|
|
});
|
|
|
|
test('deleteBrain on brain_created_by_bootstrap:false → DELETE_BRAIN_REFUSED, nothing removed [G2]', async () => {
|
|
const { hooksDir } = seedHome({ brain_created_by_bootstrap: false });
|
|
await expectRefusal(uninstallWorkspace(ws, { gbrainHomeDir: home, deleteBrain: true }), 'DELETE_BRAIN_REFUSED');
|
|
// The gate fires BEFORE any removal.
|
|
expect(existsSync(hooksDir)).toBe(true);
|
|
expect(readReceipt(home)).not.toBeNull();
|
|
});
|
|
|
|
test('deleteBrain + confirm(true): brain + bootstrap/ + receipt go; global config + clones survive [CX2-12]', async () => {
|
|
seedHome({ brain_created_by_bootstrap: true });
|
|
const messages: string[] = [];
|
|
const result = await uninstallWorkspace(ws, {
|
|
gbrainHomeDir: home,
|
|
deleteBrain: true,
|
|
confirm: async (msg) => {
|
|
messages.push(msg);
|
|
return true;
|
|
},
|
|
});
|
|
expect(result.brain_deleted).toBe(true);
|
|
expect(existsSync(join(home, 'brain.pglite'))).toBe(false);
|
|
expect(existsSync(join(home, 'bootstrap'))).toBe(false);
|
|
// NEVER wholesale: the home itself and everything foreign survives.
|
|
expect(existsSync(join(home, 'config.json'))).toBe(true);
|
|
expect(existsSync(join(home, 'clones', 'other', 'keep.txt'))).toBe(true);
|
|
// The confirm message enumerated what we know without opening an engine.
|
|
expect(messages).toHaveLength(1);
|
|
expect(messages[0]).toContain('workspace');
|
|
expect(messages[0]).toContain(join(home, 'brain.pglite'));
|
|
// Facts export offered first — facts are not derived state.
|
|
expect(result.steps.map((s) => s.kind)).toContain('facts_export_offer');
|
|
});
|
|
|
|
test('deleteBrain + confirm(false): brain survives, rest of the uninstall proceeds', async () => {
|
|
const { hooksDir } = seedHome({ brain_created_by_bootstrap: true });
|
|
const result = await uninstallWorkspace(ws, {
|
|
gbrainHomeDir: home,
|
|
deleteBrain: true,
|
|
confirm: async () => false,
|
|
});
|
|
expect(result.brain_deleted).toBe(false);
|
|
expect(existsSync(join(home, 'brain.pglite'))).toBe(true);
|
|
expect(existsSync(hooksDir)).toBe(false); // created_paths still removed
|
|
});
|
|
|
|
test('deleteBrain rm FAILURE: brain_deleted false, failure reason surfaced, receipt + telemetry kept (retry possible)', async () => {
|
|
if (typeof process.getuid === 'function' && process.getuid() === 0) return; // root ignores modes
|
|
seedHome({ brain_created_by_bootstrap: true });
|
|
// A read-only subdir makes rmSync fail mid-removal (EACCES unlinking inside).
|
|
const protectedDir = join(home, 'brain.pglite', 'protected');
|
|
mkdirSync(protectedDir, { recursive: true });
|
|
writeFileSync(join(protectedDir, 'file.txt'), 'x', 'utf8');
|
|
chmodSync(protectedDir, 0o555);
|
|
try {
|
|
const result = await uninstallWorkspace(ws, {
|
|
gbrainHomeDir: home,
|
|
deleteBrain: true,
|
|
confirm: async () => true,
|
|
});
|
|
// brain_deleted only after rm succeeded AND the dir is gone — neither here.
|
|
expect(result.brain_deleted).toBe(false);
|
|
const failure = result.skipped_paths.find((s) => s.path === join(home, 'brain.pglite'));
|
|
expect(failure?.reason).toContain('brain deletion failed');
|
|
// Receipt + bootstrap/ telemetry survive so `uninstall --delete-brain` can retry.
|
|
expect(result.receipt_removed).toBe(false);
|
|
expect(readReceipt(home)).not.toBeNull();
|
|
expect(existsSync(join(home, 'bootstrap', 'install.jsonl'))).toBe(true);
|
|
} finally {
|
|
chmodSync(protectedDir, 0o755);
|
|
}
|
|
});
|
|
|
|
test('deleteBrain containment FAILURE (data dir symlinks out): refused with reason, target survives, receipt kept', async () => {
|
|
const outside = mkdtempSync(join(tmpdir(), 'gbrain-uninstall-braintarget-'));
|
|
try {
|
|
writeFileSync(join(outside, 'victim.txt'), 'precious', 'utf8');
|
|
seedHome({ brain_created_by_bootstrap: true });
|
|
// Replace the real data dir with a symlink escaping the home.
|
|
rmSync(join(home, 'brain.pglite'), { recursive: true, force: true });
|
|
symlinkSync(outside, join(home, 'brain.pglite'));
|
|
|
|
const result = await uninstallWorkspace(ws, {
|
|
gbrainHomeDir: home,
|
|
deleteBrain: true,
|
|
confirm: async () => true,
|
|
});
|
|
expect(result.brain_deleted).toBe(false);
|
|
const failure = result.skipped_paths.find((s) => s.reason.includes('brain deletion skipped'));
|
|
expect(failure?.reason).toContain('outside');
|
|
expect(existsSync(join(outside, 'victim.txt'))).toBe(true);
|
|
expect(result.receipt_removed).toBe(false);
|
|
expect(readReceipt(home)).not.toBeNull();
|
|
} finally {
|
|
rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('deleteBrain without a confirm fn is fail-closed: brain survives', async () => {
|
|
seedHome({ brain_created_by_bootstrap: true });
|
|
const result = await uninstallWorkspace(ws, { gbrainHomeDir: home, deleteBrain: true });
|
|
expect(result.brain_deleted).toBe(false);
|
|
expect(existsSync(join(home, 'brain.pglite'))).toBe(true);
|
|
});
|
|
|
|
test('brainStats seam feeds the confirm message (sources + page count)', async () => {
|
|
seedHome({ brain_created_by_bootstrap: true });
|
|
let msg = '';
|
|
await uninstallWorkspace(ws, {
|
|
gbrainHomeDir: home,
|
|
deleteBrain: true,
|
|
brainStats: async () => ({ sources: ['workspace', 'wiki'], pages: 42 }),
|
|
confirm: async (m) => {
|
|
msg = m;
|
|
return false;
|
|
},
|
|
});
|
|
expect(msg).toContain('workspace, wiki');
|
|
expect(msg).toContain('42');
|
|
});
|
|
|
|
test('created_path outside home AND workspace → skipped with reason, file survives', async () => {
|
|
const outside = mkdtempSync(join(tmpdir(), 'gbrain-uninstall-outside-'));
|
|
try {
|
|
const victim = join(outside, 'victim.txt');
|
|
writeFileSync(victim, 'precious', 'utf8');
|
|
const { hooksDir } = seedHome();
|
|
const receipt = readReceipt(home)!;
|
|
writeReceipt(home, { ...receipt, created_paths: [hooksDir, victim] });
|
|
|
|
const result = await uninstallWorkspace(ws, { gbrainHomeDir: home });
|
|
expect(result.removed_paths).toEqual([hooksDir]);
|
|
expect(result.skipped_paths).toEqual([{ path: victim, reason: expect.stringContaining('outside') }]);
|
|
expect(existsSync(victim)).toBe(true);
|
|
} finally {
|
|
rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('created_path symlinked out of the home → skipped, target survives', async () => {
|
|
const outside = mkdtempSync(join(tmpdir(), 'gbrain-uninstall-outside-'));
|
|
try {
|
|
const victim = join(outside, 'victim.txt');
|
|
writeFileSync(victim, 'precious', 'utf8');
|
|
seedHome();
|
|
const link = join(home, 'escape-link');
|
|
symlinkSync(victim, link);
|
|
const receipt = readReceipt(home)!;
|
|
writeReceipt(home, { ...receipt, created_paths: [link] });
|
|
|
|
const result = await uninstallWorkspace(ws, { gbrainHomeDir: home });
|
|
expect(result.removed_paths).toEqual([]);
|
|
expect(result.skipped_paths[0]?.reason).toContain('outside');
|
|
expect(existsSync(victim)).toBe(true);
|
|
} finally {
|
|
rmSync(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('absent created_path → skipped as already absent (idempotent re-run)', async () => {
|
|
const { hooksDir } = seedHome();
|
|
rmSync(hooksDir, { recursive: true, force: true });
|
|
const result = await uninstallWorkspace(ws, { gbrainHomeDir: home });
|
|
expect(result.removed_paths).toEqual([]);
|
|
expect(result.skipped_paths).toEqual([{ path: hooksDir, reason: 'already absent' }]);
|
|
});
|
|
|
|
test('GBRAIN_HOME set without homeExplicit → HOME_GUARD [S3#5]', async () => {
|
|
seedHome();
|
|
process.env.GBRAIN_HOME = ws;
|
|
const err = await expectRefusal(uninstallWorkspace(ws, { gbrainHomeDir: home }), 'HOME_GUARD');
|
|
expect(err.message).toContain('GBRAIN_HOME');
|
|
});
|
|
|
|
test('GBRAIN_HOME + homeExplicit but home outside the workspace → HOME_GUARD', async () => {
|
|
seedHome();
|
|
process.env.GBRAIN_HOME = ws;
|
|
await expectRefusal(uninstallWorkspace(ws, { gbrainHomeDir: home, homeExplicit: true }), 'HOME_GUARD');
|
|
});
|
|
|
|
test('GBRAIN_HOME + homeExplicit but missing gbrain-home signature → HOME_GUARD', async () => {
|
|
// Isolated-style home inside the workspace, but with no config.json/brain.
|
|
const isolated = join(ws, '.gbrain');
|
|
mkdirSync(join(isolated, 'bootstrap'), { recursive: true });
|
|
writeReceipt(isolated, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [],
|
|
registrations: [],
|
|
});
|
|
process.env.GBRAIN_HOME = ws;
|
|
await expectRefusal(uninstallWorkspace(ws, { gbrainHomeDir: isolated, homeExplicit: true }), 'HOME_GUARD');
|
|
});
|
|
|
|
test('GBRAIN_HOME + homeExplicit + contained + signature → proceeds', async () => {
|
|
const isolated = join(ws, '.gbrain');
|
|
mkdirSync(join(isolated, 'brain.pglite'), { recursive: true });
|
|
mkdirSync(join(isolated, 'bootstrap'), { recursive: true });
|
|
writeFileSync(join(isolated, 'config.json'), '{"engine":"pglite"}', 'utf8');
|
|
writeReceipt(isolated, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [],
|
|
registrations: [],
|
|
});
|
|
process.env.GBRAIN_HOME = ws;
|
|
const result = await uninstallWorkspace(ws, { gbrainHomeDir: isolated, homeExplicit: true });
|
|
expect(result.receipt_removed).toBe(true);
|
|
});
|
|
|
|
test('symlinked home rejected via lstat', async () => {
|
|
seedHome();
|
|
const linkHome = join(mkdtempSync(join(tmpdir(), 'gbrain-uninstall-link-')), 'home-link');
|
|
symlinkSync(home, linkHome);
|
|
try {
|
|
const err = await expectRefusal(uninstallWorkspace(ws, { gbrainHomeDir: linkHome }), 'HOME_GUARD');
|
|
expect(err.message).toContain('symlink');
|
|
} finally {
|
|
rmSync(join(linkHome, '..'), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('live serve holding the PGLite lock → LIVE_SERVE, "close your agent sessions first"', async () => {
|
|
seedHome();
|
|
const lockDir = join(home, 'brain.pglite', '.gbrain-lock');
|
|
mkdirSync(lockDir, { recursive: true });
|
|
writeFileSync(
|
|
join(lockDir, 'lock'),
|
|
JSON.stringify({ pid: process.pid, acquired_at: Date.now(), refreshed_at: Date.now(), command: 'serve', subcommand: 'serve' }),
|
|
'utf8',
|
|
);
|
|
const err = await expectRefusal(uninstallWorkspace(ws, { gbrainHomeDir: home }), 'LIVE_SERVE');
|
|
expect(err.message).toContain('close your agent sessions first');
|
|
expect(err.details.pid).toBe(process.pid);
|
|
});
|
|
|
|
test('dead holder\'s stale lock does not block uninstall', async () => {
|
|
seedHome();
|
|
const proc = Bun.spawn(['true'], { stdout: 'ignore', stderr: 'ignore' });
|
|
await proc.exited;
|
|
const lockDir = join(home, 'brain.pglite', '.gbrain-lock');
|
|
mkdirSync(lockDir, { recursive: true });
|
|
writeFileSync(
|
|
join(lockDir, 'lock'),
|
|
JSON.stringify({ pid: proc.pid, acquired_at: Date.now(), command: 'serve', subcommand: 'serve' }),
|
|
'utf8',
|
|
);
|
|
const result = await uninstallWorkspace(ws, { gbrainHomeDir: home });
|
|
expect(result.receipt_removed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('probeLivePgliteHolder', () => {
|
|
test('classifies a live non-serve holder as live but not serve', () => {
|
|
const dataDir = join(home, 'brain.pglite');
|
|
mkdirSync(join(dataDir, '.gbrain-lock'), { recursive: true });
|
|
writeFileSync(
|
|
join(dataDir, '.gbrain-lock', 'lock'),
|
|
JSON.stringify({ pid: process.pid, command: 'embed --stale', subcommand: 'embed' }),
|
|
'utf8',
|
|
);
|
|
expect(probeLivePgliteHolder(dataDir)).toEqual({ pid: process.pid, serve: false });
|
|
});
|
|
|
|
test('legacy lock without subcommand falls back to command-string parsing', () => {
|
|
const dataDir = join(home, 'brain.pglite');
|
|
mkdirSync(join(dataDir, '.gbrain-lock'), { recursive: true });
|
|
writeFileSync(
|
|
join(dataDir, '.gbrain-lock', 'lock'),
|
|
JSON.stringify({ pid: process.pid, command: 'gbrain serve' }),
|
|
'utf8',
|
|
);
|
|
expect(probeLivePgliteHolder(dataDir)).toEqual({ pid: process.pid, serve: true });
|
|
});
|
|
|
|
test('absent or unreadable lock → null', () => {
|
|
expect(probeLivePgliteHolder(join(home, 'brain.pglite'))).toBeNull();
|
|
const dataDir = join(home, 'brain.pglite');
|
|
mkdirSync(join(dataDir, '.gbrain-lock'), { recursive: true });
|
|
writeFileSync(join(dataDir, '.gbrain-lock', 'lock'), 'not json', 'utf8');
|
|
expect(probeLivePgliteHolder(dataDir)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('resolveBrainDataDir', () => {
|
|
test('uses config.json database_path when contained in the home', () => {
|
|
mkdirSync(join(home, 'custom.pglite'), { recursive: true });
|
|
writeFileSync(join(home, 'config.json'), JSON.stringify({ database_path: join(home, 'custom.pglite') }), 'utf8');
|
|
expect(resolveBrainDataDir(home)).toBe(join(home, 'custom.pglite'));
|
|
});
|
|
|
|
test('falls back to <home>/brain.pglite when database_path escapes the home', () => {
|
|
writeFileSync(join(home, 'config.json'), JSON.stringify({ database_path: '/somewhere/else.pglite' }), 'utf8');
|
|
expect(resolveBrainDataDir(home)).toBe(join(home, 'brain.pglite'));
|
|
});
|
|
|
|
test('falls back when config.json is absent', () => {
|
|
expect(resolveBrainDataDir(home)).toBe(join(home, 'brain.pglite'));
|
|
});
|
|
});
|