Files
gbrain/test/sync.test.ts
T
Garry TanandClaude Fable 5 99dd1a083c v0.45.5.0 feat(autopilot): brain currency — honest daemon health, migrate quiesce fence, keyless scheduler chain (#4030)
* 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>

* fix(source-health): wall-clock ceiling for content-relative staleness

A source whose stored content timestamp trailed its last sync reported
zero lag forever — the shape that let a dead sync daemon stay invisible
for 71 days. lagFromContentMs now ramps toward stale past a ceiling
(default 72h, GBRAIN_STALENESS_CEILING_HOURS / GBRAIN_SYNC_FRESHNESS_FAIL_HOURS)
instead of returning a flat 0; ramping rather than stepping keeps the
warn tier firing before the fail tier across sync_freshness,
federation_health, and gbrain status. The ceiling resolves once per
report (hoisted out of the per-source loops), and the numeric env
plumbing moves to a shared warn-once resolver (src/core/env-number.ts)
so doctor and source-health share one memo instead of warning twice.
Doctor's wedged-lock alarm prints minutes below one hour so a
fractional ceiling override cannot produce a "held the lock for 0h"
message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(autopilot): heartbeat-truth status, self-disable guard, migrate quiesce fence

Status becomes honest and engine-free: a pure classifier maps heartbeat
age, markers, and install detection to not_installed/disabled/paused/
never_run/stale/fresh with exit codes 0/1/2, dispatched in cli.ts before
connectEngine so a live PGLite daemon's exclusive lock or a DB outage
cannot take down the alarm. Tolerance is six intervals (the adaptive
scheduler sleeps two between ticks on healthy brains; three flapped
false alarms), NaN-proof at both parse sites, and a cron'd status
monitor line no longer reads as an install.

The generated wrapper self-disables for real when its repo vanishes —
marker first, then supervisor-specific stop (launchctl bootout /
systemctl disable) since a bare exit 0 under KeepAlive is just a quieter
respawn loop — and requires three consecutive misses so a slow-mounting
volume cannot permanently kill a healthy install. Install and uninstall
clear both markers; wrapper artifacts resolve through gbrainHomePath
with GBRAIN_HOME baked in at install time.

gbrain migrate quiesces the whole write plane for its copy window: an
unconditional atomic pause-marker claim doubles as the migration mutex
(a live holder, a foreign hold, or an unwritable marker all abort;
orphan adoption is renameSync compare-and-claim), in-flight writers
drain via the DB lock table plus active-job count (supervisor presence
locks excluded) before enumeration, minion workers stop claiming queued
jobs while the marker is parked, cleanup registers at claim time, the
daemon self-clears a dead migrate's orphan, and after a clean config
flip the daemon detects the engine identity change and relaunches onto
the new engine instead of syncing into the abandoned one indefinitely.
Malformed connection URLs classify as operator config verdicts rather
than crashes, and torn config reads cannot spuriously restart the
daemon.

Marker paths, the launchd label seam, and the pid-ownership primitives
live in the new leaf module src/core/autopilot-paths.ts so migrate and
the worker never import the autopilot command surface (flag-registry
pooling); the regenerated registry reflects the new one-level imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embed,sync): keyless stale embed refuses cleanly; typechange statuses; test-home isolation

The documented agent-scheduler chain (sync, then a stale embed) exited 1
on every keyless brain because EmbeddingDisabledError fell through to a
blanket exit. A bare stale run now refuses cleanly via the pure
isKeylessStaleRefusal predicate (stderr hint, zero-failure result);
explicit asks — a slug, a slugs list, the all flag — still exit 1,
mirroring the dispatch precedence where a slugs list wins over stale.

buildSyncManifest handles the T/U/C git statuses instead of silently
dropping them (typechange routes to modified; the file-to-symlink
staleness residual is documented and filed). Status reports gain
hours_since_last_sync so the ceiling-ramped staleness_hours stops
doubling as the human-facing wall-clock number.

The sync-failures ledger honors GBRAIN_SYNC_FAILURES_DIR and the test
preload (bunfig) points it at a scratch dir, so test runs stop writing
failure rows into the developer's real brain home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): real-launchd lifecycle + agent-scheduler contract harness tier

Two serial-lane e2e files that assert behavior, not artifacts: a
launchd lifecycle suite (shimmed supervisor on every platform; REAL
launchd on darwin under a per-run unique label, own-label cleanup only)
driving install, self-disable via three strikes, status exit codes,
reinstall, and uninstall through the actual CLI; and an
agent-scheduler contract suite that runs the documented sync-and-embed
shell chain end-to-end against a keyless brain, including the
pull-failure case that must break the chain and the heartbeat that must
advance on quiet re-runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: KEY_FILES current-state for the brain-currency wave; BRAIN_CURRENCY design doc

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(migrate,minions): ownership-conditional pause release + post-claim fence re-check

Round-4 adversarial hardening on the quiesce protocol. Orphan adoption
verifies identity AFTER its atomic rename — a racer that completed the
whole adopt-and-rewrite in the read-to-rename gap gets its live marker
restored no-clobber instead of stolen. Marker release compares content
before unlinking, so a claim that changed hands can never delete the new
owner's fence mid-copy. On the worker side, a job whose claim committed
after migrate's drain probe is released back to the queue un-run
(delayed one minute, conditional on our lock token) instead of executed
into the doomed source engine. The cross-host residual — a worker on
another machine never sees a local marker file — is filed as the
DB-visible-pause follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(autopilot): migrate the crontab wrapper-detection pin to the pure seam

The inline crontab.includes check this static pin matched became
crontabIndicatesAutopilotInstall; the pin now exercises the function
directly (wrapper line still detected) and asserts the detect path is
wired through it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.46.0.0 feat(autopilot): brain currency — honest daemon health, migrate quiesce fence, keyless scheduler chain

VERSION + CHANGELOG + TODOS for the brain-currency PR-A wave, plus the
version-stamped bootstrap runbook and vendored template regen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: sync documentation for v0.46.0.0

The brain-currency wave shipped new user-facing surface the guides did not
carry: the trustworthy autopilot --status exit codes (0/1/2 + paused),
the keyless-safe sync-and-embed chain, and the migrate quiesce fence.

