mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
814258dda67945ffec9457a1e73980e947b7e462
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a81f7e05e8 |
v0.42.43.0 feat(context): push-based context (#2095) + teardown-exit hardening (#2084) (#2175)
* fix(cli): exit deliberately after bounded teardown instead of riding the 10s backstop (#2084)
Root cause: bounded teardown (endPoolBounded, #2015) RESOLVES, but lingering
sockets — embedding-provider fetch keep-alive, PgBouncer txn-mode sockets the
bound raced past — keep Bun's event loop alive, so every `gbrain query` paid
a flat 10s tax exiting via the hard-deadline force-exit banner.
Three changes, one contract:
- flushStdoutThenExit (cli-force-exit.ts): when main() resolves and the
command is not a daemon, exit deliberately — after stdout AND stderr drain
(writableLength===0, 'drain'-event + poll loop, 2s unref'd guard for a
blocked pipe). Incident #1959 (force-exit truncating piped stdout) is the
regression class; pinned by a 256KB real-pipe subprocess test.
- drainThenDisconnect (cli.ts): ONE owner-disconnect helper at all 8 sites
(op-dispatch, CLI_ONLY fall-through, search dashboard, doctor remediation
x3, ze-switch, dream, read-only timeout path). Drains the background-work
registry, then disconnect (best-effort), bounded by the 10s unref'd
hard-deadline — which is now armed around the TEARDOWN window only, not
before the op handler (the old placement would have force-killed any op
slower than 10s). Closes the filed TODOS P3 drain-hoist: six sites
previously skipped the drain entirely and had no hang timer at all.
- Inner process.exit sweep: mid-handler exits in engine-owning/output-bearing
paths (status, friction, claw-test, smoke-test, eval cross-modal /
takes-quality replay / conversation-parser / whoknows-thin, status-thin)
become process.exitCode + return so they flow through the drains and the
flush-exit. Pre-engine usage/parse/refusal exits stay as-is.
BrainRegistry.disconnectAll deliberately unchanged: zero production callers
in src/, per-engine disconnects already bounded, and the kernel reclaims
sockets on exit (src/core/timeout.ts doctrine).
DAEMON_COMMANDS gains 'watch' ahead of the #2095 push transport.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): PgBouncer transaction-mode pooler in CI + teardown e2e (#2084)
Three consecutive waves (#1972 → #2015 → #2084) fixed pooler-teardown bugs
verified only against one production deployment — CI had no transaction-mode
pooler and could never see the class. Now it can:
- docker-compose.ci.yml: `pgbouncer` service (transaction pooling) fronting
postgres-1, mirroring the production split-pool topology (direct :5432 +
pooled :6543). AUTH_TYPE=plain (pg16 SCRAM verifiers need the plaintext
password in the userlist) + IGNORE_STARTUP_PARAMETERS for the
statement_timeout/idle_in_transaction_session_timeout startup params
gbrain's client sets (the Supabase pooler whitelists the same).
- test/e2e/pgbouncer-teardown.test.ts: schema + fixture via the DIRECT url
into a dedicated `gbrain_pgbouncer` database (never races shard TRUNCATEs),
then spawns the real CLI against the POOLED url and asserts: exit 0,
stdout intact (the #1959 truncation class), and NO
"did not return within 10000ms — force-exiting" banner (pre-#2084 it
printed on 100% of query-shaped ops on this topology). Class bound, not
exact timing. Skips gracefully without GBRAIN_PGBOUNCER_URL.
- scripts/ci-local.sh: threads GBRAIN_PGBOUNCER_URL +
GBRAIN_PGBOUNCER_DIRECT_URL into all three e2e phases.
Verified live: both tests green against pgbouncer 1.25.2 in transaction mode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(schema): context_volunteer_events table (v116) — push-context feedback log (#2095)
One row per page the brain volunteers (op / reflex / watch channels).
"Used" is DERIVED, never written: pages.last_retrieved_at > volunteered_at
(the existing bumpLastRetrievedAt write-back is the open/cite signal), so
there is no second tracking path. session_id/turn are nullable
caller-supplied attribution; rationale is a deterministic template string,
never raw conversation text.
- Migration v116 (idempotent) + mirrors in src/schema.sql +
src/core/pglite-schema.ts + regenerated schema-embedded.ts (regen also
folds in pre-existing comment-only drift from the v114 links edits).
- src/core/context/volunteer-events.ts: insertVolunteerEvents (ONE
multi-row parameterized INSERT — never per-row awaited round-trips) +
purgeStaleVolunteerEvents (90-day GC, returns 0 on pre-v116 brains).
- Dream cycle purge phase prunes stale events alongside op_checkpoints /
brainstorm checkpoints / batch-retry audit files.
- RLS on Postgres comes from the v35 auto_rls_on_create_table event
trigger (the same mechanism that covered v110 page_aliases and v115
op_checkpoint_paths); the volunteer Postgres e2e pins it.
- No ::jsonb anywhere; no bootstrap probe needed (nothing references the
table pre-creation; writers guard with try/catch).
Tests: v116 shape + columns + indexes + live insert/purge round-trip on
PGLite (test/migrate.test.ts, 161 pass); schema-bootstrap-coverage green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(context): multi-turn window extraction + confidence-scored volunteer core (#2095)
- entity-salience.ts: extractCandidatesFromWindow(turns) — runs the existing
per-turn extractor across the last N turns (oldest→newest), merges by the
normalizeAlias form with occurrence/newest-turn/user-mention metadata, and
orders by salience (recency > frequency > user-role) so the MAX_CANDIDATES
cap drops stale assistant chatter, not the entity the user just named.
Closes the filed assistant-introduced-entities recall TODO; true pronoun
coreference (never-named antecedents) stays out of scope.
- retrieval-reflex.ts: ReflexPointer gains source_id + arm + confidence +
matchedNorm. ARM_CONFIDENCE (alias 0.9 / title 0.8 / slug-suffix 0.6)
lives next to the arm definitions so identity and score can't drift.
Arm-2 provenance is classified in JS (codex D8 — the combined OR can't
report which predicate matched). Federated sourceIds[] scope (alias arm
loops per source; arm 2 uses source_id = ANY — no engine-interface
change). Suppression gains 'slug-only' mode (codex D7, REQUIRED for
windowing): the legacy title-whole-word rule would suppress every entity
merely MENTIONED in a prior window turn, breaking the feature by
construction — slugs only enter context when a pointer/page was actually
surfaced. Default stays 'slug-and-title' for the window=1 legacy path.
- volunteer.ts (new): parseWindow (lenient user:/assistant: prefixes, CRLF,
unprefixed → one user turn), volunteerContext (zero-LLM: extract →
resolve → +0.05 multi-turn/newest-turn boost → min_confidence 0.7 gate →
cap 3/5; deterministic rationale strings, never raw conversation text),
and volunteerUsageStats (per-arm/channel precision from the
last_retrieved_at join, labeled approximate — 5-min throttle false
negatives, unrelated-read false positives; codex D9).
Tests: 35 green across volunteer-context (window parsing, pronoun follow-up
via assistant-introduced entity, confidence gating, slug-only suppression,
takes-fence privacy, multi-source scope, caps, stats join math) +
retrieval-reflex back-compat + resolve-ipc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ops): volunteer_context op — CLI (stdin) + MCP, drained event sink (#2095)
New read-scope op on the contract surface (CLI `gbrain volunteer-context`
with stdin → window, MCP tool for free): takes a rolling conversation
window, returns confidence-gated page pointers with rationales + synopses.
`window` is optional-unless-stats (validated in the handler, codex D9);
`stats: true` returns the volunteered-vs-used precision summary, labeled
APPROXIMATE (the 5-min last-retrieved throttle and unrelated reads both
bias the join). Source scope threads through sourceScopeOpts — federated
grants narrow the volunteer to the granted sources.
Event logging is fire-and-forget through a new `volunteer-events`
background-work sink (volunteer-events.ts, mirrors last-retrieved: tracked
dangling promise set + bounded drain + snapshot-drop on timeout so a
long-lived process never accumulates ghosts). ONE batched INSERT per call,
drained on every exit path by the commit-1 drain hoist; failure never
fails the op (pinned by an injected failing-engine test).
cli formatResult renders both shapes (pointer lines with confidence/arm/
rationale; the stats summary with per-arm precision).
Tests: op contract surface, window-required validation, sink round-trip
with session_id/turn attribution, failing-engine fail-open, federated
grant scoping, stats mode (26 green on PGLite) + a real-Postgres e2e
proving the op + sink + stats join AND that context_volunteer_events has
RLS enabled (keeps the auto-RLS event-trigger mechanism honest for v116).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(context): reflex consumes the rolling window + ambient-channel logging (#2095)
The default-on retrieval reflex now extracts entities from the last N turns
(retrieval_reflex_window_turns, default 4; env
GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS; window=1 reproduces the legacy
current-turn-only behavior exactly). assemble() passes the recent
user/assistant turns (hard cap 12); the reflex slices to the configured
window. Assistant-introduced entities and "what did she invest in?"
follow-ups whose antecedent was NAMED in the window now surface pointers —
the issue's "zero agent-initiated queries" success criterion on the
ambient path.
Under windowing, suppression switches to slug-only (codex D7): the legacy
title-whole-word rule would suppress every entity merely MENTIONED in a
prior window turn, breaking the feature by construction. Slugs only enter
prior context when a pointer/page was actually surfaced, so
already-surfaced pages still suppress. The suppression mode flows through
all three resolver rungs (host opts, serve IPC request, direct Postgres).
Ambient-channel feedback (codex D11): the server-side resolver paths
(serve IPC + direct Postgres) log volunteered pointers with
channel: 'reflex' through the drained volunteer-events sink, so
`gbrain volunteer-context --stats` measures the default-on path where most
volunteering happens. Host-injected resolvers (no gbrain engine) can't
log — documented gap. Precision gates, 1.5s ceiling, fail-open, and the
pointer cap are unchanged.
Tests: prev-assistant-turn entity fires; window=1 legacy parity; slug-only
vs already-surfaced suppression; throwing resolver stays fail-open
(16 green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(cli): gbrain watch — push transport over stdin (#2095)
The issue's headline: the brain volunteers pages as the conversation flows,
instead of waiting to be asked. `some-transcript-feed | gbrain watch` reads
turns line-by-line ('user:'/'assistant:' prefixes set the role; unprefixed
lines are user turns), keeps a rolling window (--window-turns, default 4),
and streams confidence-gated pointers with rationales to stdout (--json for
JSONL). Session dedupe rides the core's slug-only suppression — a slug is
volunteered at most once per session. Events log on channel 'watch' with
session_id + turn through the drained sink.
Lifecycle: watch BLOCKS in the stdin iteration (like `jobs work`) — an
interactive TTY stays alive until Ctrl-C/Ctrl-D, piped input ends at EOF —
so it is deliberately NOT in DAEMON_COMMANDS (reverts the commit-1
placeholder): when main() resolves the work is over, the CLI_ONLY finally
drains volunteer events via drainThenDisconnect, and the entrypoint
flush-exit ends the process. Keeping it in the daemon set would have made
the piped EOF path hang on lingering sockets — the exact #2084 class.
SIGINT closes the stream and flows through the same drain path instead of
killing mid-write. Per-turn resolution failures are fail-open (the stream
never dies on a transient DB error).
Full wiring (eng-review D12): CLI_ONLY + CLI_ONLY_SELF_HELP (WATCH_HELP) +
THIN_CLIENT_REFUSED_COMMANDS (thin clients use the volunteer_context MCP
op) + main --help entry.
Tests: 18 green — help, per-turn volunteering + clean EOF return, rolling
window via assistant-introduced entity, session dedupe, --json shape with
turn attribution, channel-watch event rows, --min-confidence gate, CRLF/
blank tolerance, daemon-gate semantics. Live smoke: piped `gbrain watch`
on a fresh PGLite brain exits 0 at EOF with no force-exit banner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: KEY_FILES + push-context guide + TODOS for the #2084/#2095 wave
- docs/architecture/KEY_FILES.md (current-state): context entries gain the
window extractor, arm provenance/confidence, suppression modes, volunteer
+ volunteer-events modules; background-work entry now lists FIVE sinks and
the drainThenDisconnect owner-disconnect contract; new entries for
src/core/cli-force-exit.ts (the exit contract) and src/commands/watch.ts.
- docs/guides/push-context.md (new): the three channels (reflex/op/watch),
the confidence model, CLI usage, config keys, and the approximate-stats
caveat. Linked from CLAUDE.md's reference map.
- CLAUDE.md: ops line mentions volunteer_context + the guide link;
bun run build:llms regenerated in the same commit (freshness test green).
- TODOS.md: #2095 deferrals filed (SSE push channel, policy skill + doctor
check, structured messages[] param); the #1981 entity-detection TODO
narrowed (window extraction covered assistant-introduced entities +
named-antecedent follow-ups); the drain-hoist P3 marked DONE by the
#2084 wave.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): truncate context_volunteer_events in setupDB (#2095)
The new feedback-log table wasn't in ALL_TABLES, so volunteered-event rows
persisted across e2e runs on a reused database and poisoned count/stats
assertions in volunteer-context-postgres on the second run. No FK to pages
(slug join), so position before pages is for hygiene only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): own the exit verdict — never trust ambient process.exitCode (#2084)
Caught by the full unit suite: `gbrain apply-migrations` on PGLite started
exiting 99. Root cause: PGLite's Emscripten runtime writes the WASM
backend's proc_exit status into process.exitCode (initdb at create-time,
the postmaster at close-time — `exitCode=status` in pglite's dist), and
the writes land ASYNCHRONOUSLY, outside any snapshot/restore window around
create/close (a guarded attempt verified this). The pre-#2084 success path
never read process.exitCode, so the pollution was invisible; the new
deliberate flush-exit propagated it faithfully.
Fix: gbrain records its own verdict. setCliExitCode(n)/getCliExitCode() in
cli-force-exit.ts — every gbrain-owned exit-code assignment routes through
the setter (still mirrored to process.exitCode for outside readers), and
both exit paths (entrypoint flushStdoutThenExit + the drainThenDisconnect
hard-deadline backstop) read the getter. Swept all assignment sites:
cli.ts (op error, friction, claw-test, smoke-test, eval runners, status,
import errors) + reindex/transcripts/brainstorm/frontmatter/autopilot.
Also updates the v0.42.20 structural pins to the drainThenDisconnect shape
(ordering invariant asserted INSIDE the helper + >=8 helper call sites,
superseding the two-inline-pairs assertion).
Verified: apply-migrations spawn test green; `init --migrate-only` exits 0;
an errored op still exits 1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: re-pin the teardown-arming invariant at its post-#2084 home
Master's v0.42.41.0 triage wave and the #2084 wave fixed the same
pre-armed-timer bug independently; the merge keeps #2084's shape (arming
inside the shared drainThenDisconnect helper, covering all 8 exit paths).
The structural pin now asserts the same invariant — no pre-try arming;
gated, unref'd, before-drain, cleared — at the helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: coverage for ambient reflex-channel logging + watch window/cap flags
Ship coverage audit (85%, gate PASS) named five gaps; the two substantive
cheap ones close here: the codex-D11 logChannel='reflex' path now has a
behavioral pin (events land on channel 'reflex' through the drained sink;
no logChannel → no events), and gbrain watch's --window-turns / --max-pages
flags are exercised (turn-1 attribution under window=1; cap to one page).
Remaining flagged-not-blocking: the wallclock-timeout branch (untestable
without >10s real-clock flake — same rationale as the arming pin),
formatResult's volunteer case (module-private), and the cycle purge wiring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: close the remaining plan-audit gaps — formatResult rendering + watch SIGINT
formatResult exported for tests (same import-safety contract as cliAliases);
test/cli-format-volunteer.test.ts pins the pointer lines, empty-gate message,
and approximate stats summary. test/watch-command.test.ts gains a real
subprocess SIGINT test: piped stdin that never reaches EOF, SIGINT mid-stream,
assert exit 0 with no force-exit banner — the drain-then-exit lifecycle under
the actual signal, not just the shared exit path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: doctor's FAIL verdict was zeroed by the owned exit — sweep stragglers + class pin
The merged-state suite caught it: doctor --fast --json reported FAIL but
exited 0. Master's v0.42.41.0 brought raw `process.exitCode =` writes
(doctor.ts hasFail ternary, extract.ts) that the #2084 verdict-owning exit
silently zeroes — getCliExitCode() deliberately never reads ambient
process.exitCode (the PGLite-Emscripten pollution defense), so any setter
that bypasses setCliExitCode reports success on failure.
Swept both sites and added the structural class pin: a test greps src/ for
raw `process.exitCode =` outside cli-force-exit.ts, so the next merge that
introduces one fails loudly instead of lying about exit codes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump version and changelog (v0.42.43.0)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: quarantine the watch SIGINT subprocess test to the serial lane
The parallel unit shards flake on concurrent CLI subprocess spawns (failed
at 7ms in-suite, green solo) — same isolation rationale as
apply-migrations-pglite-spawn.serial.test.ts and #2141's R3 quarantine.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: update project documentation for v0.42.43.0
Post-ship doc verification against the release diff (#2095 push-based
context + #2084 superset hardening), with a cross-model doc review:
- push-context.md: version tag corrected to v0.42.43.0; per-call knobs
now cover prior_context/days and watch's flag surface accurately;
feedback-log writes described as best-effort; synopsis fence-strip
described as unconditional.
- CLAUDE.md: stale operation count (~47 -> ~90); volunteer_context
release reference corrected to v0.42.43.0.
- KEY_FILES.md: ci-local entry rewritten to current topology (4-shard
parallel default, four Postgres services, transaction-mode PgBouncer
+ GBRAIN_PGBOUNCER_URL/_DIRECT_URL exports); stale E2E file counts
dropped from the selector entry.
- TESTING.md: inventory entries for the new #2084 structural pins
(cli-exit-verdict-pin, cli-pipe-truncation), the push-context test
suite (volunteer-context, watch-command, watch-sigint.serial,
cli-format-volunteer), migrate v117 coverage, and the two new E2E
files (pgbouncer-teardown env gating, volunteer-context-postgres RLS
pin); check:all row corrected (not a superset of verify).
- AGENTS.md + RELEASING.md: ci:local descriptions updated to the
sharded + pooler topology.
- CHANGELOG (wording only, entry preserved): "retrieved" instead of
"opened" for the used-signal, pooler scoped to the local CI gate,
feedback log labeled best-effort.
- llms-config.ts: index the new push-context guide; bundles
regenerated (build:llms) and freshness test green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(test): correct the v116 reference — the table shipped as migration v117
* fix: pre-landing review hardening — federated alias parallelism, trust-boundary clamps, shared protocol helpers (#2095)
Five specialist reviewers (testing/maintainability/security/performance/
data-migration) on the reconciled diff; every finding applied:
Performance: the alias arm now resolves all granted sources CONCURRENTLY
(a federated caller paid M sequential RTTs per turn — ~355ms at 5 sources
cross-region, inside the reflex's 1.5s budget); watch's session dedupe is
O(1) Set membership instead of a monotonically growing priorContext string
(O(T²) over a long-lived session); getWindowTurns iterates from the tail
(per-turn cost no longer grows with session length); the resolver's
provenance maps fold into the existing candidate pass.
Security: volunteer_context clamps caller-supplied attribution at the trust
boundary — session_id capped at 256 chars (a read-scoped token could bank
~1MiB TEXT per request, retained 90 days), turn logged only when a safe
integer (a non-integer threw inside the batched INSERT and silently dropped
the whole batch). The privacy comments now state precisely what rationale
may contain (the matched entity's surface form — which by construction
resolved to an existing alias/title/slug — never free conversation text).
Maintainability: TURN_PREFIX_RE + formatVolunteeredPage exported from
volunteer.ts and shared by watch/cli (the two surfaces can no longer
drift); volunteerEventRowsFrom is the single VolunteerEventRow assembly
site for all three channels; watch's window default now honors the same
retrieval_reflex_window_turns config knob the reflex reads; the stale
pre-v116 comments swept to pre-v117.
Testing: the two flake-class CRITICALs fixed (pipe test asserts the
backstop banner instead of a cold-CI-hostile 9s wall bound; the SIGINT test
waits on watch's new machine-readable ready line instead of a fixed 15s
sleep — 2.5s and deterministic now); new coverage for the sink's timeout
branch + ghost-reference drop, watch per-turn fail-open, untrusted knob
clamps (min_confidence/max_pages/days), window-cap ordering (newest user
mention survives), serve-IPC suppression passthrough + channel=reflex
logging, windowTurnCount edge semantics, and structural pins for the sink
registration + cycle purge wiring. The exit-verdict pin's grep is now
operator/whitespace-tolerant.
Deferred with TODOs: resolver index shapes for the per-turn query;
batched first-prune after a long dream-cycle gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(context): red-team hardening — pre-cap dedupe, delivery-side reflex logging, window clamp
Four red-team findings on the #2095 push-context surface:
- RT1 starvation: watch's session-dedupe Set filtered AFTER volunteerContext's
cap, so a recurring already-pushed entity burned cap slots every turn and
starved fresh pages behind it. VolunteerOpts.excludeSlugs now skips inside
the pointer loop BEFORE the confidence gate and the cap.
- RT3 honest stats: reflex-channel event logging moved from inside the
resolver to the DELIVERY point — serve's resolve-IPC onDelivered hook fires
only after the response write succeeds, and buildReflexAddition logs only
after the per-turn timeout admits the block. A block the client's 250ms
budget abandoned was never injected and no longer counts as volunteered.
(logChannel resolver opt removed; logDeliveredReflexPointers is the seam.)
- RT5 unbounded window: --window-turns is clamped to [1, 64] so a config typo
can't reintroduce the re-scan-everything-per-turn cost class.
- RT2/RT4 documented + filed: PGLite watch connection monopoly (WATCH_HELP,
push-context guide, TODO to route watch via serve IPC); host-resolver
suppression contract at ResolveEntitiesFn (TODO for a capability gate).
Tests: starvation guard (watch + volunteerContext unit), window clamp floor +
ceiling, delivery-side logging (helper writes channel=reflex through the
drained sink; bare resolver writes nothing; empty list no-op), IPC wiring test
rewired to onDelivered. KEY_FILES.md + push-context.md updated; build:llms run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(context): env-plane window knob works config-less; harden two gateway-state-leak victims
Three CI-only check failures, two root causes:
1. windowTurnCount ignored GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS when
loadConfig() returned null (no config file AND no DATABASE_URL — a clean
CI shard with no brain). loadConfig drops its env→config mapping in that
case, so the documented escape hatch silently died and the window fell
back to 4 → windowed extraction widened when the test set window=1 →
prior-turn entity leaked. Fixed: read the env var directly in
windowTurnCount, mirroring reflexEnabled's direct process.env read. This
is a real product bug, not just a test artifact — any config-less host
using the env hatch was affected. Regression test pins it.
2. sync-cost-preview + doctor-federation-health failed only IN-SHARD: a
sibling test configured a non-legacy (ZeroEntropy 1280-d / $0.05) gateway
and never reset it. The legacy-embedding preload only restores the
OpenAI/1536 default when the gateway slot is EMPTY, so a non-empty foreign
config survives into the next file — and a file's beforeAll runs BEFORE
the preload's restoring beforeEach, so federation-health built a
vector(1280) column and its 1536-d fixture hit CheckExpectedDim. My new
test files reshuffled the deterministic file→shard assignment, exposing
this latent ordering bug. Hardened both victims to establish the gateway
state they assert (sync-cost-preview resets to the unconfigured fallback;
federation-health pins legacy 1536 before initSchema) so they're
order-independent. Verified against a simulated leaker run before them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(context): use withEnv() in the window env-hatch test (test-isolation guard)
The regression test added in
|
||
|
|
4ee530f3c5 |
v0.42.42.0 fix(cli): bounded teardown + explicit exit — kill the 10s force-exit tax on txn-mode poolers (#2084) (#2141)
* feat(core): finishCliTeardown + flushThenExit — bounded teardown, owned exit verdict (#2084) cli-force-exit.ts becomes the single owner of one-shot CLI exit + teardown: - finishCliTeardown: bounded sink drain -> bounded disconnect under a backstop whose deadline is COMPUTED from the bounds it guards (floor 10s; GBRAIN_TEARDOWN_DEADLINE_MS env override). Arms at teardown start, never before the op handler. - flushThenExit: stdio write-fence (unref'd guard, EPIPE-safe) + REF'D aliveness grace for non-TTY stdio — Bun only delivers queued pipe writes while the process is alive (no flush API reaches the native queue). - setCliExitVerdict/currentExitCode: the exit verdict lives in a gbrain-owned channel, never read back from process.exitCode (PGLite's Emscripten runtime scribbles its own status there mid-run). - background-work.ts exports backgroundWorkSinkCount() for the deadline formula. Unit tests + a spawned-Bun harness proving byte-complete piped output. * fix(cli): route all nine disconnect sites through finishCliTeardown; one exit seam (#2084) Deletes the pre-handler 10s force-exit timer (it measured handler + teardown combined: PgBouncer txn-mode deployments paid a flat 10s banner tax on every query, and any >10s op was killed mid-run with exit 0 and truncated output). Sweeps op-dispatch, CLI_ONLY fall-through, search dashboard, read-only timeout path, dream, doctor x3 (fixing a pre-existing pool leak when DB checks throw), and ze-switch. The ONE process exit lives in main().then/catch via flushThenExit(currentExitCode()), gated by shouldForceExitAfterMain(). Exit-code writers (op-dispatch catch, reindex, transcripts, brainstorm, autopilot, frontmatter) now set the verdict through setCliExitVerdict. * fix(pglite): contain Emscripten's process.exitCode writes at PGlite.create (#2084) PGLite's WASM runtime writes its own status into process.exitCode (99 at create; in-memory brains run initdb whose status lands on a later tick; the exit status at close) — on PGLite every error exit was silently clobbered. preservingProcessExitCode wraps create() to keep the global tidy; db.close() stays unwrapped (its 0-write is baseline behavior test runners depend on). The CLI verdict itself is immune: it lives in the owned channel. * test: e2e + structural pins for the #2084 teardown contract E2E: failed op exits 1; every swept command spawned (brain-copy isolation for mutators, no-network); slow-handler regression via the deadline env knob; piped --json parses complete; teardown banner absent on every happy path; daemon survival untouched. Structural: no bare awaited engine disconnects in cli.ts; DISCONNECT_HARD_DEADLINE_MS gone; >=9 helper call sites; verdict channel + create-wrap pins. * test: fix R1 env-isolation violations in retrieval-reflex tests Pre-existing on master: both files mutated GBRAIN_RETRIEVAL_REFLEX directly, failing scripts/check-test-isolation.sh (bun run verify). Converted to the canonical withEnv() pattern; the reflex describe's beforeEach also never restored the flag, leaking it across the shard. * docs: KEY_FILES entries for the teardown contract; close + file TODOS (#2084) KEY_FILES.md: current-state entry for cli-force-exit.ts (helper + central exit seam pair, verdict channel, cli.ts-scoped claim); background-work.ts and pglite-engine.ts entries updated. TODOS.md: the drain-before-owner-disconnect P3 (filed from #1972) is done by this wave; files the trigger-gated GBRAIN_COMMAND_DEADLINE_MS follow-up (eng-review D2/D14). * fix: pre-landing review fixes (#2084) Review army (testing/maintainability/security/performance, 0 critical): - drain defense-in-depth: a throwing drain warns and still disconnects (cannot escape a caller's finally or skip the engine teardown) - behavioral tests for preservingProcessExitCode (connect pins 0; create-throw restores the pre-call verdict) - D9 widening test (live-registry sink count feeds the deadline formula), env 0/negative boundary cases, verdict mirror-write assertion - stale comments: header diagram backstop line, structural-test 'both lifecycle calls' contradiction, KEY_FILES 10s-force-exit clauses, e2e D11 falsification story corrected - named the formula's pool-end literals * fix: adversarial-review hardening — daemon-safe command resolution, flush knob, ref'd backstop (#2084) Cross-model adversarial review (Claude subagent + Codex, both P1'd it): - shouldForceExitAfterMain now resolves the command through parseGlobalFlags — the old first-non-dash heuristic read `gbrain --timeout 30s serve` as command "30s" and the new exit seam would have killed the daemon ~250ms after boot with exit 0 (unit-pinned) - GBRAIN_FLUSH_GRACE_MS env override for the non-TTY aliveness grace (batch consumers piping large payloads to slow readers can raise it; agent loops can lower it) - backstop timer is now REF'D: a hung teardown on an otherwise-empty event loop previously exited naturally — skipping the flush and surfacing PGLite's scribbled process.exitCode - flushThenExit: real process.exit latched once per process - doctor-site comment corrected; in-command process.exit teardown-bypass class (pre-existing) filed as a P2 TODO * chore: bump version and changelog (v0.42.42.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: move #2084 exitCode-containment lifecycle tests to the serial quarantine (R3) * docs: update project documentation for v0.42.42.0 - docs/TESTING.md: replace the stale 4-file serial-quarantine enumeration with a current-state description (the quarantine is glob-discovered, now several dozen files incl. the #2084 exitCode-containment suite); add unit inventory entries for test/cli-finish-teardown.test.ts and test/flush-then-exit-harness.test.ts. - docs/architecture/KEY_FILES.md: rephrase the pglite-engine exitCode containment note to current-state wording (clears the check-key-files-current-state prose-history warning). llms bundles regenerated (byte-identical: both docs are link-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: apply cross-model doc-review findings for v0.42.42.0 Codex review of docs-vs-shipped-code found 9 gaps; all verified against the code before fixing: - CHANGELOG.md (0.42.42.0 entry, precision narrowing only — no entries touched): "every CLI exit path" -> "every cli.ts disconnect site"; "on every path" -> "on every routed exit path"; dream/doctor/ze-switch claim scoped to dispatcher teardown (command-internal process.exit sites are tracked in TODOS as the open P2). - docs/architecture/KEY_FILES.md: the teardown backstop is REF'D, not unref'd (matches the F3 adversarial-review decision in the code). - src/core/cli-force-exit.ts: header diagram comment had the same stale unref'd claim + `process.exitCode ?? 0`; now matches the implementation (ref'd timer, `currentExitCode()`). Comment-only change. - docs/TESTING.md: verify is the 30-check parallel battery via run-verify-parallel.sh (was described as 4 checks); CI is 10 weighted LPT shards + dedicated verify/serial/slow jobs (was "4-way FNV on shard 1"); test:serial runs one bun process per file (not --max-concurrency=1); dead "cap: 10" line rewritten as debt guidance; inventory entries added for test/cli-should-force-exit.test.ts and test/e2e/pglite-cli-exit.serial.test.ts. bun run verify green (30/30); #2084 test files green; llms bundles regenerated (byte-identical — reference docs are link-only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: route v0.42.41.0's raw exitCode writers through the verdict channel; reconcile merged structural pins (#2084) CI fallout from merging the v0.42.41.0 triage wave into the #2084 exit-seam design — both waves fixed the same timer-placement bug independently: - doctor.ts + extract.ts set failure exit codes via raw `process.exitCode =` writes (v0.42.41.0's process.exit -> exitCode conversion); the #2084 exit seam reads only the gbrain-owned verdict channel, so doctor FAILs exited 0 (Tier 1 RLS e2e + half-migrated-Minions tests). Converted to setCliExitVerdict, same as the wallclock-124 site in the merge commit. - cli-force-exit-teardown-arming.test.ts pinned v0.42.41.0's inline finally-armed timer, which the merge replaced with finishCliTeardown; rewritten to pin the merged invariant (no pre-try arming in cli.ts; the backstop arms inside the helper before the drain). - eval-capture drain timing bound 1s -> 2s: flaked at 1023ms under CI shard load after the new test files shifted LPT shard packing (13x budget slack still proves bounded-not-hung). --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ec5fed2921 |
v0.42.20.0 fix: reliability wave — PGLite capture lock-pin + Postgres reconnect race + search embed-hang (#1762 #1745 #1775) (#1810)
* fix(core): drain fire-and-forget sinks before disconnect via a background-work registry (#1762) New src/core/background-work.ts registry (Map<name,drainer>, ordered drain, awaited abort). facts-queue (order 0, abort=shutdown), last-retrieved (1), and eval-capture (3, now self-tracked) register as sinks. Both CLI exit paths (op-dispatch finally + handleCliOnly finally) drain the registry before engine.disconnect() so a PGLite db.close() can't race in-flight work into the re-pump busy-loop that pinned the single-writer lock. Op-dispatch error path converts process.exit(1) to exitCode+return so the finally still drains. * fix(ai): bound every outbound AI call so a stalled provider can't hang (#1762/#1775) withDefaultTimeout composes a per-touchpoint default deadline (chat 300s, embed/multimodal 60s) with any caller signal via AbortSignal.any. Applied at the SDK call layer (chat generateText, expand generateObject, OCR, per-sub-batch embed) — covers native-anthropic + retries — plus per-request multimodal fetch. embedQuery forwards abortSignal. Env: GBRAIN_AI_{CHAT,EMBED,MULTIMODAL}_TIMEOUT_MS. * fix(postgres): module-mode reconnect preserves the shared singleton (#1745) reconnect() branches on connection style. Module-singleton engines re-establish idempotently via db.connect() (no-op when alive) + refresh the ConnectionManager read pool, never db.disconnect() — so a transient blip no longer nulls the shared sql out from under concurrent ops (which threw 'connect() has not been called'). Fail-loud on real connect failure. Instance pools keep teardown+recreate. * fix(search): bound the query-time embed so a stall falls back to keyword (#1775) search/query default to cheap-hybrid (embeds the query); a stalled provider made the embed never settle, so the keyword fallback never engaged and the command force-exited with no output. One shared QueryEmbedDeadline (6s, floored 2s per embed) covers both the cache-lookup and inner embeds via embedQueryBounded (abortSignal + Promise.race) → existing keyword fallback engages. Also registers the search-cache background-work drainer (now bounded). Env: GBRAIN_QUERY_EMBED_TIMEOUT_MS. * test+chore: reliability wave tests + v0.42.11.0 (#1762 #1745 #1775) New: background-work registry unit, query-embed deadline unit, eval-capture drain unit, postgres reconnect E2E (#1745), gbrain capture exit-clean case in the PGLite serial test. Updated fix-wave-structural assertions to the registry shape. VERSION/package.json/CHANGELOG -> 0.42.11.0; TODOS retrofit marked done. Incorporates + hardens PR #1763 (drain-before-disconnect + embed fetch timeout); the residual hung-Haiku hole is closed by the facts shutdown() abort belt. Co-Authored-By: ElliotDrel <noreply@github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document background-work registry + v0.42.11.0 reliability wave in CLAUDE.md (regen llms) * chore: bump release version 0.42.11.0 -> 0.42.20.0 Rename the reliability-wave release version per request. Trio (VERSION / package.json / CHANGELOG) reconciled; in-code version-tag comments and test fixtures updated; llms regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ElliotDrel <noreply@github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6f26d5e4df |
v0.41.37.0 fix: critical fix wave — reindex tag wipe, grandfather hang, Windows migration spawn, sync ReDoS (#1621 #1581 #1605 #1569) (#1665)
* fix(reindex): add-only tag reconciliation + DB-only re-chunk preserves frontmatter (#1621) reindex --markdown and re-import no longer wipe DB-side enrichment tags. Tag reconciliation is now ADD-ONLY (import-file.ts): re-import adds current frontmatter tags and never deletes, so auto/dream/signal-detector tags survive. The reindex DB-only fallback reconstructs full markdown via serializeMarkdown so re-chunking a page with no on-disk source preserves frontmatter/title/timeline. * fix(migrations): v0.13.1 grandfather chunked, source-safe, soft-delete-filtered (#1581) phaseCGrandfather rewritten from a per-page getPage+putPage loop (which hung 70+ min on an 82K-page PGLite brain) to a chunked bulk SQL pass keyed on pages.id (NOT slug — slug isn't globally unique), filtering deleted_at IS NULL, with a batched rollback log carrying source identity. * fix(migrations): run schema phases in-process to fix Windows getaddrinfo ENOTFOUND (#1605) The 9 'gbrain init --migrate-only' execSync spawns died on Windows+bun+Supabase (child DNS resolution). runMigrateOnlyCore (extracted from initMigrateOnly) runs the schema bring-up in-process for all engines, unblocking schema_version advancement. Includes async-call-site audit, a wall-clock guard, and a runGbrainSubprocess stderr-capture wrapper for the remaining backfill spawns. * fix(sync): ReDoS hardening + diagnostics for schema-pack regexes (#1569) Input-length cap in runRegexBounded + route the unbounded link-inference path through it (closes the only no-timeout ReDoS hole); star-height lint rule warns on nested-quantifier patterns; --no-schema-pack sync escape hatch; GBRAIN_SYNC_TRACE per-file begin heartbeat; PGLite serve/sync concurrency doc. Defensive hardening + diagnostics — the deterministic ~3100-file wedge root cause remains open (no repro). * docs(todos): file v0.41.37.0 fix-wave follow-ups (#1621/#1605/#1569) * chore: bump version and changelog (v0.41.37.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync README + CLAUDE.md for v0.41.37.0 critical fix wave Add reindex add-only tag reconciliation (#1621), v0.13.1 grandfather + Windows in-process migration (#1581/#1605), and schema-pack ReDoS hardening + sync --no-schema-pack / GBRAIN_SYNC_TRACE triage (#1569) to CLAUDE.md key-files annotations and README Troubleshooting. Regenerated llms-full.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ci): bump llms-full.txt budget 700KB→750KB (CLAUDE.md crossed 700KB after master merge) The build-llms size-budget test failed: llms-full.txt is 703,244 bytes after the v0.41.37.0 key-files annotations merged on top of master's v0.41.34/35/36 CLAUDE.md additions. Matches the v0.41.9.0 precedent (600→700); the single-fetch bundle still fits comfortably in modern long-context models. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
27b0e14af7 |
v0.41.8.0 fix(pglite): search/query/get exit cleanly + #1340 hint + #1342 breadcrumbs (#1405)
* fix(pglite): drain fire-and-forget last_retrieved_at writes before disconnect Closes the structural bug class behind #1247, #1269, #1290: PGLite CLI search/query/get_page commands printed results then hung at ~95-98% CPU until SIGKILL. Root cause: bumpLastRetrievedAt's IIFE races engine.disconnect() — PGLite's WASM runtime keeps Bun's event loop alive while the dangling UPDATE settles. Mirrors the existing awaitPendingSearchCacheWrites precedent landed in v0.36.1.x for #1090. Tracks every IIFE promise in a module-scoped Set, exposes awaitPendingLastRetrievedWrites(timeoutMs) that resolves once all settle. Bounded with a 5s default timeout via Promise.race so a future fire-and-forget that hangs forever can't recreate the bug class at this layer — instead, the drain stderr-warns with a pending count and returns timeout outcome so the caller can decide its fallback. Test coverage: 6 unit cases covering empty drain, single + multi-pending settle, throw-in-IIFE still settles, permanently-pending hits timeout within bound, empty pageIds does not track. This commit ships the helper + tracking + tests with NO consumer. The cli.ts wiring lands in a follow-up commit (atomic bisect units). Co-Authored-By: Park Je Hoon <jehoon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pglite): snapshot+early-null disconnect + try/finally lock-leak guard Refactor PGLiteEngine.disconnect() with two structural fixes: (1) Snapshot + early-null pattern: capture db/lock refs and null the instance fields BEFORE any await. A concurrent connect() can no longer observe `_db` pointing at a handle that's mid-close. This is PR #1337's load-bearing contribution that we DID take. (2) Wrap close + release in try/finally. Without this guard, a thrown db.close() would leak the file lock and wedge every next gbrain invocation on the stale lock. Codex outside-voice review (eng review finding #7) caught this gap when reviewing the snapshot refactor. KEEP the original close-then-release order. PR #1337's diff swapped this to release-then-close, which we explicitly REJECTED — releasing the lock before close lets a sibling process try to connect to a still-closing brain. The new lifecycle test file pins this ordering so a future maintainer reading PR #1337's diff cannot accidentally flip it. Test coverage in test/pglite-engine-disconnect.serial.test.ts: 5 cases — close-before-release ordering, early-null observable inside close, lock-still-releases on close-throw, double-disconnect idempotency, reconnect-after-disconnect clean state. `.serial` because each test creates a fresh PGLite engine (WASM cold-start cost) — running in parallel shards would starve other tests. Existing test/pglite-engine.test.ts: 100/100 still green. Co-Authored-By: Matt Dean <matt-dean-git@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pglite): classify WASM init errors so #1340 gets the right hint (#1340) Closes the user-facing half of #1340: on macOS 12.7.6 + Bun 1.3.14, the PGLite connect() catch block hardcoded the macOS 26.3 hint (#223). The actual root cause for #1340 is Bun's vfs: `/$$bunfs/root` is read-only on older macOS, so PGLite cannot extract its pglite.data WASM payload. Adds two exported helpers in pglite-engine.ts: classifyPgliteInitError(message): 'bunfs' | 'macos-26-3' | 'unknown' buildPgliteInitErrorMessage(verdict, original): string Connect catch block now routes the hint by verdict. The bunfs hint names `bun upgrade` + Node fallback. The macOS 26.3 hint keeps the existing #223 link. Unknown falls through to a generic doctor + #223 fallback. Per Codex eng-review finding #9, the bunfs regex is tightened to match either the literal `$$bunfs` marker OR ENOENT+pglite.data co-occurrence — NOT generic `pglite.data` substring (would fire on unrelated errors). Negative test pinned. Root fix is upstream Bun; this PR just stops misclassifying the failure class so support traffic doesn't conflate two unrelated bugs. Test coverage: 12 pure-function unit cases including the #1340 reporter's exact error string round-trip, the negative case Codex caught, and all three verdicts × all three message contents. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): await last-retrieved drain + narrow timeout-only force-exit (#1247, #1269, #1290) Wires the v0.40.10.0 drain helper into cli.ts and adds the IRON-RULE behavioral regression test for the search-hang class. The drain is called unconditionally for every op (not per-op-name gated — that was the original PR #1259 mistake that left search and get_page exposed). The narrow force-exit synthesis (decision D7 from the eng review, informed by Codex outside-voice findings #1+#2+#8): when the drain returns outcome:'timeout', AFTER engine.disconnect() resolves AND the command is NOT a daemon, fire process.exit(0). The drain helper already stderr-warned with the pending count, so the diagnostic signal is preserved. Without this guard, a hung underlying promise could still keep Bun's event loop alive past disconnect. CRITICALLY narrower than PR #1337's blanket force-exit: the timeout path is the only trigger. In the common case (drain settles cleanly under 5s), no force-exit fires and the behavioral subprocess test still catches future regressions. The shouldForceExitAfterMain guard excludes 'serve' so the stdio + HTTP daemons stay alive past main(). e2e/pglite-cli-exit.serial.test.ts (NEW, IRON RULE): - gbrain search "foxtrot" → exits 0 within 15s - gbrain get alpha → exits 0 within 15s with foxtrot in stdout - gbrain query "foxtrot" --no-expand → exits within 15s (no-API-key graceful) - gbrain serve --http → stays alive 3+ seconds (daemon-survival regression guard) fix-wave-structural.test.ts: - import assertion for awaitPendingLastRetrievedWrites - last-retrieved.ts exports + Set tracking + Promise.race + timeout - BEHAVIORAL positioning assertion: drain `await` appears textually BEFORE engine.disconnect `await` in the op-dispatch local-engine path. Survives variable-rename refactors; catches any new disconnect path that bypasses the drain. - shouldForceExitAfterMain excludes 'serve' AND the gate is conditioned on drainResult.outcome==='timeout' Per D8 (Codex finding #5), explicitly do NOT add a drift-guard counting bumpLastRetrievedAt callers — would block harmless refactors and miss aliases. Co-Authored-By: Park Je Hoon <jehoon@users.noreply.github.com> Co-Authored-By: Matt Dean <matt-dean-git@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): add phase breadcrumbs to performSyncInner for #1342 triage The #1342 reporter saw ZERO stderr output before their PGLite sync hang, which made the bug impossible to triage from a community report alone. Mirrors the pre-existing `[gbrain phase] sync.git_pull start/done` pattern at the major pre-pull phase boundaries so the next #1342-shaped report names WHICH phase spun. Four new breadcrumbs at: - sync.resolve_repo (top of performSyncInner) - sync.load_active_pack (before the v0.39 T1.5 pack load) - sync.validate_repo_state (only when opts.sourceId is set — the re-clone branch) - sync.detect_head (before the isDetachedHead probe) No behavior change — pure stderr instrumentation. Doesn't fix #1342 (which still needs investigation per the TODOS entry filed in this wave), but converts "hung with no output" into actionable diagnostic data the next time the bug shape is reported. Per D9 in the eng review + Codex outside-voice finding #14. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: annotate v0.40.10.0 PGLite hang wave in CLAUDE.md + regen llms Key Files entries updated: - src/core/pglite-engine.ts: documents the v0.40.10.0 disconnect refactor (snapshot+early-null + try/finally lock-leak guard, KEEPS close-then-release order), and the new classifyPgliteInitError / buildPgliteInitErrorMessage helpers for #1340 hint routing. Pins PR #1337's accepted-but-narrowed contribution and the rejected release-then-close ordering swap. - src/core/last-retrieved.ts (within the brainstorm entry): documents the new awaitPendingLastRetrievedWrites drain, the Set tracking pattern, the 5s bounded timeout, the cli.ts narrow timeout-only force-exit synthesis with the serve-daemon guard, and the three community-validated reports (#1247/#1269/#1290) the fix closes. Credits PR #1259 (drain pattern) and PR #1337 (snapshot pattern + force-exit guard idea). Regenerated llms.txt + llms-full.txt — build-llms.test.ts gates the drift, all 7 cases green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(todos): file v0.40.10.0 PGLite hang follow-ups Three deferred items from the v0.40.10.0 fix wave: 1. #1342 sync-hang investigation. Single-reporter, JS-tight-loop shape, needs reproducer before any fix. Documents the ruled-out hypotheses (lock-refresh heartbeat, v91 trigger, while-true loops) and three concrete diagnostic next steps. The v0.40.10.0 sync phase breadcrumbs make the next report actionable. 2. awaitPendingSearchCacheWrites timeout-symmetry retrofit. The #1090 drain shipped without a timeout; the v0.40.10.0 #1247 drain ships with one. Apply the same Promise.race + stderr warn pattern for symmetry. 3. Drain-helper extraction. Per D4 in the eng review: two surfaces is the threshold for noticing, three for extracting. Pair with the symmetry retrofit above as one focused refactor when a third fire-and-forget surface appears. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.40.10.0 fix(pglite): search/query/get exit cleanly + #1340 hint + #1342 breadcrumbs Closes #1247, #1269, #1290 (PGLite CLI search/query/get hang at ~95-98% CPU after printing results — three community-validated reports). Also fixes #1340 (WASM init misroutes to macOS 26.3 hint when real cause is Bun vfs read-only mount) and adds diagnostic phase breadcrumbs for the single-reporter #1342 sync-hang investigation. Core fix: track every fire-and-forget bumpLastRetrievedAt IIFE in a module-scoped Set; cli.ts awaits the drain before engine.disconnect() in the op-dispatch finally block; narrow process.exit(0) fires ONLY when the drain times out AND the command isn't a daemon. Snapshot+ early-null disconnect pattern + try/finally lock-leak guard close the partial-state race PR #1337 originally surfaced. Co-Authored-By: Park Je Hoon <jehoon@users.noreply.github.com> Co-Authored-By: Matt Dean <matt-dean-git@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: extract shouldForceExitAfterMain to its own module + add unit cases Gap-audit follow-up: cli.ts is a script entrypoint (top-level main() side effect), so importing it from a test fires the help output as a side effect. Move shouldForceExitAfterMain into src/core/cli-force-exit.ts so it can be unit-tested in isolation without the cli.ts script tail running. Adds test/cli-should-force-exit.test.ts (9 cases): bare serve, serve with flags after, global flags BEFORE the command (the load-bearing case for `gbrain --quiet serve`), op commands return true, non-daemon CLI commands return true, empty argv defaults to true, flag-only argv, default-arg fallback to process.argv.slice(2), substring-match avoidance (`serves` is NOT `serve` — strict equality via Set, not startsWith/includes). The daemon command set is now an explicit ReadonlySet — future daemons (a hypothetical `gbrain watch` or `gbrain daemon`) just add their name to DAEMON_COMMANDS rather than chaining ||. Updates fix-wave-structural.test.ts to look for the import + the new DAEMON_COMMANDS shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(version): rebase v0.40.10.0 → v0.41.6.0 (slot collision after v0.41.0.0+ landed) origin/master moved from v0.40.8.1 → v0.41.0.0 while this wave was in flight (PR #1367 minions cathedral). v0.41.1-v0.41.5 are claimed by other in-flight branches, so v0.41.6.0 is the next available slot. Bulk-renamed v0.40.10.0 → v0.41.6.0 across: - VERSION + package.json (trio audit clean: 0.41.6.0 / 0.41.6.0 / 0.41.6.0) - CHANGELOG.md (header + 3 prose references) - CLAUDE.md (Key Files annotations) - TODOS.md (follow-up entry header) - src/cli.ts + src/core/cli-force-exit.ts + src/core/last-retrieved.ts + src/core/pglite-engine.ts + src/commands/sync.ts (inline comments) - test/* (describe blocks + test file headers) - llms-full.txt (regenerated via `bun run build:llms`) bun.lock unchanged (version-only bump, no dep churn) per Codex #12. Verify: 52/52 wave tests pass after rename, typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: quarantine seed-pglite to .serial.test.ts (parallel WASM cold-start flake) The full-suite run during the v0.41.6.0 fix wave ship hit a 30s timeout in test/seed-pglite.test.ts under heavy 4-shard parallel contention (4972/4973 passed before SIGKILL). The test passes 11/11 in isolation. Root cause: each test instantiates a fresh PGLiteEngine (5 instances across the file, one per test) because each case writes to a different mkdtemp-ed dbPath. Under parallel shard load, multiple shards each cold-starting PGLite WASM simultaneously stretches the per-instance init from ~5s to 30s+. The shared-engine pattern (canonical PGLite block in CLAUDE.md R3+R4) doesn't apply here — different dbPaths require different engines. Fix per CLAUDE.md test-isolation quarantine rules: rename to `.serial.test.ts` so the file runs in the post-parallel serial pass with full WASM init capacity. Same pattern as test/pglite-engine-disconnect.serial.test.ts (added in this wave) and test/brain-registry.serial.test.ts (pre-existing). Removes test/seed-pglite.test.ts from check-test-isolation.allowlist since the .serial.test.ts rename auto-exempts it from the R3+R4 lint (scan skips *.serial.test.ts). 641 non-serial unit files scanned, lint clean. Verify: - bun test test/seed-pglite.serial.test.ts → 11/11 pass in 4.19s - scripts/check-test-isolation.sh → OK - bun run verify → all gates pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: pre-landing review fixes (C13 disconnect-hang, C1 set leak, C9 catch drain, M1 type drift) Adversarial review + maintainability specialist surfaced four real issues in the v0.41.8.0 wave. All four fixed in this commit; one deferred to TODOS.md as a v0.41+ follow-up (unusual caller pattern). **C13 [load-bearing, defense-in-depth for the wave's stated goal]:** `await engine.disconnect()` inside the op-dispatch finally can ITSELF hang on PGLite (db.close() racing OS-level FS state). When that happens, the entire wave's force-exit guard never runs — we recreate the original hang at a new layer. Fix: install an unref'd setTimeout hard-exit fallback BEFORE entering the try/catch/finally. The timer fires after DISCONNECT_HARD_DEADLINE_MS=10s with a stderr warn and process.exit(0). unref ensures it doesn't keep the loop alive on a healthy exit. Daemons (`serve`) are excluded by reusing the shouldForceExitAfterMain guard. **C9 [data freshness gap, narrow but real]:** The drain ran ONLY in the success branch of try. If `bumpLastRetrievedAt` fired (handler succeeded) but `JSON.parse(JSON.stringify(...))` or `formatResult` then threw, process.exit(1) killed the process and the in-flight UPDATE was discarded. Fix: drain in the catch path too before process.exit(1) (best-effort, bounded by the drain's own 5s timeout). **C1 [daemon leak]:** A timed-out IIFE used to stay in the pending-writes Set forever because its `.finally` never fires. Long-lived `gbrain serve` would accumulate references without bound across repeated timeouts. Fix: explicitly `delete` the snapshot's tracked promises from the Set after a timeout outcome. The IIFEs keep running (orphaned), but the Set no longer leaks references. Pinned by a new unit test that asserts the second drain after a timeout returns immediately with empty pending count. **M1 [silent type drift]:** `cli.ts` duplicated the `{outcome, pending}` literal shape instead of importing the `DrainOutcome` type that `last-retrieved.ts` exports exactly for this purpose. Two-line fix: add `type DrainOutcome` to the import and use it for `let drainResult`. Future changes to the return shape now propagate through TypeScript. **Deferred to TODOS.md (C6 — unusual caller pattern):** Concurrent connect/disconnect on the same `PGLiteEngine` instance can strand: disconnect snapshots+nulls the lock while connect is still in-flight, leaving the resolved engine with no file lock held. Fix requires an instance-level mutex; not worth the complexity for a caller pattern that doesn't appear in production (single instance per process, sequential lifecycle). Also broadened `test/fix-wave-structural.test.ts` regex to accept additional type-imports from `last-retrieved.ts` (e.g. the new `type DrainOutcome` import that M1 added). Test coverage: 53/53 wave tests pass (added C1-followup case to last-retrieved.test.ts). The C1 fix is also pinned by tightening the existing permanent-pending test's post-timeout assertion to expect empty pending count rather than the prior (stale) "stays in set" note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: post-ship documentation sync for v0.41.8.0 Consolidate the duplicate 'take advantage of v0.41.8.0' sections in the CHANGELOG entry into a single canonical block per the CLAUDE.md template. The wave originally landed with both '### How to take advantage' (line 13) and '### To take advantage' (line 57) as h3 headings. CLAUDE.md mandates one '## To take advantage of v[version]' h2 block per release entry, with verify steps + an issue-filing fallback for users hitting upgrade failures. Promoted the second block to h2, added the issue-filing step, and removed the redundant first block (the upgrade command is already covered in the verify steps). Itemized changes section was unchanged. llms.txt + llms-full.txt regenerated; structurally identical so no content changes shipped. * fix(test): find-experts-op queries schema dim instead of hardcoding 1536 (CI shard 1) CI shard 1 failed on this branch with: \"expected 1280 dimensions, not 1536\" from pgvector's CheckExpectedDim. Root cause: master's v0.36.0 changed DEFAULT_EMBEDDING_DIMENSIONS from OpenAI's 1536d to ZeroEntropy's 1280d (src/core/ai/defaults.ts:21). The test's basisEmbedding helper hardcoded dim=1536, so beforeAll's upsertChunks failed when the schema column was created at 1280d. Latent on master: the weight-aware LPT bin-packing in scripts/sharding.ts assigns files to shards deterministically based on the COMPLETE file set. My branch adds 5 new test files, which shifted find-experts-op.test.ts into shard 1. Master's shard 1 doesn't run this file (it lands in a different shard there), so the bug never surfaced in master's CI. Fix: query the actual column dim via SELECT atttypmod FROM pg_attribute after initSchema, then seed the embedding at that width. This handles both paths (no-env CI → 1280; env-configured local → 1536) without hardcoding either default. Verify: - bun test test/find-experts-op.test.ts → 11/11 pass with provider env - env -i bun test test/find-experts-op.test.ts → 11/11 pass without - bun run verify → all 21 parallel checks clean - bun run typecheck → clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lint): robust pure-bash allowlist match in check-test-isolation (CI verify) CI verify failed on PR #1405 with check:test-isolation flagging test/scripts/check-test-isolation.test.ts even though that file is on line 22 of the allowlist (and has been since v0.26.7 as a permanent exemption — its body contains process.env mutation fixtures that the lint legitimately matches). Could not reproduce locally on macOS bash 3.2 + BSD grep across any locale (C, C.UTF-8, POSIX). Suspect a subtle interaction between the prior `echo "$ALLOWLIST" | grep -qxF "$f"` form and one of: Ubuntu 24.04's bash 5 set-e/pipefail semantics, GNU grep edge case on the first-line entry, or `bun run` + GNU timeout subshell interaction. Diagnostic value of chasing further is low — the fix is to drop the grep+pipe form entirely. Switch is_allowlisted() to pure-bash `case $'\n'"$ALLOWLIST"$'\n' in *$'\n'"$f"$'\n'*) return 0 ;; esac` whole-line matching: - Locale-free (no character-class interaction) - Pipe-free (no pipefail / SIGPIPE / buffering) - Subshell-free (no env or exit-code propagation gotchas) - set-e-quirk-free (no left-side compound failure) - ~100x faster (no fork+exec per call across 689 files) Verified locally: lint OK (689 files), case-match returns true for the allowlisted file and false for a non-allowlisted file. bun run verify clean (21/21 parallel checks pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Park Je Hoon <jehoon@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Matt Dean <matt-dean-git@users.noreply.github.com> |
||
|
|
6af0c91e53 |
v0.41.3.0 fix(security/mcp): OAuth CORS lockdown + pre-register without DCR + validator surface (#1403)
* v0.41.3.0 fix(security/mcp): OAuth CORS lockdown, pre-register without DCR, validator surface
Three expanded cherry-picks plus codex-surfaced live-CORS fix, parser
rewrite, atomicity fix, DCR validator gate, SECURITY.md reconciliation.
What ships
- gbrain auth register-client gets --redirect-uri (repeatable) and
--token-endpoint-auth-method flags so the SECURITY.md-recommended
"pre-register without --enable-dcr" path actually works for claude.ai
and ChatGPT custom connectors.
- ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS = {client_secret_post,
client_secret_basic, none} validator gates all three registration
entry points (CLI, admin endpoint, DCR /register) so --enable-dcr is
no longer the looser path.
- Live Express OAuth server (/mcp, /token, /authorize, /register,
/revoke) was using default-wide-open cors() middleware — every
origin could complete a token exchange from a logged-in operator's
browser. Now default-deny; allowlist via GBRAIN_HTTP_CORS_ORIGIN.
- GBRAIN_HTTP_TRUST_PROXY env var on Express server with the same
semantics as the legacy bearer transport already had. Default
'loopback' preserved. SECURITY.md doc rewritten to match reality
(was lying that trust proxy was "disabled by default" while code
hardcoded 'loopback').
- Admin endpoint registration now atomic — INSERT-then-UPDATE for
public clients replaced with single INSERT via the new
registerClientManual(..., tokenEndpointAuthMethod) parameter (codex
outside-voice F4 catch).
- Legacy transport corsHeaders + corsPreflightHeaders consolidated
into one function gated on the allowlist for BOTH Allow-Origin and
Allow-Methods/Headers (codex F1; #983 thematically).
Surfaced by D7 codex outside-voice review on the v0.41.3 plan:
F1 (live Express CORS wide-open), F2 (indexOf parser couldn't do
repeatable flags), F3 (client_secret_basic missing from validator),
F4 (admin endpoint INSERT-then-UPDATE atomicity), F5 (DCR path
bypassed validator), F6 (env var already existed on legacy transport),
F7 (SECURITY.md vs impl doc disagreement).
Tests: 183 directly-touched cases green. Three new test files
(test/serve-http-trust-proxy.test.ts, test/serve-http-cors.test.ts,
test/auth-register-client-args.test.ts) + 18 new oauth.test.ts cases
+ 4 IRON RULE CORS preflight regressions.
Plan: ~/.claude/plans/system-instruction-you-are-working-wise-piglet.md
(D1-D11 captured, codex outside-voice integrated, GSTACK REVIEW REPORT
verdict CLEARED).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(test): audit-writer readRecent calendar-boundary flake
writer.log() uses real `new Date()` for filename computation, but the
test mocked `now` to 2026-05-22. When CI runs on a date in a different
ISO week (e.g. 2026-05-25 W22 vs the mocked W21), log() writes to one
file but readRecent(now) reads a different one — zero events overlap,
expect(2).toBe(0) fails.
Fix: write events directly to the file matching the test's mocked
`now` via writer.computeFilename(now), same pattern the cross-week
straddle test (line 234+) already used for the previous-week event.
Pre-existing test bug, surfaced when CI rolled past the week boundary
the original author wrote against. Not introduced by v0.41.3.0; fix
included here because /ship found it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
6987934ebb |
v0.39.3.0: productionize the v0.38 ingestion cathedral (smoke-test fix wave from PR #1299) (#1308)
* docs: land v0.38.0.0 production smoke-test report (PR #1299 + editor's note) PR #1299 from garrytan-agents shipped a 308-line production smoke-test report against v0.38.0.0 on Supabase+PgBouncer. Bringing the report verbatim into this worktree alongside the actual fixes (v0.38.3.0 wave). Privacy scrub passed per CLAUDE.md placeholder rule — no real people, companies, funds, or deals named. Editor's Note prepended to flag two re-diagnosed findings: - BUG-2 actual crash line is :1594-1597 (req.body === undefined → JSON.stringify returns literal undefined → Buffer.from throws), not the originally reported :1508. - WARN-5 root cause is `capture` missing from CLI_ONLY_SELF_HELP set; the detailed HELP constant exists but is unreachable. Co-Authored-By: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(capture): BUG-1 — merge frontmatter instead of double-wrap on --file Pre-fix: `gbrain capture --file foo.md` on a file that already has YAML frontmatter stamped a second outer `---` block whose `title` field was the file's own opening `---` delimiter. Users got pages with two frontmatter blocks: outer `title: '---'`, inner the real metadata. The parser then treated the second block as a body-side horizontal rule. Root cause: capture.ts:136-152 `buildContent` always prepended its own frontmatter without inspecting whether the input already had one. The title-from-first-line heuristic at :141 picked up the `---` delimiter when present. Fix: new pure helper `mergeCaptureFrontmatter(rawBody, opts)` parses existing frontmatter via gray-matter (the lib markdown.ts already uses) and merges capture's auto-fields with user-wins precedence on user- declared keys. Single output frontmatter block in all cases. Precedence: - `type`: opts.type (CLI flag) > userFm.type > 'note' - `title`: userFm.title > derived-from-body - `captured_via`: userFm.captured_via > opts.source > 'capture-cli' - `captured_at`: userFm.captured_at > now() (user can pre-stamp) - All other user keys (description, tags, slug, ...) pass through verbatim Files without frontmatter keep the original behavior (stamp fresh, wrap under derived `# heading` if body lacks markdown structure). CQ2 boil-the-lake test coverage in test/capture-build-content.test.ts (19 cases, 47 assertions): 13 specified cases including CJK title, CRLF line endings, UTF-8 BOM, empty frontmatter `---\n---\n`, malformed YAML, no-trailing-newline-before-body, user description/tags/slug passthrough; plus 3 deriveTitle helper cases, 2 --type CLI flag precedence cases, and the BUG-1 regression guard with the exact reported input shape. Decisions deferred to later wave phases: - CV3 source_kind taxonomy + CV15 canonical source resolver: Phase 3c - CV8 dedup hash from rawBody: Phase 3c (receipt) + Phase 3d (DB) - CV9 trim boundary split: Phase 3c - CV10 binary-byte guard: Phase 3c Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 2a) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(serve-http): BUG-2 — /ingest 500-HTML on missing body becomes 400 JSON Pre-fix: a POST /ingest with NO body (no Content-Length, no body bytes — common shape for misconfigured webhooks and the smoke-test repro) leaves `req.body === undefined`. The body-coercion block's `else` branch then called `Buffer.from(JSON.stringify(undefined), 'utf8')` — and `JSON.stringify(undefined) === undefined` (the literal, not the string), so `Buffer.from(undefined, 'utf8')` threw TypeError. The unhandled throw reached express's default error handler, which served an HTML 500 page. Clients expecting JSON envelopes broke. Two changes: 1. Explicit `req.body == null` null-guard at the top of the handler (BEFORE the body coercion). Catches both `null` and `undefined` request bodies and returns the same 400 `empty_body` envelope as the empty-Buffer guard at :1600. Closes the TypeError class structurally. 2. Outer try/catch wrapping the entire handler body with a `!res.headersSent` guard (codex F#16) so any unexpected throw — downstream of the inner queue.add try/catch, in a logging side-effect, or in the SSE broadcast — returns a JSON 500 envelope instead of leaking the HTML error page. Mirrors the F14 pattern around the MCP handler's transport.handleRequest at serve-http.ts:1508-1520. E2E regression test in test/e2e/serve-http-ingest-webhook.test.ts: 'BUG-2: POST with no body (undefined req.body) → 400 JSON envelope (not 500 HTML)' uses `fetch(URL, {method:'POST'})` with no body field to provoke the exact pre-fix shape. Asserts status !== 500, status === 400, content-type is application/json, body.error === 'empty_body'. The existing 'empty body → 400' test at line 200 already covered the empty-string-body case (Express raw() produces an empty Buffer; the :1600 guard fires correctly). Both cases now reach the same envelope via two structurally distinct paths. Codex F#15 correction absorbed: the misleading 'body \"\"' → empty_body test case was dropped from the plan. An empty JSON string body has bytes and is not the same as a missing body. Codex F#14 correction absorbed: header in the smoke-test verification command is `Authorization:` not `Auth:` (updated in the plan; tests already use the correct shape). Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 2b) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(put_page): WARN-8 — provenance write-through with CV6 trust gate + CV12 COALESCE Migration v81 added 4 nullable provenance columns to `pages` (source_kind, source_uri, ingested_via, ingested_at). Pre-fix, put_page wrote them into the FILE's frontmatter (operations.ts:637-644 write- through path) but never to the DB columns. `gbrain call get_page | jq .source_kind` returned null even when the JSON receipt claimed `capture-cli`. This commit closes the loop on the write side. Surface changes: - src/core/types.ts: PageInput gains 4 optional provenance fields (source_kind, source_uri, ingested_via, ingested_at) with docstring explaining the trust model. - src/core/import-file.ts: importFromContent opts accepts 3 (source_kind, source_uri, ingested_via); threads them to tx.putPage. ingested_at is NOT a caller-controllable param — engine stamps it. - src/core/operations.ts put_page op: accepts 3 client params on the wire schema (uniform across transports). CV6 trust gate: when ctx.remote !== false, IGNORES client params and server-stamps source_kind='mcp:put_page'/source_uri=null/ingested_via='mcp:put_page'. Only ctx.remote === false (capture CLI, autopilot, dream cycle) honors client values. - src/core/postgres-engine.ts + pglite-engine.ts putPage: INSERT/UPDATE SQL extended with 4 columns. ingested_at stamped to now() only when ANY provenance field is being written this call (null otherwise). ON CONFLICT clause uses COALESCE-preserve UPDATE (CV12) so a later put_page without provenance does NOT erase the original first-write audit trail — first-write-wins for routine edits. CV6 closes the spoofing surface codex caught: a write-scope OAuth token can no longer poison the audit trail with arbitrary labels ('source_kind: capture-cli' from an MCP agent). Anything that isn't strictly `ctx.remote === false` falls through to server-stamped 'mcp:put_page', matching the v0.26.9 F7b fail-closed discipline. CV12 closes the audit-trail-erasure trap: routine put_page edits (no provenance args) preserve the original ingestion's source_kind / ingested_at via `COALESCE(EXCLUDED.x, pages.x)`. Explicit re-ingestion that passes new provenance overwrites (latest-ingestion-wins). Tests in test/put-page-provenance.test.ts (11 cases, 29 assertions): - Trusted local caller: client params populate DB columns; partial provenance still triggers ingested_at stamp; omitting all leaves all 4 null. - CV6 spoofing guard: remote caller's source_kind='capture-cli' claim becomes 'mcp:put_page'; ctx.remote === undefined treated as remote (v0.26.9 F7b: fail-closed on anything not strictly false). - CV12 COALESCE-preserve: second write without provenance preserves first-write audit AND timestamp; explicit re-ingestion with new provenance overwrites; remote second write after local first records the most-recent honest source (mcp:put_page). - T2 subagent regression: namespace check fires when provenance params are present; subagent within wiki/agents/<id>/ succeeds with server- stamped provenance per CV6; missing subagentId still fail-closes. Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 3a) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(read-path): CV5 — expose put_page provenance via getPage / listPages Phase 3a wrote the 4 provenance columns into the DB via put_page. This phase makes them visible to the read side so the smoke-test verification command `gbrain call get_page <slug> | jq .source_kind` actually returns the value the write side just stored. Surface changes: - src/core/types.ts Page: 4 optional fields (source_kind, source_uri, ingested_via, ingested_at). Three-state read pattern matching v0.26.5's deleted_at convention — undefined when the SELECT projection didn't include the column (older callers), null when historical pre-v0.38 row, populated when the v0.38+ ingestion stamped it. - src/core/utils.ts rowToPage: maps the 4 columns to Page fields via the same conditional-spread pattern as deleted_at / effective_date. Older SELECTs without the projection continue to compile. - src/core/postgres-engine.ts + pglite-engine.ts getPage: projection extended to include the 4 columns. listPages uses `SELECT p.*` so the columns flow through automatically — no listPages SQL change. Both engines' putPage RETURNING clause already projects the 4 columns from Phase 3a, so the round-trip (write→get) is symmetric. get_page op + JSON renderer require no changes: `gbrain call get_page` goes through `runCall` (src/commands/call.ts:52) which is `console.log(JSON.stringify(result, null, 2))`. Since `result` is the Page object from get_page (which is the engine's getPage return, which is rowToPage), the new fields surface automatically. The markdown renderer (`gbrain get`) goes through serializeMarkdown which strips structured fields; that's the right behavior for the markdown view. Structured access stays on `gbrain call get_page` and `list_pages` (JSON output). Engine-parity test (T3) extended in test/e2e/engine-parity.test.ts with 2 new cases: - 'provenance columns: putPage writes + getPage returns identical shape on both engines' — seeds capture-cli provenance, asserts source_kind/source_uri/ingested_via match across engines and ingested_at is a Date on both. - 'provenance COALESCE-preserve UPDATE: parity on both engines (CV12)' — first write stamps capture-cli, second write WITHOUT provenance preserves the first-write source_kind / ingested_via on BOTH engines (CV12 first-write-wins is engine-uniform). Gated on DATABASE_URL — runs PGLite half always; Postgres half skips without it (existing engine-parity pattern). When the test fires, a drift between the two engines now fails loudly instead of waiting for a user's `gbrain migrate --to supabase` to surface the bug. Phase 3a test suite (test/put-page-provenance.test.ts) re-verified green (11 pass) after the read-path additions — no regression. Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 3b) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(capture): WARN-1/3/7 + CV3/CV7/CV8/CV9/CV10/CV15/A2 — capture write-side overhaul Lands the seven decisions resolved in the plan-eng-review for the capture CLI's write-side. Each addresses a specific smoke-test finding or codex outside-voice concern. Atomic single commit because all changes are in capture.ts and tightly coupled (CV9's trim split feeds CV8's hash input which the tests depend on). Decisions resolved: CV3 — source_kind taxonomy: capture ALWAYS stamps source_kind='capture-cli' in the receipt JSON AND threads it through to put_page's provenance params. Pre-fix `parsed.source ?? 'capture-cli'` conflated the DB source FK (where to write) with the ingestion-channel taxonomy (what kind of ingestion this was). --source now ONLY maps to source_id; source_kind is closed taxonomy per migration v81's documented set. CV7 — thin-client --source rejection: when isThinClient(cfg) AND parsed.source is set, exit 1 BEFORE any network call with a clear error pointing at the right fix: `gbrain auth register-client <name> --source <id>` on the server. Mirrors CV6 trust posture (server-side OAuth client registration owns source scope; per-call override would reopen the spoofing surface CV6 just closed). CV8 (CLI side) — receipt content_hash now comes from normalized rawBody, NOT the assembled fullContent (which contained a timestamp-bearing frontmatter). Two captures of identical text now produce identical content_hash, restoring the daemon's 24h LRU dedup at the CLI receipt layer. The DB hash (importFromContent) gets the same treatment in Phase 3d. CV9 — trim boundary split: introduced `normalizeForHash(s)` pure helper (trim + BOM strip + LF + NFKC). Hash input gets aggressive normalization for dedup correctness; the STORED body (passed to buildContent → mergeCaptureFrontmatter) preserves user bytes (CRLF, BOM, whitespace) for round-trip fidelity. CQ2's CRLF/BOM tests continue to pass. CV10 — binary file guard: read --file via `readFileSync(path)` with NO encoding (Buffer-side), then sniff first 8KB for NUL bytes via `detectBinaryNullByte(buf)`. Mirror the same sniff for --stdin (which also now reads Buffer-side via `readStdinBuffer()`). Real UTF-8 text (including CJK, emoji, BOM) never contains NUL bytes; binary formats (executables, archives, most image formats) do. Reject with friendly error before UTF-8 decode mangles the bytes. Deterministic test fixtures use `Buffer.from([...])` with explicit byte arrays instead of /dev/urandom (which a 256-byte sample often had no NUL in). CV15 — canonical source resolver: route through `resolveSourceWithTier(engine, parsed.source, cwd)` from src/core/source-resolver.ts (the v0.37.7.0 6-tier chain every other CLI op uses). Honors flag → env (GBRAIN_SOURCE) → dotfile (.gbrain-source) → local_path → brain_default → seed_default. Closes the WARN-3-adjacent UX divergence where capture silently used `parsed.source ?? 'default'`, ignoring env / dotfile / local_path / brain_default tiers. Bonus: resolveSourceWithTier's assertSourceExists throws a friendly error if the source is missing, BEFORE put_page is called — making capture-level pre-flight redundant for the common case (A2 fallback below handles TOCTOU + thin-client edge cases). A2 — friendly FK error rewrite: new `maybeRewriteSourceFkError(err, sourceId)` helper detects the Postgres FK-violation patterns ('pages_source_id_fk' OR 'foreign key constraint ... source') and returns a paste-ready hint: `source '<id>' is not registered. Register it first: gbrain sources add <id> --path <path>`. Applied in BOTH the local-engine catch block AND the thin-client (callRemoteTool) catch block per T1 — so the same friendly error surfaces regardless of install type. Defense-in-depth alongside CV15's upstream check (covers TOCTOU race + thin-client implicit source from dotfile pointing at a server-deleted source). WARN-1 receipt-side dedup, WARN-3 friendly source error, WARN-7 binary guard, and the source_kind taxonomy fix all become user-visible via this commit. The DB-side dedup (WARN-1's daemon side) lands in Phase 3d. HELP text updated: - Documents the 6-tier source resolution chain - Notes thin-client --source restriction - Notes binary content rejection - Notes dedup behavior (whitespace + line endings normalized) - Notes source_kind != source_id distinction Tests in test/capture-runcapture.test.ts (26 cases, 30 assertions): - CV10 detectBinaryNullByte: 10 cases incl. ASCII, CJK, emoji, BOM, start/mid NUL, PNG magic, 8KB cap boundary, empty - CV9 normalizeForHash: 6 cases incl. whitespace, BOM, CRLF, NFKC, no-op on clean, whitespace-only → empty - CV8 hash stability: 4 cases proving identical input → identical hash regardless of whitespace / line endings / timing - A2 maybeRewriteSourceFkError: 6 cases incl. raw PG message, wrapped OperationError, postgres.js-wrapped, unrelated errors, missing sourceId, non-Error throws Phase 3a + Phase 2a tests re-verified green (30 pass across both files) — no regression in the surfaces this commit didn't directly touch. Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 3c) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(import-file): CV8 — exclude timestamp frontmatter from DB content_hash Pre-fix: every gbrain capture invocation produced a fresh DB content_hash because parsed.frontmatter included captured_at (and now ingested_at) which change per call. Two consequences: - existing.content_hash === hash short-circuit never fired for identical body captures → every capture re-chunked and re-embedded unchanged content, burning embedding API spend - daemon-side 24h LRU dedup (separate consumer keyed on the same hash) silently never matched, defeating its design intent Phase 3c shipped the CLI-side fix (receipt hash from normalized rawBody). This commit shipps the DB-side fix. Approach: strip timestamp-bearing frontmatter keys before hashing, NOT strip all frontmatter. Whitelist: ['captured_at', 'ingested_at']. Future timestamp keys add to the list — small, stable surface. Why not strip ALL frontmatter? Sync would regress: a user editing a markdown file to add a tag changes the frontmatter without changing the body. The pre-fix hash captures that change; tag reconciliation fires. If we stripped all frontmatter, the hash wouldn't change, the short-circuit would fire, and the tag-add would silently no-op. The narrow whitelist preserves frontmatter-change-detection for real edits (tags, type, slug, description, ...) while ignoring the ephemeral timestamp keys that capture-cli and provenance-write-through stamp per-call. Tests in test/import-file.test.ts (4 new cases in 'CV8 DB content_hash stability' describe block): - captured_at differences → IDENTICAL hash → second capture status 'skipped' (short-circuit fires); putPage NOT called the second time - body change → DIFFERENT hash → second capture status 'imported' (real edits still flow through) - tag change → DIFFERENT hash → re-import fires (REGRESSION GUARD: proves frontmatter-change detection survives the strip) - ingested_at differences → IDENTICAL hash (provenance-only refresh doesn't invalidate the chunk cache) Combined with Phase 3c's CLI-side hash fix, WARN-1 (dedup not actually deduplicating) is now fully resolved: both the user-visible receipt hash AND the DB / daemon hash stabilize across identical-content captures. Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 3d) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): WARN-5 + WARN-6 — capture help discoverable; main --help lists BRAIN WARN-5: `gbrain capture --help` printed only the generic short-circuit fallback ('Usage: gbrain capture\\n\\ngbrain capture - run gbrain --help for the full command list.'). Root cause: `capture` was missing from CLI_ONLY_SELF_HELP at src/cli.ts:34-53. The detailed HELP constant at src/commands/capture.ts:90+ existed but was unreachable because the dispatcher's printCliOnlyHelp short-circuit at :101 fired first. WARN-6: `gbrain --help` did not mention capture / brainstorm / lsd anywhere. New v0.37/v0.38 commands were implemented and dispatched but absent from the hardcoded printHelp text. Two fixes in cli.ts: 1. Add 'capture' to CLI_ONLY_SELF_HELP. This skips the generic short-circuit, allowing the dispatch flow to reach runCapture which has its own --help branch printing the detailed HELP constant. 2. Add a pre-engine-bind '--help' short-circuit for capture in handleCliOnly (mirrors the existing sync + reinit-pglite pattern). Without this, `gbrain capture --help` on a fresh tmpdir with no config would hit the engine bind at :1077 and exit with 'Cannot connect to database' before runCapture's --help branch fires. 3. Add BRAIN section to printHelp text between TOOLS and SOURCES. Documents capture / brainstorm / lsd with their key flags, matching the tone of the existing grouped sections. Tests in test/cli-help-discoverability.test.ts (6 cases, 31 assertions): - WARN-5: capture --help contains every documented flag (--slug, --type, --file, --stdin, --source, --quiet, --json) - WARN-5: output is NOT the generic short-circuit fallback (presence of 'Examples:' + length > 10 lines + does not match the bare- short-circuit regex) - WARN-5: -h short flag works too - WARN-6: main --help mentions all 3 commands as command-line entries - WARN-6: BRAIN section heading is present and the 3 commands appear textually after it - regression: existing top-level commands (init, doctor, get, search, query, import, export, files, embed) still listed (snapshot guard against accidental deletion of other groups during the BRAIN insertion) Tests use spawnSync subprocess execution so the real dispatcher flow is exercised end-to-end (no mocking of cli.ts internals). Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 4a) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(serve-http): WARN-9 — admin register-client scopes normalization Pre-fix at serve-http.ts:1091: the /admin/api/register-client route destructured `scopes` from req.body and passed `scopes || 'read'` to `oauthProvider.registerClientManual(name, grants, scopes, uris)`. Three bugs in that single line: 1. Field-name mismatch: OAuth wire format uses `scope` (singular). The destructure looked for `scopes` (plural). A request body sending `{"scope": "read write"}` had `scopes === undefined`, fell through to the `'read'` default, and silently created a read-only client. This is the exact behavior the smoke test reported. 2. Array shapes crashed: registerClientManual's parseScopeString calls `.split(' ')` on its argument. Arrays don't have `.split`, so `{"scopes": ["read", "write"]}` threw TypeError mid-request, surfacing as a 500. 3. Empty-array truthy: `[]` is truthy in JS so `scopes || 'read'` returned the empty array, also crashing on split. Codex outside-voice (CV12) also flagged: validation depth is insufficient. `["read write"]` (a single-element array where the element contains a space) silently passes the type check but produces an unknown scope `"read write"` that registers as garbage. Same for non-string elements (null, numbers), empty strings, and duplicates. Fix: new `normalizeScopesInput(raw: unknown)` helper in src/core/scope.ts handles all four valid input shapes and rejects everything else with a typed error. The admin route accepts BOTH `scopes` (admin SPA) AND `scope` (OAuth wire), normalizes, and surfaces a 400 invalid_scopes on validation failure. Validation matrix: - undefined / null / missing → 'read' default - string → split on /\s+/, dedupe, validate each element against ALLOWED_SCOPES allowlist, re-join sorted - string[] → reject non-string elements, reject empty strings, reject internal-whitespace (catches ['read write'] bug shape), dedupe, validate each element, re-join sorted - everything else (number, boolean, plain object) → Error - empty array / whitespace-only string after split → Error - unknown scope name → InvalidScopeError ('Unknown scope "X". Allowed: ...') Output is sorted for determinism so two registrations with the same scope set produce identical DB rows. Tests in test/scope-normalize.test.ts (28 cases, 30 assertions): - Happy paths: 12 cases incl. all 4 shapes, dedupe in both directions, whitespace tolerance (tab/newline), every hierarchy scope - Rejection paths: 14 cases incl. number/object/boolean inputs, empty array, empty string, non-string array elements, empty-string element, whitespace-in-element (the codex bug), unknown scope name in both string and array forms, mix of known+unknown - Determinism: 2 cases proving sorted output is order-independent Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 4b) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(facts-absorb): WARN-4 — suppress 'No database connection' noise with diagnostic Pre-fix: every gbrain capture invocation logged '[facts:absorb] failed to log gateway_error for inbox/...: No database connection: connect() has not been called. Fix: Run gbrain init...'. Non-fatal — the page write itself succeeded — but loud per-capture noise that the smoke test rightly flagged as a user-visible bug. Root cause: the facts subsystem grabs a separate engine handle that isn't connected on the CLI capture path. The actual write succeeds via the connected engine that capture uses; the facts:absorb log is a courtesy that the doctor health check reads. Codex flagged this as symptom-treatment vs root-cause-fix (CV13). Plan picked the middle path: suppress the per-capture noise NOW, instrument first-occurrence with a stack trace so the v0.38.4 fix knows where to look. Two changes: CQ1 — typed access via instanceof + .problem field on GBrainError (NOT string-match on .message). The class GBrainError already has structured (problem, cause_description, fix) fields per src/core/types.ts:1104. Matching the structured field is impossible to silently break: if someone edits the error wording in db.ts thinking it's cosmetic, the typed access still routes correctly. String-match on .message would be the same fragility class we just closed elsewhere in this wave (capture's FK error rewrite uses both patterns deliberately because raw PG messages don't go through GBrainError). CV13 — first-occurrence diagnostic: module-scoped _hasLoggedDisconnectedFactsAbsorb flag fires ONE stderr warn with the full stack trace the first time this class occurs in a process. Subsequent occurrences are silent. The next user reporting the warning gives us the call site without an extra round-trip. Other failure classes (GBrainError with a different .problem field, plain Error, anything else) keep the loud per-call warn so real subsystem errors (PgBouncer crash, schema drift, etc.) still surface. The suppression is narrowly scoped to the known-broken-but-non-fatal WARN-4 class only. Test seam: `_resetFactsAbsorbDisconnectedFlagForTests()` exported so each test can assert from a clean slate. Tests in test/facts-absorb-log.test.ts (4 new cases in 'WARN-4 disconnected-engine suppression' describe block): - First occurrence prints ONE warn with 'WARN-4' + 'First-occurrence trace' substring; subsequent 3 calls are silent - GBrainError with a DIFFERENT problem field still warns loudly (the suppression is narrow, not class-wide) - Plain Error (non-GBrainError) still warns loudly - The engine.logIngest call STILL fires for the suppressed case (the suppression is on the WARN output, not the attempt — preserves the doctor health check's read path if/when the wiring is fixed in v0.38.4) v0.38.4 follow-up TODO filed per the plan: trace why the facts pipeline opens a separate engine handle on the CLI capture path, and either share the connected engine OR no-op the absorb-log when called from a CLI context. The diagnostic stack trace this commit prints is the input that fix needs. Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 4c) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(brainstorm): WARN-10 + CV11 — surface SQLSTATE 57014 as typed StructuredAgentError Pre-fix: brainstorm + lsd silently produced no output on PgBouncer transaction-mode environments. Postgres statement_timeout fired canceling listPrefixSampledPages or hybrid search; the unhandled error reached main()'s catch-all and surfaced as a generic 'gbrain: unknown error' line — after the user had already waited through the 10-second cost-preview window. Zero ideas, no diagnostic, no hint about what to do. Three changes per the resolved decisions: CV11 + T4 — orchestrator-level entry-point wrap (NOT per-call whack-a-mole). The public runBrainstorm becomes a thin wrapper that delegates to runBrainstormImpl inside try/catch; the catch runs classifyBrainstormError on the thrown value. Adding a new internal SQL call to runBrainstormImpl is automatically covered — codex F#20's 'scope too narrow' concern resolves structurally. A3 — reuse StructuredAgentError (the v0.19.0 envelope every new agent-facing surface uses) with code='brainstorm_timeout'. No new BrainstormError class; matches CLAUDE.md's stated convention. Future typed errors in brainstorm follow the same pattern. T4 + codex F#19 — classifier matches SQLSTATE 57014 specifically (the spec-defined query_canceled code) via postgres.js .code, alternate .sqlState, or message-substring fallback. Hint wording reads 'query canceled' (generic) covering all three PG cancel sub-causes: statement_timeout (often PgBouncer transaction-mode), lock_timeout, user-cancel. Honest under each. CV11 CLI formatter — runBrainstormCli (used by both gbrain brainstorm AND gbrain lsd) catches StructuredAgentError before main()'s catch-all sees it. Prints in the cli.ts:188-191 OperationError-block shape: 'Error [<code>]: <message>' then ' Hint: <hint>'. JSON mode emits the structured envelope (matches serializeError shape). Non-typed errors fall through to the dispatcher's existing catch — natural shape preserved (codex F#20 — no broad swallowing). Files: - src/core/brainstorm/error-classify.ts (new): isQueryCanceledError + classifyBrainstormError pure helpers. Module isolated from the orchestrator so the classifier can be unit-tested without spinning up the full brainstorm pipeline. - src/core/brainstorm/orchestrator.ts: imports the helpers; public runBrainstorm becomes a try/catch wrapper around the unchanged runBrainstormImpl. ~30 LOC change with zero body edits below. - src/commands/brainstorm.ts: catches StructuredAgentError before the generic main() handler. Imports StructuredAgentError from '../core/errors.ts'. Tests in test/brainstorm-timeout.test.ts (14 cases, 43 assertions): - isQueryCanceledError: 8 cases covering postgres.js {code}, alternate {sqlState}, message-substring fallbacks for all 3 cancel sub-causes, case-insensitive SQLSTATE match, negative cases (different codes, non-DB errors, null/undefined/non-object inputs) - classifyBrainstormError: 5 cases pinning the StructuredAgentError envelope (class, code, message), hint covers all 3 PG sub-causes (codex F#19 honesty contract), non-57014 errors pass through with SAME REFERENCE (codex F#20 — no clone, no swallow), null/undefined pass through, classified Error.message channel is descriptive - Source-shape regression guards: orchestrator.ts imports the helpers AND wraps runBrainstormImpl in try/catch at the public entry point (NOT per-call); commands/brainstorm.ts has the CLI formatter recognizing StructuredAgentError with 'Error [' + 'Hint:' shape WARN-10 root cause (PgBouncer-friendly SQL shape for listPrefixSampledPages) deferred per the plan's out-of-scope rationale — this commit adds the diagnostic surfacing so users know what hit them instead of silent no-output. TODOS.md follow-up filed in Phase 6. Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 5) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.38.3.0: capture smoke-test wave — VERSION + CHANGELOG + docs + llms regen VERSION 0.38.2.0 → 0.38.3.0; package.json mirror. CHANGELOG: ELI10-lead-first entry per CLAUDE.md voice rules. "Things you can now do" section covers all 12 user-visible fixes (BUG-1 frontmatter merge, BUG-2 /ingest empty body, WARN-1 dedup, WARN-3 friendly source error, WARN-5 capture --help, WARN-6 main --help, WARN-7 binary guard, WARN-8 provenance write+read, WARN-9 admin scopes, WARN-10 brainstorm timeout surfacing, WARN-2 type-overwrite documentation, WARN-4 facts:absorb suppression). "What you'd see in a concrete example" block shows real terminal output. "Things to watch" covers CV7 thin-client --source rejection, CV12 COALESCE- preserve UPDATE semantics, CV3 closed source_kind taxonomy, brainstorm diagnostic-only deferral, facts:absorb first-occurrence diagnostic. "To take advantage" verification block with paste-ready commands. "For contributors" credits @garrytan-agents for PR #1299 and links the plan + decision trace. TODOS.md: 7 new v0.39 follow-ups filed at the top (SQL-shape rewrite of listPrefixSampledPages for PgBouncer, magic-byte allowlist, facts:absorb root-cause trace, --source-kind override flag, ingest_capture Minion handler architecture migration, provenance-history table, ingest webhook provenance pass-through). CLAUDE.md: single consolidated v0.38.3.0 entry under "Key files" naming every touched file + every decision (D1, A1-A3, CQ1-CQ2, T1-T4, CV3, CV5-CV15) with their resolution. Future maintainers see the full surface from one paragraph instead of grepping the diff. llms.txt + llms-full.txt: regenerated via `bun run build:llms` per CLAUDE.md's mandatory chaser ('every CLAUDE.md edit needs a build:llms chaser or test/build-llms.test.ts fails in CI'). 7 cases pass post-regen. Verify gate passes: typecheck clean + all 8 shell pre-checks (privacy, jsonb, progress, wasm, admin-scope-drift, cli-executable, system-of- record, eval-glossary-fresh, synthetic-corpus-privacy, skill-brain- first, fuzz-purity). Trio audit: VERSION: 0.38.3.0 package.json: 0.38.3.0 CHANGELOG: ## [0.38.3.0] - 2026-05-22 Plan: ~/.claude/plans/system-instruction-you-are-working-async-popcorn.md (Phase 6) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tests): update legacy tests for v0.39.3.0 implementation shape changes Two CI failures from the post-merge state, both pre-existing tests pinning implementation detail that v0.39.3.0's fixes legitimately changed. Repair the tests, not the production behavior. 1) test/commands/capture.test.ts — 2 cases ('uses first non-empty line as the title' + 'caps title at 80 chars') were checking the YAML literal `title: "..."` (double-quoted). v0.39.3.0 Phase 2a (BUG-1 frontmatter merge) replaced the hand-rolled `JSON.stringify`- quoting with `matter.stringify()`, which follows YAML defaults: simple strings emit unquoted (`title: Real first line`), special- char strings get single-quoted. The semantic ("title equals X") is correct; the literal-quoting check was incidental. Parse the YAML and assert on the value via gray-matter. 2) test/fix-wave-structural.test.ts — the v0.36.1.x #1077 PKCE- public-clients regex pinned the exact destructure `const { name, scopes, tokenTtl, ... } = req.body`. v0.39.3.0 Phase 4b (WARN-9 admin scopes normalization) moved `scopes` to a separate read line so the route can accept BOTH `scopes` (admin SPA) AND `scope` (OAuth wire singular) via `?? `. Relax the destructure regex to accept either layout AND add a NEW regex pinning the `scopes ?? scope` fallback so the actual v0.39.3.0 contract is load-bearing. PKCE-fix assertions (tokenEndpointAuthMethod === 'none' + client_secret_hash = NULL + token_endpoint_auth_method = 'none') unchanged. Both fixes are tests-only — no production code change. Verify gate clean post-fix; 30/30 cases pass in the two affected test files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: rename v0.38.3.0 → v0.39.3.0 inline references across the wave The merge resolution bumped VERSION + package.json + CHANGELOG header to v0.39.3.0 (per user direction; higher than master's existing v0.39.0.0 + v0.39.1.0 commit subjects). But the wave's source code comments + test file headers + the smoke-test report's Editor's Note + the CLAUDE.md extension entry all still carried the v0.38.3.0 internal label. Sed-pass across 25 files (24 in-tree + the smoke-test report Editor's Note): - 13 src/ files: capture.ts, cli.ts, serve-http.ts, operations.ts, import-file.ts, types.ts, utils.ts, scope.ts, postgres-engine.ts, pglite-engine.ts, facts/absorb-log.ts, brainstorm/orchestrator.ts, brainstorm/error-classify.ts - 10 test files: capture-build-content, capture-runcapture, put-page-provenance, scope-normalize, cli-help-discoverability, brainstorm-timeout, facts-absorb-log, import-file, e2e/engine-parity, e2e/serve-http-ingest-webhook - docs/v0.38-smoke-test-report.md (Editor's Note only — the filename + the report body's references to v0.38.0.0 stay since they identify the historical subject) - CLAUDE.md (extension entry tag) Comments-only change; no production behavior shift. Existing tests continue to pass (175 cases across 10 wave-specific test files). llms.txt + llms-full.txt regenerated to keep the CLAUDE.md update in sync (per CLAUDE.md's mandatory build:llms chaser). Trio audit re-confirmed: VERSION: 0.39.3.0 package.json: 0.39.3.0 CHANGELOG: ## [0.39.3.0] - 2026-05-22 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
+8 |
1bc579916b |
v0.36.1.1 fix-wave: community PR triage + 28 atomic fixes (#1182)
* fix(sync): accept .tf / .tfvars / .hcl in CODE_EXTENSIONS Terraform repos were invisible to `gbrain sync --strategy code` because the three HCL-family extensions never reached the file walker. Silent data loss — the user thinks the sync covered the repo but the IaC layer was dropped on the floor. detectCodeLanguage() returns null for these extensions, so the chunker falls back to recursive (no tree-sitter grammar for HCL) — the same path toml/yaml take. Closes #878. Co-Authored-By: johnybradshaw <johnybradshaw@users.noreply.github.com> * fix(upgrade): run `bun update gbrain` from Bun's global install root `gbrain upgrade --strategy bun` was failing on canonical `bun install -g github:garrytan/gbrain` installs because `execSync('bun update gbrain')` ran in the user's shell cwd. Bun's update operates on whatever package.json it finds via cwd-walk, so a user not standing in the global root got "No package.json, so nothing to update". resolveBunGlobalRoot() returns the right directory: 1. `$BUN_INSTALL/install/global` when set (operator override). 2. `~/.bun/install/global` (Bun's documented default). 3. Walk up from realpath(argv[1]) looking for `node_modules/gbrain` — handles non-standard installs without trusting argv naming. execFileSync replaces execSync (no shell), with cwd pinned. Error path prints the exact `cd && bun update` recovery command instead of a vague hint. Closes #1029. Cherry-picked from PR #1032. Co-Authored-By: mvanhorn <mvanhorn@users.noreply.github.com> * fix(config): redact sensitive values in `config set` output (closes #892) `gbrain config set openai_api_key sk-...` was echoing the full key to stderr via `console.log('Set %s = %s', key, value)`. Shell scrollback and tmux scroll buffers commonly retain stderr for hours; a screen-share or shoulder-glance during set leaked the secret. The `show` path already redacted but used a naive `.includes('key')` substring check that would mask 'monkey' or 'parsekey' (no false-negative but ugly). Single source of truth: `isSensitiveConfigKey()` uses a word-boundary regex (`(^|[._-])(key|secret|token|password|pwd|passwd|auth)([._-]|$)/i`) so 'openai_api_key' matches but 'monkey' doesn't. `redactConfigValue()` composes the postgresql:// URL redactor + sensitive-key check, used by both `show` and `set`. Helpers exported for unit tests. Closes #892. Cherry-pick of @sharziki's PR #918 (config.ts hunk only — the extract.ts walker change in that PR is unrelated and tracked in #202). Co-Authored-By: sharziki <sharziki@users.noreply.github.com> * fix(oauth): throw InvalidTokenError so bearerAuth returns 401, not 500 `verifyAccessToken` was throwing bare `Error` on expired or invalid tokens. The MCP SDK's `requireBearerAuth` middleware catches `InvalidTokenError` and returns 401 with WWW-Authenticate; bare Error falls through to 500. Result: legitimate clients with stale tokens hit 500-not-401, so token-refresh logic (which keys off 401) never fires. Two call sites in verifyAccessToken: token-expired path and invalid-token path. Both now throw InvalidTokenError. Existing tests continue to pass because they assert on the throw, not the message class. Closes #935. Cherry-picked from PR #1012. Co-Authored-By: Aashiqe10 <Aashiqe10@users.noreply.github.com> * fix(serve): return 405 on GET /mcp instead of 404 MCP Streamable HTTP spec says GET /mcp opens an optional SSE backchannel for server-initiated messages. gbrain's transport is stateless and doesn't push server-initiated messages, so per spec we MUST return 405 with Allow: POST, DELETE — not 404. Probing clients (claude.ai, etc.) distinguish "endpoint exists, no SSE channel" from "endpoint missing" on this status code; 404 makes them give up. Cherry-picked from PR #1076. Co-Authored-By: lukejduncan <lukejduncan@users.noreply.github.com> * fix(doctor): resolve whoknows fixture from module location, not cwd `gbrain doctor` warned about a missing whoknows fixture for every install that wasn't standing in the gbrain source repo at run time — which is everyone. The check used `process.cwd()` to locate the fixture, so any real user (running doctor against `~/.gbrain`) saw a spurious warning. `resolveWhoknowsFixturePath()` walks up from `import.meta.url` looking for the source-repo signature (`src/cli.ts` + `skills/RESOLVER.md`), respects `GBRAIN_WHOKNOWS_FIXTURE_PATH` env override (absolute or cwd-relative), and returns null with an actionable warning when the fixture can't be located. Closes #969. Cherry-picked from PR #1034. Co-Authored-By: mvanhorn <mvanhorn@users.noreply.github.com> * fix(frontmatter): centralize --fix backups under ~/.gbrain/backups/ `gbrain frontmatter validate --fix` and `gbrain frontmatter generate --fix` wrote `<file>.bak` siblings into the source tree. Users running gbrain over a brain repo found .bak files scattered through people/, companies/, etc. that broke gitignore expectations and showed up in `git status` after every fix pass. Backups now land under `~/.gbrain/backups/frontmatter/<run-id>/<rel>.bak` with an iso-week-sorted run-id so a multi-fix session keeps the same parent directory. Backup directory + per-file structure mirrored from the original file's relative path. The .bak safety contract is intact for both git and non-git brain repos. Also adds `--include-catch-all` opt-in to `frontmatter generate` so the default catch-all rule (`type: note`) is no longer applied to arbitrary workspace documents that happen to live under a brain root. Closes #902. Cherry-picked from PR #903. Co-Authored-By: 100yenadmin <100yenadmin@users.noreply.github.com> * fix(config): use path.isAbsolute() for GBRAIN_HOME on Windows The GBRAIN_HOME validator rejected every valid Windows path (`C:\\Users\\...`, `D:\\gbrain`, etc.) because it used `trimmed.startsWith('/')` to check for absoluteness — only POSIX absolute paths pass that. `path.isAbsolute()` is the cross-platform check. Same fix for the `..` traversal check: split on both `/` and `\` so Windows path separators don't sneak `..` through. Closes #1019. Cherry-picked from PR #1083. Co-Authored-By: sharziki <sharziki@users.noreply.github.com> * fix(ai): warn only for the configured embedding provider, not all recipes Gateway construction was warning on stderr for every recipe with an embedding touchpoint missing max_batch_tokens — including providers the brain isn't using. Users on Voyage saw noise about OpenAI / Google / DashScope / etc. recipes that never get loaded. Filter the warning to recipes whose provider id is referenced by `embedding_model` or `embedding_multimodal_model` in the active config. The structural protection against forgetting max_batch_tokens stays in place for the recipes that actually run; the noise for unrelated recipes goes away. Cherry-picked from PR #1117. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(sync): skip git pull when repo has no origin remote `gbrain sync` ran `git pull` unconditionally and printed scary stderr on every cycle for brains that have no `origin` remote (local-only workflows, single-machine setups, brains initialized via `gbrain init --pglite` against an arbitrary directory). The pull failed harmlessly but the noise was confusing and made operators think sync was broken. `hasOriginRemote()` probes `git remote get-url origin` with stdio ignored; on failure (`no such remote`), skip the pull, print a single informational line, and proceed with the local working tree. Cherry-picked from PR #1119. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(query): drain cache writes before CLI exit The query cache write was fired with `void promise.catch(...)` — true fire-and-forget. On a fast CLI invocation (`gbrain query <q>` exits in ~50ms), the process terminates before the cache write commits. Result: the cache effectively never warms from CLI use; every query is a miss. `awaitPendingSearchCacheWrites()` tracks each in-flight cache write in a module-level Set. The CLI dispatcher awaits the set after `query` finishes formatting output but before the process exits. MCP server path unchanged (long-lived process, fire-and-forget remains correct). Cherry-picked from PR #1125. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(backlinks): dedupe (source, target) pairs within a single source page A source page that mentions the same entity N times produced N duplicate "Referenced in" lines on the target. `extractEntityRefs` returns one EntityRef per occurrence, and the per-ref `hasBacklink` check reads a snapshot of `target.content` that's frozen at outer scope — so every iteration sees "no backlink yet" and appends another gap. The cumulative effect on a long meeting note with multiple mentions of the same person was visible in PRs landing 3-5 identical Timeline entries. Track seen target slugs per source page; cap gaps at one pair. Cherry-picked from PR #967 with a current-master regression test covering both markdown-link and Obsidian-wikilink formats in the same source page. Co-Authored-By: p3ob7o <p3ob7o@users.noreply.github.com> * fix(dream): audit backlinks without mutating pages during cycle The dream/autopilot maintenance cycle ran the backlinks phase in 'fix' mode, which writes "Referenced in" timeline bullets into entity pages every sync. The graph extractor + auto-link path is the canonical link store during sync/dream/autopilot — the legacy filesystem fixer wrote markdown that fought with both the user's manual edits and the graph layer's own timeline. Cycle now runs backlinks in 'check' mode (audit-only); the materializer remains available via `gbrain check-backlinks fix` for users who really want markdown backlinks committed to disk. Cherry-picked from PR #1027. Co-Authored-By: sliday <sliday@users.noreply.github.com> * fix(autopilot --install): source ~/.zshenv before zshrc/bashrc zshenv is the canonical place for env vars in zsh on macOS — zshrc is sourced only for interactive shells, so vars exported in zshrc don't reach a non-interactive subprocess like the autopilot wrapper. Users who exported GBRAIN_DATABASE_URL, OPENAI_API_KEY, or ANTHROPIC_API_KEY in zshrc and assumed autopilot would inherit them hit silent missing- secret failures on the LaunchAgent. Source ~/.zshenv first (always reaches non-interactive shells per zsh docs), then fall back to ~/.zshrc / ~/.bashrc for users on other profile conventions. Cherry-picked from PR #966. Co-Authored-By: p3ob7o <p3ob7o@users.noreply.github.com> * fix(apply-migrations): return exit 0 on list/dry-run/up-to-date `gbrain apply-migrations list`, `gbrain apply-migrations --dry-run`, and the "All migrations up to date" path were returning from the async function but never calling `process.exit(0)`. The CLI dispatcher in cli.ts treated the implicit fall-through as exit 1 when the parent process inspected status via shell scripts, breaking automation that gates on `apply-migrations list && do-something`. Three call sites: list, dry-run, and the no-op path. All three now exit(0) explicitly. Cherry-picked from PR #1062. Co-Authored-By: nezovskii <nezovskii@users.noreply.github.com> * fix(sync): scope auto-embed to source on incremental syncs `gbrain sync --source-id X` triggered auto-embed for the affected slugs but `runEmbed` ran with no `--source` flag, so it fell back to the default source. For non-default-source syncs the page row lives at (sourceId, slug) — the embed code saw "Page not found" for the right slug under the wrong source, swallowed the error as best-effort, and the sync result reported `embedded: 0` for the wrong reason. `buildAutoEmbedArgs(slugs, sourceId)` is the new helper: when sourceId is set, prepends `--source X`. Exported for the regression test. Pairs with the upcoming source-id write-path audit (P1 #8). Cherry-picked from PR #1120. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(query): honor source_id with no-expand for cross-source search Two related corrections: 1. `gbrain query --no-expand` parsed `--no-expand` as the literal key `no_expand` instead of negating the boolean `expand` param. Result: the flag was silently ignored and expansion always ran. Now any `--no-<key>` where `<key>` is a boolean param flips it false. 2. The `query` op's source-id resolution treated `ctx.sourceId` as authoritative, so an explicit per-call `source_id` was overridden by the federated read scope. Now per-call `source_id` wins; `source_id=__all__` is an explicit opt-out for local cross-source search. Cherry-picked from PR #1124. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(doctor): child-table orphan detection (closes #1063) The autopilot orphans phase detects orphan PAGES (no inbound links via page-graph) but never scans FK-child tables. After a bulk delete or a pre-FK-migration code path, orphan rows can persist indefinitely in content_chunks, page_versions, tags, takes, raw_data, timeline_entries, or links — all declared ON DELETE CASCADE, so any orphan row is unexpected. `childTableOrphansCheck` enumerates 10 FK columns across 8 tables: - 8 NOT NULL columns (cascade): any value not in pages.id is an orphan. - 2 nullable SET NULL columns (links.origin_page_id, files.page_id): NULL is valid; only NOT-NULL-but-missing-in-pages counts. Surfaces paste-ready cleanup SQL when orphans are found. Cherry-picked from PR #1064. Co-Authored-By: vincedk-alt <vincedk-alt@users.noreply.github.com> * fix(autopilot,cycle): stop respawn-storm from steady-state 'partial' cycles Two compounding bugs under KeepAlive=true: 1. Autopilot tripped its circuit breaker on cycle.status === 'partial', not just 'failed'. 'partial' means at least one phase warned/failed while others ran — a soft signal, not fatal. On every cycle that warned, autopilot logged a failure and the supervisor respawned the worker. 2. The orphans phase emitted 'warn' when `count > 20` orphan pages. That threshold was tuned for small dev brains; on any corpus past a few hundred pages it fires every cycle in steady state. Together with bug 1, this produced visible respawn storms. Fix: - Autopilot trips only on cycle.status === 'failed'. - Orphans phase warns by ratio: orphans / total_pages > 0.5 (the real "your graph fell apart" signal), not by absolute count. Cherry-picked from PR #1113. Co-Authored-By: sergeclaesen <sergeclaesen@users.noreply.github.com> * fix(ai): reject partial embedding responses before indexing `embedSubBatch` only validated the FIRST embedding's dimension and never asserted the response length matched the input length. If a provider returned fewer embeddings than requested (rate-limit truncation, malformed response, etc.), the gateway silently indexed an offset-shifted result — every page after the missing index got the embedding of a different page's chunk. Two new guards: 1. `result.embeddings.length === texts.length` — fail loud if any count mismatch, with a paste-ready retry hint. 2. Validate dim on EVERY embedding, not just the first. Cherry-picked from PR #926. Co-Authored-By: 100yenadmin <100yenadmin@users.noreply.github.com> * fix(serve): admin register-client supports auth_code + PKCE public clients The admin dashboard's /admin/api/register-client endpoint hardcoded client_credentials and ignored grantTypes, redirectUris, and tokenEndpointAuthMethod. Result: you couldn't register a browser-based PKCE client (claude.ai Custom Connector, Cursor, etc.) through the dashboard — only confidential machine-to-machine clients worked. Pass grantTypes / redirectUris through to registerClientManual. When tokenEndpointAuthMethod === 'none', NULL out client_secret_hash so the SDK's clientAuth middleware skips the hash-vs-plaintext compare that would otherwise reject the no-secret PKCE flow. Cherry-picked from PR #1077. Co-Authored-By: lukejduncan <lukejduncan@users.noreply.github.com> * fix(extract-facts): treat slugs:[] as no-op, not unscoped full-walk `runExtractFacts` checked `opts.slugs && opts.slugs.length > 0` to decide between scoped and full-brain walk. Both `undefined` (caller omits → full walk intended) AND `[]` (sync no-op → zero work intended) fall through to the same `else` branch and triggered `engine.getAllSlugs()`. On a multi-thousand-page brain, the unintended full walk exceeded the autopilot-cycle ~600s timeout and dead-lettered the job — visible in production as `[cycle.extract_facts] start` followed by silence until `Autopilot stopping (cycle-failure-cap)`. Use presence (`opts.slugs !== undefined`), not truthiness, to distinguish the two modes. Empty array is a real incremental no-op. Closes #1096. Three regression cases in test/extract-facts-phase.test.ts: slugs=[] no-op, slugs=undefined still walks, slugs=['a'] walks just one. Co-Authored-By: navin-moorthy <navin-moorthy@users.noreply.github.com> * fix(serve): embed admin/dist into binary; serve from manifest (closes #1090) Pre-fix, /admin returned 404 on every globally-installed binary because serve-http.ts:780 resolved admin/dist via process.cwd(). The admin SPA files are checked into git but `bun build --compile` does NOT embed arbitrary directories — only assets imported via `with { type: 'file' }` ESM imports land in the compiled binary. Wire: - scripts/build-admin-embedded.ts walks admin/dist/, emits src/admin-embedded.ts with one `with { type: 'file' }` import per file + a manifest map (request path → resolved path + mime). Auto-invoked by `bun run build:admin`. - src/admin-embedded.ts is the auto-generated module. Bun resolves every file: import to a path that works at runtime inside the compiled binary (same pattern as src/core/chunkers/code.ts WASM imports). - serve-http.ts switches to two-tier resolution: cwd-relative admin/dist for dev (Vite hot-rebuild), embedded manifest otherwise. Embedded path reads bytes lazily and caches per-asset for the lifetime of the process. - scripts/check-admin-embedded.sh CI gate re-runs the generator and fails on drift (mirrors check-wasm-embedded.sh). PRs that rebuild admin/dist but forget to regenerate the embedded module fail loud. - package.json wires build:admin-embedded + check:admin-embedded. Closes #1090. * test(source-id): lock in routing regression coverage (closes #891 #978 #1078) Audit of every page write path (sync, embed, extract, dream, autopilot, wikilinks, tags, chunks) confirmed that sourceId already threads correctly through importFromContent → engine.putPage → SQL INSERT since v0.18.0. The original bug reports from #891, #978, #1078 were real at the time and got swept by the multi-source refactor; today's master is correct. This commit locks in that correctness with six PGLite regression cases (no Postgres fixture needed; runs in CI everywhere): 1. importFromContent({sourceId:"work"}) lands at source_id=work, not the silent 'default' fallback. 2. Two sources hold the same slug independently. 3. Omitting sourceId falls through to 'default' (legacy contract). 4. Chunks land under the requested source. 5. Tags land under the requested source. 6. FK integrity smoke (originally #1078). The earlier issue reports stay closed by the existing threading; this suite ensures any future refactor of the write path can't silently re-introduce the wrong-source-default bug. The 90-minute write-path audit budget from the plan resolves here. * fix(apply-migrations): unblock PGLite chain (closes #1100) `gbrain apply-migrations --yes` was wedging on the v0.11.0 (Minions) schema phase for PGLite installs. Two compounding bugs: 1. `apply-migrations` pre-flight schema-version warning connects to PGLite to read config.version, then disconnects. The brief lock hold races with downstream subprocess spawns that try to re-acquire it; the 30s lock timeout fires before the parent fully releases. Pre-flight is a *warning*; on PGLite it adds no information the orchestrators don't already handle. Skip the probe for PGLite. 2. v0.11.0 phase A spawned `gbrain init --migrate-only` as an execSync subprocess to apply schema migrations. PGLite is single-writer; the subprocess inherits HOME and tries to lock the same DB. On Postgres this works (concurrent connections OK); on PGLite it deadlocks. Route in-process for PGLite — create + connect + initSchema + disconnect directly, skipping the subprocess hop. Postgres keeps the legacy execSync path. Verified: fresh PGLite install now walks the full migration chain through v0.32.2 (Facts SoR) and lands "All migrations up to date" on re-run. Closes #1100. * fix(serve): bootstrap token env override + suppress flag (closes #1024) `gbrain serve --http` regenerated the admin bootstrap token on every restart and printed it to stderr. In supervisor-managed production deployments (LaunchAgent, systemd, k8s) every restart leaks the value into log aggregators and rotates the access for any agent that paste- copied it. Two new knobs: - **GBRAIN_ADMIN_BOOTSTRAP_TOKEN** env var: when set, used as the bootstrap secret instead of a fresh per-process token. Validated: must match `^[A-Za-z0-9_-]{32,}$` (32-char minimum), else refuse to start with a paste-ready generator hint. Failing closed beats silently accepting a weak token. - **--suppress-bootstrap-token** CLI flag: suppresses the printed token line entirely. Operator takes responsibility for tracking the value out-of-band. Startup banner now reflects the chosen source: - `Admin Token: suppressed` when the flag is set. - `Admin Token: from $GBRAIN_ADMIN_BOOTSTRAP_TOKEN` when env-sourced. - Full token print only when both are absent (default behavior, dev installs). Closes #1024. Co-Authored-By: billy-armstrong <billy-armstrong@users.noreply.github.com> * fix(config): migrate legacy 'provider' + 'model' to 'embedding_model' Pre-v0.32 docs and some community templates used a config shape: { "provider": "voyage", "model": "voyage-4-large" } The canonical shape (since the v0.31.12 gateway seam) is: { "embedding_model": "voyage:voyage-4-large" } Users on the legacy shape hit silent fallthrough to the hardcoded OpenAI default; sync + embed errored out with "OpenAI embedding requires OPENAI_API_KEY" regardless of their actual provider config. loadConfig() now translates the legacy keys at parse time: - emits a one-line stderr nudge with the paste-ready canonical key - preserves the rest of the config unchanged - skipped when `embedding_model` is already set (forward-compat) Closes #1086. Co-Authored-By: jeunessima <jeunessima@users.noreply.github.com> * chore(test): quarantine upgrade tests (process.env mutation) PR #1032's cherry-picked tests use the static-snapshot + try/finally pattern for env vars instead of the project's withEnv() helper. The test-isolation lint catches process.env mutations outside withEnv to prevent cross-test leakage in parallel runs. Renaming to *.serial.test.ts (the quarantine convention) is the documented out: runs sequentially, no cross-file race. A future cleanup PR can migrate the tests to withEnv() and drop the quarantine. * fix(test): update brain-writer .bak assertion for centralized backup path The v0.36.x frontmatter backup change ( |