mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 11:36:05 +00:00
* 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 82cc7fff mutated process.env directly, which
check:test-isolation (R1) forbids — use the withEnv() helper that restores on
exit, same as the rest of this file. Behavior identical; guard green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
357 lines
14 KiB
Bash
Executable File
357 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# scripts/ci-local.sh
|
|
#
|
|
# Local CI gate. Runs the same checks GH Actions does (and a stricter superset
|
|
# of E2E) inside Docker. See docker-compose.ci.yml.
|
|
#
|
|
# Modes:
|
|
# bash scripts/ci-local.sh # full local gate: gitleaks + unit + ALL E2E (4-way sharded)
|
|
# bash scripts/ci-local.sh --diff # full local gate: gitleaks + unit + selected E2E (4-way sharded)
|
|
# bash scripts/ci-local.sh --no-pull # skip docker compose pull (offline / debug)
|
|
# bash scripts/ci-local.sh --clean # nuke named volumes for cold debug
|
|
# bash scripts/ci-local.sh --no-shard # debug: run E2E sequentially against postgres-1 only
|
|
#
|
|
# 4-way E2E sharding: 4 pgvector services on host ports 5434-5437. The 36 E2E
|
|
# files split N/4 per shard; shards run in parallel. Within a shard, files run
|
|
# sequentially (TRUNCATE CASCADE no-race property documented in run-e2e.sh).
|
|
# Wall-time on a 16-core host: ~6 min sequential -> ~1.5-2 min sharded.
|
|
#
|
|
# Stronger than PR CI: PR CI runs only Tier 1's 2 files; this runs all 36.
|
|
|
|
set -euo pipefail
|
|
|
|
cd "$(dirname "$0")/.."
|
|
|
|
COMPOSE_FILE="docker-compose.ci.yml"
|
|
|
|
DIFF=0
|
|
NO_PULL=0
|
|
CLEAN=0
|
|
NO_SHARD=0
|
|
|
|
for arg in "$@"; do
|
|
case "$arg" in
|
|
--diff) DIFF=1 ;;
|
|
--no-pull) NO_PULL=1 ;;
|
|
--clean) CLEAN=1 ;;
|
|
--no-shard) NO_SHARD=1 ;;
|
|
*)
|
|
echo "Usage: $0 [--diff] [--no-pull] [--clean] [--no-shard]" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
cleanup() {
|
|
echo ""
|
|
echo "[ci-local] Tearing down postgres..."
|
|
docker compose -f "$COMPOSE_FILE" down --remove-orphans 2>&1 | tail -5 || true
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
if [ "$CLEAN" = "1" ]; then
|
|
echo "[ci-local] --clean: removing named volumes..."
|
|
docker compose -f "$COMPOSE_FILE" down -v --remove-orphans 2>&1 | tail -5 || true
|
|
fi
|
|
|
|
# Tier 2: --diff fast-path. If the diff is doc-only (or empty), skip the
|
|
# whole heavy gate (postgres + bun install + unit + E2E) and just verify
|
|
# gitleaks on host. Doc-only diffs go from ~25 min to ~5 seconds.
|
|
if [ "$DIFF" = "1" ]; then
|
|
CLASSIFICATION=$(bun run scripts/select-e2e.ts --classify-only 2>/dev/null || echo "ERR")
|
|
case "$CLASSIFICATION" in
|
|
DOC_ONLY)
|
|
echo "[ci-local] --diff: diff is doc-only — skipping postgres + unit + E2E (Tier 2 fast-path)."
|
|
echo "[ci-local] Running gitleaks on host as the only gate..."
|
|
if ! command -v gitleaks >/dev/null 2>&1; then
|
|
echo "[ci-local] WARN: gitleaks not installed; skipping. brew install gitleaks." >&2
|
|
else
|
|
gitleaks dir . --redact --no-banner
|
|
gitleaks git . --redact --no-banner --log-opts="origin/master..HEAD"
|
|
fi
|
|
echo "[ci-local] Doc-only fast-path complete. No code paths exercised."
|
|
trap - EXIT
|
|
exit 0
|
|
;;
|
|
EMPTY)
|
|
echo "[ci-local] --diff: diff is empty (clean branch) — running full gate per fail-closed contract."
|
|
;;
|
|
SRC)
|
|
echo "[ci-local] --diff: diff touches src/ — running selected E2E + full unit phase."
|
|
;;
|
|
*)
|
|
echo "[ci-local] WARN: select-e2e.ts --classify-only returned '$CLASSIFICATION' — running full gate." >&2
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# Pre-flight: postgres host ports for 4 shards. Defaults to 5434-5437 (avoid
|
|
# 5432 manual gbrain-test-pg, 5433 commonly held by sibling projects).
|
|
# GBRAIN_CI_PG_PORT defines BASE; shards take BASE..BASE+3.
|
|
PG_PORT_BASE="${GBRAIN_CI_PG_PORT:-5434}"
|
|
for shard in 1 2 3 4; do
|
|
port=$((PG_PORT_BASE + shard - 1))
|
|
PORT_OWNER=$(docker ps --filter "publish=$port" --format "{{.Names}}" | head -1)
|
|
if [ -n "$PORT_OWNER" ]; then
|
|
echo "[ci-local] ERROR: host port $port (shard $shard) is already used by docker container '$PORT_OWNER'." >&2
|
|
echo "[ci-local] Either stop that container or run with: GBRAIN_CI_PG_PORT=NNNN bun run ci:local" >&2
|
|
exit 1
|
|
fi
|
|
if lsof -iTCP:"$port" -sTCP:LISTEN -P -n >/dev/null 2>&1; then
|
|
echo "[ci-local] ERROR: host port $port (shard $shard) is held by a non-docker process." >&2
|
|
echo "[ci-local] Run with: GBRAIN_CI_PG_PORT=NNNN bun run ci:local" >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
export GBRAIN_CI_PG_PORT="$PG_PORT_BASE"
|
|
export GBRAIN_CI_PG_PORT_2=$((PG_PORT_BASE + 1))
|
|
export GBRAIN_CI_PG_PORT_3=$((PG_PORT_BASE + 2))
|
|
export GBRAIN_CI_PG_PORT_4=$((PG_PORT_BASE + 3))
|
|
|
|
# Step 0: gitleaks on the host (no docker, no postgres, no bun needed).
|
|
# Mirrors test.yml's separate gitleaks job. Fail loudly if not installed.
|
|
echo "[ci-local] gitleaks detect (host)..."
|
|
if ! command -v gitleaks >/dev/null 2>&1; then
|
|
echo "[ci-local] ERROR: gitleaks not installed on host." >&2
|
|
echo "[ci-local] macOS: brew install gitleaks" >&2
|
|
echo "[ci-local] Linux: https://github.com/gitleaks/gitleaks/releases" >&2
|
|
exit 1
|
|
fi
|
|
# Two scopes for pre-push:
|
|
# 1. Working-tree files (catch uncommitted secrets sitting in files)
|
|
# 2. Branch commits vs origin/master (catch secrets committed on this branch)
|
|
# Full-history scan is ~4 min on this repo's 3700+ commits; not useful pre-push.
|
|
gitleaks dir . --redact --no-banner
|
|
gitleaks git . --redact --no-banner --log-opts="origin/master..HEAD"
|
|
|
|
# Step 1: pull. Refreshes pgvector + oven/bun:1 (both are `image:` not `build:`).
|
|
if [ "$NO_PULL" = "0" ]; then
|
|
echo "[ci-local] Pulling base images (use --no-pull to skip)..."
|
|
docker compose -f "$COMPOSE_FILE" pull 2>&1 | tail -5
|
|
fi
|
|
|
|
# Step 2: 4 postgres shards up + wait for healthy.
|
|
echo "[ci-local] Starting 4 postgres shards..."
|
|
docker compose -f "$COMPOSE_FILE" up -d postgres-1 postgres-2 postgres-3 postgres-4
|
|
echo "[ci-local] Waiting for all 4 postgres shards healthy..."
|
|
for i in {1..40}; do
|
|
all_healthy=1
|
|
for shard in 1 2 3 4; do
|
|
status=$(docker compose -f "$COMPOSE_FILE" ps --format json postgres-$shard 2>/dev/null | grep -o '"Health":"[^"]*"' | head -1 | sed 's/.*":"//;s/"//')
|
|
if [ "$status" != "healthy" ]; then
|
|
all_healthy=0
|
|
break
|
|
fi
|
|
done
|
|
if [ "$all_healthy" = "1" ]; then
|
|
echo "[ci-local] All 4 postgres shards healthy."
|
|
break
|
|
fi
|
|
if [ "$i" = "40" ]; then
|
|
echo "[ci-local] ERROR: not all postgres shards became healthy in 40 attempts" >&2
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
# Step 3: smoke-test run-e2e.sh argv + shard handling.
|
|
echo "[ci-local] Smoke: run-e2e.sh argv + shard..."
|
|
SMOKE_NO_ARGS=$(bash scripts/run-e2e.sh --dry-run-list | wc -l | tr -d ' ')
|
|
EXPECTED_ALL=$(ls test/e2e/*.test.ts | wc -l | tr -d ' ')
|
|
if [ "$SMOKE_NO_ARGS" != "$EXPECTED_ALL" ]; then
|
|
echo "[ci-local] ERROR: --dry-run-list (no args) printed $SMOKE_NO_ARGS, expected $EXPECTED_ALL" >&2
|
|
exit 1
|
|
fi
|
|
SMOKE_ONE_ARG=$(bash scripts/run-e2e.sh --dry-run-list test/e2e/sync.test.ts)
|
|
if [ "$SMOKE_ONE_ARG" != "test/e2e/sync.test.ts" ]; then
|
|
echo "[ci-local] ERROR: --dry-run-list with 1 arg printed '$SMOKE_ONE_ARG'" >&2
|
|
exit 1
|
|
fi
|
|
SHARD_TOTAL=$(( $(SHARD=1/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) + \
|
|
$(SHARD=2/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) + \
|
|
$(SHARD=3/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) + \
|
|
$(SHARD=4/4 bash scripts/run-e2e.sh --dry-run-list | wc -l) ))
|
|
if [ "$SHARD_TOTAL" != "$EXPECTED_ALL" ]; then
|
|
echo "[ci-local] ERROR: shards 1-4 covered $SHARD_TOTAL files, expected $EXPECTED_ALL" >&2
|
|
exit 1
|
|
fi
|
|
echo "[ci-local] Smoke OK ($SMOKE_NO_ARGS files no-arg, 1 single-arg, ${SHARD_TOTAL}=4-shard total)."
|
|
|
|
# Step 4: build the runner-side command.
|
|
# Tier 1: 4-shard parallel UNIT + E2E. Each shard runs ~46 unit files + ~9
|
|
# E2E files against postgres-N. Guards + typecheck run ONCE before fan-out.
|
|
# --no-shard runs the legacy unsharded flow (debug aid).
|
|
if [ "$NO_SHARD" = "1" ]; then
|
|
if [ "$DIFF" = "1" ]; then
|
|
RUN_PHASES_CMD='echo "[runner] guards + typecheck"
|
|
bash scripts/check-jsonb-pattern.sh
|
|
bash scripts/check-progress-to-stdout.sh
|
|
bash scripts/check-trailing-newline.sh
|
|
bash scripts/check-wasm-embedded.sh
|
|
bun run typecheck
|
|
echo "[runner] unit (unsharded, DATABASE_URL unset)"
|
|
env -u DATABASE_URL bash scripts/run-unit-shard.sh
|
|
echo "[runner] e2e (unsharded, --diff selected)"
|
|
SELECTED=$(bun run scripts/select-e2e.ts)
|
|
if [ -z "$SELECTED" ]; then
|
|
echo "[runner] selector emitted nothing (doc-only diff); skipping E2E."
|
|
else
|
|
DATABASE_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \
|
|
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer \
|
|
GBRAIN_PGBOUNCER_DIRECT_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \
|
|
echo "$SELECTED" | xargs bash scripts/run-e2e.sh
|
|
fi'
|
|
else
|
|
RUN_PHASES_CMD='echo "[runner] guards + typecheck"
|
|
bash scripts/check-jsonb-pattern.sh
|
|
bash scripts/check-progress-to-stdout.sh
|
|
bash scripts/check-trailing-newline.sh
|
|
bash scripts/check-wasm-embedded.sh
|
|
bun run typecheck
|
|
echo "[runner] unit (unsharded, DATABASE_URL unset)"
|
|
env -u DATABASE_URL bash scripts/run-unit-shard.sh
|
|
echo "[runner] e2e (unsharded)"
|
|
DATABASE_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \
|
|
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer \
|
|
GBRAIN_PGBOUNCER_DIRECT_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \
|
|
bash scripts/run-e2e.sh'
|
|
fi
|
|
else
|
|
# Tier 1 sharded path. Each shard runs unit+E2E sequentially against its
|
|
# own postgres-N. Shards run in parallel via xargs -P4.
|
|
if [ "$DIFF" = "1" ]; then
|
|
DIFF_E2E_PREP='SELECTED=$(bun run scripts/select-e2e.ts)
|
|
if [ -z "$SELECTED" ]; then
|
|
echo "" > /tmp/e2e-selected.txt
|
|
else
|
|
echo "$SELECTED" | tr " " "\n" | grep -v "^$" > /tmp/e2e-selected.txt
|
|
fi'
|
|
else
|
|
# Empty file -> run-e2e.sh uses default glob (all 36 E2E files).
|
|
DIFF_E2E_PREP='> /tmp/e2e-selected.txt'
|
|
fi
|
|
RUN_PHASES_CMD="echo \"[runner] guards + typecheck (run once before sharding)\"
|
|
bash scripts/check-jsonb-pattern.sh
|
|
bash scripts/check-progress-to-stdout.sh
|
|
bash scripts/check-trailing-newline.sh
|
|
bash scripts/check-wasm-embedded.sh
|
|
bun run typecheck
|
|
echo \"[runner] Tier 3: building PGLite snapshot fixture (cached across reruns)\"
|
|
if [ ! -f test/fixtures/pglite-snapshot.tar ] || [ ! -f test/fixtures/pglite-snapshot.version ]; then
|
|
bun run build:pglite-snapshot
|
|
else
|
|
echo \"[runner] snapshot fixture exists; engine will validate hash at load time\"
|
|
fi
|
|
export GBRAIN_PGLITE_SNAPSHOT=test/fixtures/pglite-snapshot.tar
|
|
echo \"[runner] resolving E2E file selection (--diff aware)\"
|
|
${DIFF_E2E_PREP}
|
|
mkdir -p /tmp/shard-logs
|
|
echo \"[runner] Tier 1: 4-shard parallel unit + E2E (xargs -P4)\"
|
|
set +e
|
|
printf '%s\\n' 1 2 3 4 | xargs -P4 -I{} sh -c '
|
|
shard=\$1
|
|
log=/tmp/shard-logs/shard-\${shard}.log
|
|
echo \"[shard \${shard}] start\" > \$log
|
|
echo \"[shard \${shard}] unit phase (SHARD=\${shard}/4, DATABASE_URL unset)\" >> \$log
|
|
env -u DATABASE_URL SHARD=\${shard}/4 bash scripts/run-unit-shard.sh >> \$log 2>&1
|
|
unit_exit=\$?
|
|
if [ \$unit_exit -ne 0 ]; then
|
|
echo \"[shard \${shard}] UNIT FAILED (exit=\$unit_exit)\" >> \$log
|
|
exit \$unit_exit
|
|
fi
|
|
echo \"[shard \${shard}] e2e phase (SHARD=\${shard}/4, DATABASE_URL=postgres-\${shard})\" >> \$log
|
|
if [ -s /tmp/e2e-selected.txt ]; then
|
|
SHARD=\${shard}/4 \\
|
|
DATABASE_URL=postgresql://postgres:postgres@postgres-\${shard}:5432/gbrain_test \\
|
|
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer \\
|
|
GBRAIN_PGBOUNCER_DIRECT_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \\
|
|
xargs -a /tmp/e2e-selected.txt bash scripts/run-e2e.sh >> \$log 2>&1
|
|
else
|
|
SHARD=\${shard}/4 \\
|
|
DATABASE_URL=postgresql://postgres:postgres@postgres-\${shard}:5432/gbrain_test \\
|
|
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer \\
|
|
GBRAIN_PGBOUNCER_DIRECT_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \\
|
|
bash scripts/run-e2e.sh >> \$log 2>&1
|
|
fi
|
|
e2e_exit=\$?
|
|
if [ \$e2e_exit -ne 0 ]; then
|
|
echo \"[shard \${shard}] E2E FAILED (exit=\$e2e_exit)\" >> \$log
|
|
exit \$e2e_exit
|
|
fi
|
|
echo \"[shard \${shard}] DONE\" >> \$log
|
|
' _ {}
|
|
shard_xargs_exit=\$?
|
|
set -e
|
|
echo \"\"
|
|
echo \"=== SHARD LOGS (last 30 lines each + unit/e2e summaries) ===\"
|
|
for s in 1 2 3 4; do
|
|
echo \"\"
|
|
echo \"--- shard \$s ---\"
|
|
if [ -f /tmp/shard-logs/shard-\$s.log ]; then
|
|
# Pull the unit + E2E summary lines explicitly so they survive even if
|
|
# the file is huge. Match: bun's '<N> pass / <N> fail' pairs, run-e2e.sh's
|
|
# 'Files: ... / Tests: ...' summary, and our own shard markers.
|
|
grep -E '^\\[shard|^Files: |^Tests: |Ran [0-9]+ tests|^[[:space:]]+[0-9]+ (pass|fail|skip)\$' /tmp/shard-logs/shard-\$s.log || true
|
|
echo \" (last 30 lines for context)\"
|
|
tail -30 /tmp/shard-logs/shard-\$s.log
|
|
else
|
|
echo \"(no log file written — shard never started)\"
|
|
fi
|
|
done
|
|
echo \"\"
|
|
if [ \$shard_xargs_exit -ne 0 ]; then
|
|
echo \"[runner] One or more shards failed (xargs exit=\$shard_xargs_exit). See SHARD LOGS above.\"
|
|
exit \$shard_xargs_exit
|
|
fi
|
|
echo \"[runner] All 4 shards passed.\""
|
|
fi
|
|
|
|
INNER_CMD=$(cat <<'EOF'
|
|
set -euo pipefail
|
|
echo "[runner] bun version: $(bun --version)"
|
|
# oven/bun:1 omits git; many unit tests use mkdtemp + git init for fixtures.
|
|
if ! command -v git >/dev/null 2>&1; then
|
|
echo "[runner] Installing git (debian apt)..."
|
|
apt-get update -qq >/dev/null
|
|
apt-get install -y -qq git ca-certificates >/dev/null
|
|
fi
|
|
# Container runs as root (uid 0) against a host-uid bind-mount; mark repo +
|
|
# any worktree gitdir as safe so `git status` etc. don't refuse.
|
|
git config --global --add safe.directory '*' || true
|
|
if [ ! -d /app/node_modules ] || [ -z "$(ls -A /app/node_modules 2>/dev/null)" ]; then
|
|
echo "[runner] First run (or --clean): bun install --frozen-lockfile"
|
|
bun install --frozen-lockfile
|
|
fi
|
|
__RUN_PHASES__
|
|
EOF
|
|
)
|
|
INNER_CMD="${INNER_CMD/__RUN_PHASES__/$RUN_PHASES_CMD}"
|
|
|
|
# Conductor / git-worktree support: when `.git` is a file (not a directory),
|
|
# it points at a host gitdir outside the bind-mount. Without remounting that
|
|
# path, scripts/check-trailing-newline.sh and any other in-container `git`
|
|
# call exits 128 ("not a git repository"). Resolve the host gitdir + the
|
|
# shared common gitdir and bind-mount them at the same absolute paths.
|
|
EXTRA_MOUNTS=()
|
|
if [ -f .git ]; then
|
|
WORKTREE_GITDIR=$(awk '{print $2}' .git)
|
|
if [ -d "$WORKTREE_GITDIR" ]; then
|
|
COMMONDIR_FILE="$WORKTREE_GITDIR/commondir"
|
|
if [ -f "$COMMONDIR_FILE" ]; then
|
|
COMMON_REL=$(cat "$COMMONDIR_FILE")
|
|
COMMON_GITDIR=$(cd "$WORKTREE_GITDIR" && cd "$COMMON_REL" && pwd)
|
|
else
|
|
COMMON_GITDIR="$WORKTREE_GITDIR"
|
|
fi
|
|
# Mount the higher-level common gitdir; covers worktrees/<name> automatically.
|
|
EXTRA_MOUNTS+=( -v "${COMMON_GITDIR}:${COMMON_GITDIR}:ro" )
|
|
echo "[ci-local] Worktree detected; mounting shared gitdir: $COMMON_GITDIR"
|
|
fi
|
|
fi
|
|
|
|
echo "[ci-local] Running checks inside runner container..."
|
|
docker compose -f "$COMPOSE_FILE" run --rm "${EXTRA_MOUNTS[@]:-}" runner bash -c "$INNER_CMD"
|
|
|
|
echo ""
|
|
echo "[ci-local] All checks passed."
|