- docs/guides/live-sync.md: keyless clean-refusal note on the embed --stale
  primitive; new "gate on the daemon's heartbeat" verify item with the
  exit-code contract.
- docs/guides/cron-schedule.md: the recommended autopilot health-gate cron
  line (the status classifier already special-cases this monitor line so it
  never reads as an install).
- docs/ENGINES.md: migrate --to now documents the pause-marker mutex, worker
  job-pickup fence, DB-lock drain (GBRAIN_MIGRATE_QUIESCE_SECONDS, default
  300), crash-safe release, and daemon relaunch after the flip.
- docs/operations/headless-install.md: keyless mode's "refuse cleanly" claim
  made concrete — the documented chain exits 0 on a bare stale embed.
- INSTALL_FOR_AGENTS.md Step 7: health-gate bullet + keyless-safe chain note.
- skills/maintain/SKILL.md: autopilot check now teaches the exit-code
  semantics and the --json report fields.
- docs/TESTING.md: inventory entries for the two new harness-tier serial
  suites (real-launchd lifecycle, agent-scheduler shell-chain contract).
- llms-full.txt regenerated (TESTING.md is inlined in the bundle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: cross-model doc-review fixes for v0.46.0.0

Round two from the post-ship documentation review, checked against the code:

- CHANGELOG.md: two shipped fixes were missing from the 0.46.0.0 entry
  (git typechange/unmerged statuses no longer dropped by sync; a wedged
  lock holder past the staleness ceiling now fails doctor with the
  break-lock remedy), and the env-resolver bullet over-claimed its
  coverage — narrowed to the doctor + staleness-threshold vars it
  actually serves. Additive + accuracy only; no entries replaced.
- docs/guides/live-sync.md: Tricky Spots item for the staleness
  wall-clock ceiling (GBRAIN_STALENESS_CEILING_HOURS, default 72h,
  ramp-not-step) and the hours_since_last_sync status field.
- docs/ENGINES.md: the quiesce paragraph over-stated marker release —
  now describes the pid-checked orphan adoption, the daemon self-heal
  poll, and the confirm-the-pid manual step for daemonless hosts.
- docs/guides/minions-fix.md: the verify block's bare autopilot --status
  now notes the exit code is the verdict (and the set -e caveat).
- llms-full.txt regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.45.5.0 chore(release): re-version 0.46.0.0 -> 0.45.5.0

Same release content; the version slots into the 0.45.x line per the
natural-next-off-master convention. VERSION, package.json, CHANGELOG
header, bootstrap runbook stamp, vendored template regen, lockfile, and
llms bundles all move together (trio audit green; stamp + template +
llms freshness guards re-run green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(skills): refresh skills.lock version stamp for 0.45.5.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 09:40:57 -07:00

1184 lines
46 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
import { buildSyncManifest, isSyncable, pathToSlug, pruneDir, isCodeFilePath } from '../src/core/sync.ts';
import { buildAutoEmbedArgs, buildGitInvocation } from '../src/commands/sync.ts';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
import { join } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
describe('buildSyncManifest', () => {
test('parses A/M/D entries from single commit', () => {
const output = `A\tpeople/new-person.md\nM\tpeople/existing-person.md\nD\tpeople/deleted-person.md`;
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual(['people/new-person.md']);
expect(manifest.modified).toEqual(['people/existing-person.md']);
expect(manifest.deleted).toEqual(['people/deleted-person.md']);
expect(manifest.renamed).toEqual([]);
});
test('parses R100 rename entries', () => {
const output = `R100\tpeople/old-name.md\tpeople/new-name.md`;
const manifest = buildSyncManifest(output);
expect(manifest.renamed).toEqual([{ from: 'people/old-name.md', to: 'people/new-name.md' }]);
expect(manifest.added).toEqual([]);
expect(manifest.modified).toEqual([]);
expect(manifest.deleted).toEqual([]);
});
test('parses partial rename (R075)', () => {
const output = `R075\tpeople/old.md\tpeople/new.md`;
const manifest = buildSyncManifest(output);
expect(manifest.renamed).toEqual([{ from: 'people/old.md', to: 'people/new.md' }]);
});
test('T (typechange) counts as modified — was silently dropped', () => {
// file <-> symlink. Reachable with the flags gbrain actually passes
// (`--name-status -M`). Dropping it meant the change never reached the
// index until some later commit happened to touch the same path.
const manifest = buildSyncManifest(`T\tpeople/now-a-symlink.md`);
expect(manifest.modified).toEqual(['people/now-a-symlink.md']);
expect(manifest.added).toEqual([]);
expect(manifest.deleted).toEqual([]);
});
test('U (unmerged) degrades to modified rather than vanishing', () => {
// Only reachable in a conflicted worktree, which sync does not run against.
// Defensive: re-import beats silently skipping.
expect(buildSyncManifest(`U\tpeople/conflicted.md`).modified).toEqual(['people/conflicted.md']);
});
test('C (copy) imports the destination — unreachable today, defensive', () => {
// Requires -C/--find-copies, which gbrain does not pass. If the flags ever
// change, the copy destination is a NEW path that must be imported, and the
// source is untouched — so it is an add, not a rename.
const manifest = buildSyncManifest(`C100\tpeople/src.md\tpeople/copy.md`);
expect(manifest.added).toEqual(['people/copy.md']);
expect(manifest.renamed).toEqual([]);
expect(manifest.deleted).toEqual([]);
});
test('handles empty diff', () => {
const manifest = buildSyncManifest('');
expect(manifest.added).toEqual([]);
expect(manifest.modified).toEqual([]);
expect(manifest.deleted).toEqual([]);
expect(manifest.renamed).toEqual([]);
});
test('handles mixed entries with blank lines', () => {
const output = `A\tpeople/a.md\n\nM\tpeople/b.md\n\nD\tpeople/c.md`;
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual(['people/a.md']);
expect(manifest.modified).toEqual(['people/b.md']);
expect(manifest.deleted).toEqual(['people/c.md']);
});
test('skips malformed lines', () => {
const output = `A\tpeople/a.md\ngarbage line\nM\tpeople/b.md`;
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual(['people/a.md']);
expect(manifest.modified).toEqual(['people/b.md']);
});
});
describe('isSyncable', () => {
test('accepts normal .md files', () => {
expect(isSyncable('people/pedro-franceschi.md')).toBe(true);
expect(isSyncable('meetings/2026-04-03-lunch.md')).toBe(true);
expect(isSyncable('daily/2026-04-05.md')).toBe(true);
expect(isSyncable('notes.md')).toBe(true);
});
test('accepts .mdx files', () => {
expect(isSyncable('components/hero.mdx')).toBe(true);
expect(isSyncable('docs/getting-started.mdx')).toBe(true);
});
test('rejects non-.md/.mdx files', () => {
expect(isSyncable('people/photo.jpg')).toBe(false);
expect(isSyncable('config.json')).toBe(false);
expect(isSyncable('src/cli.ts')).toBe(false);
});
test('rejects files in hidden directories', () => {
expect(isSyncable('.git/config')).toBe(false);
expect(isSyncable('.obsidian/plugins.md')).toBe(false);
expect(isSyncable('people/.hidden/secret.md')).toBe(false);
});
test('rejects .raw/ sidecar directories', () => {
expect(isSyncable('people/pedro.raw/source.md')).toBe(false);
expect(isSyncable('dir/.raw/notes.md')).toBe(false);
});
test('rejects skip-list basenames', () => {
expect(isSyncable('schema.md')).toBe(false);
expect(isSyncable('index.md')).toBe(false);
expect(isSyncable('log.md')).toBe(false);
expect(isSyncable('README.md')).toBe(false);
expect(isSyncable('people/README.md')).toBe(false);
});
test('accepts ops/ — ordinary content directory, not pruned (#2404)', () => {
expect(isSyncable('ops/deploy-log.md')).toBe(true);
expect(isSyncable('ops/config.md')).toBe(true);
expect(isSyncable('ops/tasks.md')).toBe(true);
});
// ────────────────────────────────────────────────────────────────
// v0.36 walker drift fix (closes #923, #202): node_modules exclusion
// ────────────────────────────────────────────────────────────────
test('CRITICAL latent-bug regression: rejects node_modules paths at any depth', () => {
// Pre-v0.36, isSyncable had no node_modules check. Any markdown file
// under a non-dot `node_modules` directory slipped through. This is
// the canonical latent-bug fix gated by IRON RULE per the wave plan.
expect(isSyncable('node_modules/some-pkg/README.md')).toBe(false);
expect(isSyncable('node_modules/some-pkg/CHANGELOG.md')).toBe(false);
expect(isSyncable('node_modules/some-pkg/docs/api.md')).toBe(false);
expect(isSyncable('apps/web/node_modules/dep/notes.md')).toBe(false);
});
});
describe('pruneDir', () => {
test('blocks node_modules (no leading dot, the latent-bug case)', () => {
expect(pruneDir('node_modules')).toBe(false);
});
test('blocks dot-prefix dirs (.git, .obsidian, .raw, .cache, etc.)', () => {
expect(pruneDir('.git')).toBe(false);
expect(pruneDir('.obsidian')).toBe(false);
expect(pruneDir('.raw')).toBe(false);
expect(pruneDir('.cache')).toBe(false);
expect(pruneDir('.vscode')).toBe(false);
});
test('allows ops — ordinary content dir, not a vendor tree (#2404)', () => {
expect(pruneDir('ops')).toBe(true);
});
test('blocks vendored / generated trees', () => {
expect(pruneDir('vendor')).toBe(false);
expect(pruneDir('dist')).toBe(false);
expect(pruneDir('build')).toBe(false);
expect(pruneDir('venv')).toBe(false);
});
test('blocks *.raw sidecar dirs (gbrain convention)', () => {
expect(pruneDir('.raw')).toBe(false);
expect(pruneDir('pedro.raw')).toBe(false);
expect(pruneDir('article.raw')).toBe(false);
});
test('allows normal content dirs', () => {
expect(pruneDir('wiki')).toBe(true);
expect(pruneDir('people')).toBe(true);
expect(pruneDir('meetings')).toBe(true);
expect(pruneDir('corpus')).toBe(true);
expect(pruneDir('2026')).toBe(true);
});
test('empty string returns true (defensive default)', () => {
expect(pruneDir('')).toBe(true);
});
});
describe('isCodeFilePath', () => {
test('v0.36.x #878 regression: Terraform / HCL extensions are admitted', () => {
expect(isCodeFilePath('infra/main.tf')).toBe(true);
expect(isCodeFilePath('infra/prod.tfvars')).toBe(true);
expect(isCodeFilePath('modules/network/variables.hcl')).toBe(true);
});
test('extensions are case-insensitive', () => {
expect(isCodeFilePath('INFRA/MAIN.TF')).toBe(true);
expect(isCodeFilePath('Modules/Net/Vars.HCL')).toBe(true);
});
test('does not false-positive on lookalike suffixes', () => {
expect(isCodeFilePath('docs/notes.txt')).toBe(false);
expect(isCodeFilePath('readme.tflint')).toBe(false);
expect(isCodeFilePath('config.hcling')).toBe(false);
});
test('still accepts the v0.20.0 baseline set (regression guard)', () => {
expect(isCodeFilePath('src/foo.ts')).toBe(true);
expect(isCodeFilePath('src/bar.py')).toBe(true);
expect(isCodeFilePath('config.toml')).toBe(true);
});
});
describe('pathToSlug', () => {
test('strips .md extension and lowercases', () => {
expect(pathToSlug('people/pedro-franceschi.md')).toBe('people/pedro-franceschi');
});
test('normalizes to lowercase', () => {
expect(pathToSlug('People/Pedro-Franceschi.md')).toBe('people/pedro-franceschi');
});
test('strips leading slash', () => {
expect(pathToSlug('/people/pedro.md')).toBe('people/pedro');
});
test('normalizes backslash separators', () => {
expect(pathToSlug('people\\pedro.md')).toBe('people/pedro');
});
test('handles flat files', () => {
expect(pathToSlug('notes.md')).toBe('notes');
});
test('handles nested paths', () => {
expect(pathToSlug('projects/gbrain/spec.md')).toBe('projects/gbrain/spec');
});
test('adds repo prefix when provided', () => {
expect(pathToSlug('people/pedro.md', 'brain')).toBe('brain/people/pedro');
});
test('no prefix when not provided', () => {
expect(pathToSlug('people/pedro.md')).toBe('people/pedro');
});
test('handles empty string', () => {
expect(pathToSlug('')).toBe('');
});
test('handles file with only extension', () => {
expect(pathToSlug('.md')).toBe('');
});
test('slugifies spaces to hyphens', () => {
expect(pathToSlug('Apple Notes/2017-05-03 ohmygreen.md')).toBe('apple-notes/2017-05-03-ohmygreen');
});
test('strips special characters', () => {
expect(pathToSlug('notes/meeting (march 2024).md')).toBe('notes/meeting-march-2024');
});
});
describe('isSyncable edge cases', () => {
test('rejects uppercase .MD extension', () => {
// isSyncable checks path.endsWith('.md'), so .MD should fail
expect(isSyncable('people/someone.MD')).toBe(false);
});
test('rejects files with no extension', () => {
expect(isSyncable('README')).toBe(false);
});
test('accepts deeply nested .md files', () => {
expect(isSyncable('a/b/c/d/e/f/deep.md')).toBe(true);
});
test('rejects .md files inside nested hidden dirs', () => {
expect(isSyncable('docs/.internal/secret.md')).toBe(false);
});
});
describe('buildSyncManifest edge cases', () => {
test('handles tab-separated fields correctly', () => {
const output = "A\tpath/to/file.md";
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual(['path/to/file.md']);
});
test('handles multiple renames', () => {
const output = [
'R100\told/a.md\tnew/a.md',
'R095\told/b.md\tnew/b.md',
].join('\n');
const manifest = buildSyncManifest(output);
expect(manifest.renamed).toHaveLength(2);
expect(manifest.renamed[0].from).toBe('old/a.md');
expect(manifest.renamed[1].from).toBe('old/b.md');
});
test('ignores unknown status codes', () => {
const output = "X\tunknown/file.md";
const manifest = buildSyncManifest(output);
expect(manifest.added).toEqual([]);
expect(manifest.modified).toEqual([]);
expect(manifest.deleted).toEqual([]);
expect(manifest.renamed).toEqual([]);
});
});
// ────────────────────────────────────────────────────────────────
// performSync dry-run (v0.17 regression guard for full-sync silent writes)
// ────────────────────────────────────────────────────────────────
describe('performSync dry-run never writes', () => {
let engine: PGLiteEngine;
let repoPath: string;
// One PGLite per file — beforeEach wipes data only. Each test still gets a
// fresh git repo via mkdtempSync, but skips the ~20s PGLite cold-start.
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-sync-dryrun-'));
execSync('git init', { cwd: repoPath, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: repoPath, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: repoPath, stdio: 'pipe' });
mkdirSync(join(repoPath, 'people'), { recursive: true });
writeFileSync(join(repoPath, 'people/alice.md'), [
'---',
'type: person',
'title: Alice',
'---',
'',
'Alice is a person.',
].join('\n'));
writeFileSync(join(repoPath, 'people/bob.md'), [
'---',
'type: person',
'title: Bob',
'---',
'',
'Bob is another person.',
].join('\n'));
execSync('git add -A && git commit -m "initial"', { cwd: repoPath, stdio: 'pipe' });
});
afterEach(() => {
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
});
test('first-sync dry-run does NOT write to DB or advance the bookmark', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const result = await performSync(engine, {
repoPath,
dryRun: true,
noPull: true,
noEmbed: true,
});
// Status + counts reflect what WOULD be imported.
expect(result.status).toBe('dry_run');
expect(result.added).toBe(2); // alice + bob, both syncable
expect(result.chunksCreated).toBe(0);
expect(result.embedded).toBe(0);
// DB is clean: no pages written.
expect(await engine.getPage('people/alice')).toBeNull();
expect(await engine.getPage('people/bob')).toBeNull();
// Bookmark NOT set — this is the regression the guard enforces.
expect(await engine.getConfig('sync.last_commit')).toBeNull();
expect(await engine.getConfig('sync.repo_path')).toBeNull();
});
test('first sync without origin skips git pull noise and uses local working tree', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const messages: string[] = [];
const originalError = console.error;
console.error = (...args: unknown[]) => { messages.push(args.map(String).join(' ')); };
try {
const result = await performSync(engine, {
repoPath,
noEmbed: true,
});
expect(result.status).toBe('first_sync');
} finally {
console.error = originalError;
}
expect(messages.some(m => m.includes('No origin remote') && m.includes('skipping git pull'))).toBe(true);
expect(messages.some(m => m.includes('sync.git_pull start'))).toBe(false);
expect(messages.some(m => m.includes('git pull failed'))).toBe(false);
});
test('incremental dry-run does NOT write to DB or advance the bookmark', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// First do a real sync to seed the bookmark.
const real = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
expect(real.status).toBe('first_sync');
const bookmarkAfterReal = await engine.getConfig('sync.last_commit');
expect(bookmarkAfterReal).not.toBeNull();
// Add a third file.
writeFileSync(join(repoPath, 'people/carol.md'), [
'---',
'type: person',
'title: Carol',
'---',
'',
'Carol joins the cast.',
].join('\n'));
execSync('git add -A && git commit -m "add carol"', { cwd: repoPath, stdio: 'pipe' });
// Incremental sync in dry-run mode.
const result = await performSync(engine, {
repoPath,
dryRun: true,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('dry_run');
expect(result.added).toBe(1); // carol only
expect(result.chunksCreated).toBe(0);
expect(result.embedded).toBe(0);
// carol is NOT in the DB.
expect(await engine.getPage('people/carol')).toBeNull();
// alice + bob still present from the real sync.
expect(await engine.getPage('people/alice')).not.toBeNull();
expect(await engine.getPage('people/bob')).not.toBeNull();
// Bookmark unchanged — still at the pre-carol commit.
const bookmarkAfterDry = await engine.getConfig('sync.last_commit');
expect(bookmarkAfterDry).toBe(bookmarkAfterReal);
});
test('strategy-changing dry-run preserves previously indexed out-of-strategy pages', async () => {
const { performSync } = await import('../src/commands/sync.ts');
await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
const pageBefore = await engine.getPage('people/alice');
const bookmarkBefore = await engine.getConfig('sync.last_commit');
expect(pageBefore).not.toBeNull();
expect(bookmarkBefore).not.toBeNull();
writeFileSync(join(repoPath, 'people/alice.md'), [
'---',
'type: person',
'title: Alice',
'---',
'',
'Alice changed after the initial sync.',
].join('\n'));
execSync('git add -A && git commit -m "update alice"', { cwd: repoPath, stdio: 'pipe' });
const result = await performSync(engine, {
repoPath,
strategy: 'code',
dryRun: true,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('dry_run');
const pageAfter = await engine.getPage('people/alice');
expect(pageAfter).not.toBeNull();
expect(pageAfter!.compiled_truth).toBe(pageBefore!.compiled_truth);
expect(await engine.getConfig('sync.last_commit')).toBe(bookmarkBefore);
});
test('strategy-changing real sync deletes previously indexed out-of-strategy pages', async () => {
const { performSync } = await import('../src/commands/sync.ts');
await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
});
expect(await engine.getPage('people/alice')).not.toBeNull();
writeFileSync(join(repoPath, 'people/alice.md'), [
'---',
'type: person',
'title: Alice',
'---',
'',
'Alice changed after the initial sync.',
].join('\n'));
execSync('git add -A && git commit -m "update alice"', { cwd: repoPath, stdio: 'pipe' });
await performSync(engine, {
repoPath,
strategy: 'code',
noPull: true,
noEmbed: true,
});
expect(await engine.getPage('people/alice')).toBeNull();
});
test('dry-run does not attempt git pull when origin exists', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const remotePath = mkdtempSync(join(tmpdir(), 'gbrain-sync-dryrun-remote-'));
try {
execSync('git init --bare', { cwd: remotePath, stdio: 'pipe' });
execSync(`git remote add origin ${JSON.stringify(remotePath)}`, {
cwd: repoPath,
stdio: 'pipe',
});
const messages: string[] = [];
const originalError = console.error;
console.error = (...args: unknown[]) => {
messages.push(args.map(String).join(' '));
};
try {
const result = await performSync(engine, {
repoPath,
dryRun: true,
noEmbed: true,
});
expect(result.status).toBe('dry_run');
} finally {
console.error = originalError;
}
expect(messages.some(message => message.includes('sync.git_pull start'))).toBe(false);
expect(messages.some(message => message.includes('git pull failed'))).toBe(false);
} finally {
rmSync(remotePath, { recursive: true, force: true });
}
});
test('full-sync (--full) dry-run does NOT write to DB or advance the bookmark', async () => {
const { performSync } = await import('../src/commands/sync.ts');
// Seed the bookmark so we hit the full-sync-with-bookmark path when --full is set.
await performSync(engine, { repoPath, noPull: true, noEmbed: true });
// Clear DB so we can observe that a --full dry-run doesn't re-import.
await (engine as any).db.exec(`DELETE FROM content_chunks; DELETE FROM pages;`);
const bookmarkBefore = await engine.getConfig('sync.last_commit');
expect(bookmarkBefore).not.toBeNull();
const result = await performSync(engine, {
repoPath,
full: true, // force full-sync path
dryRun: true,
noPull: true,
noEmbed: true,
});
expect(result.status).toBe('dry_run');
expect(result.added).toBe(2); // alice + bob would be imported
expect(result.chunksCreated).toBe(0);
// DB empty — full-sync dry-run did not reimport.
expect(await engine.getPage('people/alice')).toBeNull();
expect(await engine.getPage('people/bob')).toBeNull();
// Bookmark unchanged.
const bookmarkAfter = await engine.getConfig('sync.last_commit');
expect(bookmarkAfter).toBe(bookmarkBefore);
});
test('SyncResult exposes embedded count field', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const result = await performSync(engine, {
repoPath,
dryRun: true,
noPull: true,
noEmbed: true,
});
// Structural assertion: the contract includes `embedded: number`.
expect(typeof result.embedded).toBe('number');
});
test('--include-gitignored imports ignored files even when git HEAD is unchanged', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const first = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
});
expect(first.status).toBe('first_sync');
writeFileSync(join(repoPath, '.gitignore'), 'Meetings/\n');
execSync('git add .gitignore && git commit -m "ignore generated meetings"', { cwd: repoPath, stdio: 'pipe' });
const checkpoint = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
});
expect(checkpoint.status).toBe('up_to_date');
mkdirSync(join(repoPath, 'Meetings'), { recursive: true });
writeFileSync(join(repoPath, 'Meetings/weekly.md'), [
'---',
'type: meeting',
'title: Weekly',
'---',
'',
'Generated meeting notes.',
].join('\n'));
const withoutFlag = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
});
expect(withoutFlag.status).toBe('up_to_date');
expect(await engine.getPage('meetings/weekly')).toBeNull();
const withFlag = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
includeGitignored: true,
});
expect(withFlag.added).toBe(1);
const page = await engine.getPage('meetings/weekly');
expect(page).not.toBeNull();
expect(page!.title).toBe('Weekly');
});
test('detached HEAD skips git pull and ingests local working-tree files', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const seeded = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
});
expect(seeded.status).toBe('first_sync');
execSync('git checkout --detach HEAD', { cwd: repoPath, stdio: 'pipe' });
writeFileSync(join(repoPath, 'people/detached-local.md'), [
'---',
'type: person',
'title: Detached Local',
'---',
'',
'This file exists only in the detached working tree.',
].join('\n'));
const errors: string[] = [];
const originalError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args.map(String).join(' '));
};
try {
const result = await performSync(engine, {
repoPath,
noEmbed: true,
noExtract: true,
});
expect(result.status).toBe('synced');
expect(result.added).toBe(1);
expect(result.pagesAffected).toContain('people/detached-local');
} finally {
console.error = originalError;
}
expect(errors.join('\n')).toContain(`Detached HEAD on ${repoPath}; skipping git pull. Syncing from local working tree.`);
expect(errors.join('\n')).not.toContain('git pull failed');
const page = await engine.getPage('people/detached-local');
expect(page).not.toBeNull();
expect(page!.title).toBe('Detached Local');
});
test('detached HEAD with --no-pull also ingests local working-tree files', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const seeded = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
});
expect(seeded.status).toBe('first_sync');
execSync('git checkout --detach HEAD', { cwd: repoPath, stdio: 'pipe' });
writeFileSync(join(repoPath, 'people/detached-nopull.md'), [
'---',
'type: person',
'title: Detached NoPull',
'---',
'',
'Only in detached working tree, --no-pull caller.',
].join('\n'));
const result = await performSync(engine, {
repoPath,
noPull: true,
noEmbed: true,
noExtract: true,
});
expect(result.status).toBe('synced');
expect(result.added).toBe(1);
expect(result.pagesAffected).toContain('people/detached-nopull');
const page = await engine.getPage('people/detached-nopull');
expect(page).not.toBeNull();
expect(page!.title).toBe('Detached NoPull');
});
});
describe('sync regression — #132 nested transaction deadlock', () => {
test('src/commands/sync.ts does not wrap the add/modify loop in engine.transaction()', async () => {
const source = await Bun.file(new URL('../src/commands/sync.ts', import.meta.url)).text();
// Accept either of the historical loop shapes: the original inline
// `for (const path of [...filtered.added, ...filtered.modified])` or
// the v0.15.2 progress-wrapped variant where the list is hoisted into
// a local `addsAndMods` variable first.
const inlineIdx = source.indexOf('for (const path of [...filtered.added, ...filtered.modified]');
const hoistedIdx = source.indexOf('const addsAndMods = [...filtered.added, ...filtered.modified]');
const loopStart = inlineIdx !== -1 ? inlineIdx : hoistedIdx;
expect(loopStart).toBeGreaterThan(-1);
const prelude = source.slice(0, loopStart);
const lastTxIdx = prelude.lastIndexOf('engine.transaction');
if (lastTxIdx !== -1) {
const lineStart = prelude.lastIndexOf('\n', lastTxIdx) + 1;
const line = prelude.slice(lineStart, prelude.indexOf('\n', lastTxIdx));
expect(line.trim().startsWith('//')).toBe(true);
}
});
});
describe('resolveSlugByPathOrSourcePath (CJK wave v0.32.7, codex F4)', () => {
let pgEngine: PGLiteEngine;
beforeAll(async () => {
pgEngine = new PGLiteEngine();
await pgEngine.connect({});
await pgEngine.initSchema();
});
afterAll(async () => {
await pgEngine.disconnect();
});
beforeEach(async () => {
await (pgEngine as any).db.exec('DELETE FROM content_chunks');
await (pgEngine as any).db.exec('DELETE FROM pages');
});
test('returns stored slug when source_path matches a row', async () => {
const { resolveSlugByPathOrSourcePath } = await import('../src/commands/sync.ts');
// Seed a frontmatter-fallback page: slug doesn't derive from path (emoji)
await pgEngine.executeRaw(
`INSERT INTO pages (slug, type, title, compiled_truth, page_kind, source_path)
VALUES ('projects/launch', 'project', 'Launch', 'body', 'markdown', '🚀.md')`,
);
const slug = await resolveSlugByPathOrSourcePath(pgEngine, '🚀.md');
expect(slug).toBe('projects/launch');
});
test('falls back to resolveSlugForPath when no source_path matches', async () => {
const { resolveSlugByPathOrSourcePath } = await import('../src/commands/sync.ts');
// No row seeded — fallback returns the path-derived slug.
const slug = await resolveSlugByPathOrSourcePath(pgEngine, 'concepts/hello-world.md');
expect(slug).toBe('concepts/hello-world');
});
test('scoped by source_id when provided', async () => {
const { resolveSlugByPathOrSourcePath } = await import('../src/commands/sync.ts');
// Same source_path under TWO sources — without source_id scope we'd
// get either at random. With source_id we get the right one.
await pgEngine.executeRaw(
`INSERT INTO sources (id, name) VALUES ('source-a', 'A') ON CONFLICT DO NOTHING`,
);
await pgEngine.executeRaw(
`INSERT INTO sources (id, name) VALUES ('source-b', 'B') ON CONFLICT DO NOTHING`,
);
await pgEngine.executeRaw(
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, page_kind, source_path)
VALUES ('source-a', 'slug-a/page', 'note', 'A', 'a', 'markdown', '🚀.md')`,
);
await pgEngine.executeRaw(
`INSERT INTO pages (source_id, slug, type, title, compiled_truth, page_kind, source_path)
VALUES ('source-b', 'slug-b/page', 'note', 'B', 'b', 'markdown', '🚀.md')`,
);
expect(await resolveSlugByPathOrSourcePath(pgEngine, '🚀.md', 'source-a')).toBe('slug-a/page');
expect(await resolveSlugByPathOrSourcePath(pgEngine, '🚀.md', 'source-b')).toBe('slug-b/page');
});
});
describe('git() helper invocation order (CJK wave v0.32.7)', () => {
// The git CLI requires `-c key=val` to appear BEFORE the subcommand,
// and `-C path` BEFORE the subcommand too. Pin the emit order so a future
// refactor can't silently put `-c` after the subcommand and break CJK
// path emission.
test('core.quotepath=false is always emitted first', () => {
const argv = buildGitInvocation('/repo', ['diff', '--name-status']);
expect(argv).toEqual([
'-c', 'core.quotepath=false',
'-C', '/repo',
'diff', '--name-status',
]);
});
test('extra configs append AFTER quotepath, BEFORE -C and subcommand', () => {
const argv = buildGitInvocation('/repo', ['diff'], ['foo=bar', 'baz=qux']);
expect(argv).toEqual([
'-c', 'core.quotepath=false',
'-c', 'foo=bar',
'-c', 'baz=qux',
'-C', '/repo',
'diff',
]);
});
test('empty args produces a valid invocation', () => {
const argv = buildGitInvocation('/repo', []);
expect(argv).toEqual([
'-c', 'core.quotepath=false',
'-C', '/repo',
]);
});
});
describe('sync auto-embed arguments', () => {
test('scopes incremental source sync embedding to the same source', () => {
expect(buildAutoEmbedArgs(['hello-js'], 'source-a')).toEqual([
'--source',
'source-a',
'--slugs',
'hello-js',
]);
});
test('keeps default-source sync embed arguments unchanged', () => {
expect(buildAutoEmbedArgs(['people/alice'])).toEqual(['--slugs', 'people/alice']);
});
});
// #1970: sync silently full-walks forever when last_commit is unreachable.
// The bookmark can point at a commit orphaned by a history rewrite (force-push,
// master→main consolidation, squash). The old guard sent BOTH "object missing"
// AND "not an ancestor" to a blind full re-walk that never advanced the bookmark.
// The fix: only a truly-absent object forces a full reconcile; a present-but-
// non-ancestor bookmark is diffed tree-to-tree directly (`git diff A..B` needs
// no ancestry). Plus F-A (full-sync delete reconcile), F-B (oversized-diff
// fallback), F-C (rename-to-unsyncable deletes the old page).
describe('#1970: unreachable last_commit bookmark recovery', () => {
let engine: PGLiteEngine;
const repos: string[] = [];
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
afterEach(() => {
while (repos.length) {
const d = repos.pop();
if (d) rmSync(d, { recursive: true, force: true });
}
});
function personMd(title: string, body: string): string {
return ['---', 'type: person', `title: ${title}`, '---', '', body].join('\n');
}
/** Create a temp git repo seeded with the given files + an initial commit. */
function mkRepo(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-1970-'));
repos.push(dir);
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
for (const [rel, content] of Object.entries(files)) {
mkdirSync(join(dir, rel, '..'), { recursive: true });
writeFileSync(join(dir, rel), content);
}
execSync('git add -A && git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
return dir;
}
const SYNC_OPTS = { noPull: true, noEmbed: true, noExtract: true, sourceId: 'default' } as const;
async function bookmark(): Promise<string | null> {
const rows = await engine.executeRaw<{ last_commit: string | null }>(
`SELECT last_commit FROM sources WHERE id = 'default'`,
);
return rows[0]?.last_commit ?? null;
}
async function captureLog<T>(fn: () => Promise<T>): Promise<{ result: T; out: string }> {
const lines: string[] = [];
const origLog = console.log;
console.log = (...args: unknown[]) => { lines.push(args.map(String).join(' ')); };
try {
const result = await fn();
return { result, out: lines.join('\n') };
} finally {
console.log = origLog;
}
}
test('orphan-present (not an ancestor): diffs tree-to-tree, imports only the delta, advances bookmark', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({
'people/alice.md': personMd('Alice', 'Alice is a person.'),
'people/bob.md': personMd('Bob', 'Bob is a person.'),
});
const first = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(first.status).toBe('first_sync');
const orphan = await bookmark();
expect(orphan).not.toBeNull();
// Rewrite history: amend the only commit (adds delta.md). The previous tip
// is now orphaned but still on disk — cat-file succeeds, is-ancestor fails.
writeFileSync(join(repo, 'people/carol.md'), personMd('Carol', 'Carol joins.'));
execSync('git add -A && git commit --amend -m "amended with carol"', { cwd: repo, stdio: 'pipe' });
// Sanity: the stored bookmark is present but no longer an ancestor of HEAD.
expect(execSync(`git cat-file -t ${orphan}`, { cwd: repo }).toString().trim()).toBe('commit');
let isAncestor = true;
try { execSync(`git merge-base --is-ancestor ${orphan} HEAD`, { cwd: repo, stdio: 'pipe' }); }
catch { isAncestor = false; }
expect(isAncestor).toBe(false);
const { result, out } = await captureLog(() => performSync(engine, { repoPath: repo, ...SYNC_OPTS }));
// Incremental diff path (status 'synced'), NOT a full re-walk ('first_sync').
expect(result.status).toBe('synced');
expect(result.added).toBe(1);
expect(out).toContain('not an ancestor of HEAD');
expect(await engine.getPage('people/carol')).not.toBeNull();
// Bookmark advanced off the orphan onto the rewritten HEAD.
const advanced = await bookmark();
expect(advanced).not.toBe(orphan);
expect(advanced).toBe(execSync('git rev-parse HEAD', { cwd: repo }).toString().trim());
});
test('orphan-absent (object gc\'d): falls back to a full reconcile', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
// Simulate an orphaned-AND-pruned bookmark: a valid-shaped SHA with no object.
await engine.executeRaw(
`UPDATE sources SET last_commit = $1 WHERE id = 'default'`,
['deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'],
);
writeFileSync(join(repo, 'people/bob.md'), personMd('Bob', 'Bob is a person.'));
execSync('git add -A && git commit -m "add bob"', { cwd: repo, stdio: 'pipe' });
const result = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
// Object absent → authoritative full reconcile.
expect(result.status).toBe('first_sync');
expect(await engine.getPage('people/bob')).not.toBeNull();
expect(await engine.getPage('people/alice')).not.toBeNull();
});
test('divergence: a file present in the orphan tree but dropped from HEAD is deleted', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({
'people/alice.md': personMd('Alice', 'Alice is a person.'),
'people/bob.md': personMd('Bob', 'Bob is a person.'),
});
await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(await engine.getPage('people/bob')).not.toBeNull();
// Rewrite the tip: drop bob, edit alice. Orphans the prior tip (still on disk).
execSync('git rm people/bob.md', { cwd: repo, stdio: 'pipe' });
writeFileSync(join(repo, 'people/alice.md'), personMd('Alice', 'Alice was corrected.'));
execSync('git add -A && git commit --amend -m "drop bob, edit alice"', { cwd: repo, stdio: 'pipe' });
const result = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(result.status).toBe('synced');
expect(await engine.getPage('people/bob')).toBeNull(); // deleted
const alice = await engine.getPage('people/alice');
expect(alice!.compiled_truth).toContain('corrected'); // updated
});
test('F-C: a rename whose destination is unsyncable deletes the old page', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({ 'people/carol.md': personMd('Carol', 'Carol is a person.') });
await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(await engine.getPage('people/carol')).not.toBeNull();
// git mv keeps content identical → classified as a 100% rename (R100).
// The destination .txt is unsyncable, so without the F-C fix the old page
// would linger (the rename drops out of both `renamed` and `deleted`).
execSync('git mv people/carol.md people/carol.txt', { cwd: repo, stdio: 'pipe' });
execSync('git commit -m "rename carol to txt"', { cwd: repo, stdio: 'pipe' });
const result = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(result.status).toBe('synced');
expect(await engine.getPage('people/carol')).toBeNull();
});
test('F-A: full reconcile purges stale file-backed pages but spares manual + metafile pages', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({
'people/alice.md': personMd('Alice', 'Alice is a person.'),
'people/bob.md': personMd('Bob', 'Bob is a person.'),
});
await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
// A manually-curated page (put_page) — source_path stays NULL.
await engine.putPage('manual/note', {
type: 'note', title: 'Manual Note', compiled_truth: 'Hand-authored, not from a file.',
}, { sourceId: 'default' });
// A metafile-backed page (e.g. an older import or direct put_page of log.md).
// Its source_path is unsyncable, so the reconcile must NOT delete it (#1433).
await engine.putPage('people/log', {
type: 'note', title: 'Log', compiled_truth: 'metafile page', source_path: 'people/log.md',
}, { sourceId: 'default' });
// Delete bob's backing file, then force a full reconcile.
execSync('git rm people/bob.md', { cwd: repo, stdio: 'pipe' });
execSync('git commit -m "remove bob"', { cwd: repo, stdio: 'pipe' });
const result = await performSync(engine, { repoPath: repo, full: true, ...SYNC_OPTS });
expect(result.status).toBe('first_sync');
expect(result.deleted).toBeGreaterThanOrEqual(1);
expect(await engine.getPage('people/bob')).toBeNull(); // stale file-backed → purged
expect(await engine.getPage('people/alice')).not.toBeNull(); // still present → kept
expect(await engine.getPage('manual/note')).not.toBeNull(); // null source_path → spared
expect(await engine.getPage('people/log')).not.toBeNull(); // metafile source_path → spared
});
test('F-B: an undiffable-but-present bookmark falls back to a full reconcile instead of throwing', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
// A blob SHA: cat-file -t succeeds ("blob", so objectPresent=true), but
// `git diff <blob>..HEAD` errors — the same failure shape as an oversized
// post-rewrite diff hitting git()'s timeout/buffer limits. Must fall back,
// not throw.
const blob = execSync('git rev-parse HEAD:people/alice.md', { cwd: repo }).toString().trim();
await engine.executeRaw(`UPDATE sources SET last_commit = $1 WHERE id = 'default'`, [blob]);
const result = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(result.status).toBe('first_sync'); // fell back cleanly
expect(await engine.getPage('people/alice')).not.toBeNull();
});
test('convergence: after orphan recovery, a later commit syncs incrementally to up_to_date', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({ 'people/alice.md': personMd('Alice', 'Alice is a person.') });
await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
// Orphan + recover.
writeFileSync(join(repo, 'people/bob.md'), personMd('Bob', 'Bob is a person.'));
execSync('git add -A && git commit --amend -m "amended with bob"', { cwd: repo, stdio: 'pipe' });
const recovered = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(recovered.status).toBe('synced');
// A subsequent ordinary commit now syncs incrementally (bookmark is sane).
writeFileSync(join(repo, 'people/carol.md'), personMd('Carol', 'Carol joins.'));
execSync('git add -A && git commit -m "add carol"', { cwd: repo, stdio: 'pipe' });
const next = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(next.status).toBe('synced');
expect(next.added).toBe(1);
// No further changes → up_to_date (converged).
const settled = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(settled.status).toBe('up_to_date');
});
});
describe('v0.42.52.0: 0-changes sync bumps last_sync_at heartbeat (D4 invariant preserved)', () => {
let engine: PGLiteEngine;
const repos: string[] = [];
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
afterEach(() => {
while (repos.length) {
const d = repos.pop();
if (d) rmSync(d, { recursive: true, force: true });
}
});
function personMd(title: string, body: string): string {
return ['---', 'type: person', `title: ${title}`, '---', '', body].join('\n');
}
function mkRepo(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-heartbeat-'));
repos.push(dir);
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
for (const [rel, content] of Object.entries(files)) {
mkdirSync(join(dir, rel, '..'), { recursive: true });
writeFileSync(join(dir, rel), content);
}
execSync('git add -A && git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
return dir;
}
const SYNC_OPTS = { noPull: true, noEmbed: true, noExtract: true, sourceId: 'default' } as const;
async function lastSyncAt(): Promise<string | null> {
const rows = await engine.executeRaw<{ last_sync_at: string | null }>(
`SELECT last_sync_at FROM sources WHERE id = 'default'`,
);
return rows[0]?.last_sync_at ?? null;
}
test('consecutive 0-changes syncs advance last_sync_at without advancing last_commit', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({
'people/alice.md': personMd('Alice', 'Alice is a person.'),
});
const first = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(first.status).toBe('first_sync');
const afterFirst = await lastSyncAt();
expect(afterFirst).not.toBeNull();
const firstRows = await engine.executeRaw<{ last_commit: string | null }>(
`SELECT last_commit FROM sources WHERE id = 'default'`,
);
const lastCommit = firstRows[0]?.last_commit;
expect(lastCommit).not.toBeNull();
// Wait 1.1s so the DB clock will tick past `afterFirst`.
await new Promise((r) => setTimeout(r, 1100));
const second = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(second.status).toBe('up_to_date');
const afterSecond = await lastSyncAt();
expect(afterSecond).not.toBeNull();
expect(afterSecond).not.toEqual(afterFirst); // heartbeat bumped
// D4 invariant: last_commit is unchanged on 0-changes sync.
const lastCommitRows = await engine.executeRaw<{ last_commit: string | null }>(
`SELECT last_commit FROM sources WHERE id = 'default'`,
);
expect(lastCommitRows[0]?.last_commit).toEqual(lastCommit);
});
});