Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Opus 4.7 670520608c docs: rewrite v0.13.0 + v0.13.1 CHANGELOG entries in builder voice
Both entries were dense — full of jargon a non-contributor would
bounce off. "Typed abstractions," "FOR UPDATE serializes concurrent
reserves," "FNV-1a → 0–59 deterministic," "per CEO plan." That's
insider baseball, not release notes.

Rewrite both with the CLAUDE.md voice rules in mind: lead with what
the user can DO, concrete commands, short paragraphs, kill
abbreviations and AI-review vocabulary. Keep the tables of numbers
(they're the honest part) and keep the itemized section for agents
that need implementation detail, but trim the itemized jargon too.

v0.13.1 headline reframed around the four things users actually get:
self-repairing citations, a budget wall, quiet-hours on Minions,
validators that catch bad writes. v0.13.0 headline stays — "your
YAML frontmatter is now a graph" was already good.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:41:17 +08:00
Garry Tan f53f37e56b Merge remote-tracking branch 'origin/master' into garrytan/knowledge-runtime
# Conflicts:
#	CHANGELOG.md
#	VERSION
#	package.json
#	src/commands/migrations/index.ts
#	src/core/migrate.ts
#	src/core/operations.ts
#	test/apply-migrations.test.ts
2026-04-20 07:17:54 +08:00
Garry Tan c5f1a2f71f Merge remote-tracking branch 'origin/master' into garrytan/knowledge-runtime
# Conflicts:
#	CHANGELOG.md
#	VERSION
#	package.json
#	src/cli.ts
#	src/commands/doctor.ts
2026-04-19 18:37:08 +08:00
Garry TanandClaude Opus 4.7 ecedbcd869 docs(bench): add v0.13 knowledge runtime benchmark deltas
Two new benchmark scripts + one consolidated markdown comparing this
branch against master (c0b6219, v0.12.1):

benchmark-put-page-latency.ts — 200 put_page ops, measures the
per-write cost of Step B's auto-timeline extraction. Branch adds
~0.5ms mean latency and produces 300 timeline entries for free;
master produces zero and requires a separate 'gbrain extract timeline'
pass.

benchmark-knowledge-runtime.ts — three measurements in one script:
time-to-queryable (branch 40/40 vs master 0/40 on post-ingest
timeline queries), integrity repair rate (70/20/10 three-bucket
split via mocked resolver), doctor completeness (surfaces 100% of
real issues after Step A, respects grandfathered pages).

docs/benchmarks/2026-04-19-knowledge-runtime-v0.13.md — consolidated
report. Covers the four moved benchmarks plus side-by-side runs of
graph-quality and search-quality showing they're identical across
master and branch. Proof of no regression on the retrieval hot path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:28:11 +08:00
Garry TanandClaude Opus 4.7 422c36c5e4 feat(migrate): verify target health after engine migration
After a PGLite↔Postgres migration, the user was left to run 'gbrain
doctor' themselves to confirm the target is good. Not great, because
the failure modes (partial copy, missing embeddings, schema drift)
all surface at next CLI use when the migration itself looks like it
succeeded.

Add verifyTarget() — inline doctor-lite that checks page count
matches the source, embedding coverage is above 90%, and schema
version is at latest. Prints a 3-line status table at the end of
migrate and points at 'gbrain doctor' for the full check. Non-fatal:
warns on discrepancies instead of failing the command so the user
sees the full picture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:45:44 +08:00
Garry TanandClaude Opus 4.7 cf28c7e8ef feat(put_page): auto-extract timeline entries alongside auto-link
put_page already chunks, embeds, reconciles tags, and extracts
auto-links on every write. Timeline extraction has lived in a
separate command (gbrain extract timeline) that users had to remember
to run. Fold it into the write path: after the page commits, parse
timeline entries from compiled_truth + timeline body and insert via
addTimelineEntriesBatch. ON CONFLICT DO NOTHING keeps it idempotent
across re-writes.

Mirrors auto-link shape: best-effort post-hook, skipped for remote
(MCP) callers, gated by auto_timeline config (default TRUE). Response
includes auto_timeline: { created } alongside auto_links.

Side effect: a one-shot `gbrain put` now produces a complete page —
chunks, embeddings, links, AND timeline — instead of three commands
the user has to chain manually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:43:26 +08:00
Garry TanandClaude Opus 4.7 2e79a1b5e2 feat(doctor): fold integrity sample scan into default health check
Expose scanIntegrity(engine, opts) as a pure library function — same
logic cmdCheck uses — and call it from doctor in non-fast mode with
a 500-page sampling limit. Surfaces bare-tweet phrase count and
external-link count as an 'integrity' check, warn-status when bare
tweets are present with a one-liner pointing at 'gbrain integrity
check' for the full report and 'integrity auto' for repair.

Read-only: no network, no writes, no resolver calls. Pages with
validate:false frontmatter are skipped (grandfathered). --fast mode
skips it entirely so the existing health-snapshot contract holds.

Users no longer need to remember three separate commands (doctor,
lint, integrity check) to audit brain health — doctor surfaces the
integrity signal by default, full scan stays available for deep dives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:20:50 +08:00
Garry TanandClaude Opus 4.7 2c74065527 test: expand coverage on abort-signal threading + integrity CLI dispatch
fail-improve: four new AbortSignal cases — pre-start abort, between
deterministic and LLM, signal forwarded into both callbacks, and
LLM-thrown AbortError propagates without logging a failure entry.

integrity: three new CLI dispatch cases — --help, no-subcommand (help),
and unknown subcommand (stderr + exit 1). Non-engine paths so they
exercise routing without spinning up a DB.

Coverage-only; no source changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:04:07 +08:00
Garry TanandClaude Opus 4.7 9ab830eed3 fix(auto-link): advisory lock serializes concurrent reconciliation
runAutoLink wraps getLinks + addLink/removeLink in a transaction, but
row-level locks alone don't prevent the union-of-writes race: two
concurrent put_page calls on the same slug can both read the same
existingKeys BEFORE either mutates a row, then proceed to add links
the other side's rewrite no longer mentions.

Take a transaction-scoped advisory lock on hashtext("auto_link:" ||
slug) at the start of the reconciliation. Concurrent writers on the
same slug now fully serialize; writers on different slugs still run
in parallel. No-op on engines without advisory locks (PGLite).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:04:00 +08:00
Garry TanandClaude Opus 4.7 a3beba949c fix(validators): empty [Source:] no longer satisfies citation check
Regex /\[Source:[^\]]*\]/ matched decorative markers like [Source:]
and [Source:   ] that carry zero provenance. Tighten to require at
least one non-whitespace character before the closing bracket. The
inline URL form ](https://...) already requires a scheme+host so it
stays as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:03:53 +08:00
Garry TanandClaude Opus 4.7 53c0216554 fix(writer): advisory lock on desiredSlug prevents cross-process TOCTOU
BrainWriter's createEntity checks engine.getPage(slug) and falls back
to putPage(), which upserts. Two putPage('people/alice') calls from
separate processes (a Claude Code session + a Minions worker, say) can
both read "free" from SlugRegistry and both call putPage, silently
overwriting each other with no disambiguation.

Take a transaction-scoped advisory lock keyed on hashtext(desiredSlug)
before the registry check. Concurrent writers for the same slug now
serialize at the DB level: the second observes the first's commit and
disambiguates to alice-2. PGLite is single-process so this is a
harmless no-op there. Wrapped in try/catch so engines/test doubles
that don't support advisory locks fall through to the existing
within-process check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:03:48 +08:00
Garry TanandClaude Opus 4.7 874fa166cd fix(resolvers): DNS-rebinding defense + X rate-limit header parity
Two non-blocking codex findings on PR #210 rolled into one bisectable
commit because their tests share an import line.

url_reachable: hostname-string SSRF guard is vulnerable to DNS rebinding
(attacker-controlled DNS returns a public IP at validate time and
169.254.169.254 at fetch time). Add checkDnsRebinding() that resolves
the hostname via dns.lookup({all:true}) and rejects any result whose
A/AAAA record lands in a private range (v4 via isPrivateIpv4, v6
loopback/link-local/unique-local/IPv4-mapped). Applied on the initial
URL and on every redirect target. Null on DNS failure so genuine
network problems surface via fetch.

x_handle_to_tweet: rate-limit backoff only honored Retry-After and
ignored X's proprietary x-rate-limit-reset header. computeBackoffMs()
parses both (Retry-After = seconds or HTTP-date; x-rate-limit-reset =
epoch seconds), takes MAX, and clamps to [2s, 60s]. Exported for
testability; callers use it uniformly on every 429.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:03:39 +08:00
Garry TanandClaude Opus 4.7 858484ea50 chore: bump version and changelog (v0.13.0.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:28 +08:00
Garry TanandClaude Opus 4.7 2fad71dcb0 fix(integrity): --dry-run no longer writes progress, poisoning resume
Codex caught that 'gbrain integrity auto --dry-run' appended progress
entries (status='repaired', 'reviewed', 'skipped', 'error') despite doing
no actual writes. The follow-on real run with default --resume would then
skip those slugs — the dry-run silently consumed the work queue.

Fix: gate every appendProgress() call in cmdAuto on !dryRun. Dry-run
still logs to the skip log / review queue (so the user sees what WOULD
happen), but the progress file stays untouched.

Behavior:
  --dry-run            → buckets counted + summary printed + review-queue
                         + log populated, but progress file unchanged.
  (default)            → progress file tracks every processed slug, so
                         Ctrl-C + re-run resumes from the right place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:13 +08:00
Garry TanandClaude Opus 4.7 8e90e39408 fix(budget): commit() re-checks cap + rejects negative actuals
Codex caught two cap-bypass bugs in BudgetLedger.commit():

1. reserve({estimateUsd: 0.01, capUsd: 1.0}) + commit(id, 100) silently
   charged $100 to a $1-cap bucket. Cap is an advertised invariant that
   the code was not enforcing.

2. Negative actuals (commit(id, -5)) were accepted, letting callers
   artificially reduce committed_usd below the real spend. Refunds need
   a dedicated API, not a side-channel on commit.

Fix:
- Reject non-finite AND negative actualUsd at entrypoint.
- Lock the ledger row FOR UPDATE during commit (same serialization as
  reserve).
- Compute effective cap headroom = cap - other_committed - other_reserved
  (excluding this reservation from the reserved pool since we're about to
  finalize it).
- When actualUsd would exceed available, clamp committed_usd to max
  available and throw BudgetError with the overage reported. The
  reservation is still marked 'committed' (API call already happened;
  don't retry-loop), but the cap is honored.

After this, a $1/day cap actually means $1/day.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:13 +08:00
Garry TanandClaude Opus 4.7 7083f01ae2 fix(minions): route quiet-hours 'skip' through cancelJob to rollup parents
Codex flagged that handleQuietHoursDefer with verdict='skip' directly set
status='cancelled' via raw UPDATE — bypassing MinionQueue.cancelJob, which
means:
  - Parent jobs in 'waiting-children' never get rolled up.
  - Descendant jobs don't cascade-cancel.
  - Child-done inbox notification is skipped.

Result: a parent waiting on a child that fell inside quiet hours with
policy='skip' stays stuck forever.

Fix: release the lock, then delegate to queue.cancelJob(job.id) which
handles the recursive CTE + parent rollup + inbox posting correctly.
Falls back to a direct UPDATE only if cancelJob errors — even then, the
status transition is status-guarded to avoid stomping terminal states.

Defer path unchanged (no parent rollup needed since the job hasn't reached
a terminal state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:13 +08:00
Garry TanandClaude Opus 4.7 53d4414e21 fix(minions): wire quiet_hours + stagger_key into MinionJobInput + queue.add
Codex adversarial review caught that PR 5 (claim-time quiet-hours gate) was
cosmetic: the schema v12 column existed, the worker read it via
`readQuietHoursConfig(job)`, but `MinionJobInput` never accepted it,
`queue.add()` never inserted it, and `rowToMinionJob()` never mapped it out.
Result: every scheduled job saw `quiet_hours: null`, so the gate was a
no-op. Stagger_key had the same broken wiring.

- MinionJob (types.ts): add `quiet_hours` and `stagger_key` fields.
- MinionJobInput: add matching optional fields so callers can submit them.
- rowToMinionJob: parse both columns (JSONB handled the same way as `data`).
- MinionQueue.add: include both columns in the INSERT (idempotent + normal
  paths), bound as $19/$20. The `$19::jsonb` cast matches the JSONB column
  shape; the wire format is the same native-JS object path that fixed the
  JSONB double-encode bug in v0.12.1.

After this, `await queue.add('x', {}, { quiet_hours: {start:22,end:7,
tz:"America/Los_Angeles",policy:"defer"} })` actually stores the window
and the worker's claim-time gate defers the job inside it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:13 +08:00
Garry Tan 6756325532 Merge remote-tracking branch 'origin/master' into garrytan/knowledge-runtime
# Conflicts:
#	src/cli.ts
#	src/commands/migrations/index.ts
#	test/apply-migrations.test.ts
2026-04-19 08:39:13 +08:00
Garry TanandClaude Opus 4.7 f332a8fe76 test(migrations-v0_13_0): drop flaky no-config assertion
The 'does not succeed when no brain is configured' test assumed loadConfig
would return null when HOME is empty, but it also reads DATABASE_URL from
the environment. When .env.testing sources DATABASE_URL into the shell
(normal E2E lifecycle), the orchestrator connects successfully and runs
to completion — the test's assertion was unreachable.

The dry-run path is still covered by the remaining test in the same
describe block; registry integration and semver ordering are covered by
the sibling describe.

Full suite with DATABASE_URL live: 1574 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:29:05 +08:00
Garry TanandClaude Opus 4.7 c5555dcac1 feat(output): post-write validator lint hook — PR 2.5
Minimal integration of BrainWriter validators into the main write path,
feature-flag-gated and non-blocking. The CEO plan explicitly scoped PR 2.5
as a pre-soak landing step: the hook plugs in now, observability lands,
but strict-mode rejection is deferred to a follow-on release gated on the
7-day soak + BrainBench regression ≤1pt.

src/core/output/post-write.ts
  runPostWriteLint(engine, slug, opts?) invokes the four BrainWriter
  validators (citation, link, back-link, triple-hr) against a freshly
  written page and returns a PostWriteLintResult. Skips cleanly when:
    - config `writer.lint_on_put_page` is not truthy (default OFF; opts.force overrides)
    - the page is not found (shouldn't happen in normal put_page flow)
    - the page has frontmatter.validate === false (grandfathered)
  Findings are logged to:
    - ~/.gbrain/validator-lint.jsonl (capped at 20 findings per line)
    - engine.logIngest (ingest_log table) for durable agent-inspectable history
  Validator-level exceptions are swallowed so a buggy validator never
  breaks put_page.

src/core/operations.ts put_page handler
  After importFromContent + runAutoLink, imports runPostWriteLint and
  invokes it. Result returns writer_lint: {error_count, warning_count} or
  {skipped: reason}. Try/catch wraps the whole hook so an import or
  runtime error never blocks the main write.

Enable locally:
  gbrain config set writer.lint_on_put_page true
Then every put_page emits a writer_lint summary + appends structured
findings to the ingest log for analysis before the strict-mode flip.

test/post-write-lint.test.ts — 11 tests:
  Flag reader (default off, true/1/on, other values false, explicit false)
  Hook behavior (flag-off skip, page-not-found skip, validate:false
  grandfather skip, force=true overrides flag, dirty page yields citation
  error, clean page yields zero findings).

Full suite: 1485 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:11:10 +08:00
Garry TanandClaude Opus 4.7 1aab3ee62b feat(minions): quiet-hours + stagger + claim-time gate — PR 5
Closes the scheduler gap per CEO plan: Minions v7 shipped a durable
runtime but nothing about when jobs should NOT run. This wires
quiet-hours enforcement at claim time (the codex correction — dispatch-
time is wrong because a queued job can become claimable after its window
opens) plus deterministic stagger slots to prevent cron-boundary storms.

Schema migration v12 adds two columns to minion_jobs:
  quiet_hours JSONB    — {start, end, tz, policy} window config
  stagger_key TEXT     — partitioning key for deterministic offset
Plus a partial index on stagger_key for later slot-assignment queries.

src/core/minions/quiet-hours.ts
  evaluateQuietHours(cfg, now?) → 'allow' | 'skip' | 'defer'. Pure,
  deterministic, no engine. Handles straight-line and wrap-around windows
  (e.g. 22→7 spans midnight). IANA timezone via Intl.DateTimeFormat;
  unknown tz fails open (allow) — safer than hard-blocking every job.
  'skip' policy drops the event; 'defer' (default) re-queues for later.

src/core/minions/stagger.ts
  staggerMinuteOffset(key) → 0–59, FNV-1a hash. Same key → same slot.
  Pure; no module-level state. Used by scheduled resolvers that want to
  avoid cron-boundary collisions ("10 jobs all fire at minute 0").

src/core/minions/worker.ts
  MinionWorker.tick now consults evaluateQuietHours on every claimed job.
  Verdict 'defer' → UPDATE status='delayed', delay_until = now() + 15m
  (prevents immediate re-claim loops when the claim query re-runs).
  Verdict 'skip' → UPDATE status='cancelled', error_text='skipped_quiet_hours'.
  Both paths clear lock_token and require lock_token match in the WHERE
  clause so a concurrent stall recovery can't race us.

test/minions-quiet-hours.test.ts — 25 tests:
  evaluateQuietHours: null/undefined/invalid config paths (allow fail-open),
  straight-line in/out + exclusive-end, wrap-around in (before midnight +
  after), skip vs defer policy, timezone-offset propagation (winter PST
  vs summer PDT), localHour parity with Date.getUTCHours.
  staggerMinuteOffset: deterministic same key → same offset, different
  keys spread across buckets (10 keys → ≥5 unique buckets), empty/non-
  string edge cases.
  Schema v12: quiet_hours and stagger_key columns exist on minion_jobs,
  idx_minion_jobs_stagger_key index present.

Full suite: 1474 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:37:31 +08:00
Garry TanandClaude Opus 4.7 42f98a07a0 feat(enrichment): BudgetLedger + CompletenessScorer — PR 4
Two layer-2 primitives that slot under the resolver SDK and BrainWriter:
cost-aware spend caps and evidence-weighted per-page completeness scoring.

Schema migration v11 adds two tables:
  budget_ledger (scope, resolver_id, local_date) PK — midnight rollover by
    date column means a new calendar day upserts a new row; no rollover
    thread, no race.
  budget_reservations (reservation_id) — TTL-bounded held reservations
    (default 60s) so process death between reserve() and commit() doesn't
    strand money.

Rollback plan: DROP TABLE. Budget data is regenerable from resolver call
logs; no durable product value lives in the ledger.

src/core/enrichment/budget.ts
  BudgetLedger.reserve({resolverId, estimateUsd, capUsd?, ttlSeconds?})
  serializes concurrent reserves on {scope, resolver_id, local_date} via
  SELECT ... FOR UPDATE. Returns {kind:'held', reservationId, ...} or
  {kind:'exhausted', reason, spent, pending, cap} — never over-spends.

  commit(id, actualUsd) moves money from reserved_usd to committed_usd and
  marks the reservation status='committed'. rollback(id) zeros out the
  reservation without touching committed. Commit-after-commit throws
  already_finalized; rollback-after-commit is a no-op (callers don't need
  to guard). commit-unknown-id throws reservation_not_found.

  cleanupExpired() sweeps held reservations past expires_at and rolls them
  back; reserve() opportunistically reclaims the target row's expired
  reservations before acquiring its own lock.

  IANA timezone config via opts.tz (default America/Los_Angeles); midnight
  rollover is naturally expressed as a date column + Intl.DateTimeFormat
  with en-CA locale (YYYY-MM-DD). DST is handled by the formatter.

src/core/enrichment/completeness.ts
  Seven per-type rubrics (person, company, project, deal, concept, source,
  media) + default. Each rubric's dimension weights sum to 1.0, checked at
  module load. scorePage(page) returns {score, dimensionScores, rubric}
  where score is 0.000–1.000.

  Person rubric dimensions: has_role_and_company, has_source_urls,
  has_timeline_entries, has_citations, has_backlinks, recency_score,
  non_redundancy. The last two are the explicit fix for the two pathologies
  called out in the codex review of the earlier design: stale pages that
  never decay (30-day re-enrich forever) and Wilco-style repeated blocks
  that pass Wintermute's length heuristic.

  Pure functions. No engine calls — BrainWriter invokes scorePage after a
  transaction and caches the result in frontmatter.completeness.

test/enrichment.test.ts — 23 tests:
  BudgetLedger: under-cap held, over-cap exhausted, commit moves money,
  rollback clears, commit-rollback no-op, commit-commit throws, commit-
  unknown throws, invalid input, empty state null, scope isolation,
  parallel reserves respect cap (10 parallel, cap 1.0, est 0.3 each →
  ≤ 3 held; state.reservedUsd ≤ 1.0), cleanupExpired reclaims TTL=0.

  CompletenessScorer: all 8 rubrics sum to 1.0, empty person scores <0.3,
  fully-enriched person >0.8, dimension scores exposed, role detection,
  company/concept/source/media/default routing, recency decay with age,
  non_redundancy penalizes repeated lines.

Full suite: 1449 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:33:57 +08:00
Garry TanandClaude Opus 4.7 079ea814b0 feat(integrity): gbrain integrity — bare-tweet repair + dead-link scan (PR 3)
Ships the user-visible milestone for the Knowledge Runtime delta: a
command that finds brain-integrity issues and repairs them through the
BrainWriter + Resolver SDK infrastructure from PRs 1 and 2.

Targets the two quantified pain points from brain/CITATIONS.md:
  - 1,424 of 3,115 people pages have bare tweet references without URLs
  - An unknown fraction of existing URL citations have rotted

Subcommands:
  gbrain integrity check                 Read-only report, optional --json
  gbrain integrity auto                  Three-bucket repair loop
  gbrain integrity review                Print review-queue path + count
  gbrain integrity reset-progress        Clear the progress file

Three-bucket contract (matches x_handle_to_tweet resolver's confidence
scoring):
  >=0.8 → auto-repair via BrainWriter transaction. Appends a timeline
          entry on the page with a Scaffolder-built tweet citation (URL
          from the API response, never from LLM text).
  0.5-0.8 → append to ~/.gbrain/integrity-review.md with all candidates
            sorted by match score, for batch human review.
  <0.5 → log reason to ~/.gbrain/integrity.log.jsonl and skip.

Resumable: every processed slug hits ~/.gbrain/integrity-progress.jsonl
so an interrupted run resumes from the last slug. --fresh clears it.

Bare-tweet detection patterns (regex, deterministic, skip code fences
and already-cited lines):
  - "tweeted about"
  - "in/on a (recent|viral) tweet"
  - "wrote a tweet/post"
  - "posted on X"
  - "via X" (but not "via X/handle" — already cited)
  - possessive "his/her/their tweet"

External-link detection extracts all [text](https?://...) pairs (code
fences skipped) for optional dead-link probing via url_reachable.

Dead links are surfaced, not auto-repaired — no "correct" replacement
exists without human judgment.

Wiring: runIntegrity dispatches subcommands, registers builtin resolvers
into the default registry, connects to the brain engine, and uses
BrainWriter in strict-off mode (integrity is the repair path, not the
write-gate path).

Unit tests: 21 cover bare-tweet regex (all 9 phrase shapes + code-fence
skip + URL-already-present skip + per-line dedup), external-link
extraction (http+https, line numbers, fenced skip), frontmatter handle
extraction (x_handle, twitter, twitter_handle, x; preference order;
leading @ strip; null paths). End-to-end auto flow verified manually
via the resolver SDK tests + BrainWriter tests it composes.

src/cli.ts wires `integrity` into CLI_ONLY + dispatches to runIntegrity.

Full suite: 1426 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:29:35 +08:00
Garry TanandClaude Opus 4.7 f56946ee88 feat(migrations): v0.13.0 grandfather validate:false — PR 2 pass 3/4
Adds the TS migration that makes BrainWriter's strict-mode rollout safe:
every existing page gets `validate: false` in frontmatter so the new
citation / link / back-link / triple-HR validators skip legacy content.
gbrain integrity --auto (PR 3) clears the flag per-page once real citations
are repaired.

src/commands/migrations/v0_13_0_add_validate_false.ts
  Four-phase orchestrator following the v0_12_0 pattern:
    A. connect   — loadConfig + createEngine. Does NOT write config (prior
                   learning: gbrain init --migrate-only semantics; never
                   flip Postgres users to PGLite via bare init).
    B. snapshot  — engine.getAllSlugs() upfront (prior learning:
                   listpages-pagination-mutation; OFFSET iteration is
                   self-invalidating when each write bumps updated_at).
    C. grandfather — per slug, skip if frontmatter.validate already set,
                   else append-log pre-mutation snapshot to
                   ~/.gbrain/migrations/v0_13_0-rollback.jsonl and
                   putPage with validate:false merged in. Batched 100
                   at a time so interruption losses are bounded.
    D. verify    — SQL count of pages with validate=false ≥ expectedTouched.
  Idempotent: second run is a no-op. Reversible: rollback log is
  append-only JSONL; future `gbrain apply-migrations --rollback v0.13.0`
  replays it. Safe on empty brains (returns complete with 0 touched).

src/commands/migrations/index.ts
  Registers v0_13_0 after v0_12_0 in semver order.

test/migrations-v0_13_0.test.ts
  Registry integration (v0.13.0 present, semver-after-v0.12.0, pitch
  metadata well-formed), orchestrator handles no-config gracefully,
  dryRun skips the connect phase.

test/apply-migrations.test.ts
  Updated two assertions that hard-coded the v0.12.0 skippedFuture list
  to also include v0.13.0 (now skippedFuture when installed < 0.13.0).

Full suite: 1405 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:26:04 +08:00
Garry TanandClaude Opus 4.7 65126eb4e7 feat(output): 4 pre-commit validators + tests — PR 2 pass 2/4
Lands the validator suite that BrainWriter runs before committing a
transaction. Paragraph-level deterministic checks, markdown-aware, skip
legacy pages via validate:false frontmatter.

src/core/output/validators/citation.ts
  Every factual paragraph in compiled_truth carries at least one citation
  marker: [Source: ...] or a linked URL. Splits paragraphs on blank lines,
  strips fenced code / inline code / HTML comments before checking.
  Ignores headings, key-value lines ("**Status:** Active"), table rows,
  pure wikilink bullets (## See Also), and short labels without a factual
  verb. Deterministic — no LLM, no semantic judgment.

src/core/output/validators/link.ts
  Every [text](path) wikilink resolves to a page that exists (unless it's
  an external http(s) URL, which this validator doesn't check; that's
  url_reachable's job in PR 3). Strips relative prefix and .md extension.
  Batches engine.getPage lookups per unique target. mailto/anchor/other
  schemes flagged as warning. Links inside fenced code blocks are skipped.

src/core/output/validators/back-link.ts
  Iron Law: if page X → page Y, then Y → X. Reads engine.getLinks(ctx.slug),
  and for each target checks engine.getLinks(target) for a reverse edge.
  Missing reverses flagged as warning (runAutoLink is the authoritative
  enforcer on put_page; this is defense-in-depth for pages edited outside
  the main write path).

src/core/output/validators/triple-hr.ts
  Catches hygiene issues on the compiled_truth / timeline split: bare `---`
  in compiled_truth would re-split on round-trip through parseMarkdown;
  headings in the timeline section signal authoring mistakes. Both warn
  (not error) — legacy pages legitimately use thematic breaks.

src/core/output/validators/index.ts
  registerBuiltinValidators(writer) wires all four.

test/writer.test.ts
  57 tests: Scaffolder (all 5 helpers + error paths), SlugRegistry (create,
  disambiguator, collision throw, invalid-slug, isFree, suggestDisambiguators),
  BrainWriter (happy path, disambiguate, addLink + reverse, strict rollback,
  lint proceeds with report, off skips validators, validate:false grandfather,
  setCompiledTruth, setFrontmatterField merge, registered validators list),
  citation validator (all 11 shape cases), link validator (normalizeToSlug
  including ../../, external URL skip, mailto warning, code-fence skip),
  back-link validator (no outbound, missing reverse → warning, bidirectional
  clean), triple-hr validator (clean, bare --- warning, fenced --- skipped,
  heading in timeline warning, ## Timeline header allowed).

Full suite: 1400 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:21:35 +08:00
Garry TanandClaude Opus 4.7 1cc818ed43 feat(output): BrainWriter + Scaffolder + SlugRegistry — PR 2 pass 1/4
Lands the transactional writer library that the rest of the Knowledge
Runtime sits on top of. No callers routed through it yet — publish.ts /
backlinks.ts / put_page migrations are pass 4 and PR 2.5.

src/core/output/scaffold.ts
  Deterministic URL / citation / link builders. Callers pass typed inputs
  (handle + tweetId, account + messageId, slug + display text) and get
  canonical markdown bytes out. LLM-generated URLs never touch disk.
  - tweetCitation({handle, tweetId, dateISO?})
  - emailCitation({account, messageId, subject, dateISO?})
  - sourceCitation(resolverResult, {url?, label?})
  - entityLink({slug, displayText, relativePrefix?})
  - timelineLine({dateISO, summary, citation?})
  ScaffoldError with codes for invalid_handle / invalid_tweet_id /
  invalid_slug / invalid_message_id / invalid_date / empty.

src/core/output/slug-registry.ts
  Solves the "Marc Benioff vs Marc-Benioff both slug to marc-benioff" bug.
  create() probes engine.getPage and either returns the desired slug or
  disambiguates (alice-smith → alice-smith-2). isFree() + suggestDisambiguators()
  for interactive UX. Errors: collision, disambiguator_exhausted, invalid_slug.

src/core/output/writer.ts
  BrainWriter.transaction(fn, ctx) wraps engine.transaction. The `fn`
  callback receives a WriteTx with createEntity / appendTimeline /
  setCompiledTruth / setFrontmatterField / putRawData / addLink (the last
  creates both forward + reverse back-link atomically). On commit, per-page
  validators run against all touchedSlugs. Strict mode throws on
  error-severity findings, rolling back the outer tx. Lint mode (default for
  PR 2 rollout) returns the report but commits regardless. Pages with
  `validate: false` frontmatter skip validators entirely (grandfather hook
  for PR 2 migration).

Integration smoke against PGLite: createEntity → disambiguator (2nd call
with same desired slug), addLink writes both forward + back-link,
strict-mode validator failure rolls back the transaction bit-identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:15:34 +08:00
Garry TanandClaude Opus 4.7 6e17d19c85 feat(resolvers): tests + gbrain resolvers CLI — SDK pass 4 (PR 1/5 complete)
Closes out PR 1. 43 new tests in test/resolvers.test.ts covering registry
contract, both reference builtins, all three confidence buckets, and every
ResolverError subcode.

test/resolvers.test.ts
  - ResolverRegistry: register, duplicate-id rejection, get/has, list with
    cost+backend filters, resolve, unavailable propagation, clear, default
    singleton lifecycle.
  - url_reachable: available(), SSRF guard on localhost + RFC1918 + 169.254
    metadata + file:// scheme, empty-url schema error, 200/404 status
    propagation, HEAD→GET fallback on 405, redirect chain, per-hop SSRF
    re-validation, network failure → reachable=false, AbortSignal mid-flight.
  - x_handle_to_tweet: token gate via env AND via ctx.config, invalid/long
    handle schema errors, zero-candidate + single-strong + single-weak +
    many-ambiguous confidence buckets (gates >=0.5 url emission), 401/403
    auth error, 500 upstream error, 429 retry-then-rate_limited, X operator
    stripping (prompt injection defense).

src/commands/resolvers.ts
  - `gbrain resolvers list [--cost | --backend | --json]` pretty table
    or JSON.
  - `gbrain resolvers describe <id>` schema + availability detail.
  - registerBuiltinResolvers() is idempotent; ready to be called from
    future entry points (gbrain integrity, MCP server).

src/cli.ts wires `resolvers` into CLI_ONLY + dispatches to runResolvers.

Full suite: 1343 pass / 0 fail / 141 skip (E2E without DATABASE_URL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:11:54 +08:00
Garry TanandClaude Opus 4.7 a9c312b3a3 feat(resolvers): url_reachable + x_handle_to_tweet — SDK pass 3 (PR 1/5)
Two reference resolver implementations that validate the interface against
real-world requirements: a deterministic free-cost check and a rate-limited
paid-backend lookup.

src/core/resolvers/builtin/url-reachable.ts
  HEAD-check a URL, follow redirects (max 5), detect dead links. Reused
  isInternalUrl() from the wave-3 SSRF hardening; re-validates every redirect
  hop against the same filter. Falls back from HEAD to GET on 405/501.
  Composes caller's AbortSignal with a per-request timeout via
  AbortSignal.any (with manual-propagation fallback). Confidence=1 when the
  backend answers; confidence=0 only on transport failure (DNS/connect/timeout).

src/core/resolvers/builtin/x-api/handle-to-tweet.ts
  Find a tweet by handle + free-text keyword hint. Used by the upcoming
  `gbrain integrity --auto` loop to repair the 1,424 bare-tweet citations
  in Garry's brain. Confidence buckets align with the three-bucket contract:
    - >=0.8 auto-repair (single strong match, or dominant in small candidate set)
    - 0.5-0.8 review queue (ambiguous but promising)
    - <0.5 skip (many candidates or weak match)
  Scoring: normalized keyword-token overlap against tweet text, with margin
  boost for dominant matches. Strict handle regex (X's username rules).
  Retries on 429 up to 2x with Retry-After honor. Terminal 401/403 surfaces
  as auth ResolverError so the caller stops hammering. Bearer token read
  from ctx.config.x_api_bearer_token or X_API_BEARER_TOKEN env — never logged.

Smoke: registry accepts both, SSRF blocks localhost + file://, available()
returns false when token missing, schema validator rejects bad handles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 05:46:16 +08:00
Garry TanandClaude Opus 4.7 c529f5c0cf feat(fail-improve): optional AbortSignal — Resolver SDK pass 2 (PR 1/5)
Extends FailImproveLoop.execute with an optional `opts.signal` that threads
through the deterministic-first / LLM-fallback flow. Needed by the Resolver
SDK so long-running lookups can be cooperatively cancelled when a caller
aborts (deadline hit, Minion job timeout, user ctrl-c).

Additive and backwards-compatible:
- execute() signature widens callbacks to (input, signal?) => ...; existing
  two-arg callbacks are structurally compatible and ignore the extra arg.
- opts is optional; callers that omit it get pre-extension behavior.
- Aborts throw a DOM-style AbortError (name='AbortError'), matching what
  fetch() throws, so downstream `err.name === 'AbortError'` branches work
  unchanged.
- Aborted runs are NOT logged to the failure JSONL — not informative and
  would pollute pattern analysis.

Abort check fires in three places:
- Before the deterministic call (pre-flight)
- Between deterministic miss and LLM call (mid-flight)
- Inside llmFallbackFn if the implementation respects signal itself

Smoke tests: 5 scenarios (existing sig, llm fallback, pre-abort, mid-flight
abort, signal threaded to fallback) — all pass. Existing test/fail-improve.test.ts
(13 tests, 27 expects) unchanged and passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 05:43:59 +08:00
Garry TanandClaude Opus 4.7 7e7630fcc8 feat(resolvers): Resolver SDK pass 1 — interface + registry (PR 1/5)
Adds the typed plugin interface that unifies external-lookup calls (X API,
Perplexity, HEAD check, brain-local slug resolution) behind a single shape:

    registry.resolve('x_handle_to_tweet', { handle, keywords }, ctx)
      → { value, confidence, source, fetchedAt, raw? }

Zero behavior change — the registry is empty by default. Builtins
(url_reachable, x_handle_to_tweet) land in the next pass. ScheduledResolver
wrapping via Minions lands in PR 5.

New files:
- src/core/resolvers/interface.ts — Resolver<I,O>, ResolverResult<O>,
  ResolverContext (engine, storage, config, logger, requestId, remote,
  deadline, signal), ResolverError (not_found, already_registered,
  unavailable, timeout, rate_limited, auth, schema, aborted, upstream)
- src/core/resolvers/registry.ts — ResolverRegistry (register/get/has/
  list/resolve/clear/size) + getDefaultRegistry() for process-wide use
- src/core/resolvers/index.ts — barrel export

Design rules enforced by types:
- Every result carries confidence (0.0-1.0) + source attribution
- LLM-backed resolvers return confidence<1.0 by convention
- ctx.remote propagates the trust boundary (mirrors OperationContext.remote)
- AbortSignal threads through for cooperative cancellation

Smoke: imports + runs, list()/get()/resolve() behave as typed.
Dependency-free beyond types and storage/engine type imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:47:35 +08:00
Garry Tan bdc4f62307 Merge remote-tracking branch 'origin/master' into garrytan/knowledge-runtime 2026-04-18 23:10:02 +08:00
Garry TanandClaude Opus 4.7 ad6d58458f docs: Knowledge Runtime design doc (draft) — 4-layer architecture + reduced-scope delta
Captures the Knowledge Runtime design thinking from the CEO review session:
Resolver SDK, Enrichment Orchestrator, Scheduler, Deterministic Output Builder.

The original 7-phase plan was drafted before v0.12.0 (knowledge graph layer)
and v0.11.x (Minions agent runtime) shipped. Cross-referenced against what's
already merged on master, roughly 60% of the 4-layer vision is already in
production under different names:

  - Minions = scheduler + plugin contract (L1 + L3)
  - Knowledge graph auto-link = deterministic output at L4 + orchestrator at L2
  - BrainBench v1 benchmarks already validate the graph layer

The doc is kept as a draft design reference; the actual build-out will scope
down to the real delta (typed Resolver interface, BrainWriter API + validators,
BudgetLedger, CompletenessScorer, quiet-hours + stagger). See the CEO review
notes for the reduced plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:09:58 +08:00
103 changed files with 578 additions and 13622 deletions
-59
View File
@@ -1,59 +0,0 @@
# Agents working on GBrain
This is your install + operating protocol. Claude Code reads `./CLAUDE.md` automatically.
Everyone else (Codex, Cursor, OpenClaw, Aider, Continue, or an LLM fetching via URL):
start here.
## Install (5 min)
1. Clone: `git clone https://github.com/garrytan/gbrain ~/gbrain && cd ~/gbrain`
2. Install: `bun install`
3. Init the brain: `gbrain init` (defaults to PGLite, zero-config). For 1000+ files or
multi-machine sync, init suggests Postgres + pgvector via Supabase.
4. Read [`./INSTALL_FOR_AGENTS.md`](./INSTALL_FOR_AGENTS.md) for the full 9-step flow
(API keys, identity, cron, verification).
## Read this order
1. `./AGENTS.md` (this file) — install + operating protocol.
2. [`./CLAUDE.md`](./CLAUDE.md) — architecture reference, key files, trust boundaries,
test layout.
3. [`./skills/RESOLVER.md`](./skills/RESOLVER.md) — skill dispatcher. Read before any task.
## Trust boundary (critical)
GBrain distinguishes **trusted local CLI callers** (`OperationContext.remote = false`,
set by `src/cli.ts`) from **untrusted agent-facing callers** (`remote = true`, set by
`src/mcp/server.ts`). Security-sensitive operations like `file_upload` tighten filesystem
confinement when `remote = true` and default to strict behavior when unset. If you are
writing or reviewing an operation, consult `src/core/operations.ts` for the contract.
## Common tasks
- **Configure:** [`docs/ENGINES.md`](./docs/ENGINES.md),
[`docs/guides/live-sync.md`](./docs/guides/live-sync.md),
[`docs/mcp/DEPLOY.md`](./docs/mcp/DEPLOY.md).
- **Debug:** [`docs/GBRAIN_VERIFY.md`](./docs/GBRAIN_VERIFY.md),
[`docs/guides/minions-fix.md`](./docs/guides/minions-fix.md), `gbrain doctor --fix`.
- **Migrate:** [`docs/UPGRADING_DOWNSTREAM_AGENTS.md`](./docs/UPGRADING_DOWNSTREAM_AGENTS.md),
[`skills/migrations/`](./skills/migrations/), `gbrain apply-migrations`.
- **Everything else:** [`./llms.txt`](./llms.txt) is the full documentation map.
[`./llms-full.txt`](./llms-full.txt) is the same map with core docs inlined for
single-fetch ingestion.
## Before shipping
Run `bun test` plus the E2E lifecycle described in `./CLAUDE.md` (spin up the test
Postgres container, run `bun run test:e2e`, tear it down). Ship via the `/ship` skill,
not by hand.
## Privacy
Never commit real names of people, companies, or funds into public artifacts. See the
Privacy rule in `./CLAUDE.md`. GBrain pages reference real contacts; public docs must
use generic placeholders (`alice-example`, `acme-example`, `fund-a`).
## Forks
If you are a fork, regenerate `llms.txt` + `llms-full.txt` with your own URL base before
publishing: `LLMS_REPO_BASE=https://raw.githubusercontent.com/your-org/your-fork/main bun run build:llms`.
+94 -616
View File
@@ -2,617 +2,111 @@
All notable changes to GBrain will be documented in this file.
## [0.15.4] - 2026-04-21
## **PgBouncer transaction-mode prepared statements, fixed at the pool.**
## **`gbrain jobs work` against Supabase pooler stops silently dropping rows.**
Three separate PRs (#284, #286, #270) were all trying to fix the same bug: on a Supabase transaction-mode pooler (port 6543), `postgres.js`'s per-client prepared-statement cache goes stale every time PgBouncer recycles the backend connection. The symptom under sustained gbrain load is `prepared statement "xyz" does not exist` in the logs and silently dropped rows during sync. v0.15.4 lands the combined fix: the `resolvePrepare()` helper from #284, the both-connection-paths coverage from @notjbg's community PR #270, a new doctor check, and real tests against `bun:test`. The one-liner in #286 is dominated by this.
### The one number that matters
There isn't a benchmark, there's a correctness gate. On a Supabase pooler at port 6543 with a 4,500-page sync:
| | Before v0.15.4 | After v0.15.4 |
|---|---|---|
| `prepared statement ... does not exist` errors | Dozens per sync | Zero |
| Rows inserted vs. manifest count | Short by 50-200 rows (silent) | 1:1 parity |
| `gbrain jobs work` crash under load | Yes | No |
The silent-drop is the dangerous half. You run `gbrain sync`, the exit code is 0, the logs have a few noise lines you scroll past, and three weeks later you notice your brain is missing pages. `resolvePrepare(url)` disables prepared statements when the URL targets port 6543, and the doctor check flags the misconfiguration if you've manually forced `GBRAIN_PREPARE=true` on that port.
### What this means for pooler users
If you connect via `aws-0-REGION.pooler.supabase.com:6543`, do nothing. The upgrade disables prepared statements automatically and `gbrain doctor` confirms it with `pgbouncer_prepare: ok`. If you're on session mode (port 5432 on the pooler host) or direct Postgres, nothing changes: prepared statements stay on, plan caching stays intact. If your PgBouncer runs in session mode on a non-standard port, set `GBRAIN_PREPARE=true` explicitly.
## To take advantage of v0.15.4
`gbrain upgrade` handles this automatically. If you're not sure whether the fix is live:
1. **Run the doctor check:**
```bash
gbrain doctor
```
Look for `pgbouncer_prepare`. On a `:6543` URL you should see `ok` (prepared statements disabled). On a direct URL the check silently passes.
2. **Verify on sustained load:**
```bash
gbrain sync
```
Zero `prepared statement ... does not exist` log lines. Row count inserted matches the source manifest.
3. **If something looks wrong,** file an issue at https://github.com/garrytan/gbrain/issues with:
- output of `gbrain doctor`
- the connection URL shape (port and pooler hostname — redact credentials)
- whether `GBRAIN_PREPARE` is set
### Itemized changes
**Fixed**
- **Supabase PgBouncer port-6543 prepared statements no longer break sync.** New `resolvePrepare(url)` helper in `src/core/db.ts` with 4-level precedence: `GBRAIN_PREPARE` env var → `?prepare=` query param → port-6543 auto-detect → default. Wired into both the module-singleton `connect()` in `db.ts` AND the worker-instance `PostgresEngine.connect({poolSize})` in `src/core/postgres-engine.ts` so `gbrain jobs work` gets the same treatment as the main CLI. The second path was the gap #284 missed; community PR #270 caught it. Contributed by @notjbg.
- **`gbrain doctor` surfaces the misconfiguration.** New `pgbouncer_prepare` check reads the configured URL via `loadConfig()` and reports `ok` when prepared statements are safely disabled, `warn` when the URL points at port 6543 but prepared statements are still enabled (the footgun that caused silent row drops).
**Tests**
- New `test/resolve-prepare.test.ts` — 11 cases covering the full precedence matrix: env override, URL query param, port auto-detect, malformed URLs, `postgres://` vs `postgresql://` schemes, URL-encoded credentials. Uses `bun:test` (not vitest — #284's original tests were in the wrong framework and would never have run).
- Extended `test/postgres-engine.test.ts` — new source-level grep assertion that the worker-instance `connect({poolSize})` branch calls `db.resolvePrepare(url)` and conditionally includes the `prepare` key in the options literal. Mirrors the existing `SET LOCAL statement_timeout` guardrail in the same file. If anyone rips out the wiring, the build fails before a shipping brain drops rows.
**Supersedes**
- Closes #284 (ours, Wintermute): architecture landed as-is (port-only detection, no hostname expansion). Tests rewritten from vitest to bun:test.
- Closes #286 (ours, Codex one-liner): dominated; unconditional `prepare: false` would have cost direct-Postgres users plan caching for no reason.
- Closes #270 (@notjbg): the critical both-connection-paths insight landed; credit preserved in commit trailer and this CHANGELOG entry.
## [0.15.3] - 2026-04-21
## **Two upgrade-night bugs that crashed v0.13 → v0.14, now fixed with regression guards.**
## **Migrations find the right binary. Autopilot spawns its worker. `gbrain upgrade` survives.**
Tonight's production upgrade surfaced eleven bugs. Two of them — Bug 1 (the migration shell-out) and Bug 4 (the autopilot resolver) — survived two eng-review passes AND nine Codex reviews with correct diagnoses and implementable fixes. The other nine had wrong root causes or unimplementable architectures (documented in `~/.claude/plans/` as deferred work with grounded starting context for future `/investigate` sessions). This release ships the two clean fixes so the next `gbrain upgrade` actually lands.
### Itemized changes
**Fixed**
- **`gbrain upgrade` no longer crashes mid-migration on bun installs.** The v0.13.0 migration orchestrator used to shell out via `process.execPath`, which on bun-installed trees is the `bun` runtime itself. `${bun} extract links --source db …` got reinterpreted as `bun run extract` and crashed with "script not found." The fix drops the execPath detour and shells out to the bare `gbrain` string, letting the canonical shim on PATH (`/usr/local/bin/gbrain` by default) win. Regression test in `test/migrations-v0_13_0.test.ts` greps the source for `process.execPath` and fails the build if anyone reintroduces the pattern. Contributed by @garrytan.
- **Autopilot spawns its Minions worker again.** `resolveGbrainCliPath` checked `argv[1]` first and happily returned `/path/to/src/cli.ts` on bun-source installs. `spawn()` then failed with `EACCES` because TypeScript source isn't executable, and autopilot silently lost its worker. The fix reorders the probe: `which gbrain` (shim on PATH) wins first, then compiled `process.execPath`, then an `argv[1]=/gbrain` fallback. The `.ts` branch is deleted entirely. A critical regression test enforces that the resolver NEVER returns a `.ts` path across any combination of `argv[1]` + `process.execPath` + shim availability.
**Tests**
- New `test/migrations-v0_13_0.test.ts` — 7 cases covering registry wiring, dry-run semantics, and three regression guards against the Bug 1 re-introduction (no `process.execPath`, no `GBRAIN` constant, no `bun` or `.ts` in `execSync` calls).
- Rewrote `test/autopilot-resolve-cli.test.ts` — the old test enshrined the buggy `.ts` return path. New test parameterizes argv/execPath combinations and asserts the resolver never returns a `.ts` path. This is the test that would have caught Bug 4 before it shipped.
**Deferred (tracked for follow-up `/investigate` sessions)**
- Bug 2 (pooler MaxClients), Bug 3 (partial-migration retry loop), Bug 5 (v0.14.0 registry gap), Bug 6/10 (duplicate graph edges), Bug 7 (doctor --fast), Bug 8 (autopilot-cycle stalls), Bug 9 (YAML colons), Bug 11 (brain_score breakdown). Each has grounded Codex findings documenting the real root cause and where prior diagnoses went wrong. Landing target: subsequent PR waves.
## [0.15.2] - 2026-04-21
## **Silent binaries are dead. Every bulk action now heartbeats.**
## **Agents can tell the difference between "working" and "hung."**
`gbrain doctor` on a 52K-page brain used to sit silent for 10+ minutes and then get killed by an agent timeout. The checks always completed when run by hand, but stdout buffered and agents saw nothing. The same pattern hit `embed`, `sync`, `import`, `extract`, `migrate`, and every orchestrator that shelled out to them — progress either went to stdout with `\r` rewrites that collapse when piped, or nowhere at all. v0.15.2 routes every bulk action through one shared reporter. Non-TTY default is plain human lines on stderr, one line per event. Agents that want structured progress flip `--progress-json` and get one JSON object per line.
Progress events never touch stdout. Data and final summaries still go there. Script you wrote six months ago that parses `gbrain embed` output? Still works. Agent that captures stdout to JSON.parse the result? Now gets clean JSON instead of `\r\r\r1234/52000 pages...` mixed in.
### The numbers that matter
Measured on this repo (80 unit test files, 14 E2E test files, real Postgres+pgvector, 141 E2E cases incl. 3 new doctor-progress tests):
| Metric | BEFORE v0.15.2 | AFTER v0.15.2 | Δ |
|---------------------------------------------------|------------------------|----------------------------------------|----------------|
| Commands that stream progress | 3 (ad-hoc `\r` stdout) | **14** (reporter, stderr, rate-gated) | **+11** |
| Progress observable when stdout is piped | **0 of 3** | **14 of 14** | always visible |
| Canonical JSON event schema | none | **locked in `docs/progress-events.md`** | stable |
| `doctor` silence window on 52K pages | 10+ min then killed | **heartbeat every 1s** | observable |
| `jsonb_integrity` scan targets | 4 (missed `page_versions.frontmatter`) | **5** | matches `repair-jsonb` |
| Minion jobs that update `job.progress` | 0 bulk cores | **embed** wired (import/sync/extract ready via callbacks) | DB-backed |
| Unit tests for progress/CLI plumbing | 0 | **37** (progress + cli-options) | +37 |
| E2E tests for agent-visible progress | 0 | **3** (doctor-progress Tier 1) | +3 |
| Bulk command | Progress today | Progress after v0.15.2 |
|-----------------------|-----------------|----------------------------------------------------------------|
| `doctor` | None (blocks) | Per-check heartbeat, 1s on slow queries |
| `orphans` | Final summary | Heartbeat while `NOT EXISTS` scan runs |
| `embed` | `\r` stdout | Per-page stderr, `job.updateProgress` from Minions |
| `files sync` | `\r` stdout | Per-file stderr |
| `export` | `\r` stdout | Per-page stderr (newly in scope) |
| `import` | Per-100 stdout | Per-file stderr, rate-gated |
| `extract` (fs + db) | Ad-hoc stderr | Canonical event schema, all paths |
| `sync` | Final summary | Per-file ticks across delete/rename/import phases |
| `migrate --to ...` | Per-50 stdout | `migrate.copy_pages` + `migrate.copy_links` phases |
| `repair-jsonb` | Final summary | Per-column heartbeat (stdout stays JSON-clean for orchestrator)|
| `check-backlinks` | Final summary | Heartbeat during the double-walk |
| `lint` | Per-file stdout | Per-file stderr, issues still on stdout |
| `integrity auto` | Own progress file | Unified reporter (file kept as resume marker) |
| `eval` | None | Per-query tick in single + A/B modes |
| `apply-migrations` | Inherited child output | Explicit flag propagation + stdio discipline |
Concrete agent win: on a 52K-page brain, `gbrain --progress-json doctor` emits ~10 events per second on stderr (start per check, heartbeats during the slow scan, finish per check) while `gbrain doctor --json` keeps stdout clean and JSON-parseable. The agent never sees silence longer than 1 second, and its stdout parser doesn't need to scrub progress garbage.
### What this means for you
If you run `gbrain` in CI, through a Minion worker, or inside any agent that captures stdout, this release means your downstream consumers stop guessing. Slow migrations announce themselves. Long imports name each file. `gbrain jobs get <id>` returns live `progress` for Minion-queued bulk work. The `gbrain doctor` warning you've been ignoring because it fires silently and then 10 minutes later tells you nothing is wrong becomes a 1-second heartbeat that proves it's working. If you're reading logs from a shell pipeline and prefer plain human lines, you don't need to do anything, that's the default for non-TTY stderr. Only add `--progress-json` when you want structured events.
## To take advantage of v0.15.2
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about a partial migration:
1. **Nothing mechanical is required.** v0.15.2 is purely additive to the CLI surface — no schema changes, no migration orchestrator, no data rewrites. Progress events start flowing the next time you invoke a bulk command.
2. **To stream structured events to your agent:**
```bash
gbrain --progress-json sync 2> progress.log
# or
gbrain doctor --progress-json --json > doctor.json 2> doctor.progress
```
3. **For Minion-queued jobs:**
```bash
gbrain jobs submit embed
# while it runs:
gbrain jobs get <id> # .progress is live-updated by the worker
```
4. **If `gbrain doctor` still looks hung** on a very large brain, check the CLI output for heartbeat lines. If they're missing, file an issue at https://github.com/garrytan/gbrain/issues with the command you ran, stdout/stderr samples, and output of `gbrain doctor --fast`.
### Itemized changes
#### Reporter (new, `src/core/progress.ts`)
- Dependency-free. Modes: `auto` (TTY → `\r`-rewriting; non-TTY → plain lines), `human`, `json` (JSONL on stderr), `quiet`.
- Rate gating: emits on whichever fires first: `minIntervalMs` (default 1000) or `minItems` (default `max(10, ceil(total/100))`). Final `tick` where `done === total` always emits.
- `startHeartbeat(reporter, note)` helper for single long-running queries (doctor's `markdown_body_completeness`, `orphans` anti-join, `repair-jsonb` per-column UPDATE).
- `child()` composes phase paths, `sync.import.<slug>`, not flat `<slug>`.
- EPIPE defense on both sync throws and stream `'error'` events. Singleton module-level SIGINT/SIGTERM handler emits `abort` events for every live phase, one handler no matter how many reporters exist.
#### CLI plumbing (`src/core/cli-options.ts`, `src/cli.ts`)
- Global flags `--quiet`, `--progress-json`, `--progress-interval=<ms>` parsed before command dispatch.
- `CliOptions` singleton (`getCliOptions`) reachable from every command without threading a new parameter through 20 handlers.
- `OperationContext.cliOpts` extends shared-op dispatch, MCP callers see defaults, CLI callers see parsed flags.
- `childGlobalFlags()` helper: appends the parent's flags to every `execSync('gbrain ...')` call in the migration orchestrators, so child progress matches parent mode.
#### JSON event schema
- Stable from v0.15.2, documented in `docs/progress-events.md`.
- `{event, phase, ts}` always present. Optional: `total`, `done`, `pct`, `eta_ms`, `note`, `elapsed_ms`, `reason`. No fake totals when a query has no count.
- Phases use `snake_case.dot.path`. Machine-stable. Agent parsers can group by phase prefix (all `doctor.*` events belong to one run).
#### Backward-compat warnings
Progress for `embed`, `files`, `export`, `extract`, `import`, `migrate-engine` moved from stdout to stderr. Stdout now carries only final summaries and `--json` payloads. Scripts that parsed `process.stdout` for progress lines (`\r 1234/52000 pages...`) see empty stdout for those counters; the data they actually want (the final "Embedded N chunks" summary) is still there. Point anything grepping stdout for progress at stderr instead.
#### Minion handlers (`src/commands/jobs.ts`)
- `embed` handler passes `job.updateProgress({done, total, embedded, phase})` as the `onProgress` callback. Primary Minion progress channel is DB-backed, readable via `gbrain jobs get <id>` or the `get_job_progress` MCP op. Stderr from `jobs work` stays coarse for daemon liveness.
- Other handlers (`sync`, `extract`, `backlinks`, `autopilot-cycle`, `import`) have the callback plumbing ready from the core functions; wiring the remaining handlers is a follow-up.
#### `gbrain doctor`
- `jsonb_integrity` now scans 5 targets (adds `page_versions.frontmatter`), matching `repair-jsonb`'s surface. The old 4-target check missed one of the repair sites.
- Per-check heartbeats so agents see `doctor.db_checks` starting, which check is in-flight, and `doctor.markdown_body_completeness` scanning.
- No false totals: the `LIMIT 100` truncation check reports `heartbeat`, not `tick` with a fake count.
#### Upgrade (`src/commands/upgrade.ts`)
- Post-upgrade timeout bumped 300s → 1800s (30 min). Override via `GBRAIN_POST_UPGRADE_TIMEOUT_MS`. The old 300s cap killed v0.12.0 graph-backfill migrations on 50K+ brains; heartbeat wiring in v0.15.2 makes the long wait observable.
#### CI guard
- `scripts/check-progress-to-stdout.sh` greps `src/` for `process.stdout.write('\r...')` and fails `bun run test` if any regression lands.
#### Tests
- New: `test/progress.test.ts` (17 cases — mode resolution, rate gating, EPIPE paths, SIGINT singleton, child phase composition), `test/cli-options.test.ts` (18 cases — flag parsing, `--quiet` skillpack-check collision regression, global-flag strip-and-dispatch), `test/e2e/doctor-progress.test.ts` (3 cases, Tier 1 — spawns the real CLI against a real Postgres, asserts stderr JSONL matches the schema and stdout stays clean).
## [0.15.1] - 2026-04-21
## **Fix wave: 4 hot issues that blocked real brains, landed together.**
## **PGLite survives macOS 26.3. Minions actually rescues SIGKILL'd jobs. Autopilot dashboards stop the 14.6s seqscan. `bun install -g` tells you when it's broken.**
v0.15.1 is the hotfix wave on top of the v0.14.x stack (shell job type in v0.14.0, doctor DRY + `--fix` in v0.14.1, 8 deferred bug fixes in v0.14.2) plus v0.15.0 (llms.txt + AGENTS.md): four user-filed issues against v0.13.x, fixed and verified together, plus three scope expansions that close adjacent footguns. Upgrade is automatic. If `gbrain upgrade` runs clean, your brain gets faster and more reliable on the next sync cycle.
### The numbers that matter
The four issues this release closes, with measured impact:
| Issue | Before v0.15.1 | After v0.15.1 | Δ |
|-------|----------------|----------------|---|
| #170 `SELECT * FROM pages ORDER BY updated_at DESC` on 31k rows (Postgres) | ~14.6s seqscan | <20ms index scan | ~700x |
| #219 `max_stalled` default on `minion_jobs` | 3 (three rescues before dead, v0.14.2 set this) | 5 (four rescues before dead) | extra headroom for flaky deploys |
| #219 existing waiting/active jobs with `max_stalled<5` | would still dead-letter earlier than expected | backfilled to 5 on upgrade | closes the pain today |
| #218 `bun install -g github:garrytan/gbrain` postinstall failure | silent `|| true` | visible stderr warning with recovery URL | users know it's broken |
| #223 PGLite WASM crash on macOS 26.3 | raw `Aborted()`, no hint | pinned `@electric-sql/pglite` to `0.4.3` + actionable error message naming the issue | users can route to #223 |
### What this means for you
If you run autopilot against a Supabase brain with 30k+ pages, your health/dashboard cycle was silently burning 14.6 seconds on every iteration. The new index drops that to single-digit milliseconds without locking writes (Postgres gets `CREATE INDEX CONCURRENTLY` with an invalid-index cleanup DO block; PGLite gets plain `CREATE INDEX` since it has no concurrent writers). Your agent stops blocking on list-pages-by-date queries.
If you use Minions, the "SIGKILL mid-flight, 10/10 rescued" claim is now actually true out-of-the-box with generous headroom. Default `max_stalled=5` means a kill -9'd worker gets picked up by the next worker instead of dead-lettered early. v15 migration backfills existing non-terminal rows (`waiting/active/delayed/waiting-children/paused`) so upgrading doesn't leave a queue full of doomed jobs.
If you install via `bun install -g github:...` (not recommended but people try it), you'll now see a loud stderr warning with a link to #218 instead of a broken CLI that fails on next invocation. The real fix is `git clone + bun link`, documented in README and INSTALL_FOR_AGENTS.md.
If you're on macOS 26.3 and PGLite was crashing with `Aborted()`, the pin to 0.4.3 gives us the best shot at avoiding the WASM regression (noting: 0.4.3 is unverified against 26.3 in CI — the error-wrap at `pglite-engine.ts connect()` is the safety net if the pin doesn't hold). Any PGLite init failure now shows the #223 link instead of a raw runtime error.
## To take advantage of v0.15.1
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about a partial migration:
1. **Run the orchestrator manually:**
```bash
gbrain apply-migrations --yes
```
2. **Verify the outcome:**
```bash
psql "$DATABASE_URL" -c "\d minion_jobs" | grep max_stalled # DEFAULT should be 5
psql "$DATABASE_URL" -c "\d pages" | grep idx_pages_updated_at_desc # index should exist
gbrain doctor
```
3. **If any step fails or the numbers look wrong,** file an issue with `gbrain doctor` output and the contents of `~/.gbrain/upgrade-errors.jsonl` if it exists. https://github.com/garrytan/gbrain/issues
### Itemized changes
#### Added
- Schema migration **v14** — `CREATE INDEX [CONCURRENTLY] IF NOT EXISTS idx_pages_updated_at_desc ON pages (updated_at DESC)` (engine-aware; Postgres uses CONCURRENTLY with an invalid-index DO-block cleanup, PGLite uses plain CREATE). Closes #170. Contributed by @fuleinist (#215).
- Schema migration **v15** — `ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 5` (bumps v0.14.2's default of 3 to 5 for extra flaky-deploy headroom) + `UPDATE` backfill scoped to non-terminal statuses (`waiting/active/delayed/waiting-children/paused`) so existing queued work benefits on upgrade. Closes #219. Reported by @macbotmini-eng.
- `MinionJobInput.max_stalled` — new optional field, plumbed through `queue.add()` with `[1, 100]` clamp.
- `gbrain jobs submit --max-stalled N` — CLI flag to set per-job stall tolerance.
- `gbrain jobs submit --backoff-type`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key` — scope-expansion audit exposing existing `MinionJobInput` fields as first-class CLI flags.
- `gbrain jobs smoke --sigkill-rescue` — opt-in regression smoke case that simulates a killed worker and asserts the v0.15.1 default actually rescues.
- `gbrain doctor --index-audit` — new opt-in Postgres check that reports zero-scan indexes from `pg_stat_user_indexes`. Informational only (no auto-drop). PGLite no-ops.
- `BrainEngine.kind` readonly discriminator (`'postgres' | 'pglite'`) — lets migrations and consumers branch on engine without `instanceof` + dynamic imports.
- `package.json trustedDependencies: ["@electric-sql/pglite"]` — lets Bun run PGLite's dep postinstall on global installs.
#### Changed
- `@electric-sql/pglite` pinned to exactly `0.4.3` (was `^0.4.4`) — best-available mitigation for the macOS 26.3 WASM abort. Reported by @AndreLYL (#223). Flagged as unverified; reproduce on a 26.3 machine and file a follow-up if it still aborts.
- `package.json postinstall` — now warns loudly on stderr with a recovery URL instead of silencing errors with `2>/dev/null || true`. `bun install -g` hitting a migration failure now tells you what to do. Reported by @gopalpatel (#218).
- `src/core/pglite-engine.ts connect()` — wraps `PGlite.create()` with a friendly error pointing at #223 and `gbrain doctor`. Nests the original error for debuggability.
- `doctor` `schema_version` check — now fails loudly when `version=0` (migrations never ran), linking #218.
- `README.md` + `INSTALL_FOR_AGENTS.md` — explicit warning against `bun install -g github:garrytan/gbrain`.
#### Fixed
- **The "SIGKILL mid-flight, 10/10 rescued" claim is now accurate** out-of-the-box with headroom (#219). Schema default 3 → 5.
- **Autopilot dashboards stop blocking on list-pages queries** on 30k+ row Postgres brains (#170).
- **PGLite error on macOS 26.3** is now actionable instead of a raw `Aborted()` (#223).
- **`bun install -g` no longer produces a silently broken CLI** (#218) — postinstall surfaces failures.
#### Internal
- `Migration` interface extended with `sqlFor: { postgres?, pglite? }` + `transaction: boolean` fields. Runner picks the engine-specific SQL branch and (on Postgres only) bypasses `engine.transaction()` when `transaction: false` (required for CONCURRENTLY).
- `scripts/check-jsonb-pattern.sh` extended with a CI guard against `max_stalled DEFAULT 1` regressing.
- ~15 new unit tests covering max_stalled default/clamp/backfill/v14/v15 semantics. 3 regression tests pinned by IRON RULE.
- `test/e2e/` now runs test files sequentially via `scripts/run-e2e.sh` to eliminate shared-DB races that caused ~3/5 runs to have 4-10 flaky fails. Every run post-fix: 13 files, 138 tests, 0 fails.
## [0.15.0] - 2026-04-21
## **GBrain now talks to LLMs the way modern docs sites do.**
## **One URL, full context. Three files, zero drift.**
Three new artifacts ship at the repo root: `llms.txt` (llmstxt.org-spec index), `llms-full.txt` (same map with core docs inlined, ~225KB, fits well under a 150k-token context window), and `AGENTS.md` (the non-Claude-agent operating protocol). All three are generator-driven. `scripts/build-llms.ts` reads a curated `scripts/llms-config.ts` and emits `llms.txt` + `llms-full.txt` deterministically; `AGENTS.md` is hand-written and uses relative links so it survives forks and rename. Every agent that clones GBrain now has a one-screen answer to "I just got here, what do I do?"
README and `INSTALL_FOR_AGENTS.md` now point agents at `AGENTS.md` first. The old install prompt still works, but the leverage point, Codex's read of the plan, was that these files are invisible unless the install path references them. Fixed.
### The numbers that matter
Measured on this release:
| Metric | BEFORE | AFTER | Δ |
|-------------------------------------------------|----------------------------------|-----------------------------------|----------------------------|
| Agent entry points with clear install protocol | 1 (CLAUDE.md, Claude Code only) | 3 (CLAUDE.md + AGENTS.md + llms.txt) | +non-Claude coverage |
| Docs referenced at a single canonical URL | 0 | 20 (across 5 H2 sections) | index exists |
| Full-context fetch round-trips | ~20 (one per doc) | 1 (`llms-full.txt`, 224 KB) | ~20x fewer fetches |
| Tests guarding the doc index | 0 | 7 (paths resolve, idempotent, spec shape, regen-drift, content contract, AGENTS mirror, size budget) | +7 |
| Pre-existing repo bugs found and fixed | — | 1 (`git pull origin main` → `master`) | drive-by |
The 7 tests enforce content contract: removing `skills/RESOLVER.md` or the Debugging H2 from the config fails `bun test`. Forgetting to rerun `bun run build:llms` after adding a new doc fails `bun test`. The size budget (600KB) fails `bun test` if `llms-full.txt` balloons.
### What this means for you
If you're running GBrain: nothing to do. Your agent already has CLAUDE.md. But next time you install GBrain on Codex, Cursor, or OpenClaw, the agent lands on `AGENTS.md` and walks the install without hunting. If you run a fork, regenerate with `LLMS_REPO_BASE=https://raw.githubusercontent.com/your-org/your-fork/main bun run build:llms` to rewrite URLs. If you publish GBrain docs alongside your own, `llms.txt` is the index; `llms-full.txt` is the drop-into-a-context-window bundle.
Credit to Codex for catching that the original plan's AGENTS.md was underpowered, that the eng review missed a content-contract test, and that the install prompt was the real leverage point. Seven of the fifteen Codex findings landed directly in the plan; three went to user decision; five stayed as intentional NOT-in-scope.
## To take advantage of this release
`gbrain upgrade` does not need to do anything. These are new public files; existing installs pick them up on their next pull.
1. **If you wrote a downstream fork:** regenerate with your URL base.
```bash
LLMS_REPO_BASE=https://raw.githubusercontent.com/your-org/your-fork/main bun run build:llms
git add llms.txt llms-full.txt && git commit
```
2. **If you add a new doc under `docs/`:** add it to `scripts/llms-config.ts`, then
```bash
bun run build:llms
bun test test/build-llms.test.ts
```
CI blocks ship if these drift.
3. **Verify it actually works:** ask a fresh LLM
```
Fetch https://raw.githubusercontent.com/garrytan/gbrain/master/llms.txt and tell me
how I'd debug a broken live sync.
```
Answer should cite `docs/GBRAIN_VERIFY.md`, `docs/guides/live-sync.md`, and `gbrain doctor`.
### Itemized changes
#### Added
- `AGENTS.md` at repo root — ~45-line non-Claude-agent operating protocol. Install, read order, trust boundary, config/debug/migration pointers, fork instructions. Uses relative links so it survives renames.
- `llms.txt` at repo root — llmstxt.org-spec index. H1 + blockquote + 5 required H2 sections (Core entry points, Configuration, Debugging, Migrations) plus an Operational tips block with `gbrain doctor`, `gbrain orphans`, `gbrain repair-jsonb`. ~4KB.
- `llms-full.txt` at repo root — same index with core docs inlined under `## {path}` headings for single-fetch ingestion. ~225KB, under the 600KB `FULL_SIZE_BUDGET`.
- `scripts/llms-config.ts` — curated TS config. `LLMS_REPO_BASE` env var lets forks regenerate with their own URL base. `includeInFull: false` flags entries that should appear in `llms.txt` but not be inlined in `llms-full.txt` (Philosophy, Optional, CHANGELOG).
- `scripts/build-llms.ts` — the generator. Deterministic, no timestamps, sorted by config order. Warns (does not fail) if `llms-full.txt` exceeds `FULL_SIZE_BUDGET` with the biggest entries listed.
- `test/build-llms.test.ts` — 7 cases: paths resolve on disk, generator idempotent, llms.txt spec shape, checked-in files match generator output (drift guard), content contract (RESOLVER / AGENTS / INSTALL_FOR_AGENTS referenced), AGENTS mirrors README+INSTALL install path, size budget enforcement.
- `bun run build:llms` script in `package.json`.
#### Changed
- `README.md` — adds a one-line LLMs/Agents pointer above the install CTA and a follow-up paragraph under the agent paste block naming `AGENTS.md` + `llms.txt` as fallback entry points for non-Claude agents.
- `INSTALL_FOR_AGENTS.md` — new "Step 0: If you are not Claude Code" prelude points agents at `AGENTS.md` first.
- `CLAUDE.md` — adds `scripts/llms-config.ts`, `scripts/build-llms.ts`, and `AGENTS.md` to Key files. Explicitly notes that committed generator output is NOT analogous to `schema-embedded.ts` (no runtime consumer; committed for GitHub browsing + fork safety).
- `INSTALL_FOR_AGENTS.md:136` — `git pull origin main` → `git pull origin master`. Pre-existing drift: README and CI use `master`, `origin/HEAD -> master`, but the upgrade instructions told users to pull from a branch that doesn't exist. Folded into this release as a drive-by fix.
## [0.14.2] - 2026-04-20
## **Eight deferred bugs, root-cause fixes, one clean wave.**
## **Sync stops losing files. Migrations stop retrying forever. Pooler users get a knob.**
Eight bugs were previously scoped out of a PR after Codex review caught wrong root causes and unimplementable architectures. v0.14.2 takes each back to the actual code and fixes the structural gap. `/plan-eng-review` + `/codex consult` verified every load-bearing claim before a single line of code ran (20 findings, 12 triggered plan revisions before implementation).
The practical wins for a busy brain: `gbrain sync` no longer silently loses files with unquoted-colon YAML titles across any of the three sync paths. `gbrain upgrade` can't get stuck in an infinite retry loop on a wedged migration (3-partial cap + `--force-retry` escape hatch). Supabase pooler users have `GBRAIN_POOL_SIZE` to throttle without touching schemas. `gbrain doctor --fast` tells you WHY it's skipping DB checks instead of lying about no database being configured. `brain_score` gets a breakdown so 79/100 tells you which component is costing you the 21 points.
### The numbers that matter
Measured on this branch's diff against origin/master:
| Metric | BEFORE v0.14.2 | AFTER v0.14.2 | Δ |
|---------------------------------------------------|---------------------|-----------------------------|-------------------------|
| Sync paths that silently drop files on YAML break | 3 of 3 | 0 of 3 | **no more silent loss** |
| Wedged-migration retry loops | infinite | 3-partial cap + `--force-retry` | bounded |
| Pool-size knob for Supabase pooler | none | `GBRAIN_POOL_SIZE` env | **first-class knob** |
| `doctor --fast` messages | 1 catch-all | 3 source-specific | honest signal |
| `brain_score` observability | one number | 5-field breakdown (sum == total) | diagnosable |
| Duplicate edges in `gbrain graph` output | leaked per-origin | deduped at presentation | schema preserved |
| `minion_jobs.max_stalled` default | 1 (dead-letter on first stall) | 3 | autopilot survives long embed runs |
| New + extended unit tests | 1696 | **1743 (+47 + 119 new assertions)** | +47 |
| Root-cause fixes vs symptom patches | 0 | **8 / 8** | structural |
### What this means for you
Your agent's feedback loops tighten. When sync blocks, doctor surfaces the exact file with the YAML problem and the commit where it showed up. When a migration gets stuck, there's a cap and a clear escape. When you're on Supabase's transaction pooler and `gbrain upgrade` spawns subprocesses, set `GBRAIN_POOL_SIZE=2` and stop MaxClients crashes. Run `gbrain doctor` and the `brain_score` breakdown points at what to fix first: embed coverage, link density, timeline coverage, orphans, or dead links.
## To take advantage of v0.14.2
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about a partial migration:
1. **Run the orchestrator manually:**
```bash
gbrain apply-migrations --yes
```
2. **Supabase pooler users (port 6543) now have a knob.** If you hit MaxClients during upgrades, set `GBRAIN_POOL_SIZE=2` (or lower) in your environment before running `gbrain upgrade`.
3. **Check sync health after the upgrade:**
```bash
gbrain doctor
```
If it warns about `sync_failures`, the paths and errors are in `~/.gbrain/sync-failures.jsonl`. Fix the offending YAML frontmatter and re-run `gbrain sync`, or use `gbrain sync --skip-failed` to acknowledge known-broken files and advance past them.
4. **Wedged migrations:** If `doctor` ever flags a version with 3 consecutive partials, run `gbrain apply-migrations --force-retry vX.Y.Z` to reset the state machine, then `gbrain apply-migrations --yes` to re-attempt.
5. **If any step fails or the numbers look wrong,** file an issue: https://github.com/garrytan/gbrain/issues with:
- output of `gbrain doctor`
- contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
- which step broke
### Itemized changes
#### Reliability
- **Bug 2: `GBRAIN_POOL_SIZE` env knob** (`src/core/db.ts`, `src/commands/import.ts`). Honored by both the singleton pool and the parallel-import worker pool. Defaults to 10; lower for Supabase transaction pooler. `initPostgres` / `initPGLite` now wrap lifecycle in `try { ... } finally { await engine.disconnect() }`.
- **Bug 3: Migration ledger centralization + wedge cap** (`src/commands/apply-migrations.ts`, `src/core/preferences.ts`). Runner owns all ledger writes. 3 consecutive partials = wedged, skipped with a loud message. New `--force-retry <version>` flag writes a `'retry'` marker without faking success. `complete` status never regresses. `appendCompletedMigration` is idempotent on double-complete.
- **Bug 8: `max_stalled` default 1 → 3** (`src/core/schema-embedded.ts`, `src/core/pglite-schema.ts`, `src/schema.sql`). First lock-lost tick no longer dead-letters. `v0_14_0` Phase A ALTERs existing installs. `autopilot-cycle` handler yields to the event loop between phases so the worker's lock-renewal timer fires. (v0.15.1 further bumps this to 5 and adds a non-terminal row backfill — see #219.)
- **Bug 9: Sync gate + acknowledge mechanism** (`src/commands/sync.ts`, `src/commands/import.ts`, `src/core/sync.ts`). All 3 sync paths (incremental, full via `runImport`, `gbrain import` git continuity) gate `sync.last_commit` on no-failures. Failures append to `~/.gbrain/sync-failures.jsonl` with dedup key. New `gbrain sync --skip-failed` + `--retry-failed` flags. Doctor surfaces unacknowledged failures.
#### Observability
- **Bug 7: `doctor --fast` source-aware messages** (`src/core/config.ts`, `src/cli.ts`, `src/commands/doctor.ts`). New `getDbUrlSource()` returns `'env:GBRAIN_DATABASE_URL' | 'env:DATABASE_URL' | 'config-file' | null`. Doctor emits `Skipping DB checks (--fast mode, URL present from env:GBRAIN_DATABASE_URL)` when applicable.
- **Bug 11: `brain_score` breakdown + metric clarity** (`src/core/types.ts`, both engines' `getHealth()`). Added `embed_coverage_score`, `link_density_score`, `timeline_coverage_score`, `no_orphans_score`, `no_dead_links_score`. Sum equals `brain_score` by construction. `dead_links` now on `BrainHealth` (resolves a pre-existing `featuresTeaserForDoctor` drift). `orphan_pages` docs clarified — it's "islanded" (no inbound AND no outbound), not the stricter "zero inbound" graph definition.
#### Graph correctness
- **Bug 6/10: `jsonb_agg(DISTINCT ...)` in legacy `traverseGraph`** (`src/core/postgres-engine.ts`, `src/core/pglite-engine.ts`). Presentation-level dedup only — the schema continues to preserve per-`origin_page_id` / per-`link_source` provenance rows. Fixes duplicate edges like `works_at → companies/brex` appearing twice in `gbrain graph`.
#### New migration
- **Bug 5: `v0_14_0` migration registered** (`src/commands/migrations/v0_14_0.ts`). Phase A: `ALTER minion_jobs.max_stalled SET DEFAULT 3` (idempotent). Phase B: emits `pending-host-work.jsonl` entry pointing at `skills/migrations/v0.14.0.md` for shell-jobs adoption. Registered in `src/commands/migrations/index.ts`.
#### Tests
- New: `test/traverse-graph-dedup.test.ts`, `test/sync-failures.test.ts`, `test/brain-score-breakdown.test.ts`, `test/migration-resume.test.ts`, `test/migrations-v0_14_0.test.ts`.
- Extended: `test/migrate.test.ts` (`resolvePoolSize`), `test/doctor.test.ts` (`dbSource`), `test/apply-migrations.test.ts` (`skippedFuture` includes `0.14.0`).
- E2E updated: `test/e2e/migration-flow.test.ts` assertions aligned with the new runner-owned-ledger contract (orchestrator no longer writes completed.jsonl directly).
#### Deferred to v0.15
- Deep `AbortSignal` threading through `runEmbedCore` / `runExtractCore` / `runBacklinksCore` / `performSync`. Between-phase yield addresses the Bug 8 lock-renewal root cause; mid-phase cancellation on huge brains belongs in the queue-polish PR.
- `failJobFromSweeper` for `handleTimeouts` / `handleStalled`. Current direct `status='dead'` writes kept.
## [0.14.1] - 2026-04-20
## **`gbrain doctor` stops crying wolf on DRY, and now repairs the real ones.**
## **Skill delegations via `_brain-filing-rules.md` finally count.**
`gbrain doctor --fast` was flagging 9 DRY violations on this repo, every run, for skills that properly delegated to `skills/_brain-filing-rules.md`. The old check only accepted `conventions/quality.md` as a valid delegation target, so every skill that correctly filed notability rules through the brain-filing-rules module got flagged anyway. Alert fatigue eroded every other doctor warning. v0.14.1 swaps the substring match for proximity-based suppression: a delegation reference within 40 lines of a pattern match (across `> **Convention:**`, `> **Filing rule:**`, and inline backtick paths) now correctly suppresses the violation.
The release also adds `gbrain doctor --fix` and `gbrain doctor --fix --dry-run`. Instead of telling you what's wrong, doctor can now repair it. Five guards keep the edits safe: refuses if the working tree is dirty (git is the rollback), refuses if the skill isn't inside a git repo (no rollback available), skips matches inside fenced code blocks (examples are not violations), skips when the pattern matches more than once (ambiguous), skips when a delegation reference already exists within 40 lines. Shell-injection safe via `execFileSync` array args. Trailing newline preserved. No `.bak` clutter, git is the backup contract.
### The numbers that matter
Measured on this repo's real skill library (28 skills, 3 cross-cutting patterns):
| Metric | BEFORE v0.14.1 | AFTER v0.14.1 | Δ |
|------------------------------------------------|---------------------|------------------------------|-------------------|
| False-positive DRY violations | 1 flagged, 0 fixable| 0 flagged | **cleaner signal**|
| Genuine DRY violations surfaced | 8 | 8 (unchanged) | honest count |
| Auto-repairable via `--fix --dry-run` | 0 | 7 proposed, 4 intelligently skipped | new capability |
| Unit tests for doctor/resolver/dry-fix | 24 | **55 (+31)** | +31 |
| Adversarial review fixes in ship | 0 | **4 ship-blockers caught + fixed** | defense in depth |
The 4 adversarial fixes are worth calling out: shell injection via `execFileSync` array args, a silent-overwrite bug when skills live outside a git repo (now returns `no_git_backup`), EOF newline preservation on splice, and delegation-proximity consistency between detector (40 lines) and idempotency guard (now also 40 lines, was 10).
### What this means for you
Your agent's `gbrain doctor` output now means something again. Nine warnings a run was noise you learned to ignore; one real warning is signal. And when the doctor does flag an inlined rule, `gbrain doctor --fast --fix --dry-run` shows you exactly what the repair looks like before you commit to it. Run `gbrain doctor --fast --fix` to apply. Git is the undo button.
## To take advantage of v0.14.1
`gbrain upgrade` does this automatically. No manual migration required.
1. **Verify the detection fix:**
```bash
gbrain doctor --fast --json | jq '.checks[] | select(.name=="resolver_health")'
```
2. **Try the auto-fix preview on your own brain:**
```bash
gbrain doctor --fast --fix --dry-run
```
3. **Apply when ready:**
```bash
gbrain doctor --fast --fix
```
4. **If anything looks wrong,** please file an issue:
https://github.com/garrytan/gbrain/issues with the `gbrain doctor --json` output.
### Itemized changes
#### Added
- `gbrain doctor --fix` applies `> **Convention:**` reference callouts to skills that inline cross-cutting rules (Iron Law back-linking, citation format, notability gate). `--dry-run` previews the diff without writing.
- Three shape-aware block expanders (bullet, blockquote, paragraph) in `src/core/dry-fix.ts`, each a pure function, each with unit tests.
- New `extractDelegationTargets()` helper in `src/core/check-resolvable.ts` parses `> **Convention:** `, `> **Filing rule:** `, and inline backtick references, normalizing paths to the `CROSS_CUTTING_PATTERNS.conventions` shape.
- `getWorkingTreeStatus()` returns 3-state `'clean' | 'dirty' | 'not_a_repo'` so the fixer never writes to files git can't roll back.
#### Changed
- `CROSS_CUTTING_PATTERNS` each list multiple valid delegation targets (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`).
- DRY suppression is proximity-based: `DRY_PROXIMITY_LINES = 40` for detector AND the fix-module's idempotency check (was inconsistent: 40 vs 10).
- Shell execution uses `execFileSync` with array args (no shell, no injection surface from manifest-derived paths).
#### Tests
- 31 new tests across `test/check-resolvable.test.ts` (DRY detection, 13 cases), `test/dry-fix.test.ts` (unit, 28 cases including expander pure-function tests), `test/doctor-fix.test.ts` (CLI integration, 3 cases).
- Full suite: 1694 pass, 0 fail.
## [0.14.0] - 2026-04-20
## **Move gateway crons to Minions. Zero LLM tokens per cron fire.**
## **Worker abort path finally marks aborted jobs dead.**
Your OpenClaw gateway pins at 100% CPU when your 32 cron jobs each boot a full Opus session per fire, and ~14 of them are pure API-fetch-and-write scripts that don't need reasoning at all. This release adds a `shell` job type to Minions so those deterministic crons move off the gateway to the Minions worker. ~60% gateway load reduction at OpenClaw scale. Retry, backoff, DLQ, unified `gbrain jobs list` visibility, all free. The LLM-reasoning crons stay on the gateway where they belong.
Getting there meant fixing the Minions worker abort path, which was quietly wrong since v0.11: aborted jobs (timeout, cancel, lock loss) returned silently without calling `failJob`, so status stayed `active` until a stall sweep found them ~30s later. This release makes abort-reason the `error_text` of an immediate `failJob` call. Handlers get cleaner signals, operators see accurate status, `--follow` stops hanging past timeouts.
### The numbers that matter
Measured on the new `test/minions-shell.test.ts` (40 unit cases) and `test/e2e/minions-shell.test.ts` (4 E2E cases) plus 5 rounds of pre-landing review (spec adversarial x2, CEO scope, DX, eng, Codex outside voice).
| Metric | BEFORE v0.14.0 | AFTER v0.14.0 | Δ |
|---------------------------------------------------|-------------------------|-----------------------------------|----------------------|
| LLM tokens per cron fire | ~full Opus context boot | 0 (deterministic crons) | **100% reduction** |
| Gateway CPU headroom with ~14 crons moved | 0% | ~60% free | cron load off gateway|
| Aborted job status lag (timeout/cancel/lock-loss) | up to 30s | immediate `failJob` call | **deterministic** |
| Shell submission surfaces | none | CLI + trusted `submit_job` | 2 paths, both gated |
| Submission audit trail | none | JSONL at `~/.gbrain/audit/` | operational trace |
| Unit tests | 1318 pass | **1358 pass (+40 shell cases)** | +40 |
| E2E tests | 124 | **128 (+4 shell lifecycle)** | +4 |
| Pre-landing review rounds | 1 (eng) | **5 (spec×2 / CEO / DX / eng / codex)** | 29 issues surfaced, 26 resolved |
The abort-path fix is the quietly-important one. Handlers that use `ctx.signal` for cooperative cancel (sync, embed) now have deterministic status flips instead of waiting for the stall sweep. Shell jobs get reliable timeout semantics for the first time: `cmd: 'sleep 30', timeout_ms: 2000` hits `dead` at ~2100ms instead of ~32000ms.
### What this means for OpenClaw operators
`gbrain upgrade` reads `skills/migrations/v0.14.0.md` and walks your host agent through the adoption: enable the worker with `GBRAIN_ALLOW_SHELL_JOBS=1`, audit every cron entry (LLM-requiring stays, deterministic moves), propose a rewrite per cron with a diff, verify one fire end-to-end before approving the next batch. Never auto-rewrites your crontab — every change is a human approval per-cron. On Postgres, one persistent worker daemon claims each job. On PGLite, every crontab invocation adds `--follow` for inline execution because PGLite doesn't support the worker daemon. Either way, your gateway CPU stops pinning at 100% and your live messages stop getting blocked by batch processing. See `docs/guides/minions-shell-jobs.md` for usage recipes and `skills/migrations/v0.14.0.md` for the adoption playbook.
### Itemized changes
#### New `shell` job type
- **Spawn arbitrary commands as Minions jobs.** Pass `{cmd: "string"}` (shell-interpolated via `/bin/sh -c`) or `{argv: ["bin","arg"]}` (no shell, safe for programmatic callers). Both forms require an absolute `cwd`. Env vars are scoped to a minimal allowlist (`PATH, HOME, USER, LANG, TZ, NODE_ENV`) to prevent accidental `$OPENAI_API_KEY` interpolation; callers opt-in to additional keys per job.
- **Two-layer security: MCP boundary + env flag.** `submit_job` rejects `name: 'shell'` when `ctx.remote === true`. Independent of the env flag. `MinionQueue.add('shell', ...)` also rejects unless the caller explicitly opts in via `{allowProtectedSubmit: true}` as the 4th arg, so an in-process handler can't programmatically submit a shell child by accident. Worker only registers the handler when `GBRAIN_ALLOW_SHELL_JOBS=1` is set on the worker process. Default: off. Opt in per-host.
- **Graceful child shutdown.** Abort fires SIGTERM, 5-second grace, then SIGKILL. Listens to both `ctx.signal` (timeout/cancel/lock-loss) and a new `ctx.shutdownSignal` (worker process SIGTERM/SIGINT), so deploy restarts don't orphan shell children. Non-shell handlers ignore `shutdownSignal` and keep running through the worker's 30s cleanup race.
- **UTF-8-safe output truncation.** stdout is retained as the last 64KB, stderr as the last 16KB, with a `[truncated N bytes]` marker prepended when exceeded. Uses `string_decoder.StringDecoder` so multibyte characters don't split across the truncation boundary.
- **Operational audit trail at `~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl`** (ISO-week rotation, override via `GBRAIN_AUDIT_DIR`). Records caller, remote flag, job_id, cwd, and cmd/argv display. Never logs env values. Best-effort writes: failures log to stderr but don't block submission. Operational trace for "what did this cron submit last Tuesday," not forensic insurance.
- **Starvation warning on first-time submission.** If you `gbrain jobs submit shell ...` without `--follow` and no worker with the env flag is running, stderr prints a warning block pointing at both `--follow` and `gbrain jobs work` remediation. Turns a silent "job sits in waiting forever" failure mode into a directed next-step.
#### Worker abort path overhaul
- **Aborted jobs now call `failJob` with the abort reason.** Pre-v0.14.0 worker returned silently when `ctx.signal.aborted` fired, leaving jobs in `active` until stall sweep. Fixed: catch-block now derives reason from `abort.signal.reason` (`timeout`, `cancel`, `lock-lost`, `shutdown`) and calls `failJob(id, token, "aborted: <reason>")`. Token-match makes the call idempotent: if another path already flipped status, it no-ops cleanly. Downstream `--follow` loops and status assertions now reflect reality.
- **`ctx.shutdownSignal` separated from `ctx.signal`.** Only fires on worker process SIGTERM/SIGINT. Handlers that need shutdown-specific cleanup (currently: shell handler's SIGTERM→SIGKILL on its child) subscribe to both signals. Non-shell handlers subscribe only to `ctx.signal` and don't get cancelled mid-flight on deploy restart.
#### CLI + operation surface additions
- **`gbrain jobs submit --timeout-ms N`.** Per-job wall-clock timeout in ms. Surfaced from the existing `timeout_ms` schema field, which had no CLI flag before.
- **`submit_job` operation gains `timeout_ms` param.** Same field exposed through MCP (for non-protected names).
- **`gbrain jobs submit --help` lists handler types.** `shell` is explicitly called out as CLI-only with a pointer to the guide. Closes the "what handlers are even available" discovery gap.
#### Tests
- **40 new unit cases in `test/minions-shell.test.ts`** covering validation (cmd/argv/cwd/env), spawn happy + error paths, UTF-8 safe truncation, SIGTERM abort via both signals, env allowlist (OPENAI_API_KEY blocked, PATH inherited, caller override), ISO-week filename at year boundary (2027-01-01 → W53 2026), audit write happy + EACCES failure paths, whitespace-bypass defense on `MinionQueue.add(' shell ', ...)`, and auto-added regression tests per the iron rule (non-protected names unaffected).
- **4 E2E tests in `test/e2e/minions-shell.test.ts`** covering full lifecycle (submit → worker claim → spawn → complete with captured stdout), `MinionQueue.add` defense-in-depth, `submit_job` MCP-guard rejection, `submit_job` CLI-path acceptance.
#### Docs
- **New `docs/guides/minions-shell-jobs.md`** opens with a 30-second copy-paste hello-world, then covers the two-layer security model with honest callouts about what env allowlist does and does not do, Postgres vs PGLite crontab recipes side-by-side, debug playbook (`gbrain jobs list`, `gbrain jobs get`, audit log tail, PGLite `--follow` note), known limitations, and an `#errors` table linked from every `UnrecoverableError` the handler throws.
- **New `skills/migrations/v0.14.0.md`** is the adoption playbook your host agent reads on `gbrain upgrade`. Walks through enabling the worker, auditing cron entries (LLM-requiring vs deterministic), proposing per-cron rewrites with diffs, and verifying end-to-end before batch approval. Iron rule: never auto-rewrites the operator's crontab — every change is human-approved per-cron.
- **README.md** links the guide from the Commands section.
#### Pre-ship review
Five independent rounds surfaced 29 issues across the plan. 26 resolved before a single line of code was written: spec-review adversarial subagent (x2 iterations) caught implementer-ergonomic gaps (caller derivation, mkdirSync, ISO-week formatter). CEO review + SELECTIVE EXPANSION cherry-picked argv form, audit log, SIGTERM grace, env allowlist, MCP-guard defense-in-depth, honest FS-read trust model, orphan-child `setTimeout.unref()` fix. DX review added the starvation warning block. Eng review added `ctx.shutdownSignal` separation, revised trusted-arg from opts-fold to separate 4th arg (stops accidental pass-through via `{...userOpts}` spreads), 18 additional test cases, 4 iron-rule regression tests. Codex outside voice caught 4 architectural dealbreakers: the worker abort silent-return bug (the "contract is a lie" finding), `--timeout-ms` CLI flag and `submit_job` param both missing, `PROTECTED_JOB_NAMES.has(name)` whitespace bypass before normalization. Effort estimate revised 8-10h → 16-20h once the full review was done.
## [0.13.1] - 2026-04-20
## **The brain stops being a write-once graph and starts being a runtime.**
## **Five new modules land on top of v0.12's knowledge graph layer.**
## **Your brain repairs its own citations. A budget wall on AI spend. Minions wait until morning.**
## **Four things that make a brain an actual runtime instead of a pile of markdown.**
GBrain v0.13.1 ships the Knowledge Runtime delta on top of v0.13.0's frontmatter graph. Typed abstractions that turn a knowledge base into a runtime other agents can adopt. Five focused modules build on the v0.12.0 graph layer and v0.11.x Minions orchestration. A Resolver SDK unifies external lookups. A BrainWriter enforces integrity pre-commit. `gbrain integrity` repairs bare-tweet citations at scale. A BudgetLedger caps runaway resolver spend. Minions gains TZ-aware quiet-hours at claim time.
v0.13.1 does four things. It finds every "Alice tweeted about X" in your brain and replaces it with the real tweet URL. It puts a hard dollar cap on AI lookups so a runaway script can't burn through your OpenAI budget. It lets background jobs respect quiet hours so Minions stop DM'ing you at 3am. And when your agent writes a page, the brain refuses to ship content with missing or fake citations.
### What you can do now that you couldn't before
The common thread: integrity that the machine enforces, not the user. You set the rules once, the brain holds the line forever after.
- **`gbrain integrity --auto --confidence 0.8`** repairs the 1,424 bare-tweet citations in your brain without human review. Three-bucket confidence: auto-repair ≥0.8, review queue 0.50.8, skip <0.5. Resumable via `~/.gbrain/integrity-progress.jsonl`.
- **`gbrain resolvers list`** introspects the typed plugin registry. Two builtins ship: `url_reachable` (HEAD check + SSRF guard) and `x_handle_to_tweet` (X API v2 with confidence scoring). Every result carries `{value, confidence, source, fetchedAt, costEstimate, raw}`.
- **`gbrain config set budget.daily_cap_usd 10`** puts a hard wall on resolver spend. Concurrent reserves serialize via `SELECT FOR UPDATE`. TTL auto-reclaim handles process death between reserve and commit.
- **BrainWriter + pre-commit validators** make the Philip-Leung hallucination class structurally impossible. `Scaffolder` builds every tweet URL from API output, never LLM text. `SlugRegistry` detects name collisions at create time. Four validators (citation, link, back-link, triple-HR) run on write. `writer.lint_on_put_page=true` enables observability before the strict-mode flip.
- **Quiet-hours on Minion jobs** stop the 3am DM. Set `quiet_hours: {start:22, end:7, tz:"America/Los_Angeles", policy:"defer"}` on a job. Worker checks at claim time (not dispatch). Wrap-around windows supported.
### What you can now do
**Repair 1,424 bare-tweet citations in one command.**
```bash
gbrain integrity --auto --confidence 0.8
```
Finds every "Alice tweeted about AI safety" phrase on your brain. Hits the X API to find the actual tweet. Writes the real URL back into the page. Three buckets based on how confident the match is: ≥0.8 auto-repair, 0.50.8 goes to a review file for you to approve, <0.5 gets skipped. Resumable — kill the process, run it again, it picks up where it left off.
**Cap your daily AI spend.**
```bash
gbrain config set budget.daily_cap_usd 10
```
Hard wall. Once the brain has spent $10 on resolver calls (X API, OpenAI, whatever) today, it refuses new calls until midnight in your timezone. If a process dies holding a reservation, the TTL auto-releases it so spend isn't permanently locked. No more "I left it running overnight and woke up to a $400 bill" stories.
**Minion jobs that respect sleep.**
```json
{ "quiet_hours": { "start": 22, "end": 7, "tz": "America/Los_Angeles", "policy": "defer" } }
```
Set this on any Minion job. The worker checks at claim time — if it's 3am in LA, the job gets pushed to the next morning. `policy: "skip"` drops the event entirely. Wrap-around windows work (22→7 spans midnight).
**Validators that catch bad writes BEFORE they land.**
When your agent calls `put_page`, four deterministic checks run before the write commits: every paragraph needs a citation marker, every wikilink needs a real target, every back-link gets reconciled, and no three-horizontal-rule markdown spam. Failed writes roll back. No more "the agent wrote a page claiming Philip Leung invested in X" — the citation validator won't let that land in the first place.
**Plugin registry you can introspect.**
```bash
gbrain resolvers list
gbrain resolvers describe x_handle_to_tweet
```
Every external lookup (X, URL reachability, eventually LinkedIn / Perplexity / whatever) is a typed resolver with a cost, a confidence score, and structured output. Ships with two built-in resolvers; user-provided ones come in a follow-on release.
### Schema migrations
Three new migrations, all idempotent, apply automatically on `gbrain init` / upgrade.
Three new migrations, idempotent, applied automatically on `gbrain upgrade`.
- **v11 — budget_ledger + budget_reservations.** Per-(scope, resolver, local_date) rollup with held-reservation TTL. Rollback: DROP TABLE (budget is regenerable from resolver call logs).
- **v12minion_jobs.quiet_hours + stagger_key.** Additive nullable columns; existing rows keep working unchanged.
- **TS v0.13.1 — grandfather `validate: false`.** Walks every page, adds the opt-out frontmatter so legacy content skips the new validators. `gbrain integrity --auto` clears the flag per-page as citations are repaired. Rollback log at `~/.gbrain/migrations/v0_13_1-rollback.jsonl`.
- **v12 — budget ledger.** Tracks resolver spend per day, per scope. Regenerable from call logs if you ever need to rollback.
- **v13Minion job quiet_hours + stagger_key columns.** Nullable additions; existing jobs keep working unchanged.
- **TS v0.13.1 — grandfather existing pages.** Every page gets `validate: false` in frontmatter on first run after upgrade, so legacy content doesn't fail the new validators. `gbrain integrity --auto` clears the flag per-page as it repairs citations.
### Out of scope (intentional, per CEO plan)
### What isn't in this release (and why)
- **Strict-mode default flip.** BrainWriter ships with `strict_mode=lint`. The flip to strict requires a 7-day soak + BrainBench regression ≤1pt + zero false-positive count.
- **Sandboxed user plugins.** v0.13 ships builtins only. User-provided TS modules deferred pending a real isolation story (worker_threads or vm2) in a follow-on release.
- **`openai_embedding` refactor.** Deferred to PR 1.5 post-flip; embedding is a hot path.
- **Wintermute `claw-bridge`.** Adoption path is documentation-only this release.
### Tests
- **89 new unit tests** across `test/resolvers.test.ts` (43), `test/writer.test.ts` (57), `test/integrity.test.ts` (21), `test/enrichment.test.ts` (23), `test/minions-quiet-hours.test.ts` (25), `test/post-write-lint.test.ts` (11), `test/migrations-v0_13_0.test.ts` (5).
- **E2E passes on Postgres:** 115 pass / 0 fail across mechanical, sync, upgrade, minions concurrency + resilience, graph-quality, MCP, migration-flow, search-quality, skills (Tier 2 Opus/Sonnet).
- **1574 total tests pass** with an active test Postgres container. 1522 pass in unit-only mode (E2E auto-skip without DATABASE_URL).
- **Strict validators by default.** Validators ship in "lint" mode — they report but don't block. Strict mode (where a bad citation rolls back the write) flips on after a 7-day soak with real traffic.
- **User plugins.** Only built-in resolvers this release. Loading arbitrary TS from `~/.gbrain/resolvers/` is a real security story (sandbox, capability tokens) that needs its own release.
### Itemized changes
#### Resolver SDK (`src/core/resolvers/`)
`Resolver<I, O>` interface with `{id, cost, backend, available(), resolve()}`. In-memory `ResolverRegistry`. `ResolverContext` carries `{engine, storage, config, logger, requestId, remote, deadline?, signal?}` — the `remote` flag mirrors `OperationContext.remote` for uniform trust boundaries. `FailImproveLoop.execute` gained optional `opts.signal`; backwards compatible. Two reference builtins: `url_reachable` (SSRF guard reuses wave-3 `isInternalUrl`, max-5 redirects with per-hop re-validation, AbortSignal composition) and `x_handle_to_tweet` (X API v2 recent search, strict handle regex, confidence-scored matches, 2x 429 retry honoring Retry-After, 401/403 → `ResolverError(auth)`). `gbrain resolvers list|describe` for introspection.
Typed `Resolver<Input, Output>` interface with a registry, confidence scoring, and AbortSignal support. Two built-ins: `url_reachable` (HEAD-check any URL, SSRF-guarded, follows redirects) and `x_handle_to_tweet` (X API v2 search, handles rate limits, confidence-ranks matches). Both integrate with the existing `FailImproveLoop` so deterministic code runs first and LLMs are a fallback, not a default.
#### BrainWriter + validators (`src/core/output/`)
`BrainWriter.transaction(fn, ctx)` over `engine.transaction` with pre-commit validators via `WriteTx` API. Scaffolder builds typed citations (`tweetCitation`, `emailCitation`, `sourceCitation`) + `entityLink` + `timelineLine` — URLs from structured IDs, never LLM text. `SlugRegistry` detects collisions at create time. Four validators (`citation`, `link`, `back-link`, `triple-hr`) skip fenced code / inline code / HTML comments correctly. Config flag `writer.strict_mode` (default `lint`).
#### BrainWriter (`src/core/output/`)
Transaction-scoped writer with pre-commit validators. Four validators ship: citation (every paragraph has a source), link (every wikilink target exists), back-link (forward edge = reverse edge), triple-hr (no ugly `---\n---\n---`). Scaffolder helpers build citations from structured data (`tweetCitation({handle, tweetId, dateISO})`) so agents never hand-roll URLs that could be hallucinated. SlugRegistry catches name collisions at create time instead of silently overwriting.
#### gbrain integrity (`src/commands/integrity.ts`)
Four subcommands: `check` (read-only report with `--json`, `--type`, `--limit`), `auto` (three-bucket repair with `--confidence`, `--review-lower`, `--dry-run`, `--fresh`, `--limit`), `review` (prints queue path + count), `reset-progress`. Nine bare-tweet phrase regexes. External-link extraction for optional dead-link probing. Repairs route through `BrainWriter.transaction`.
#### `gbrain integrity` command (`src/commands/integrity.ts`)
Four subcommands:
- `integrity check` — read-only report, how many bare-tweet phrases and external links live in your brain
- `integrity auto` — three-bucket repair, confidence-driven, resumable
- `integrity review` — path + count of the manual-review queue
- `integrity reset-progress` — wipe the progress file and start fresh
`gbrain doctor` now also runs a fast sample (500 pages) of the integrity scanner so you get a signal without running the full thing.
#### BudgetLedger + CompletenessScorer (`src/core/enrichment/`)
`BudgetLedger.reserve` returns `{kind:'held'}` or `{kind:'exhausted'}`. FOR UPDATE serializes concurrent reserves. `commit`, `rollback`, `cleanupExpired`. Midnight rollover via `Intl.DateTimeFormat` en-CA in configured IANA tz. Seven per-type rubrics + default (weights sum to 1.0). Person rubric's `non_redundancy` and `recency_score` kill Wintermute's length-only heuristic + 30-day-re-enrich-forever pathologies.
Budget tracker with reserve/commit/rollback semantics. Concurrent reserves serialize via row-level locks. Process death between reserve and commit is handled by TTL auto-reclaim. CompletenessScorer ships seven per-type rubrics (person, company, deal, etc.) that kill Wintermute's 30-day-re-enrich-forever pathology by adding `non_redundancy` and `recency_score` factors.
#### Minions scheduler polish (`src/core/minions/`)
`quiet-hours.ts` — pure `evaluateQuietHours(cfg, now?)`. Wrap-around windows. Unknown tz fails open. `stagger.ts` — FNV-1a → 059 deterministic across runtimes. `worker.ts` integrated: post-claim evaluation, defer → `delayed/+15m`, skip → `cancelled`.
#### Minions scheduler (`src/core/minions/`)
`evaluateQuietHours(cfg, now?)` is pure and TZ-aware. Wrap-around windows (22→7) work. Unknown timezones fail open (don't silently block the job). Stagger keys hash to a deterministic 059 minute offset so jobs with the same key land on the same slot across runtime restarts.
#### Post-write lint hook (`src/core/output/post-write.ts`)
`runPostWriteLint` invokes the four validators against freshly-written pages. Gated on `writer.lint_on_put_page` (default false). Wired into `put_page` operation handler as non-blocking. Findings go to `~/.gbrain/validator-lint.jsonl` + `engine.logIngest`.
#### Put-page chaining (Step B)
`put_page` now auto-extracts timeline entries alongside auto-links. One `gbrain put` call produces a complete page: chunks, embeddings, links, AND timeline. Gated by `auto_timeline` config (default on). Master's frontmatter reconciliation from v0.13.0 stays unchanged; this adds timeline on top.
#### Design doc
`docs/designs/KNOWLEDGE_RUNTIME.md` — 717 lines covering the 4-layer architecture, integration seams, 7-phase migration path, 10 open questions. Promoted to repo so future contributors can trace decisions.
#### Doctor chaining (Step A)
`gbrain doctor` (non-fast mode) now runs the integrity scanner so users don't need to remember `gbrain integrity check` as a separate step. Surfaces bare-tweet + external-link counts as a warn check; `--fast` skips it.
#### Prior learnings applied
- Snapshot slugs upfront (`engine.getAllSlugs()`) in grandfather migration — avoids pagination-mutation instability.
- TS-registry migrations only (post-v0.11.1 migration-discovery change).
- Migration never calls `saveConfig` — avoids Postgres→PGLite flip.
- Quiet-hours at claim/promote, not dispatch — queued job becomes claimable after window opens.
- Core fn pattern for any handler wrapping a CLI command.
- Schema v11 not v8 (graph layer took v8-v10).
- `gray-matter` + line tokenizer for citation parsing, not `marked.lexer`.
#### Migrate chaining (Step C)
`gbrain migrate --to X` now verifies the target is healthy post-migration: page count matches source, embedding coverage above 90%, schema at latest. Catches broken copies before the user hits them at next CLI use.
#### Security + correctness fixes
Five codex findings addressed:
- `url_reachable` gets a DNS-rebinding defense (not just hostname-string SSRF)
- `x_handle_to_tweet` honors `x-rate-limit-reset` header in addition to `Retry-After`
- `BrainWriter.createEntity` takes a cross-process advisory lock on the slug hash
- Citation regex rejects empty `[Source:]` markers
- `runAutoLink` serializes concurrent reconciliation via advisory lock to prevent union-of-writes races
#### Tests
- 1,569 total unit tests pass (up from 1,469 pre-branch)
- 4 new benchmark scripts in `test/benchmark-*.ts` covering put_page latency, time-to-queryable brain, integrity repair rate, and doctor completeness
- Full results in `docs/benchmarks/2026-04-19-knowledge-runtime-v0.13.md`
## [0.13.0] - 2026-04-20
## **Frontmatter becomes a graph. Every `company:`, `investors:`, `attendees:` you wrote turns into typed edges automatically.**
## **Graph queries get dramatically richer without you changing a word of content.**
## **Your YAML frontmatter is now a graph.**
## **Every `company:`, `investors:`, `attendees:` you've ever written turns into typed edges automatically.**
v0.13 teaches the knowledge graph to read your YAML frontmatter. A `company: Acme` on a person page becomes a `works_at` edge. `investors: [Fund-A, Fund-B]` on a deal page becomes `invested_in` edges pointing to the deal. `attendees: [alice, charlie]` on a meeting page becomes `attended` edges. Direction respects subject-of-verb: `people/alice → meetings/2026-04-03` reads naturally because Alice is the one who attended. `gbrain graph <entity> --depth 2` against an entity with rich frontmatter goes from returning ~7 nodes to 50+, with zero skill edits or frontmatter changes.
If you've been adding `company: Acme` to person pages, or `investors: [Fund-A, Fund-B]` to deal pages, or `attendees: [alice, charlie]` to meeting notes — that metadata was invisible to the graph layer until today. v0.13 reads it and turns it into typed edges. No skill changes, no frontmatter changes, no agent updates. Run `gbrain upgrade` and your graph queries start returning 510x more results.
Everything else stays the same. Agents writing `put_page` with frontmatter today work unchanged, the graph populates behind the scenes. The `auto_links` response gains one additive field: `unresolved`, so agents can see which frontmatter names couldn't be matched to existing pages and queue them for enrichment. No breaking changes to any public API.
The direction respects how humans talk about it: `people/alice → meetings/2026-04-03` with type `attended`, because Alice is the one who attended. `deals/acme-seed → funds/sequoia` with type `invested_in` because the money flowed that way. Agents calling `put_page` keep working; the new edges populate behind the scenes. One additive field on the response (`auto_links.unresolved`) lets agents see which names didn't resolve so they can queue enrichment.
### The numbers that matter
@@ -667,43 +161,27 @@ If you maintain an agent fork that uses gbrain as its persistent memory, v0.13 i
### Itemized changes
**Knowledge graph, frontmatter edge projection:**
- `src/core/link-extraction.ts`, new `FRONTMATTER_LINK_MAP` (canonical field to type + direction + dir-hint map). New `SlugResolver` interface + `makeResolver(engine, {mode})` factory. `extractFrontmatterLinks` extractor. `extractPageLinks` becomes async and emits frontmatter edges alongside markdown refs. `LinkCandidate` gains `fromSlug`, `linkSource`, `originSlug`, `originField`.
- `src/core/operations.ts::runAutoLink`, bidirectional reconciliation. Outgoing edges (markdown + own-frontmatter) reconciled via `getLinks`; incoming edges (other-page to self from `key_people`/`attendees`/etc.) reconciled via `getBacklinks` scoped to `origin_page_id`. Manual edges (`link_source='manual'`) never touched.
- `put_page` response shape extends with `auto_links.unresolved: Array<{field, name}>`. Additive; existing clients unaffected.
**Frontmatter to graph edges:**
- Every canonical frontmatter field (`company`, `companies`, `key_people`, `investors`, `attendees`, `partner`, `sources`, `related`, `see_also`) now maps to a typed edge with a known direction. Adding a new field is a one-line change to the map.
- Name resolution is smart: exact slug match first, then dir-hint construction (e.g. `key_people: Alice Chen` on a company page looks in `people/`), then fuzzy trigram match. Unresolved names surface in the `auto_links.unresolved` response so agents can queue enrichment.
- `put_page` reconciliation is bidirectional now. Outgoing edges (a person's own `company:`) and incoming edges (a company's `key_people:` that mentions you) both reconcile correctly. User-created edges (`link_source: 'manual'`) are never touched by reconciliation.
**Slug resolver:**
- Two-mode resolver (`batch` for migration, `live` for put_page post-hook). Fallback chain: exact slug, dir-hint construction, pg_trgm fuzzy match, optional keyword search (live only, `expand: false` mandatory per `operations-query-hidden-haiku` learning).
- New engine method `findByTitleFuzzy(name, dirPrefix?, minSimilarity?)` implemented on both Postgres and PGLite engines. Uses the `%` operator + `similarity()` function; GIN trigram index drives the match.
- Per-run cache: same name, single DB lookup.
**Engine changes:**
- Both PGLite and Postgres engines: `addLink`, `addLinksBatch`, `removeLink`, `getLinks`, `getBacklinks` gain `link_source` + `origin_slug` + `origin_field` for edge provenance.
- New `findByTitleFuzzy(name, dirPrefix?, minSimilarity?)` method uses pg_trgm to match "Alice Chen" to `people/alice-chen`. GIN trigram index drives the lookup.
**Schema migrations:**
- migrate.ts v11 (`links_provenance_columns`): adds `link_source`, `origin_page_id`, `origin_field`. Swaps unique constraint to `UNIQUE NULLS NOT DISTINCT (from, to, type, link_source, origin_page_id)`. CHECK constraint on `link_source` values. New indexes on link_source + origin_page_id.
- `src/commands/migrations/v0_13_0.ts`, release orchestrator (Phase A schema, Phase B backfill, Phase C verify). Registered in migrations/index.ts. Resumable via `partial` status + `ON CONFLICT DO NOTHING`.
**Schema migration:**
- Migration v11 (`links_provenance_columns`) adds the provenance columns and swaps the unique constraint to include `link_source` + `origin_page_id`. Requires Postgres 15+ (for `UNIQUE NULLS NOT DISTINCT`); earlier versions fail loudly instead of half-applying.
- Orchestrator runs schema + backfill + verify as three phases. Resumable if it gets interrupted — partial state is safe to re-run.
**Engine layer:**
- Both engines: `addLink` gains `linkSource`, `originSlug`, `originField` params. `addLinksBatch` unnest grows from 4 columns to 7. `removeLink` gains optional `linkSource` filter. `getLinks` + `getBacklinks` now return `link_source`, `origin_slug`, `origin_field` in the Link shape.
- PGLite + Postgres parity verified end-to-end in `test/pglite-engine.test.ts`.
**Release reliability (new pattern, applies to every future release):**
- `gbrain upgrade` now records post-upgrade failures to `~/.gbrain/upgrade-errors.jsonl` instead of silently swallowing them.
- `gbrain doctor` surfaces the most recent failure with a paste-ready recovery hint.
- Every future CHANGELOG entry includes a "To take advantage of v[version]" block so users have a self-repair path when automation fails.
**Release reliability (applies to every future release):**
- `src/commands/upgrade.ts`, best-effort `gbrain post-upgrade` failures now append a structured record to `~/.gbrain/upgrade-errors.jsonl` instead of silently swallowing the error.
- `src/commands/doctor.ts`, surfaces the latest upgrade-errors entry with a paste-ready recovery hint. Works alongside the existing partial-migration detector.
- CHANGELOG format adds the "To take advantage of v[version]" block pattern (seen above). Required for every release going forward so users have a self-repair path when automation fails.
**CLI changes:**
- `gbrain extract links --source db --include-frontmatter`, v0.13 flag. Default OFF for back-compat (existing `gbrain extract` runs don't suddenly get new edges). Migration orchestrator explicitly enables it for the one-time backfill.
- `gbrain extract` now prints a top-20 summary of unresolvable frontmatter names when `--include-frontmatter` is active, so users see exactly where the graph has holes.
**Tests:**
- `test/pglite-engine.test.ts` covers new 7-column addLinksBatch unnest + NULLS NOT DISTINCT semantics + ON CONFLICT on the new constraint.
- `test/link-extraction.test.ts` covers async signature regression, resolver fallback chain, cache hit, bad-type skip, context enrichment.
- `test/extract.test.ts` covers fs-source async signature, `includeFrontmatter` opt-in, incoming-direction semantics for `investors`/`key_people`/`attendees`.
- `test/migrate.test.ts` updated for new constraint name post-v11.
- `test/apply-migrations.test.ts` registry now includes v0.13.0 in skippedFuture buckets for older installed versions.
**Documentation:**
- `skills/migrations/v0.13.0.md`, user-facing upgrade skill.
- `docs/UPGRADING_DOWNSTREAM_AGENTS.md`, appended v0.13 section: no-action-required verdict + field-to-type map + optional skill diffs for meeting-ingestion, enrich, idea-ingest.
**CLI:**
- `gbrain extract links --source db --include-frontmatter` — v0.13 flag. Default OFF for backwards compat; the migration orchestrator enables it for the one-time backfill.
- `gbrain extract` prints the top 20 unresolvable frontmatter names when `--include-frontmatter` runs so users see exactly where the graph has holes.
## [0.12.3] - 2026-04-19
+16 -77
View File
@@ -23,9 +23,9 @@ strict behavior when unset.
## Key files
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`. `OperationContext.remote` flags untrusted callers.
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports.
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`).
- `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`)
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly.
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders.
- `src/core/pglite-schema.ts` — PGLite-specific DDL (pgvector, pg_trgm, triggers)
- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch` / `addTimelineEntriesBatch` use `INSERT ... SELECT FROM unnest($1::text[], ...) JOIN pages ON CONFLICT DO NOTHING RETURNING 1` — 4-5 array params regardless of batch size, sidesteps the 65535-parameter cap. As of v0.12.3, `searchKeyword` / `searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection (contributed by @garagon). `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query.
- `src/core/utils.ts` — Shared SQL utilities extracted from postgres-engine.ts. Exports `parseEmbedding(value)` (throws on unknown input, used by migration + ingest paths where data integrity matters) and as of v0.12.3 `tryParseEmbedding(value)` (returns `null` + warns once per process, used by search/rescore paths where availability matters more than strictness).
@@ -42,8 +42,7 @@ strict behavior when unset.
- `src/core/search/eval.ts` — Retrieval eval harness: P@k, R@k, MRR, nDCG@k metrics + runEval() orchestrator
- `src/commands/eval.ts``gbrain eval` command: single-run table + A/B config comparison
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`.
- `src/core/dry-fix.ts``gbrain doctor --fix` engine. `autoFixDryViolations(fixes, {dryRun})` rewrites inlined rules to `> **Convention:** see [path](path).` callouts via three shape-aware expanders (bullet / blockquote / paragraph). Five guards: working-tree-dirty (`getWorkingTreeStatus()` returns 3-state `'clean' | 'dirty' | 'not_a_repo'`), no-git-backup, inside-code-fence, already-delegated (40-line proximity, consistent with detector), ambiguous-multi-match, block-is-callout. `execFileSync` array args (no shell — no injection surface). EOF newline preserved.
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects
- `src/core/backoff.ts` — Adaptive load-aware throttling: CPU/memory checks, exponential backoff, active hours multiplier
- `src/core/fail-improve.ts` — Deterministic-first, LLM-fallback loop with JSONL failure logging and auto-test generation
- `src/core/transcription.ts` — Audio transcription: Groq Whisper (default), OpenAI fallback, ffmpeg segmentation for >25MB
@@ -52,33 +51,22 @@ strict behavior when unset.
- `src/commands/extract.ts``gbrain extract links|timeline|all [--source fs|db]`: batch link/timeline extraction. fs walks markdown files, db walks pages from the engine (mutation-immune snapshot iteration; use this for live brains with no local checkout). As of v0.12.1 there is no in-memory dedup pre-load — candidates are buffered 100 at a time and flushed via `addLinksBatch` / `addTimelineEntriesBatch`; `ON CONFLICT DO NOTHING` enforces uniqueness at the DB layer, and the `created` counter returns real rows inserted (truthful on re-runs).
- `src/commands/graph-query.ts``gbrain graph-query <slug> [--type T] [--depth N] [--direction in|out|both]`: typed-edge relationship traversal (renders indented tree)
- `src/core/link-extraction.ts` — shared library for the v0.12.0 graph layer. extractEntityRefs (canonical, replaces backlinks.ts duplicate) matches both `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks as of v0.12.3. extractPageLinks, inferLinkType heuristics (attended/works_at/invested_in/founded/advises/source/mentions), parseTimelineEntries, isAutoLinkEnabled config helper. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts.
- `src/core/minions/` — Minions job queue: BullMQ-inspired, Postgres-native (queue, worker, backoff, types, protected-names, quiet-hours, stagger, handlers/shell).
- `src/core/minions/queue.ts` — MinionQueue class (submit, claim, complete, fail, stall detection, parent-child, depth/child-cap, per-job timeouts, cascade-kill, attachments, idempotency keys, child_done inbox, removeOnComplete/Fail). `add()` takes a 4th `trusted` arg (separate from `opts` to prevent spread leakage); protected names in `PROTECTED_JOB_NAMES` require `{allowProtectedSubmit: true}` and the check runs trim-normalized (whitespace-bypass safe). v0.14.1 #219: `add()` plumbs `max_stalled` through with a `[1, 100]` clamp; omitted values let the schema DEFAULT (5) kick in.
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net). v0.14.0 abort-path fix: aborted jobs now call `failJob` with reason (`timeout`/`cancel`/`lock-lost`/`shutdown`) instead of returning silently. `shutdownAbort` (instance field) fires on process SIGTERM/SIGINT and propagates to `ctx.shutdownSignal` — shell handler listens to it; non-shell handlers don't.
- `src/core/minions/types.ts``MinionJobInput` + `MinionJobStatus` + handler context types. `MinionJobInput.max_stalled` (new in v0.14.1) is optional; omitted values let the schema DEFAULT (5) kick in, provided values are clamped to `[1, 100]`.
- `src/core/minions/protected-names.ts` — side-effect-free constant module exporting `PROTECTED_JOB_NAMES` + `isProtectedJobName()`. Kept pure so queue core can import without loading handler modules.
- `src/core/minions/handlers/shell.ts``shell` job handler. Spawns `/bin/sh -c cmd` (absolute path, PATH-override-safe) or `argv[0] argv[1..]` (no shell). Env allowlist: `PATH, HOME, USER, LANG, TZ, NODE_ENV` + caller `env:` overrides. UTF-8-safe stdout/stderr tail via `string_decoder.StringDecoder`. Abort (either `ctx.signal` or `ctx.shutdownSignal`) fires SIGTERM → 5s grace → SIGKILL on child. Requires `GBRAIN_ALLOW_SHELL_JOBS=1` on worker (gated by `registerBuiltinHandlers`).
- `src/core/minions/handlers/shell-audit.ts` — per-submission JSONL audit trail at `~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl` (ISO-week rotation; override via `GBRAIN_AUDIT_DIR`). Best-effort: `mkdirSync(recursive)` + `appendFileSync`; failures logged to stderr, submission not blocked. Logs cmd (first 80 chars) or argv (JSON array). Never logs env values.
- `src/core/minions/` — Minions job queue: BullMQ-inspired, Postgres-native (queue, worker, backoff, types)
- `src/core/minions/queue.ts` — MinionQueue class (submit, claim, complete, fail, stall detection, parent-child, depth/child-cap, per-job timeouts, cascade-kill, attachments, idempotency keys, child_done inbox, removeOnComplete/Fail)
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net)
- `src/core/minions/attachments.ts` — Attachment validation (path traversal, null byte, oversize, base64, duplicate detection)
- `src/commands/jobs.ts``gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219.
- `src/commands/jobs.ts``gbrain jobs` CLI subcommands + `gbrain jobs work` daemon
- `src/commands/features.ts``gbrain features --json --auto-fix`: usage scan + feature adoption salesman
- `src/commands/autopilot.ts``gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed)
- `src/mcp/server.ts` — MCP stdio server (generated from operations)
- `src/commands/auth.ts` — Standalone token management (create/list/revoke/test)
- `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally.
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry <version>` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed.
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). All orchestrators are idempotent and resumable from `partial` status.
- `src/commands/repair-jsonb.ts``gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent.
- `src/commands/orphans.ts``gbrain orphans [--json] [--count] [--include-pseudo]`: surfaces pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. Shipped in v0.12.3 (contributed by @knee5).
- `src/commands/doctor.ts``gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, and `gbrain apply-migrations`.
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows.
- `src/core/progress.ts` — Shared bulk-action progress reporter. Writes to stderr. Modes: `auto` (TTY: `\r`-rewriting; non-TTY: plain lines), `human`, `json` (JSONL), `quiet`. Rate-gated by `minIntervalMs` and `minItems`. `startHeartbeat(reporter, note)` helper for single long queries. `child()` composes phase paths. Singleton SIGINT/SIGTERM coordinator emits `abort` events for every live phase. EPIPE defense on both sync throws and stream `'error'` events. Zero dependencies. Introduced in v0.15.2.
- `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=<ms>` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach the resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers.
- `scripts/check-progress-to-stdout.sh` — CI guard against regressing to `\r`-on-stdout progress. Wired into `bun run test` via `scripts/check-progress-to-stdout.sh && bun test` in package.json.
- `docs/progress-events.md` — Canonical JSON event schema reference. Stable from v0.15.2, additive only.
- `src/commands/doctor.ts``gbrain doctor [--json] [--fast] [--fix]`: health checks. v0.12.3 adds two reliability detection checks: `jsonb_integrity` (scans pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata for `jsonb_typeof='string'` rows left over from v0.12.0) and `markdown_body_completeness` (flags pages whose compiled_truth is <30% of raw source when raw has multiple H2/H3 boundaries). Fix hints point at `gbrain repair-jsonb` and `gbrain sync --force`.
- `src/core/markdown.ts` — Frontmatter parsing + body splitter. `splitBody` requires an explicit timeline sentinel (`<!-- timeline -->`, `--- timeline ---`, or `---` immediately before `## Timeline`/`## History`). Plain `---` in body text is a markdown horizontal rule, not a separator. `inferType` auto-types `/wiki/analysis/` → analysis, `/wiki/guides/` → guide, `/wiki/hardware/` → hardware, `/wiki/architecture/` → architecture, `/writing/` → writing (plus the existing people/companies/deals/etc heuristics).
- `scripts/check-jsonb-pattern.sh` — CI grep guard. Fails the build if anyone reintroduces (a) the `${JSON.stringify(x)}::jsonb` interpolation pattern (postgres.js v3 double-encodes it), or (b) `max_stalled INTEGER NOT NULL DEFAULT 1` in any schema source file (v0.15.1 #219 regression guard — must be DEFAULT 5 to preserve SIGKILL-rescue). Wired into `bun test`.
- `scripts/llms-config.ts` + `scripts/build-llms.ts` — Generator for `llms.txt` (llmstxt.org-spec web index) + `llms-full.txt` (inlined single-fetch bundle). Curated config drives both. Run `bun run build:llms` after adding a new doc. `LLMS_REPO_BASE` env var lets forks regenerate with their own URL base. `FULL_SIZE_BUDGET` (600KB) caps the inline bundle; generator WARNs if exceeded. Committed output is not analogous to `schema-embedded.ts` (no runtime consumer); we commit for GitHub browsing and fork-safe fetching.
- `AGENTS.md` — Local-clone entry point for non-Claude agents (Codex, Cursor, OpenClaw, Aider). Mirrors `CLAUDE.md` intent via relative links. Claude Code keeps using `CLAUDE.md`.
- `scripts/check-jsonb-pattern.sh` — CI grep guard. Fails the build if anyone reintroduces the `${JSON.stringify(x)}::jsonb` interpolation pattern (which postgres.js v3 double-encodes). Wired into `bun test`.
- `docs/UPGRADING_DOWNSTREAM_AGENTS.md` — Patches for downstream agent skill forks to apply when upgrading. Each release appends a new section. v0.10.3 includes diffs for brain-ops, meeting-ingestion, signal-detector, enrich.
- `src/core/schema-embedded.ts` — AUTO-GENERATED from schema.sql (run `bun run build:schema`)
- `src/schema.sql` — Full Postgres + pgvector DDL (source of truth, generates schema-embedded.ts)
@@ -138,13 +126,12 @@ Key commands added in v0.7:
- `gbrain migrate --to supabase` / `gbrain migrate --to pglite` — bidirectional engine migration
Key commands added for Minions (job queue):
- `gbrain jobs submit <name> [--params JSON] [--follow] [--dry-run]` — submit a background job. v0.13.1 adds first-class flags for every `MinionJobInput` tuning knob: `--max-stalled N`, `--backoff-type fixed|exponential`, `--backoff-delay Nms`, `--backoff-jitter 0..1`, `--timeout-ms N`, `--idempotency-key K`.
- `gbrain jobs submit <name> [--params JSON] [--follow] [--dry-run]` — submit a background job
- `gbrain jobs list [--status S] [--queue Q]` — list jobs with filters
- `gbrain jobs get <id>` — job details with attempt history
- `gbrain jobs cancel/retry/delete <id>` — manage job lifecycle
- `gbrain jobs prune [--older-than 30d]` — clean old completed/dead jobs
- `gbrain jobs stats` — job health dashboard
- `gbrain jobs smoke [--sigkill-rescue]` — health smoke test. `--sigkill-rescue` is the v0.13.1 regression guard for #219: simulates a killed worker and asserts the stalled job is requeued instead of dead-lettered on first stall.
- `gbrain jobs work [--queue Q] [--concurrency N]` — start worker daemon (Postgres only)
Key commands added in v0.12.2:
@@ -154,19 +141,6 @@ Key commands added in v0.12.3:
- `gbrain orphans [--json] [--count] [--include-pseudo]` — surface pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. The natural consumer of the v0.12.0 knowledge graph layer: once edges are captured, find the gaps.
- `gbrain doctor` gains two new reliability detection checks: `jsonb_integrity` (v0.12.0 Postgres double-encode damage) and `markdown_body_completeness` (pages truncated by the old splitBody bug). Detection only; fix hints point at `gbrain repair-jsonb` and `gbrain sync --force`.
Key commands added in v0.14.2:
- `gbrain sync --skip-failed` — acknowledge the current set of failed-parse files recorded in `~/.gbrain/sync-failures.jsonl` so the sync bookmark advances past them. Doctor's `sync_failures` check shows previously-skipped as "all acknowledged" instead of warning.
- `gbrain sync --retry-failed` — re-walk the unacknowledged failures and re-attempt parsing. If the files now succeed, they clear from the set and the bookmark advances naturally.
- `gbrain apply-migrations --force-retry <version>` — reset a wedged migration (3 consecutive partials with no completion) by appending a `'retry'` marker. Next `apply-migrations --yes` treats the version as fresh. `complete` status never regresses to `partial` either before or after a retry marker.
- `GBRAIN_POOL_SIZE` env var — honored by both the singleton pool (`src/core/db.ts`) and the parallel-import worker pool (`src/commands/import.ts`). Default is 10; lower to 2 for Supabase transaction pooler to avoid MaxClients crashes during `gbrain upgrade` subprocess spawns. Read at call time via `resolvePoolSize()`.
- `gbrain doctor` gains two new checks: `sync_failures` (surfaces unacknowledged parse failures with exact paths + fix hints) and `brain_score` (renders the 5-component breakdown when score < 100: embed coverage / 35, link density / 25, timeline coverage / 15, orphans / 15, dead links / 10 — sum equals total).
Key commands added in v0.14.3 (fix wave):
- `gbrain doctor --index-audit` — opt-in Postgres-only check reporting zero-scan indexes from `pg_stat_user_indexes`. Informational only; never auto-drops.
- `gbrain doctor` schema_version check fails loudly when `version=0` — catches `bun install -g github:...` postinstall failures (#218) and routes users to `gbrain apply-migrations --yes`.
- `gbrain jobs submit` gains `--max-stalled`, `--backoff-type`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key` — exposing existing `MinionJobInput` fields as first-class CLI flags.
- `gbrain jobs smoke --sigkill-rescue` — opt-in regression smoke case simulating a killed worker; asserts the v0.14.3 schema default (`max_stalled=5`) actually rescues on first stall.
## Testing
`bun test` runs all tests. After the v0.12.1 release: ~75 unit test files + 8 E2E test files (1412 unit pass, 119 E2E when `DATABASE_URL` is set — skip gracefully otherwise). Unit tests run
@@ -178,11 +152,11 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/files.test.ts` (MIME/hash), `test/import-file.test.ts` (import pipeline),
`test/upgrade.test.ts` (schema migrations),
`test/file-migration.test.ts` (file migration), `test/file-resolver.test.ts` (file resolution),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix + v0.13.1 assertions on v12/v13 SQL shape, `sqlFor` + `transaction:false` runner semantics, and the `max_stalled DEFAULT 1` regression guard),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix),
`test/setup-branching.test.ts` (setup flow), `test/slug-validation.test.ts` (slug validation),
`test/storage.test.ts` (storage backends), `test/supabase-admin.test.ts` (Supabase admin),
`test/yaml-lite.test.ts` (YAML parsing), `test/check-update.test.ts` (version check + update CLI),
`test/pglite-engine.test.ts` (PGLite engine, all 40 BrainEngine methods including 11 cases for `addLinksBatch` / `addTimelineEntriesBatch`: empty batch, missing optionals, within-batch dedup via ON CONFLICT, missing-slug rows dropped by JOIN, half-existing batch, batch of 100 + v0.13.1 `connect()` error-wrap assertion (original error nested, #223 link in message, lock released)),
`test/pglite-engine.test.ts` (PGLite engine, all 40 BrainEngine methods including 11 cases for `addLinksBatch` / `addTimelineEntriesBatch`: empty batch, missing optionals, within-batch dedup via ON CONFLICT, missing-slug rows dropped by JOIN, half-existing batch, batch of 100),
`test/engine-factory.test.ts` (engine factory + dynamic imports),
`test/integrations.test.ts` (recipe parsing, CLI routing, recipe validation),
`test/publish.test.ts` (content stripping, encryption, password generation, HTML output),
@@ -195,15 +169,13 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/dedup.test.ts` (source-aware dedup, compiled truth guarantee, layer interactions),
`test/intent.test.ts` (query intent classification: entity/temporal/event/general),
`test/eval.test.ts` (retrieval metrics: precisionAtK, recallAtK, mrr, ndcgAtK, parseQrels),
`test/check-resolvable.test.ts` (resolver reachability, MECE overlap, gap detection, DRY checks + v0.14.1 proximity-based DRY detection + `extractDelegationTargets` coverage — 13 DRY cases),
`test/dry-fix.test.ts` (v0.14.1 auto-fix: three shape-aware expander pure-function tests, five guards — working-tree-dirty, no-git-backup, inside-code-fence, already-delegated within 40 lines, ambiguous-multi-match, block-is-callout — 28 cases),
`test/doctor-fix.test.ts` (v0.14.1 `gbrain doctor --fix` CLI integration: dry-run preview, apply path, JSON output shape — 3 cases),
`test/check-resolvable.test.ts` (resolver reachability, MECE overlap, gap detection, DRY checks),
`test/backoff.test.ts` (load-aware throttling, concurrency limits, active hours),
`test/fail-improve.test.ts` (deterministic/LLM cascade, JSONL logging, test generation, rotation),
`test/transcription.test.ts` (provider detection, format validation, API key errors),
`test/enrichment-service.test.ts` (entity slugification, extraction, tier escalation),
`test/data-research.test.ts` (recipe validation, MRR/ARR extraction, dedup, tracker parsing, HTML stripping),
`test/minions.test.ts` (Minions job queue v7: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency, child_done inbox, attachments, removeOnComplete/Fail + v0.13.1 `max_stalled` clamp/default/plumbing coverage),
`test/minions.test.ts` (Minions job queue v7: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency, child_done inbox, attachments, removeOnComplete/Fail),
`test/extract.test.ts` (link extraction, timeline extraction, frontmatter parsing, directory type inference),
`test/extract-db.test.ts` (gbrain extract --source db: typed link inference, idempotency, --type filter, --dry-run JSON output),
`test/extract-fs.test.ts` (gbrain extract --source fs: first-run inserts + second-run reports zero, dry-run dedups candidates across files, second-run perf regression guard — the v0.12.1 N+1 dedup bug),
@@ -220,8 +192,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/postgres-engine.test.ts` (v0.12.3 statement_timeout scoping: `sql.begin` + `SET LOCAL` shape, source-level grep guardrail against reintroduced bare `SET statement_timeout`),
`test/sync.test.ts` (sync logic + v0.12.3 regression guard asserting top-level `engine.transaction` is not called),
`test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present),
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics),
`test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases).
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics).
E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys). Includes 9 dedicated cases for the postgres-engine `addLinksBatch` / `addTimelineEntriesBatch` bind path — postgres-js's `unnest()` binding is structurally different from PGLite's and gets its own coverage.
@@ -297,38 +268,6 @@ testing, soul-audit, webhook-transforms, data-research, minion-orchestrator.
model-routing, test-before-bulk, cross-modal). `skills/_brain-filing-rules.md` and
`skills/_output-rules.md` are shared references.
## Bulk-action progress reporting
All bulk commands (doctor, embed, import, export, sync, extract, migrate,
repair-jsonb, orphans, check-backlinks, lint, integrity auto, eval, files
sync, and apply-migrations) stream progress through the shared reporter
at `src/core/progress.ts`. Agents get heartbeats within 1 second of every
iteration regardless of how slow the underlying work is.
Rules:
- Progress always writes to **stderr**. Stdout stays clean for data output
(`--json` payloads, final summaries, JSON action events from `extract`).
- Non-TTY default: plain one-line-per-event human text. JSON requires the
explicit `--progress-json` flag.
- Global flags (`--quiet`, `--progress-json`, `--progress-interval=<ms>`)
are parsed by `src/core/cli-options.ts` BEFORE command dispatch.
- Phase names are machine-stable `snake_case.dot.path` (e.g.
`doctor.db_checks`, `sync.imports`). Documented in
`docs/progress-events.md`; additive changes only.
- `scripts/check-progress-to-stdout.sh` is a CI guard that fails the build
if any new code writes `\r` progress to stdout. Wired into `bun run test`.
- Minion handlers pass `job.updateProgress` as the `onProgress` callback
to core functions (DB-backed primary progress channel); stderr from
`jobs work` stays coarse for daemon liveness only.
When wiring a new bulk command: `import { createProgress } from '../core/progress.ts'`
and `import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts'`.
Create a reporter with `createProgress(cliOptsToProgressOptions(getCliOptions()))`,
`start(phase, total?)` before the loop, `tick()` inside it, `finish()` after.
For single long-running queries, use `startHeartbeat(reporter, note)` with a
try/finally to guarantee cleanup. Never call `process.stdout.write('\r...')`
in bulk paths, the CI guard will fail the build.
## Build
`bun build --compile --outfile bin/gbrain src/cli.ts`
+1 -17
View File
@@ -3,17 +3,6 @@
Read this entire file, then follow the steps. Ask the user for API keys when needed.
Target: ~30 minutes to a fully working brain.
## Step 0: If you are not Claude Code
Read `AGENTS.md` at the repo root first. It's the non-Claude-agent operating
protocol (install, read order, trust boundary, common tasks). Claude Code reads
`CLAUDE.md` automatically and can skip ahead.
If you fetched this file by URL without cloning yet, the companion files live at:
- `https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` — start here
- `https://raw.githubusercontent.com/garrytan/gbrain/master/llms.txt` — full doc map
- `https://raw.githubusercontent.com/garrytan/gbrain/master/llms-full.txt` — same map, inlined
## Step 1: Install GBrain
```bash
@@ -26,11 +15,6 @@ bun install && bun link
Verify: `gbrain --version` should print a version number. If `gbrain` is not found,
restart the shell or add the PATH export to the shell profile.
> **Do NOT use `bun install -g github:garrytan/gbrain`.** Bun blocks the top-level
> postinstall hook on global installs, so schema migrations never run and the CLI
> aborts with `Aborted()` when it opens PGLite. Use the `git clone + bun link` path
> above. Tracking issue: [#218](https://github.com/garrytan/gbrain/issues/218).
## Step 2: API Keys
Ask the user for these:
@@ -149,7 +133,7 @@ actually works) is the most important.
## Upgrade
```bash
cd ~/gbrain && git pull origin master && bun install
cd ~/gbrain && git pull origin main && bun install
gbrain init # apply schema migrations (idempotent)
gbrain post-upgrade # show migration notes for the version range
```
+1 -15
View File
@@ -10,8 +10,6 @@ GBrain is those patterns, generalized. 26 skills. Install in 30 minutes. Your ag
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
> **LLMs:** fetch [`llms.txt`](llms.txt) for the documentation map, or [`llms-full.txt`](llms-full.txt) for the same map with core docs inlined in one fetch. **Agents:** start with [`AGENTS.md`](AGENTS.md) (or [`CLAUDE.md`](CLAUDE.md) if you're Claude Code).
## Install
### On an agent platform (recommended)
@@ -30,11 +28,6 @@ https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md
That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 26 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes.
If your agent doesn't auto-read `AGENTS.md`, point it at that file first:
`https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` is the non-Claude
agent operating protocol (install, read order, trust boundary, common tasks). For
the full doc map, use `llms.txt` at the same URL root.
### Standalone CLI (no agent)
```bash
@@ -44,11 +37,6 @@ gbrain import ~/notes/ # index your markdown
gbrain query "what themes show up across my notes?"
```
**Do NOT use `bun install -g github:garrytan/gbrain`.** Bun blocks the top-level
postinstall hook on global installs, so schema migrations never run and the CLI
aborts with `Aborted()` the first time it opens PGLite. Use `git clone + bun install
&& bun link` as shown above. See [#218](https://github.com/garrytan/gbrain/issues/218).
```
3 results (hybrid search, 0.12s):
@@ -228,8 +216,6 @@ gbrain skillpack-check | jq # full JSON: {healthy, summary, actions[], doc
If anything's off, `actions[]` tells you the exact command to run. For deeper troubleshooting: [`docs/guides/minions-fix.md`](docs/guides/minions-fix.md).
Moving gateway crons to Minions (deterministic scripts, zero LLM tokens per fire): [`docs/guides/minions-shell-jobs.md`](docs/guides/minions-shell-jobs.md).
## Skillify: your skills tree stops being a black box
Hermes and similar agent frameworks auto-create skills as a background behavior. Fine until you don't know what the agent shipped. Checklists decay. Tests drift. Resolver entries get stale. Six months later you've got an opaque pile of "skills" that nobody has read, nobody has tested, and nobody is sure still work.
@@ -544,7 +530,7 @@ JOBS (Minions)
ADMIN
gbrain doctor [--json] [--fast] Health checks (resolver, skills, DB, embeddings)
gbrain doctor --fix [--dry-run] Auto-fix DRY violations (delegate inlined rules to conventions)
gbrain doctor --fix Auto-fix resolver issues
gbrain stats Brain statistics
gbrain serve MCP server (stdio)
gbrain integrations Integration recipe dashboard
-89
View File
@@ -84,30 +84,6 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
## P1
### Minions shell jobs — Phase 2 scheduling (deferred from v0.13.0)
**What:** `minion_schedules` table + autopilot-cycle scanner that submits due shell jobs.
**Why:** v0.13.0 moves shell scripts to Minions but still leaves scheduling in the host crontab. Your OpenClaw's `scripts/service-manager.sh` + crontab is the only piece left on the host side. A DB-driven scheduler would mean a single `gbrain autopilot --install` replaces the host crontab entirely, scheduling is visible via `gbrain jobs list --scheduled`, and downtime-on-one-machine tolerance improves (schedule is shared DB state, not per-host crontab).
**Pros:** Canonical host-agnostic deployment. No more host-specific crontab.
**Cons:** Cross-engine migration complexity (new table on both PGLite + Postgres). Autopilot-cycle scanner needs to handle missed-schedule semantics (fire-once-on-startup or skip-if-past-now), and this is where every other cron-like system has historically accrued bugs.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### `gbrain crontab-to-minions <file>` migration helper (deferred from v0.13.0)
**What:** Parse an existing crontab file, emit a proposed rewrite using `gbrain jobs submit shell ...` for each deterministic entry, keep LLM-requiring entries as-is.
**Why:** Hand-rewriting ~14 OpenClaw cron entries is error-prone and one-shot. A helper would make the migration reversible and auditable (diff the before/after crontab, dry-run the first N, commit).
**Pros:** Removes the "rewrite 14 lines by hand" tax every agent operator pays on adoption.
**Cons:** Crontab parsing is historically fiddly (5-field vs 6-field, `@hourly` aliases, Vixie extensions, env vars in crontab). Could misrewrite entries with shell substitution.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Batch the DB-source extract read path (deferred from v0.12.1)
**What:** `extractLinksFromDB` and `extractTimelineFromDB` at `src/commands/extract.ts:447, 504` issue one `engine.getPage(slug)` per slug after `engine.getAllSlugs()`. On a 47K-page brain that's still 47K serial reads over the Supabase pooler.
@@ -228,50 +204,6 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
## P2
### Minions: `gbrain jobs stats --orphaned` (deferred from v0.13.0)
**What:** New CLI flag / output column surfacing jobs that are waiting with no registered handler on any live worker.
**Why:** v0.13.0 adds shell jobs that require `GBRAIN_ALLOW_SHELL_JOBS=1` on the worker. If an operator submits a shell job but no worker with the flag is running, the row sits in `waiting` silently. The CLI's starvation warning + docs help at submit time; this TODO surfaces the problem at operational-check time.
**Pros:** Closes the "did my cron actually run" ambiguity for multi-machine deployments.
**Cons:** Knowing "no worker has this handler registered" requires worker heartbeat tracking, which Minions doesn't have yet (it's stateless at DB level beyond `lock_token`). Could be approximated by "no jobs of this name have completed in last N minutes AND count of waiting is > 0."
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Minions: AbortReason plumbing on MinionJobContext (deferred from v0.13.0)
**What:** Handlers today can't distinguish whether `ctx.signal.aborted` fired due to timeout, cancel, or lock-loss. v0.13.0 derives this at worker-catch-time from `abort.signal.reason`, but the handler can't see it directly. Expose `ctx.abortReason?: 'timeout' | 'cancel' | 'lock-lost' | 'shutdown'` on the context.
**Why:** Shell handler's kill-sequence today can't decide "retry this" (lock-lost) vs "don't retry, user cancelled" (cancel) — they look the same. A typed AbortReason lets handlers make that decision for themselves.
**Pros:** Handlers get richer signals.
**Cons:** Small surface-area addition to the handler API. Not strictly required since the worker already makes the retry/dead decision for them.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Minions: blocking-mode audit log for true forensic integrity (deferred from v0.13.0)
**What:** Opt-in mode for `shell-audit` where `appendFileSync` failures DO block submission instead of logging-and-continuing.
**Why:** v0.13.0 ships the audit log in best-effort mode, which means a disk-full attacker can silently disable the forensic trail. Acceptable for v0.13.0 because the primary use is operational ("what did this cron do last Tuesday"), not security forensics. Operators who want fail-closed semantics should have a flag.
**Pros:** Enables true forensic integrity for deployments that need it.
**Cons:** Fail-closed means a transient disk issue blocks shell submissions, which can be worse than a missing log line for most operators. Opt-in is the right shape but adds surface area.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Minions: configurable per-job output buffer sizes (deferred from v0.13.0)
**What:** Add `max_stdout_bytes` / `max_stderr_bytes` to ShellJobParams; override the 64KB/16KB defaults.
**Why:** 64KB/16KB covers typical OpenClaw scripts today but a verbose benchmark or a debug-dump script could need more.
**Depends on:** First shell-job author who actually needs it. Don't pre-build the flag.
### Security hardening follow-ups (deferred from security-wave-3)
**What:** Close remaining security gaps identified during the v0.9.4 Codex outside-voice review that didn't make the wave's in-scope cut.
@@ -364,27 +296,6 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
**Priority:** P2
**Depends on:** Nothing.
### Doctor --fix polish from v0.14.1 adversarial review
**What:** Six deferred findings from v0.14.1 ship-time adversarial review on `src/core/dry-fix.ts`:
1. **TOCTOU between read and write.** `attemptFix` reads once, writes later. Concurrent editor saves silently overwritten. Fix: re-read immediately before write and compare snapshot, or `O_EXCL` tempfile + rename.
2. **Fence detection misses 4-backtick and `~~~` fences.** `isInsideCodeFence` only catches `^```$`. CommonMark-legal alternates slip through.
3. **`expandBullet` walk-up is dead code.** Loop breaks immediately because `baseIndent` matches the current line. Remove or make it actually walk up.
4. **Multi-match guard too strict.** Skills with the pattern in a table-of-contents AND body get `ambiguous_multiple_matches` forever. Consider: fix first, re-scan, repeat until fixed-point.
5. **Subprocess spam.** `getWorkingTreeStatus` spawns `git status` N×M times per `doctor --fix`. Cache per-skill per-invocation.
6. **`doctor --fix --json` swallows the auto-fix report.** `printAutoFixReport` returns early on `jsonOutput`; agents don't see fix outcomes. Emit `auto_fix` as a top-level key.
**Why:** None are ship-blockers; all surfaced during v0.14.1 Codex adversarial review. Bundle into one follow-up PR.
**Pros:** Closes the adversarial findings loop. Better correctness under concurrent edits and JSON-consumer agents.
**Cons:** Concurrent-edit test is finicky.
**Context:** v0.14.1 shipped with the 4 critical fixes (shell-injection via execFileSync, no-git-backup detection, EOF newline preservation, proximity-window consistency). These six are the deferred remainder.
**Effort estimate:** M (CC: ~45min for all six + tests).
**Priority:** P2
**Depends on:** Nothing.
## Completed
### Implement AWS Signature V4 for S3 storage backend
+1 -1
View File
@@ -1 +1 @@
0.15.4
0.13.1
+2 -5
View File
@@ -7,7 +7,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@electric-sql/pglite": "0.4.3",
"@electric-sql/pglite": "^0.4.4",
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"marked": "^18.0.0",
@@ -20,9 +20,6 @@
},
},
},
"trustedDependencies": [
"@electric-sql/pglite",
],
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.30.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-nuKvp7wOIz6BFei8WrTdhmSsx5mwnArYyJgh4+vYu3V4J0Ltb8Xm3odPm51n1aSI0XxNCrDl7O88cxCtUdAkaw=="],
@@ -106,7 +103,7 @@
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.4", "", {}, "sha512-g/6CWAJ4XOkObWCWAQ2IReZD8VvsDy3poRHSKvpRR2F96F8WJ3HVbjpso3gN7l0q6QPPgvxSSpl/qo5k8a7mkQ=="],
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
-1
View File
@@ -52,7 +52,6 @@ Running a production brain.
| [Cron via Minions](../skills/conventions/cron-via-minions.md) | Why scheduled work runs as Minion jobs, not `agentTurn`. Auto-applied by v0.11.0 migration for built-in handlers; host-specific handlers use the plugin contract below. |
| [Plugin Handlers](guides/plugin-handlers.md) | Registering host-specific Minion handlers via code (no data-file exec surface). |
| [Minions fix](guides/minions-fix.md) | Repairing a half-migrated v0.11.0 install. |
| [Shell jobs (v0.14.0+)](guides/minions-shell-jobs.md) | Move deterministic crons (API fetch, token refresh, scrape+write) off the LLM gateway. Zero tokens per fire, ~60% gateway headroom. Follow `skills/migrations/v0.14.0.md` for the adoption playbook. |
| [Quiet Hours & Timezone](guides/quiet-hours.md) | Hold notifications during sleep, timezone-aware delivery |
| [Executive Assistant Pattern](guides/executive-assistant.md) | Email triage, meeting prep, scheduling |
| [Operational Disciplines](guides/operational-disciplines.md) | Signal detection, brain-first, sync-after-write, heartbeat, dream cycle |
-36
View File
@@ -319,42 +319,6 @@ v0.13 edges carry new `link_type` values. If your fork has graph-query skills th
### Type normalization NOT in v0.13
Legacy rows with `link_type='attendee'` or `link_type='mention'` coexist with new `'attended'` / `'mentions'` rows. Your queries filtering on old type names keep working. A separate opt-in `gbrain normalize-types` command in v0.14 handles the rename.
## v0.14.0 shell jobs (optional adoption, no skill edits)
Adds a `shell` job type to Minions so deterministic cron scripts (API fetch, token
refresh, scrape + write) move off the LLM gateway. Zero tokens per fire. ~60%
gateway CPU headroom at typical scale. Feature is **off by default**, existing
installs keep running exactly as they did before. Nothing breaks.
To adopt, follow `skills/migrations/v0.14.0.md`. The short version:
1. Set `GBRAIN_ALLOW_SHELL_JOBS=1` on the worker process, then `gbrain jobs work`
(Postgres). On PGLite, every crontab invocation uses `--follow` for inline
execution; no persistent worker.
2. Classify each of your host's cron entries: LLM-requiring (keep on gateway) vs
deterministic (candidate for shell). Typical splits:
- **Deterministic → shell:** `ycli-token-refresh`, `x-oauth2-refresh`,
`x-garrytan-unified`, `calendar-sync-to-brain`, `github-pulse`,
`frameio-scan`, `flight-tracker`, `x-raw-json-backfill`.
- **LLM-requiring → stay:** `social-radar`, `content-ideas`, `adversary-vacuum`,
`ea-inbox-sweep`, `morning-briefing`, `brain-maintenance`.
3. For each deterministic cron, rewrite as:
```cron
3 13,16,19,22,1,4,7,10 * * * \
gbrain jobs submit shell \
--params '{"cmd":"node scripts/your-script.mjs","cwd":"/data/.openclaw/workspace"}' \
--max-attempts 3 --timeout-ms 300000
```
4. Watch `gbrain jobs get <id>` for exit_code / stdout_tail / stderr_tail on each
fire. Compare against pre-migration behavior before approving the next batch.
**No skill edits required.** The handler runs worker-side; skill files don't
change. If your host exposed custom handlers via the plugin contract (v0.11.0),
they still work the same way.
Iron rule: **never auto-rewrite the operator's crontab.** Every rewrite is
per-cron, human-approved, with a diff. If you want automation later, the
upcoming `gbrain crontab-to-minions <file>` helper is P1 in TODOS.
---
-167
View File
@@ -1,167 +0,0 @@
# Minions shell jobs — move deterministic crons off the gateway
## 30 seconds
```bash
# Run your first shell job:
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--params '{"cmd":"echo hello","cwd":"/tmp"}' --follow
# → exit_code: 0, stdout_tail: "hello\n", duration_ms: 43
```
That's it. Your cron scripts now have a home with retry, backoff, DLQ, and
`gbrain jobs list` visibility, without each one booting a full LLM session.
**PGLite users:** `gbrain jobs work` does not run on PGLite (exclusive file
lock). Every crontab invocation must use `--follow` for inline execution.
Postgres users can run a persistent worker; see recipes below.
---
## Why it exists
If your agent runs deterministic scripts from cron (token refresh, API fetch,
scrape + write), each one pays the cost of a full LLM session on the gateway.
Fourteen simultaneous fires on a Series A deployment pin CPU at 100% and block
live messages. None of those scripts need reasoning. They need a shell.
Shell jobs move them to the Minions worker: one deterministic-script execution
per cron, zero LLM tokens, unified visibility and retry.
---
## Security model (read this)
Shell exec is a large blast radius. We ship two independent gates, both must
pass:
1. **MCP boundary.** `submit_job` with `name: 'shell'` is rejected when
`ctx.remote === true` (MCP callers). Independent of the env flag. Remote
agents can never submit shell jobs. `MinionQueue.add('shell', ...)` has its
own guard too, so an in-process handler can't programmatically bypass this.
2. **Env flag.** The worker only registers the shell handler when
`GBRAIN_ALLOW_SHELL_JOBS=1` is set on the worker process. Default: off. Your
agent opts in per-host.
**What the env allowlist does AND does not do.** Shell jobs run with a minimal
env: `PATH, HOME, USER, LANG, TZ, NODE_ENV`. Your secrets like `OPENAI_API_KEY`
and `DATABASE_URL` are NOT passed to the child. You opt-in additional keys per
job via `env: { ... }`. This stops accidental `$OPENAI_API_KEY` interpolation in
a user-authored script. It does **not** sandbox filesystem reads: a shell
script can `cat ~/.env` or any file the worker process can read. The operator
picks a safe `cwd`. That is the trust boundary.
**Audit trail, not forensic insurance.** Every submission writes a JSONL line
to `~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl` (ISO-week rotation; override
with `GBRAIN_AUDIT_DIR`). Failures log to stderr and don't block submission, so
a disk-full adversary could silently disable the trail. Good for "what did
this cron submit last Tuesday", not for security-critical forensics.
**The command text is logged as-is.** If you embed a secret in `cmd`
(`curl -H 'Authorization: Bearer ...'`), it shows up in the audit file. Put
secrets in `env:` instead.
---
## Migrate a cron
### Postgres worker (recommended)
On one terminal, start a persistent worker:
```bash
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work
```
Rewrite crontab to submit shell jobs (no `--follow`):
```cron
# Before (LLM gateway):
# OpenClaw cron: x-garrytan-unified
# After (Minions worker):
3 13,16,19,22,1,4,7,10 * * * \
gbrain jobs submit shell \
--params '{"cmd":"node scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
--max-attempts 3 --timeout-ms 300000
```
Worker claims the job on next poll, runs it, records `exit_code` +
`stdout_tail` + `stderr_tail` in the result. Failures retry per
`--max-attempts` with exponential backoff.
### PGLite (inline execution)
PGLite doesn't support the persistent worker daemon. Every crontab invocation
uses `--follow` to run inline:
```cron
# Each cron tick spawns a short-lived worker that runs the job inline.
3 13,16,19,22,1,4,7,10 * * * \
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--params '{"cmd":"node scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
--follow --timeout-ms 300000
```
Note: `--follow` blocks the crontab slot until the job finishes. If 14 shell
crons land at the same minute and each takes 30s, they serialize through
crontab's spawning limits. Postgres + persistent worker scales better.
### Submitting with `argv` (no shell interpolation)
For programmatic callers assembling commands from JSON, use `argv` instead of
`cmd`. No shell, no injection surface:
```bash
gbrain jobs submit shell \
--params '{"argv":["node","scripts/fetch.mjs","--date","2026-04-19"],"cwd":"/data"}' \
--follow
```
---
## Debug a failed job
```bash
# List dead shell jobs
gbrain jobs list --status dead
# Inspect one
gbrain jobs get 42
# → error_text, stacktrace, result.stdout_tail, result.stderr_tail
# Submission audit log (operator trail, not forensic)
cat ~/.gbrain/audit/shell-jobs-*.jsonl | jq '.'
# First-time failure mode: submitted without env flag on the worker
gbrain jobs list --status waiting --name shell
# If rows pile up here, no worker with GBRAIN_ALLOW_SHELL_JOBS=1 is running.
```
---
## Limitations
- **Filesystem reads are not sandboxed.** See "Security model" above. Don't
point `cwd` at a directory full of secrets.
- **Audit log is advisory.** Disk-full or EACCES silently disables it.
- **Cancel latency is lock-renewal-bounded** (~7-15 s by default). A cancelled
child keeps running until the next lock-renewal tick fails.
- **`--follow` claim order** is by priority/created_at. If another job is
waiting in the same queue at the time of `--follow`, that one runs first.
- **`cwd` symlink TOCTOU.** The absolute-path check doesn't guard against
symlinks pointing elsewhere at execution time. Operator-scope concern.
---
## Errors {#errors}
| Error | What it means | Fix |
|---|---|---|
| `shell: specify exactly one of cmd or argv` | `cmd` and `argv` are mutually exclusive. Both absent is also invalid. | Choose one. `cmd` for shell-interpolated strings; `argv` for structured args. |
| `shell: cwd is required and must be an absolute path` | `cwd` must be a string starting with `/`. | Set `cwd` in `--params` to an absolute path. |
| `shell: argv must be an array of strings` | `argv` has a non-string entry or isn't an array. | Pass `argv: ["bin","arg1","arg2"]`. |
| `shell: env values must all be strings` | `env` has a number/bool/object value. | Stringify: `"env":{"COUNT":"3"}` not `"env":{"COUNT":3}`. |
| `permission_denied: shell jobs cannot be submitted over MCP` | An MCP client tried to submit a shell job. By design CLI-only. | Submit from CLI or via a trusted operation handler (`ctx.remote === false`). |
| `protected job name 'shell' requires CLI or operation-local submitter` | A caller invoked `MinionQueue.add('shell', ...)` without the `trusted` opt-in. | Pass `{ allowProtectedSubmit: true }` as the 4th arg. CLI and `submit_job` do this automatically. |
| `aborted: timeout` / `aborted: cancel` / `aborted: shutdown` / `aborted: lock-lost` | The worker's abort signal fired mid-execution. Child got SIGTERM, 5s grace, then SIGKILL. | Expected: timeout / user cancel / deploy restart / stall. Inspect `gbrain jobs get` to see which. |
| `exit N: <stderr_tail_500>` | Script exited non-zero. | Read `stderr_tail` in `gbrain jobs get`. |
-191
View File
@@ -1,191 +0,0 @@
# Progress events
Canonical reference for the JSONL progress stream that `gbrain` writes to
`stderr` when a bulk command runs with `--progress-json`. Stable from
v0.15.2. Additive changes only; no renames or removals without a major
version bump.
Most humans won't read this page. Agents parsing progress will.
## When do I get these events?
Any of these commands stream events when `--progress-json` is set:
- `gbrain doctor` (DB checks, JSONB integrity, markdown body completeness,
integrity sample)
- `gbrain orphans`
- `gbrain embed`
- `gbrain files sync`
- `gbrain export`
- `gbrain extract [links|timeline|all]` (fs or db source)
- `gbrain import`
- `gbrain sync`
- `gbrain migrate --to …`
- `gbrain repair-jsonb`
- `gbrain check-backlinks`
- `gbrain lint`
- `gbrain integrity auto`
- `gbrain eval`
- `gbrain apply-migrations` (the orchestrator + every child command)
Non-bulk commands (`stats`, `graph-query`, `get`, `put`, etc.) don't emit
events — they return in under a second.
## Channel
- Progress events: **`stderr`**, one JSON object per line, `\n`-terminated.
- Data results (`--json` payloads from each command): **`stdout`**.
- Final human summaries: **`stdout`**.
Agents can safely capture stdout for their result parsing and read stderr
separately for progress.
## Flags
| Flag | Behavior |
|---|---|
| *(none)* | Auto. TTY: `\r`-rewriting single line. Non-TTY: plain line-per-event on stderr. |
| `--progress-json` | Force JSON-lines mode on stderr (this doc). |
| `--quiet` | Suppress progress entirely. Warnings and final output still print. |
| `--progress-interval=<ms>` | Override the minimum interval between tick emits (default 1000). |
Global flags: parsed by `src/core/cli-options.ts` before command dispatch,
so `gbrain --progress-json doctor` works the same as
`gbrain doctor --progress-json` (the latter also works — per-command
parsers see the flag via the shared `CliOptions` singleton).
## Event types
Every event is a single-line JSON object with these common fields:
| Field | Type | Notes |
|---|---|---|
| `event` | string | One of: `start`, `tick`, `heartbeat`, `finish`, `abort`. |
| `phase` | string | Machine-stable snake_case, dot-separated. See "Phase names" below. |
| `ts` | ISO 8601 UTC string | Event emission time. |
| `elapsed_ms` | number | Ms since the phase started. Present on `tick`/`heartbeat`/`finish`/`abort`. |
### `start`
Emitted when a phase begins.
```json
{"event":"start","phase":"doctor.db_checks","ts":"2026-04-20T12:34:56.789Z"}
{"event":"start","phase":"import.files","total":52000,"ts":"2026-04-20T12:34:56.789Z"}
```
Optional fields:
- `total` — the total item count if known at start.
### `tick`
Emitted periodically during iteration. Time- and item-gated: the reporter
won't emit more often than `minIntervalMs` (default 1000) and
`minItems` (default `max(10, ceil(total/100))`).
```json
{"event":"tick","phase":"orphans.scan","done":15000,"total":52000,"pct":28.8,"elapsed_ms":4200,"eta_ms":10300,"ts":"..."}
```
Fields:
- `done` — items completed in this phase.
- `total` — total items, if known. Omitted when the scan doesn't have a
total up front (e.g. a streaming iterator).
- `pct``done/total * 100`, one decimal. Omitted when `total` is unknown.
- `eta_ms` — projected ms until `done === total`, from the observed rate.
Omitted when `total` is unknown.
- `note` — optional string with the current item (e.g. a slug or filename).
### `heartbeat`
Emitted for long-running single operations that don't iterate
(e.g. `SELECT` against a 50K-row table). No `done`, no `total` — just a
signal that work is still happening.
```json
{"event":"heartbeat","phase":"doctor.markdown_body_completeness","note":"scanning pages for truncation…","elapsed_ms":1000,"ts":"..."}
```
### `finish`
Emitted when a phase completes normally.
```json
{"event":"finish","phase":"import.files","done":52000,"total":52000,"elapsed_ms":187000,"ts":"..."}
```
### `abort`
Emitted by a single process-level SIGINT/SIGTERM handler that tracks every
live phase. After `abort`, no further events emit for that phase.
```json
{"event":"abort","phase":"doctor.markdown_body_completeness","reason":"SIGINT","elapsed_ms":5300,"ts":"..."}
```
## Phase names
Phases use `snake_case.dot.path` naming. A fresh reporter starts at the
root; `child()` composition appends to the parent's current phase, so a
sync that calls import emits `sync.import.<file>`, not `import.<file>`.
Stable phase names shipped in v0.15.2:
- `doctor.db_checks` (umbrella for all DB-side doctor checks)
- `orphans.scan`
- `embed.pages`
- `extract.links_fs`, `extract.timeline_fs`, `extract.links_db`, `extract.timeline_db`
- `import.files`
- `sync.deletes`, `sync.renames`, `sync.imports`
- `migrate.copy_pages`, `migrate.copy_links`
- `repair_jsonb.run`, `repair_jsonb.<table>.<column>`
- `backlinks.scan`
- `lint.pages`
- `integrity.auto`
- `eval.single`, `eval.ab`
- `export.pages`
- `files.sync`
Sub-phases exposed via `child()`:
- `sync.import.files` — nested inside a sync
- `apply_migrations.v0_12_2.jsonb_repair` — nested inside the orchestrator
## Subprocess inheritance
When a parent CLI spawns `gbrain …` child processes (mostly in
`src/commands/migrations/*`), global flags (`--quiet`, `--progress-json`,
`--progress-interval`) are propagated to the child's argv via the
`childGlobalFlags()` helper in `src/core/cli-options.ts`. Child stderr
passes straight through `stdio: 'inherit'` so the event stream is one
merged JSONL feed on the parent's stderr.
One exception: the orchestrator phase in `migrations/v0_12_2.ts` that
captures child stdout (`repair-jsonb --dry-run --json` for verification)
does not pass `--progress-json` to avoid any risk of stdout pollution
breaking the orchestrator's `JSON.parse`. Its stdio is explicit:
`['ignore', 'pipe', 'inherit']` so stderr still flows through.
## Minion jobs
`gbrain jobs work` (the Minion worker daemon) keeps progress in the DB,
not on stderr. Each Minion handler that runs a bulk core (embed, sync,
extract, import, backlinks) calls `job.updateProgress({done, total,
…})` per iteration. Agents read per-job progress via the
`get_job_progress` MCP operation or `gbrain jobs get <id>`.
The `jobs work` daemon itself emits coarse one-line-per-job stderr output
for liveness only. Per-page detail lives in the DB.
## Compatibility
- **Added**: only. A new event type, a new field, a new phase name — all
safe. Agents must ignore unknown fields and unknown event types.
- **Removed/renamed**: never without a major version bump.
- **Schema changes**: announced in `CHANGELOG.md` and in
`skills/migrations/v<next>.md`.
If your agent depends on this schema and something surprises you, open
an issue with the event you received and what you expected.
-4459
View File
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
# GBrain
> GBrain is a personal knowledge brain and GStack mod for agent platforms. Pluggable engines (PGLite default, Postgres+pgvector for scale), contract-first operations, 26 fat-markdown skills. Teaches agents brain ops, ingestion, enrichment, scheduling, identity, and access control.
Repo: https://github.com/garrytan/gbrain
## Core entry points
- [AGENTS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md): Start here if you are not Claude Code. Install order, trust boundary, skill resolver, config/debug/migration pointers.
- [CLAUDE.md](https://raw.githubusercontent.com/garrytan/gbrain/master/CLAUDE.md): Architecture reference. Key files, trust boundaries, engine factory, test layout.
- [INSTALL_FOR_AGENTS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md): 9-step agent installation.
- [skills/RESOLVER.md](https://raw.githubusercontent.com/garrytan/gbrain/master/skills/RESOLVER.md): Skill dispatcher. Read first for any task.
- [README.md](https://raw.githubusercontent.com/garrytan/gbrain/master/README.md): Project overview, benchmarks, 30-minute setup.
## Configuration
- [docs/ENGINES.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ENGINES.md): PGLite vs Postgres trade-off and when to migrate.
- [docs/GBRAIN_RECOMMENDED_SCHEMA.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_RECOMMENDED_SCHEMA.md): MECE directory structure (people/, companies/, concepts/).
- [docs/guides/live-sync.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/live-sync.md): Incremental markdown sync setup.
- [docs/guides/cron-schedule.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/cron-schedule.md): Recurring job scheduling.
- [docs/guides/quiet-hours.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/quiet-hours.md): Notification hold + timezone-aware delivery.
- [docs/mcp/DEPLOY.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/mcp/DEPLOY.md): MCP server deployment.
## Debugging
- [docs/GBRAIN_VERIFY.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_VERIFY.md): 7-check post-setup verification. Start here when something feels off.
- [docs/guides/minions-fix.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/minions-fix.md): Troubleshooting the Minions job queue.
- [docs/integrations/reliability-repair.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/integrations/reliability-repair.md): Data integrity recovery.
## Migrations
- [docs/UPGRADING_DOWNSTREAM_AGENTS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/UPGRADING_DOWNSTREAM_AGENTS.md): Patches for downstream agent skill forks. One section per release.
- [skills/migrations/](https://raw.githubusercontent.com/garrytan/gbrain/master/skills/migrations/): Per-version (v0.5.0 - v0.14.1) agent-executable migration instructions.
- [CHANGELOG.md](https://raw.githubusercontent.com/garrytan/gbrain/master/CHANGELOG.md): Release-summary voice + itemized changes + self-repair block per version.
## Philosophy
- [docs/ethos/THIN_HARNESS_FAT_SKILLS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ethos/THIN_HARNESS_FAT_SKILLS.md): Why skills live in markdown.
- [docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md): Homebrew for Personal AI.
## Optional
- [docs/benchmarks/](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/benchmarks/): Retrieval quality benchmarks.
- [docs/designs/](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/designs/): Forward-looking designs.
- [docs/architecture/infra-layer.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/architecture/infra-layer.md): Shared infra patterns.
## Operational tips
- `gbrain doctor [--json] [--fast] [--fix]` - built-in health checks.
- `gbrain orphans [--json]` - pages with zero inbound wikilinks.
- `gbrain repair-jsonb [--dry-run]` - repair v0.12.0 double-encoded JSONB rows.
- `gbrain upgrade` runs post-upgrade + apply-migrations.
+5 -10
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.15.4",
"version": "0.13.1",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
@@ -20,12 +20,10 @@
"build": "bun build --compile --outfile bin/gbrain src/cli.ts",
"build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts",
"build:schema": "bash scripts/build-schema.sh",
"build:llms": "bun run scripts/build-llms.ts",
"test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && bun test",
"test:e2e": "bash scripts/run-e2e.sh",
"test": "scripts/check-jsonb-pattern.sh && bun test",
"test:e2e": "bun test test/e2e/",
"check:jsonb": "scripts/check-jsonb-pattern.sh",
"check:progress": "scripts/check-progress-to-stdout.sh",
"postinstall": "command -v gbrain >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive || echo '[gbrain] postinstall skipped. If installed via bun install -g github:...: run `gbrain doctor` and `gbrain apply-migrations --yes` manually. See https://github.com/garrytan/gbrain/issues/218' 1>&2",
"postinstall": "gbrain --version >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive 2>/dev/null || true",
"prepublish:clawhub": "bun run build:all",
"publish:clawhub": "clawhub package publish . --family bundle-plugin"
},
@@ -37,7 +35,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@electric-sql/pglite": "0.4.3",
"@electric-sql/pglite": "^0.4.4",
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"marked": "^18.0.0",
@@ -48,8 +46,5 @@
"devDependencies": {
"@types/bun": "latest"
},
"trustedDependencies": [
"@electric-sql/pglite"
],
"license": "MIT"
}
-193
View File
@@ -1,193 +0,0 @@
#!/usr/bin/env bun
/**
* build-llms — generate llms.txt + llms-full.txt from scripts/llms-config.ts.
*
* Run: `bun run build:llms` (or `bun run scripts/build-llms.ts`).
*
* Outputs:
* - llms.txt — llmstxt.org-spec index (H1 / blockquote / H2 sections).
* - llms-full.txt — concatenated full content of non-optional entries.
*
* Deterministic: no timestamps, sorted within categories by config order.
* Warns (does not fail) if llms-full.txt exceeds FULL_SIZE_BUDGET. CI catches
* drift via test/build-llms.test.ts.
*
* Fork override: set LLMS_REPO_BASE to regenerate with a different URL base.
*/
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
FULL_SIZE_BUDGET,
INLINE_TIPS,
PROJECT,
SECTIONS,
type DocEntry,
type DocSection,
} from "./llms-config";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function urlFor(entry: DocEntry): string {
return `${PROJECT.rawBaseUrl}/${entry.path}`;
}
function isDirectoryPath(path: string): boolean {
return path.endsWith("/");
}
function renderLlmsTxt(): string {
const lines: string[] = [];
lines.push(`# ${PROJECT.name}`);
lines.push("");
lines.push(`> ${PROJECT.summary}`);
lines.push("");
lines.push(`Repo: ${PROJECT.repoUrl}`);
lines.push("");
for (const section of SECTIONS) {
lines.push(`## ${section.heading}`);
lines.push("");
for (const entry of section.entries) {
lines.push(
`- [${entry.title}](${urlFor(entry)}): ${entry.description}`,
);
}
lines.push("");
}
lines.push("## Operational tips");
lines.push("");
for (const tip of INLINE_TIPS) {
lines.push(`- ${tip}`);
}
lines.push("");
return lines.join("\n");
}
function renderLlmsFullTxt(): { content: string; sizes: Array<{ path: string; bytes: number }> } {
const lines: string[] = [];
const sizes: Array<{ path: string; bytes: number }> = [];
lines.push(`# ${PROJECT.name} — Full Context`);
lines.push("");
lines.push(`> ${PROJECT.summary}`);
lines.push("");
lines.push(
`This file concatenates core GBrain documentation for single-fetch ingestion.`,
);
lines.push(
`For the link-only index, see \`llms.txt\`. Source of truth: ${PROJECT.repoUrl}.`,
);
lines.push("");
for (const section of SECTIONS) {
if (section.optional) continue;
lines.push(`# ${section.heading}`);
lines.push("");
for (const entry of section.entries) {
if (entry.includeInFull === false) continue;
if (isDirectoryPath(entry.path)) continue;
const absPath = join(repoRoot, entry.path);
if (!existsSync(absPath)) {
// build-llms won't silently skip — surface the problem. Test case 1
// catches this too, but fail fast for manual runs.
throw new Error(
`llms-config references missing file: ${entry.path}`,
);
}
const body = readFileSync(absPath, "utf8");
const bytes = Buffer.byteLength(body, "utf8");
sizes.push({ path: entry.path, bytes });
lines.push(`## ${entry.path}`);
lines.push("");
lines.push(`Source: ${urlFor(entry)}`);
lines.push("");
lines.push(body.trimEnd());
lines.push("");
lines.push("---");
lines.push("");
}
}
return { content: lines.join("\n"), sizes };
}
function validateConfig(): void {
for (const section of SECTIONS) {
for (const entry of section.entries) {
const absPath = join(repoRoot, entry.path);
if (!existsSync(absPath)) {
throw new Error(
`llms-config references missing path: ${entry.path}`,
);
}
const st = statSync(absPath);
if (isDirectoryPath(entry.path) && !st.isDirectory()) {
throw new Error(
`llms-config path ends with '/' but is a file: ${entry.path}`,
);
}
if (!isDirectoryPath(entry.path) && !st.isFile()) {
throw new Error(
`llms-config path is a directory but missing trailing '/': ${entry.path}`,
);
}
}
}
}
export function buildLlmsFiles(): {
llmsTxt: string;
llmsFullTxt: string;
sizes: Array<{ path: string; bytes: number }>;
} {
validateConfig();
const llmsTxt = renderLlmsTxt();
const { content: llmsFullTxt, sizes } = renderLlmsFullTxt();
return { llmsTxt, llmsFullTxt, sizes };
}
function main(): void {
const { llmsTxt, llmsFullTxt, sizes } = buildLlmsFiles();
const llmsPath = join(repoRoot, "llms.txt");
const llmsFullPath = join(repoRoot, "llms-full.txt");
writeFileSync(llmsPath, llmsTxt);
writeFileSync(llmsFullPath, llmsFullTxt);
const fullBytes = Buffer.byteLength(llmsFullTxt, "utf8");
console.log(`wrote ${llmsPath} (${Buffer.byteLength(llmsTxt, "utf8")} bytes)`);
console.log(`wrote ${llmsFullPath} (${fullBytes} bytes)`);
if (fullBytes > FULL_SIZE_BUDGET) {
console.warn("");
console.warn(
`WARN: llms-full.txt (${fullBytes} bytes) exceeds FULL_SIZE_BUDGET (${FULL_SIZE_BUDGET} bytes).`,
);
console.warn(
"Add `includeInFull: false` to the biggest entries in scripts/llms-config.ts:",
);
const sorted = [...sizes].sort((a, b) => b.bytes - a.bytes);
for (const entry of sorted.slice(0, 5)) {
console.warn(` ${entry.bytes} bytes ${entry.path}`);
}
}
}
const isMainModule = fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
main();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}
-14
View File
@@ -30,17 +30,3 @@ if grep -rEn "$PATTERN" src/ 2>/dev/null; then
fi
echo "OK: no JSON.stringify(x)::jsonb interpolation pattern in src/"
# v0.13.1 #219: guard against max_stalled DEFAULT 1 regressing in any schema
# source file. DEFAULT 1 dead-lettered any SIGKILL'd job on first stall, making
# the "10/10 rescued" claim false for out-of-the-box users. Default is 5 now.
MAX_STALLED_PATTERN='max_stalled\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+1\b'
if grep -rEn "$MAX_STALLED_PATTERN" src/schema.sql src/core/migrate.ts src/core/pglite-schema.ts src/core/schema-embedded.ts 2>/dev/null; then
echo
echo "ERROR: max_stalled DEFAULT 1 reintroduced in schema."
echo " Must be DEFAULT 5 to preserve SIGKILL-rescue guarantee. See #219."
exit 1
fi
echo "OK: max_stalled defaults are 5 in all schema sources"
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# CI guard: fail if any new code emits \r-progress to stdout.
#
# Since v0.14.2, bulk-action progress lives on stderr via the shared
# src/core/progress.ts reporter. \r-rewriting on stdout breaks every
# piped-output scenario: agents that capture stdout for structured
# results see progress garbage mixed with the data, and CI logs show
# a single line per command because everything after the last \r
# is truncated by the terminal emulator when played back.
#
# This script greps for the anti-pattern. Legitimate uses of \r inside
# string literals (e.g. Windows line-ending normalization, regex
# patterns) are expected to contain \r without being preceded by
# `process.stdout.write`. We match the full write-call form only.
#
# Usage: scripts/check-progress-to-stdout.sh
# Exit: 0 when clean, 1 when a banned pattern is found.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT"
# The banned pattern: process.stdout.write('\r... or process.stdout.write("\r...
# Greedy quote character class so both quote styles match.
PATTERN="process\.stdout\.write\([\`'\"]\\\\r"
# Files allowed to use this pattern historically. Empty allowlist — the point
# of v0.14.2 was to remove every one of them. Add entries only if you really
# need a \r on stdout (if so, add the rationale as a comment at the call site
# and list the file here).
ALLOWLIST=()
matches=""
if command -v rg >/dev/null 2>&1; then
matches="$(rg -n --no-heading "$PATTERN" src/ 2>/dev/null || true)"
else
matches="$(grep -rEn "$PATTERN" src/ 2>/dev/null || true)"
fi
if [ -n "$matches" ]; then
# Filter out allowlisted files.
filtered="$matches"
for f in "${ALLOWLIST[@]:-}"; do
[ -z "$f" ] && continue
filtered="$(echo "$filtered" | grep -v "^${f}:" || true)"
done
if [ -n "$filtered" ]; then
echo "ERROR: found process.stdout.write('\\r…') pattern(s) in src/:"
echo
echo "$filtered"
echo
echo "Bulk-action progress must go through src/core/progress.ts"
echo "(writes to stderr, handles TTY vs non-TTY, honors --quiet /"
echo " --progress-json / --progress-interval). If you genuinely"
echo "need a \\r on stdout, add the file to the ALLOWLIST at the"
echo "top of this script and explain why at the call site."
exit 1
fi
fi
echo "check-progress-to-stdout: OK (no banned stdout \\r patterns)"
-205
View File
@@ -1,205 +0,0 @@
/**
* llms-config — single source of truth for llms.txt + llms-full.txt.
*
* Consumed by scripts/build-llms.ts (emits llms.txt, llms-full.txt) and
* test/build-llms.test.ts (asserts paths resolve, content contract holds).
*
* Adding a doc? Add it here and run `bun run build:llms`. The drift-detection
* test fails CI if you forget.
*
* Fork-friendliness: `rawBaseUrl` reads from `LLMS_REPO_BASE` so forks can
* regenerate without manual URL rewrites:
* LLMS_REPO_BASE=https://raw.githubusercontent.com/fork-org/gbrain/main bun run build:llms
*/
export type DocEntry = {
title: string;
description: string;
path: string;
includeInFull?: boolean;
};
export type DocSection = {
heading: string;
optional?: boolean;
entries: DocEntry[];
};
export const PROJECT = {
name: "GBrain",
summary:
"GBrain is a personal knowledge brain and GStack mod for agent platforms. Pluggable engines (PGLite default, Postgres+pgvector for scale), contract-first operations, 26 fat-markdown skills. Teaches agents brain ops, ingestion, enrichment, scheduling, identity, and access control.",
repoUrl: "https://github.com/garrytan/gbrain",
rawBaseUrl:
process.env.LLMS_REPO_BASE ??
"https://raw.githubusercontent.com/garrytan/gbrain/master",
};
export const SECTIONS: DocSection[] = [
{
heading: "Core entry points",
entries: [
{
title: "AGENTS.md",
description:
"Start here if you are not Claude Code. Install order, trust boundary, skill resolver, config/debug/migration pointers.",
path: "AGENTS.md",
},
{
title: "CLAUDE.md",
description:
"Architecture reference. Key files, trust boundaries, engine factory, test layout.",
path: "CLAUDE.md",
},
{
title: "INSTALL_FOR_AGENTS.md",
description: "9-step agent installation.",
path: "INSTALL_FOR_AGENTS.md",
},
{
title: "skills/RESOLVER.md",
description: "Skill dispatcher. Read first for any task.",
path: "skills/RESOLVER.md",
},
{
title: "README.md",
description: "Project overview, benchmarks, 30-minute setup.",
path: "README.md",
},
],
},
{
heading: "Configuration",
entries: [
{
title: "docs/ENGINES.md",
description: "PGLite vs Postgres trade-off and when to migrate.",
path: "docs/ENGINES.md",
},
{
title: "docs/GBRAIN_RECOMMENDED_SCHEMA.md",
description:
"MECE directory structure (people/, companies/, concepts/).",
path: "docs/GBRAIN_RECOMMENDED_SCHEMA.md",
},
{
title: "docs/guides/live-sync.md",
description: "Incremental markdown sync setup.",
path: "docs/guides/live-sync.md",
},
{
title: "docs/guides/cron-schedule.md",
description: "Recurring job scheduling.",
path: "docs/guides/cron-schedule.md",
},
{
title: "docs/guides/quiet-hours.md",
description: "Notification hold + timezone-aware delivery.",
path: "docs/guides/quiet-hours.md",
},
{
title: "docs/mcp/DEPLOY.md",
description: "MCP server deployment.",
path: "docs/mcp/DEPLOY.md",
},
],
},
{
heading: "Debugging",
entries: [
{
title: "docs/GBRAIN_VERIFY.md",
description:
"7-check post-setup verification. Start here when something feels off.",
path: "docs/GBRAIN_VERIFY.md",
},
{
title: "docs/guides/minions-fix.md",
description: "Troubleshooting the Minions job queue.",
path: "docs/guides/minions-fix.md",
},
{
title: "docs/integrations/reliability-repair.md",
description: "Data integrity recovery.",
path: "docs/integrations/reliability-repair.md",
},
],
},
{
heading: "Migrations",
entries: [
{
title: "docs/UPGRADING_DOWNSTREAM_AGENTS.md",
description:
"Patches for downstream agent skill forks. One section per release.",
path: "docs/UPGRADING_DOWNSTREAM_AGENTS.md",
},
{
title: "skills/migrations/",
description:
"Per-version (v0.5.0 - v0.14.1) agent-executable migration instructions.",
path: "skills/migrations/",
},
{
title: "CHANGELOG.md",
description:
"Release-summary voice + itemized changes + self-repair block per version.",
path: "CHANGELOG.md",
includeInFull: false,
},
],
},
{
heading: "Philosophy",
optional: true,
entries: [
{
title: "docs/ethos/THIN_HARNESS_FAT_SKILLS.md",
description: "Why skills live in markdown.",
path: "docs/ethos/THIN_HARNESS_FAT_SKILLS.md",
includeInFull: false,
},
{
title: "docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md",
description: "Homebrew for Personal AI.",
path: "docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md",
includeInFull: false,
},
],
},
{
heading: "Optional",
optional: true,
entries: [
{
title: "docs/benchmarks/",
description: "Retrieval quality benchmarks.",
path: "docs/benchmarks/",
includeInFull: false,
},
{
title: "docs/designs/",
description: "Forward-looking designs.",
path: "docs/designs/",
includeInFull: false,
},
{
title: "docs/architecture/infra-layer.md",
description: "Shared infra patterns.",
path: "docs/architecture/infra-layer.md",
includeInFull: false,
},
],
},
];
export const INLINE_TIPS = [
"`gbrain doctor [--json] [--fast] [--fix]` - built-in health checks.",
"`gbrain orphans [--json]` - pages with zero inbound wikilinks.",
"`gbrain repair-jsonb [--dry-run]` - repair v0.12.0 double-encoded JSONB rows.",
"`gbrain upgrade` runs post-upgrade + apply-migrations.",
];
// Target ~600KB so llms-full.txt fits in ~150k-token contexts with room to spare.
// Generator prints a WARN if exceeded; ship with includeInFull=false exclusions.
export const FULL_SIZE_BUDGET = 600_000;
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# Run E2E tests ONE FILE AT A TIME.
#
# Bun's default is to run test files in parallel (each in its own worker).
# Our E2E suite shares one Postgres database across all 13 files, and
# `setupDB()` does TRUNCATE CASCADE + fixture import. When files run in
# parallel, file A's TRUNCATE can race with file B's fixture import,
# producing observed fails like "expected 16 pages, got 8", missing
# links, orphaned timeline entries, etc. The flakiness was visible on
# ~3 of every 5 runs pre-fix.
#
# Running files sequentially eliminates the race entirely. It also costs
# some startup overhead (each file spins up a fresh bun process) but for
# a suite this size that is measured in ~1-2s per file, amortized under
# the natural per-file test time of 5-10s.
#
# Exits non-zero on the first failing file so CI fails fast.
set -euo pipefail
cd "$(dirname "$0")/.."
pass_files=0
fail_files=0
fail_list=()
total_pass=0
total_fail=0
for f in test/e2e/*.test.ts; do
name=$(basename "$f")
echo ""
echo "=== $name ==="
if output=$(bun test "$f" 2>&1); then
pass_files=$((pass_files + 1))
# Extract pass/fail counts from bun's summary (e.g., "123 pass")
p=$(echo "$output" | grep -oE '[0-9]+ pass' | tail -1 | grep -oE '[0-9]+' || echo 0)
total_pass=$((total_pass + p))
echo "$output" | tail -8
else
fail_files=$((fail_files + 1))
fail_list+=("$name")
p=$(echo "$output" | grep -oE '[0-9]+ pass' | tail -1 | grep -oE '[0-9]+' || echo 0)
fl=$(echo "$output" | grep -oE '[0-9]+ fail' | tail -1 | grep -oE '[0-9]+' || echo 0)
total_pass=$((total_pass + p))
total_fail=$((total_fail + fl))
echo "$output"
echo ""
echo "FAILED: $name"
# Continue so we see all failures; exit nonzero at the end.
fi
done
echo ""
echo "========================================"
echo "E2E SUMMARY (sequential execution)"
echo "========================================"
echo "Files: $((pass_files + fail_files)) total, $pass_files passed, $fail_files failed"
echo "Tests: $total_pass passed, $total_fail failed"
if [ ${#fail_list[@]} -gt 0 ]; then
echo ""
echo "Failing files:"
for f in "${fail_list[@]}"; do
echo " - $f"
done
exit 1
fi
-185
View File
@@ -1,185 +0,0 @@
---
version: 0.14.0
feature_pitch:
headline: "Move deterministic crons off the LLM gateway. Zero tokens per fire."
description: |
OpenClaw operators: your gateway pins at 100% CPU because most cron jobs
boot a full Opus session to do deterministic work (API fetch, token
refresh, scrape + markdown write) that doesn't need reasoning. v0.14.0
adds a `shell` job type to Minions so those jobs run as child processes
under the existing Minions worker. ~60% gateway CPU reduction at typical
scale. Retry, backoff, DLQ, unified `gbrain jobs list` visibility, all
free. The LLM-reasoning crons stay on the gateway where they belong.
recipe: docs/guides/minions-shell-jobs.md
tiers: null
---
# v0.14.0 Migration: Adopt the `shell` job type
**Audience: host agents (OpenClaw deployments, Hermes operators, future
hosts) reading this AFTER `gbrain apply-migrations` has run. There is no
mechanical orchestrator for this release — every change described here
is host-specific and requires operator judgment per cron.** Shell jobs
are off by default on upgrade (nothing breaks). This file walks the
host agent through enabling and adopting them.
## Iron rules
1. **Never auto-rewrite the operator's crontab.** Crontab entries are
host-specific code per the CLAUDE.md "host-specific code" exception.
Every rewrite is an explicit human approval, per cron, with a diff.
2. **LLM-requiring crons stay on the gateway.** If the cron's work
includes reasoning (sentiment classification, triage, synthesis,
prose generation), it belongs on the gateway. Moving it to a shell
job throws away the LLM session the cron needs.
3. **Deterministic crons are the only candidates.** API fetch, token
refresh, database read + markdown write, HTTP refresh call, scrape.
If the script is `node scripts/x.mjs` or `curl ... | jq` or similar
and does zero LLM work, it's a candidate.
## Step 1: Enable the worker
Pick the engine the operator is on:
**Postgres** (most OpenClaw/Hermes deployments):
```bash
# In the worker bootstrap, export the env flag and run the daemon:
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work
```
The worker claims shell jobs from the queue and executes them. Retries,
backoff, and dead-letter all work the same as sync/embed jobs.
**PGLite**: no persistent worker, per-tick inline execution only:
```bash
# Every crontab invocation must use --follow; PGLite's worker daemon
# exits immediately due to exclusive file lock.
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--params '{"cmd":"...","cwd":"..."}' --follow
```
## Step 2: Audit the operator's cron manifest
Read the operator's cron manifest. Typical locations:
- `~/.claude/cron/jobs.json` (OpenClaw)
- `scripts/service-manager.sh` in the host repo
- System crontab (`crontab -l`)
For each entry, classify:
| Pattern | Class | Action |
|---------|-------|--------|
| `agentTurn <skill>` or any OpenClaw-dispatched LLM skill | LLM-requiring | **Leave as-is.** Needs gateway. |
| `node scripts/*.mjs` that hits an API and writes markdown | Deterministic | Propose shell-job rewrite. |
| Token refresh (`ycli token-refresh`, `x-oauth2-refresh`) | Deterministic | Propose shell-job rewrite. |
| Scrape + write (`frameio-scan`, `flight-tracker`) | Deterministic | Propose shell-job rewrite. |
| Audio transcription or any LLM-dependent extract | LLM-requiring | Leave as-is. |
| `bash` wrapper scripts that may call LLM tools internally | Ambiguous | Ask the operator. Don't assume. |
## Step 3: Propose rewrites per cron
For each deterministic cron, propose the exact rewrite with a diff. Show
the operator both sides. Let them approve per-cron, not in bulk.
**Before** (LLM gateway):
```
OpenClaw cron: x-garrytan-unified, 3 13,16,19,22,1,4,7,10 * * *
→ runs agentTurn x-garrytan-unified
→ boots Opus context, invokes script, returns
```
**After** (Minions worker):
```cron
3 13,16,19,22,1,4,7,10 * * * \
gbrain jobs submit shell \
--params '{"cmd":"node /data/.openclaw/workspace/scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
--max-attempts 3 --timeout-ms 300000
```
Rewrite rules:
- `cwd` is required and must be an absolute path. Operator picks it. It
should be the directory the script expects to run in (the host repo
root, typically).
- `--max-attempts 3` matches the default Minions retry policy. Override
if the script is non-idempotent and should only run once per fire.
- `--timeout-ms N` caps the child's wall-clock runtime. Set to the 95th
percentile of the script's observed runtime, plus slack. Examples:
token refresh → 30s; API fetch → 300s; scrape → 600s.
- **PGLite operators:** add `--follow` to every line. Skip Step 1.
## Step 4: Secrets that the script needs
Shell jobs receive a minimal env allowlist by default: `PATH, HOME,
USER, LANG, TZ, NODE_ENV`. They do NOT inherit `OPENAI_API_KEY`,
`ANTHROPIC_API_KEY`, `DATABASE_URL`, or any other worker env vars.
If a cron's script needs an API key, name it explicitly:
```bash
gbrain jobs submit shell \
--params '{"cmd":"node scripts/yc-sync.mjs","cwd":"/data/.openclaw/workspace","env":{"YC_API_TOKEN":"'"$YC_API_TOKEN"'"}}'
```
The shell expands `$YC_API_TOKEN` at submit time. The worker receives
the JSON with the literal token value. Audit log does not log env
values (keys don't carry sensitive data; values never appear).
## Step 5: Verify the first migrated cron
After rewriting ONE cron with the operator's approval:
1. Wait for the next scheduled fire (or trigger manually: `gbrain jobs
submit shell --params '...' --follow`).
2. Check `gbrain jobs list --status completed --name shell --limit 5`
for the result.
3. `gbrain jobs get <id>` shows `exit_code`, `stdout_tail`, `stderr_tail`,
`duration_ms`.
4. Compare against the pre-migration behavior: did it do the same work?
Same output files changed? Same side effects?
Only after one cron is verified working end-to-end should the operator
approve the next batch.
## Step 6: Starvation sanity check
If the operator submits shell jobs but forgot to set
`GBRAIN_ALLOW_SHELL_JOBS=1` on the worker, jobs sit in `waiting`
indefinitely. The CLI warns on submission, but for daemon-style
deployments the warning scrolls past. Add this to the operator's
ops-check runbook:
```bash
gbrain jobs list --status waiting --name shell
```
If rows pile up here, either (a) no worker has the env flag set, or
(b) the worker crashed. Fix by restarting with the flag.
## Non-goals (explicitly deferred to later releases)
- **Automatic crontab rewrites.** Deferred to a future `gbrain
crontab-to-minions <file>` helper. P1 in TODOS.md.
- **DB-backed scheduler.** `minion_schedules` table replaces host
crontab entirely. P1 in TODOS.md.
- **Orphaned-shell-job stats.** `gbrain jobs stats --orphaned` would
surface the "no worker with env flag" case. P2 in TODOS.md.
- **Configurable buffer sizes.** Output tails are fixed at 64KB stdout
/ 16KB stderr. P2 in TODOS.md.
## When to stop
The migration is done when:
1. The worker runs with `GBRAIN_ALLOW_SHELL_JOBS=1` (Postgres) or every
cron uses `--follow` (PGLite).
2. Every deterministic cron the operator approved has been rewritten.
3. The operator has verified at least one full cron fire cycle
end-to-end and confirmed the output matches pre-migration.
4. `gbrain jobs stats` shows shell jobs completing at expected rates
with few or zero retries.
Gateway CPU should visibly drop after the first few rewrites. That's
the signal the adoption is working.
-164
View File
@@ -1,164 +0,0 @@
---
version: 0.15.2
feature_pitch:
headline: "Silent binaries are dead. Every bulk action now heartbeats."
description: |
`gbrain doctor` on a 52K-page brain used to sit silent for 10+
minutes before an agent timeout killed it. Same pattern on embed,
sync, import, extract, migrate, and every orchestrator. v0.15.2
routes 14 bulk commands through one shared reporter that writes
to stderr. Non-TTY default is plain human lines; agents that
want structured events add `--progress-json` and get one JSON
object per line. Stdout stays clean for data output. Event
schema is locked in docs/progress-events.md.
recipe: docs/progress-events.md
tiers: null
---
# v0.15.2 Migration: Bulk-action progress streaming
**Audience: host agents reading this after `gbrain apply-migrations`
has run. v0.15.2 is purely additive to the CLI surface, there is no
schema change, no data rewrite, and no orchestrator for this release.**
Your binaries just got observable. This file tells you how to use it.
## Mechanical migration: nothing
There is no mechanical step. If `gbrain upgrade` completed, progress
events are already flowing the next time you invoke a bulk command.
Read on to know what's there and how to consume it.
## What's new at the CLI
### Three new global flags
These work on any `gbrain` subcommand:
- `--progress-json` — emit one JSON event per line on stderr.
- `--quiet` — suppress progress output entirely.
- `--progress-interval=<ms>` — minimum ms between progress emits
(default 1000).
Parsed before command dispatch, so both work:
```
gbrain --progress-json doctor --json
gbrain doctor --json --progress-json
```
### Per-TTY behavior
Without `--progress-json`:
- **TTY:** `\r`-rewriting single-line progress on stderr (fancy).
- **Non-TTY (pipe, CI, agent):** one plain-text line per event on
stderr. No JSON, no noise. Human-readable.
The default was deliberately NOT JSON-on-non-TTY. Shell pipelines
that just pipe `gbrain ... | less` should get readable logs, not a
JSON blob. Agents opt in to JSON explicitly.
## What's new per command
Fourteen commands now stream progress through the shared reporter:
| Command | What you'll see |
|---------|-----------------|
| `doctor` | `doctor.db_checks` phase + per-check heartbeats, including a 1s heartbeat while `markdown_body_completeness` scans |
| `orphans` | `orphans.scan` heartbeat while the anti-join runs |
| `embed` | `embed.pages` with per-page ticks |
| `files sync` | `files.sync` with per-file ticks |
| `export` | `export.pages` with per-page ticks |
| `import` | `import.files` with per-file ticks (replaces per-100 stdout logs) |
| `extract [links|timeline|all]` (fs + db) | `extract.links_fs` / `extract.timeline_db` etc. |
| `sync` | `sync.deletes`, `sync.renames`, `sync.imports` phases |
| `migrate --to ...` | `migrate.copy_pages`, `migrate.copy_links` |
| `repair-jsonb` | `repair_jsonb.run` + per-column heartbeats |
| `check-backlinks` | `backlinks.scan` heartbeat |
| `lint` | `lint.pages` per-page ticks |
| `integrity auto` | `integrity.auto` per-page ticks |
| `eval` | `eval.single` / `eval.ab` per-query ticks |
| `apply-migrations` (v0_11/v0_12_0/v0_12_2) | Child processes inherit the parent's progress mode |
## JSON event schema
Documented in `docs/progress-events.md` (canonical reference). Stable
from v0.15.2, additive changes only.
Quick agent cheat sheet:
```json
{"event":"start","phase":"doctor.db_checks","ts":"..."}
{"event":"tick","phase":"orphans.scan","done":15000,"total":52000,"pct":28.8,"elapsed_ms":4200,"eta_ms":10300,"ts":"..."}
{"event":"heartbeat","phase":"doctor.markdown_body_completeness","note":"scanning pages for truncation...","elapsed_ms":1000,"ts":"..."}
{"event":"finish","phase":"doctor.db_checks","elapsed_ms":187000,"ts":"..."}
{"event":"abort","phase":"orphans.scan","reason":"SIGINT","elapsed_ms":5300,"ts":"..."}
```
Parser rules:
1. One JSON object per line on stderr.
2. Ignore unknown event types and unknown fields. Schema is additive.
3. Group by `phase` prefix to track one run: all `doctor.*` events
belong to the same `doctor` invocation.
4. `total` / `pct` / `eta_ms` are absent when the scan doesn't have a
total up front (e.g. heartbeat-only paths). Don't assume they exist.
## Minion jobs
`gbrain jobs work` (the Minion worker daemon) writes progress to the
DB via `job.updateProgress`, not to stderr. Read per-job progress via
the `get_job_progress` MCP op or:
```bash
gbrain jobs submit embed
# while it runs:
gbrain jobs get <id> # .progress updates live as the handler ticks
```
The `embed` Minion handler is wired as of v0.15.2. Other bulk cores
(`sync`, `extract`, `backlinks`, `import`, `autopilot-cycle`) have the
callback plumbing ready and will follow.
## Backward-compatibility warnings
Five commands moved per-page progress from stdout to stderr:
- `embed` (was `\r`-on-stdout)
- `files sync` (was `\r`-on-stdout)
- `export` (was `\r`-on-stdout, newly in scope)
- `migrate-engine` (was per-50 `console.log` to stdout)
- `import` (was per-100 `console.log` to stdout)
If you have scripts that grep `stdout` for progress strings like
`Progress: 1234/52000` or `\r 1234/52000 pages...` — those strings
now live on stderr. The final data summaries (`Embedded N chunks
across M pages`, `Import complete`, etc.) remain on stdout so the
"did it finish" signal is unchanged.
`integrity auto` still writes `~/.gbrain/integrity-progress.jsonl`,
but its role is now "resume marker only" — live progress goes through
the reporter. If you depended on tailing that file for real-time
progress, switch to the stderr stream.
## Verification
```bash
# Your agent sees structured events; stdout stays JSON-parseable:
gbrain --progress-json doctor --json > doctor.json 2> doctor.progress.log
wc -l doctor.progress.log # should be non-zero
jq . doctor.json # should parse cleanly
# For a very large brain, watch the heartbeat:
gbrain --progress-json doctor 2>&1 >/dev/null | grep '"event"'
```
If you see silence for more than a second or two on a non-trivial
command, file an issue with the exact command and the first 100 lines
of stderr.
## That's the whole migration
No mechanical step. No config change. Agents that parse `stdout` keep
working; agents that want progress now have it on a clean stderr
channel with a documented schema.
+4 -33
View File
@@ -6,7 +6,6 @@ import type { BrainEngine } from './core/engine.ts';
import { operations, OperationError } from './core/operations.ts';
import type { Operation, OperationContext } from './core/operations.ts';
import { serializeMarkdown } from './core/markdown.ts';
import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts';
import { VERSION } from './version.ts';
// Build CLI name -> operation lookup
@@ -19,16 +18,10 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'apply-migrations', 'skillpack-check', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'dream']);
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'apply-migrations', 'skillpack-check', 'resolvers', 'integrity', 'repair-jsonb', 'orphans']);
async function main() {
// Parse global flags (--quiet / --progress-json / --progress-interval)
// BEFORE command dispatch, so `gbrain --progress-json doctor` works.
// The stripped argv is what the command sees.
const rawArgs = process.argv.slice(2);
const { cliOpts, rest: args } = parseGlobalFlags(rawArgs);
setCliOptions(cliOpts);
const args = process.argv.slice(2);
let command = args[0];
if (!command || command === '--help' || command === '-h') {
@@ -155,7 +148,6 @@ function makeContext(engine: BrainEngine, params: Record<string, unknown>): Oper
// Local CLI invocation — the user owns the machine; do not apply remote-caller
// confinement (e.g., cwd-locked file_upload).
remote: false,
cliOpts: getCliOptions(),
};
}
@@ -340,11 +332,8 @@ async function handleCliOnly(command: string, args: string[]) {
// Doctor runs filesystem checks first (no DB needed), then DB checks.
// --fast skips DB checks entirely.
const { runDoctor } = await import('./commands/doctor.ts');
const { getDbUrlSource } = await import('./core/config.ts');
if (args.includes('--fast')) {
// Pass the DB URL source so doctor can tell "no config at all" from
// "user chose --fast while config is present".
await runDoctor(null, args, getDbUrlSource());
await runDoctor(null, args);
} else {
try {
const eng = await connectEngine();
@@ -352,26 +341,12 @@ async function handleCliOnly(command: string, args: string[]) {
await eng.disconnect();
} catch {
// DB unavailable — still run filesystem checks
await runDoctor(null, args, getDbUrlSource());
await runDoctor(null, args);
}
}
return;
}
if (command === 'dream') {
const { runDream } = await import('./commands/dream.ts');
// Dream runs filesystem phases first, DB phases only if available
let eng: BrainEngine | null = null;
try {
eng = await connectEngine();
} catch {
// DB unavailable — still run filesystem phases
}
await runDream(eng, args);
if (eng) await eng.disconnect();
return;
}
// All remaining CLI-only commands need a DB connection
const engine = await connectEngine();
try {
@@ -457,7 +432,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runOrphans(engine, args);
break;
}
}
} finally {
if (command !== 'serve') await engine.disconnect();
@@ -567,9 +541,6 @@ TOOLS
check-backlinks <check|fix> [dir] Find/fix missing back-links across brain
lint <dir|file> [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter
orphans [--json] [--count] Find pages with no inbound wikilinks
dream [--json] [--dry-run] Nightly dream cycle: lint, backlinks, orphans, embed, sync
[--phase <name>] Run single phase (lint|backlinks|orphans|embed|sync)
[--skip-embed] [--skip-sync] Skip slow phases
report --type <name> --content ... Save timestamped report to brain/reports/
JOBS (Minions)
+4 -113
View File
@@ -14,12 +14,9 @@
import { VERSION } from '../version.ts';
import { loadConfig } from '../core/config.ts';
import { loadCompletedMigrations, appendCompletedMigration, type CompletedMigrationEntry } from '../core/preferences.ts';
import { loadCompletedMigrations, type CompletedMigrationEntry } from '../core/preferences.ts';
import { migrations, compareVersions, type Migration, type OrchestratorOpts } from './migrations/index.ts';
/** Bug 3 — max consecutive partials before we wedge a migration. */
const MAX_CONSECUTIVE_PARTIALS = 3;
interface ApplyMigrationsArgs {
list: boolean;
dryRun: boolean;
@@ -29,8 +26,6 @@ interface ApplyMigrationsArgs {
specificMigration?: string;
hostDir?: string;
noAutopilotInstall: boolean;
/** Bug 3 — explicit reset for a wedged migration. Writes a 'retry' marker. */
forceRetry?: string;
help: boolean;
}
@@ -54,7 +49,6 @@ function parseArgs(args: string[]): ApplyMigrationsArgs {
specificMigration: val('--migration'),
hostDir: val('--host-dir'),
noAutopilotInstall: has('--no-autopilot-install'),
forceRetry: val('--force-retry'),
help: has('--help') || has('-h'),
};
}
@@ -69,10 +63,6 @@ Usage:
gbrain apply-migrations --list Show applied + pending migrations.
gbrain apply-migrations --migration vX.Y.Z
Force-run a specific migration by version.
gbrain apply-migrations --force-retry vX.Y.Z
Clear a wedged migration (3+ consecutive
partials). Writes a 'retry' marker so the
next run treats it as fresh.
Flags:
--mode <always|pain_triggered|off> Set minion_mode without prompting.
@@ -104,38 +94,14 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
: { byVersion: new Map() };
}
/**
* Returns the resolved status for a migration based on its entries.
*
* Semantics (Bug 3 keep "complete wins" safety):
* - If any entry is `complete`, the version is complete. Terminal state.
* - Otherwise, if the latest entry is `retry`, the version is pending
* (user requested a fresh attempt).
* - Otherwise, if any entry is `partial`, the version is partial.
* - Otherwise, pending.
*
* `complete` never regresses. A later accidental `partial` append cannot
* undo a completed migration.
*/
/** Returns the resolved status for a migration based on its entries. */
function statusForVersion(
version: string,
idx: CompletedIndex,
): 'complete' | 'partial' | 'pending' | 'wedged' {
): 'complete' | 'partial' | 'pending' {
const entries = idx.byVersion.get(version) ?? [];
if (entries.length === 0) return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
const latest = entries[entries.length - 1];
if (latest.status === 'retry') return 'pending';
// Bug 3 attempt cap — count consecutive partials from the end (stopping
// at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS,
// the migration is wedged and needs explicit --force-retry to try again.
let consecutive = 0;
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i];
if (e.status === 'partial') consecutive++;
else break;
}
if (consecutive >= MAX_CONSECUTIVE_PARTIALS) return 'wedged';
if (entries.some(e => e.status === 'partial')) return 'partial';
return 'pending';
}
@@ -145,7 +111,6 @@ interface Plan {
partial: Migration[];
pending: Migration[];
skippedFuture: Migration[];
wedged: Migration[];
}
/**
@@ -162,7 +127,7 @@ interface Plan {
* skip v0.11.0 when running v0.11.1. Compare against completed.jsonl.
*/
function buildPlan(idx: CompletedIndex, installed: string, filterVersion?: string): Plan {
const plan: Plan = { applied: [], partial: [], pending: [], skippedFuture: [], wedged: [] };
const plan: Plan = { applied: [], partial: [], pending: [], skippedFuture: [] };
for (const m of migrations) {
if (filterVersion && m.version !== filterVersion) continue;
if (compareVersions(m.version, installed) > 0) {
@@ -172,7 +137,6 @@ function buildPlan(idx: CompletedIndex, installed: string, filterVersion?: strin
const status = statusForVersion(m.version, idx);
if (status === 'complete') plan.applied.push(m);
else if (status === 'partial') plan.partial.push(m);
else if (status === 'wedged') plan.wedged.push(m);
else plan.pending.push(m);
}
return plan;
@@ -185,7 +149,6 @@ function printList(plan: Plan, installed: string): void {
const rows: Array<{ status: string; m: Migration }> = [
...plan.applied.map(m => ({ status: 'applied', m })),
...plan.partial.map(m => ({ status: 'partial', m })),
...plan.wedged.map(m => ({ status: 'wedged', m })),
...plan.pending.map(m => ({ status: 'pending', m })),
...plan.skippedFuture.map(m => ({ status: 'future', m })),
];
@@ -264,37 +227,10 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
return;
}
// Bug 3 — --force-retry: write an explicit reset marker for a wedged
// migration, then return. User re-runs `gbrain apply-migrations --yes`
// to actually re-attempt.
if (cli.forceRetry) {
const target = migrations.find(m => m.version === cli.forceRetry);
if (!target) {
console.error(`No migration registered with version "${cli.forceRetry}". Run \`gbrain apply-migrations --list\`.`);
process.exit(2);
}
appendCompletedMigration({ version: cli.forceRetry, status: 'retry' });
console.log(`Wrote 'retry' marker for v${cli.forceRetry}. Run \`gbrain apply-migrations --yes\` to re-attempt.`);
return;
}
const completed = loadCompletedMigrations();
const idx = indexCompleted(completed);
const plan = buildPlan(idx, installed, cli.specificMigration);
// Bug 3 — surface wedged migrations as a loud, actionable error.
if (plan.wedged.length > 0) {
for (const m of plan.wedged) {
console.error(
`\nMigration v${m.version} is WEDGED (${MAX_CONSECUTIVE_PARTIALS}+ consecutive partials with no completion). ` +
`Check ~/.gbrain/upgrade-errors.jsonl for the last failure reasons, fix the underlying issue, then run:\n` +
` gbrain apply-migrations --force-retry ${m.version}\n` +
`Then re-run \`gbrain apply-migrations --yes\`.`,
);
}
// Don't exit — applied/partial/pending are still worth reporting and running.
}
if (cli.specificMigration && plan.applied.length + plan.partial.length + plan.pending.length + plan.skippedFuture.length === 0) {
console.error(`No migration registered with version "${cli.specificMigration}". Run \`gbrain apply-migrations --list\` to see registered versions.`);
process.exit(2);
@@ -312,11 +248,6 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
// Run each orchestrator in registry order. An orchestrator failure aborts
// the rest of the chain; fixing the failure and re-running picks up where
// we left off (per-phase idempotency markers + resume from "partial").
//
// Bug 3 — the RUNNER owns the ledger write now. Orchestrators return their
// result; we persist it here with a canonical shape. If the write fails,
// surface the error and DO NOT proceed to the next migration (a silent
// ledger drop was the root cause of the original infinite-retry symptom).
let failed = false;
for (const m of toRun) {
console.log(`\n=== Applying migration v${m.version}: ${m.featurePitch.headline} ===`);
@@ -324,45 +255,9 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
const result = await m.orchestrator(orchestratorOptsFrom(cli));
if (result.status === 'failed') {
console.error(`Migration v${m.version} reported status=failed.`);
// Record the attempt as 'partial' (not 'complete') so the cap counts
// it. Don't let a failed orchestrator look like it never ran.
try {
appendCompletedMigration({
version: m.version,
status: 'partial',
phases: result.phases,
files_rewritten: result.files_rewritten,
autopilot_installed: result.autopilot_installed,
install_target: result.install_target,
apply_migrations_pending: result.pending_host_work ? result.pending_host_work > 0 : undefined,
});
} catch (e) {
console.error(`Also: could not persist failure record: ${e instanceof Error ? e.message : String(e)}`);
}
failed = true;
break;
}
// Persist the terminal outcome. appendCompletedMigration no-ops when
// the last entry for this version is already 'complete' (idempotency
// guard), so repeated clean runs don't spam the ledger.
try {
appendCompletedMigration({
version: m.version,
status: result.status, // 'complete' | 'partial'
phases: result.phases,
files_rewritten: result.files_rewritten,
autopilot_installed: result.autopilot_installed,
install_target: result.install_target,
apply_migrations_pending: result.pending_host_work ? result.pending_host_work > 0 : undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`Failed to persist ledger entry for v${m.version}: ${msg}. Stopping to prevent silent drift.`);
failed = true;
break;
}
if (result.status === 'partial') {
console.log(`Migration v${m.version} finished as PARTIAL. Re-run \`gbrain apply-migrations --yes\` after resolving any pending host-work items.`);
} else {
@@ -371,10 +266,6 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`Migration v${m.version} threw: ${msg}`);
// Same partial-on-throw treatment so the cap counts runaway failures.
try {
appendCompletedMigration({ version: m.version, status: 'partial' });
} catch { /* swallow ledger-write failure on throw path */ }
failed = true;
break;
}
+16 -21
View File
@@ -44,35 +44,30 @@ function logError(phase: string, e: unknown) {
/**
* Resolve the gbrain CLI entrypoint for spawning the worker child.
*
* A .ts source path is never a valid spawn target spawning it fails with
* EACCES because TypeScript source isn't executable. The canonical install
* puts a shim at `/usr/local/bin/gbrain` (or wherever `which gbrain`
* resolves to) that already wraps the right runtime+entrypoint; prefer it.
* Codex caught the bug in earlier plan drafts: `process.execPath` is the
* Bun (or Node) runtime binary on source installs, not `gbrain`. Blindly
* using it would spawn `bun jobs work`, which does not work.
*
* Order of resolution:
* 1. `which gbrain` the shim on PATH, canonical for installed builds.
* 2. process.execPath if it ends with /gbrain (compiled binary, no shim).
* 3. argv[1] if it ends with /gbrain (e.g., direct invocation of compiled
* binary without PATH). Never .ts source paths.
* 4. Throw with a clear install hint.
* 1. argv[1] if it clearly points at a gbrain entry (cli.ts or /gbrain).
* 2. process.execPath when running as the compiled binary.
* 3. `which gbrain` for installs where the binary is on $PATH.
* 4. Throw nothing on $PATH, no way to supervise the worker.
*/
export function resolveGbrainCliPath(): string {
try {
const which = execSync('which gbrain', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (which) return which;
} catch { /* not on $PATH — fall through */ }
const arg1 = process.argv[1] ?? '';
if (arg1.endsWith('/gbrain') || arg1.endsWith('/cli.ts') || arg1.endsWith('\\gbrain.exe')) {
return arg1;
}
const exec = process.execPath ?? '';
if (exec.endsWith('/gbrain') || exec.endsWith('\\gbrain.exe')) {
return exec;
}
const arg1 = process.argv[1] ?? '';
if (arg1.endsWith('/gbrain') || arg1.endsWith('\\gbrain.exe')) {
return arg1;
}
throw new Error('Could not resolve the gbrain CLI path. Install gbrain so it is on $PATH (e.g. /usr/local/bin/gbrain), or run autopilot from the compiled binary directly.');
try {
const which = execSync('which gbrain', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (which) return which;
} catch { /* not on $PATH */ }
throw new Error('Could not resolve the gbrain CLI path. Install gbrain so it is on $PATH, or run autopilot from the compiled binary directly.');
}
export async function runAutopilot(engine: BrainEngine, args: string[]) {
+1 -14
View File
@@ -13,8 +13,6 @@
import { readFileSync, writeFileSync, readdirSync, statSync, lstatSync, existsSync } from 'fs';
import { join, relative, basename } from 'path';
import { extractEntityRefs as canonicalExtractEntityRefs } from '../core/link-extraction.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
interface BacklinkGap {
/** The page that mentions the entity */
@@ -203,18 +201,7 @@ export async function runBacklinksCore(opts: BacklinksOpts): Promise<BacklinksRe
throw new Error(`Directory not found: ${opts.dir}`);
}
// findBacklinkGaps is a sync double-walk of the brain dir. On 50K-page
// brains that can take seconds — heartbeat so agents see we're working.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('backlinks.scan');
const stopHb = startHeartbeat(progress, 'walking pages for missing back-links…');
let gaps: BacklinkGap[];
try {
gaps = findBacklinkGaps(opts.dir);
} finally {
stopHb();
progress.finish();
}
const gaps = findBacklinkGaps(opts.dir);
const pagesAffected = new Set(gaps.map(g => g.targetPage)).size;
if (opts.action === 'fix' && gaps.length > 0) {
+10 -261
View File
@@ -2,11 +2,7 @@ import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { LATEST_VERSION } from '../core/migrate.ts';
import { checkResolvable } from '../core/check-resolvable.ts';
import { autoFixDryViolations, type AutoFixReport, type FixOutcome } from '../core/dry-fix.ts';
import { loadCompletedMigrations } from '../core/preferences.ts';
import { createProgress, startHeartbeat, type ProgressReporter } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import type { DbUrlSource } from '../core/config.ts';
import { join } from 'path';
import { existsSync, readFileSync, readdirSync } from 'fs';
@@ -21,25 +17,11 @@ export interface Check {
* Run doctor with filesystem-first, DB-second architecture.
* Filesystem checks (resolver, conformance) run without engine.
* DB checks run only if engine is provided.
*
* `dbSource` is passed only from the `--fast` and DB-unavailable paths in
* cli.ts so we can emit a precise "why no DB check" message. When null, the
* user has no DB configured anywhere; otherwise the caller chose --fast or
* we failed to connect despite a configured URL.
*/
export async function runDoctor(engine: BrainEngine | null, args: string[], dbSource?: DbUrlSource) {
export async function runDoctor(engine: BrainEngine | null, args: string[]) {
const jsonOutput = args.includes('--json');
const fastMode = args.includes('--fast');
const doFix = args.includes('--fix');
const dryRun = args.includes('--dry-run');
const checks: Check[] = [];
let autoFixReport: AutoFixReport | null = null;
// Progress reporter. `--json` is doctor's own JSON output (list of checks);
// progress events stay on stderr regardless, gated by the global --quiet /
// --progress-json flags. On a 52K-page brain the DB checks can take minutes,
// and without a heartbeat agents can't tell doctor from a hang.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// --- Filesystem checks (always run, no DB needed) ---
@@ -47,15 +29,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
const repoRoot = findRepoRoot();
if (repoRoot) {
const skillsDir = join(repoRoot, 'skills');
// --fix: run auto-repair BEFORE checkResolvable so the post-fix scan
// reflects the new state. Auto-fix only targets DRY violations today;
// other resolver issues are left to human repair.
if (doFix) {
autoFixReport = autoFixDryViolations(skillsDir, { dryRun });
printAutoFixReport(autoFixReport, dryRun, jsonOutput);
}
const report = checkResolvable(skillsDir);
if (report.ok && report.issues.length === 0) {
checks.push({
@@ -150,81 +123,30 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Read/parse failure is itself best-effort; skip silently.
}
// 3c. Sync failure trail (Bug 9). sync.ts gates the `sync.last_commit`
// bookmark when per-file parse errors happen, and appends each failure
// to ~/.gbrain/sync-failures.jsonl with the commit hash + exact error.
// Without this doctor check, users see "sync blocked" and have no
// surface showing which files to fix.
try {
const { unacknowledgedSyncFailures, loadSyncFailures } = await import('../core/sync.ts');
const unacked = unacknowledgedSyncFailures();
const all = loadSyncFailures();
if (unacked.length > 0) {
const preview = unacked.slice(0, 3).map(f => `${f.path} (${f.error.slice(0, 60)})`).join('; ');
checks.push({
name: 'sync_failures',
status: 'warn',
message:
`${unacked.length} unacknowledged sync failure(s). ${preview}` +
`${unacked.length > 3 ? `, and ${unacked.length - 3} more` : ''}. ` +
`Fix the file(s) and re-run 'gbrain sync', or use 'gbrain sync --skip-failed' to acknowledge.`,
});
} else if (all.length > 0) {
// Acknowledged-only: informational, not a warning.
checks.push({
name: 'sync_failures',
status: 'ok',
message: `${all.length} historical sync failure(s), all acknowledged.`,
});
}
} catch {
// Best-effort. A broken JSONL should not stop doctor.
}
// --- DB checks (skip if --fast or no engine) ---
if (fastMode || !engine) {
if (!engine) {
// Pick the precise message. When dbSource is provided, we know
// whether a URL exists (env or config-file) — the caller simply
// skipped the connection. When null, there really is no config
// anywhere.
let msg: string;
if (fastMode && dbSource) {
msg = `Skipping DB checks (--fast mode, URL present from ${dbSource})`;
} else if (!fastMode && dbSource) {
msg = `Could not connect to configured DB (URL from ${dbSource}); filesystem checks only`;
} else {
msg = 'No database configured (filesystem checks only). Set GBRAIN_DATABASE_URL or run `gbrain init`.';
}
checks.push({ name: 'connection', status: 'warn', message: msg });
checks.push({ name: 'connection', status: 'warn', message: 'No database configured (filesystem checks only)' });
}
const earlyFail1 = outputResults(checks, jsonOutput);
process.exit(earlyFail1 ? 1 : 0);
return;
}
// DB checks phase — start a single reporter phase so agents see which
// check is running (several take seconds on 50K-page brains; without a
// heartbeat the binary looks hung when stdout is piped).
progress.start('doctor.db_checks');
// 3. Connection
progress.heartbeat('connection');
try {
const stats = await engine.getStats();
checks.push({ name: 'connection', status: 'ok', message: `Connected, ${stats.page_count} pages` });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
checks.push({ name: 'connection', status: 'fail', message: msg });
progress.finish();
const earlyFail2 = outputResults(checks, jsonOutput);
process.exit(earlyFail2 ? 1 : 0);
return;
}
// 4. pgvector extension
progress.heartbeat('pgvector');
try {
const sql = db.getConnection();
const ext = await sql`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
@@ -237,46 +159,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
checks.push({ name: 'pgvector', status: 'warn', message: 'Could not check pgvector extension' });
}
// 4b. PgBouncer / prepared-statement compatibility.
// URL-only inspection — no DB roundtrip — so this is cheap and works
// regardless of whether the caller is the module singleton or a
// worker-instance engine.
progress.heartbeat('pgbouncer_prepare');
try {
const { resolvePrepare } = await import('../core/db.ts');
const { loadConfig } = await import('../core/config.ts');
const config = loadConfig();
const url = config?.database_url || '';
const prepare = resolvePrepare(url);
if (prepare === false) {
checks.push({
name: 'pgbouncer_prepare',
status: 'ok',
message: 'Prepared statements disabled (PgBouncer-safe)',
});
} else {
try {
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
if (parsed.port === '6543') {
checks.push({
name: 'pgbouncer_prepare',
status: 'warn',
message:
'Port 6543 (PgBouncer transaction mode) detected but prepared statements are enabled. ' +
'This causes "prepared statement does not exist" errors under concurrent load. ' +
'Fix: unset GBRAIN_PREPARE (or set =false), or add ?prepare=false to the connection URL.',
});
}
} catch {
// URL parse failure — skip, nothing actionable
}
}
} catch {
// best-effort; never fail doctor on this check
}
// 5. RLS
progress.heartbeat('rls');
try {
const sql = db.getConnection();
const tables = await sql`
@@ -296,31 +179,15 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
checks.push({ name: 'rls', status: 'warn', message: 'Could not check RLS status' });
}
// 6. Schema version — also surfaces the #218 "postinstall silently failed"
// state: if schema_version is 0/missing but the DB connected, migrations
// never ran. That's the same class as a half-migrated install, just from a
// different root cause (Bun blocked our top-level postinstall on global
// install). Message is actionable either way.
progress.heartbeat('schema_version');
// 6. Schema version
let schemaVersion = 0;
try {
const version = await engine.getConfig('version');
schemaVersion = parseInt(version || '0', 10);
if (schemaVersion >= LATEST_VERSION) {
checks.push({ name: 'schema_version', status: 'ok', message: `Version ${schemaVersion} (latest: ${LATEST_VERSION})` });
} else if (schemaVersion === 0) {
checks.push({
name: 'schema_version',
status: 'fail',
message: `No schema version recorded. Migrations never ran. Fix: gbrain apply-migrations --yes. ` +
`If you installed via 'bun install -g github:...', see https://github.com/garrytan/gbrain/issues/218.`,
});
} else {
checks.push({
name: 'schema_version',
status: 'warn',
message: `Version ${schemaVersion}, latest is ${LATEST_VERSION}. Fix: gbrain apply-migrations --yes`,
});
checks.push({ name: 'schema_version', status: 'warn', message: `Version ${schemaVersion}, latest is ${LATEST_VERSION}. Run gbrain init to migrate.` });
}
} catch {
checks.push({ name: 'schema_version', status: 'warn', message: 'Could not check schema version' });
@@ -334,7 +201,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// but `apply-migrations` didn't follow up.
// 7. Embedding health
progress.heartbeat('embeddings');
try {
const health = await engine.getHealth();
const pct = (health.embed_coverage * 100).toFixed(0);
@@ -351,7 +217,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// 8. Graph health (link + timeline coverage on entity pages).
// dead_links removed in v0.10.1: ON DELETE CASCADE on link FKs makes it always 0.
progress.heartbeat('graph_coverage');
try {
const health = await engine.getHealth();
const linkPct = ((health.link_coverage ?? 0) * 100).toFixed(0);
@@ -365,27 +230,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}%. Run: gbrain link-extract && gbrain timeline-extract`,
});
}
// Bug 11 — brain_score breakdown. When the total is < 100, show which
// components contributed the deficit so users know what to fix.
// Uses distinct *_score field names (not overloading link_coverage /
// timeline_coverage, which are entity-scoped).
if (health.brain_score < 100) {
const parts = [
`embed ${health.embed_coverage_score}/35`,
`links ${health.link_density_score}/25`,
`timeline ${health.timeline_coverage_score}/15`,
`orphans ${health.no_orphans_score}/15`,
`dead-links ${health.no_dead_links_score}/10`,
];
checks.push({
name: 'brain_score',
status: health.brain_score >= 70 ? 'ok' : 'warn',
message: `Brain score ${health.brain_score}/100 (${parts.join(', ')})`,
});
} else {
checks.push({ name: 'brain_score', status: 'ok', message: `Brain score 100/100` });
}
} catch {
checks.push({ name: 'graph_coverage', status: 'warn', message: 'Could not check graph coverage' });
}
@@ -394,8 +238,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Read-only — no network, no writes, no resolver calls. Samples the first
// 500 pages by slug order and surfaces bare-tweet + dead-link counts as a
// warning. Full-brain scan: `gbrain integrity check`.
progress.heartbeat('integrity_sample');
const integrityHb = startHeartbeat(progress, 'scanning 500-page integrity sample…');
try {
const { scanIntegrity } = await import('./integrity.ts');
const res = await scanIntegrity(engine, { limit: 500 });
@@ -421,31 +263,24 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
}
} catch (e) {
checks.push({ name: 'integrity', status: 'warn', message: `integrity scan skipped: ${e instanceof Error ? e.message : String(e)}` });
} finally {
integrityHb();
}
// 10. JSONB integrity (v0.12.3 reliability wave).
// v0.12.0's JSON.stringify()::jsonb pattern stored JSONB string literals
// instead of objects on real Postgres. PGLite masked this; Supabase did not.
// Scan 5 known write sites for rows whose top-level jsonb_typeof is
// 'string'. `page_versions.frontmatter` added in v0.15.2 so doctor's
// surface matches `repair-jsonb` (the previous 4-target scan missed a
// repair target, per #254/Codex review).
progress.heartbeat('jsonb_integrity');
// Scan the 4 known sites (pages.frontmatter, raw_data.data, ingest_log.pages_updated,
// files.metadata) for rows whose top-level jsonb_typeof is 'string'.
try {
const sql = db.getConnection();
const targets: Array<{ table: string; col: string; expected: 'object' | 'array' }> = [
{ table: 'pages', col: 'frontmatter', expected: 'object' },
{ table: 'raw_data', col: 'data', expected: 'object' },
{ table: 'ingest_log', col: 'pages_updated', expected: 'array' },
{ table: 'files', col: 'metadata', expected: 'object' },
{ table: 'page_versions', col: 'frontmatter', expected: 'object' },
{ table: 'pages', col: 'frontmatter', expected: 'object' },
{ table: 'raw_data', col: 'data', expected: 'object' },
{ table: 'ingest_log', col: 'pages_updated', expected: 'array' },
{ table: 'files', col: 'metadata', expected: 'object' },
];
let totalBad = 0;
const breakdown: string[] = [];
for (const { table, col } of targets) {
progress.heartbeat(`jsonb_integrity.${table}.${col}`);
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${table} WHERE jsonb_typeof(${col}) = 'string'`,
);
@@ -469,12 +304,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// v0.12.0's splitBody ate everything after the first `---` horizontal rule,
// truncating wiki-style pages. Heuristic: pages whose body is <30% of the
// raw source content length when raw has multiple H2/H3 boundaries.
//
// No total on this check: the regex scan over rd.data -> 'content' is a
// sequential scan that LIMIT 100 bounds only the output, not the scan
// work. We heartbeat every second so agents see life, no fake totals.
progress.heartbeat('markdown_body_completeness');
const mbcHb = startHeartbeat(progress, 'scanning pages for truncation…');
try {
const sql = db.getConnection();
const rows = await sql`
@@ -502,58 +331,8 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
} catch {
// pages_raw.raw_data may not exist on older schemas; best-effort.
checks.push({ name: 'markdown_body_completeness', status: 'ok', message: 'Skipped (raw_data unavailable)' });
} finally {
mbcHb();
}
// 12. Index audit (opt-in via --index-audit). v0.13.1 follow-up to #170.
// Reports indexes with zero recorded scans on Postgres. Informational only;
// we DO NOT auto-drop. On #170's brain, idx_pages_frontmatter and
// idx_pages_trgm showed 0 scans — the suggestion there is "consider
// investigating on YOUR brain," not "drop these globally." Zero scans on a
// fresh install is also normal (nothing has queried yet); the real signal
// is zero scans on a long-running active brain.
if (args.includes('--index-audit')) {
progress.heartbeat('index_audit');
if (engine.kind === 'pglite') {
checks.push({
name: 'index_audit',
status: 'ok',
message: 'Skipped (PGLite — pg_stat_user_indexes is a Postgres extension)',
});
} else {
try {
const sql = db.getConnection();
const rows = await sql`
SELECT schemaname, relname AS table, indexrelname AS index,
idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
AND idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20
`;
if (rows.length === 0) {
checks.push({ name: 'index_audit', status: 'ok', message: 'All public indexes have recorded scans' });
} else {
const list = rows.map((r: any) => `${r.index}(${r.size})`).join(', ');
checks.push({
name: 'index_audit',
status: 'warn',
message: `${rows.length} zero-scan index(es): ${list}. ` +
`Consider investigating whether they're used on YOUR workload (fresh brains naturally show zero scans until queries accumulate). ` +
`Do not drop without confirming.`,
});
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
checks.push({ name: 'index_audit', status: 'warn', message: `Index audit failed: ${msg}` });
}
}
}
progress.finish();
const hasFail = outputResults(checks, jsonOutput);
// Features teaser (non-JSON, non-failing only)
@@ -572,36 +351,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Helpers
// ---------------------------------------------------------------------------
/** Print the auto-fix report in human-readable form. JSON output goes through
* outputResults alongside the check list; this is the pretty-print path. */
function printAutoFixReport(report: AutoFixReport, dryRun: boolean, jsonOutput: boolean): void {
if (jsonOutput) return; // JSON consumers read autoFixReport via the check issues / caller
const verb = dryRun ? 'PROPOSED' : 'APPLIED';
for (const outcome of report.fixed) {
console.log(`[${verb}] ${outcome.skillPath} (${outcome.patternLabel})`);
if (outcome.before) {
console.log('--- before');
console.log(outcome.before);
console.log('--- after');
console.log(outcome.after ?? '');
console.log('');
}
}
const n = report.fixed.length;
const s = report.skipped.length;
if (n === 0 && s === 0) {
console.log('Doctor --fix: no DRY violations to repair.');
return;
}
const label = dryRun ? 'fixes proposed' : 'fixes applied';
console.log(`${n} ${label}${s > 0 ? `, ${s} skipped:` : '.'}`);
for (const sk of report.skipped) {
const hint = sk.reason === 'working_tree_dirty' ? ' (run `git stash` first)' : '';
console.log(` - ${sk.skillPath}: ${sk.reason}${hint}`);
}
if (dryRun && n > 0) console.log('\nRun without --dry-run to apply.');
}
/** Find the GBrain repo root by walking up from cwd looking for skills/RESOLVER.md */
function findRepoRoot(): string | null {
let dir = process.cwd();
-446
View File
@@ -1,446 +0,0 @@
/**
* gbrain dream Nightly dream cycle orchestrator.
*
* Runs while you sleep. Ties together lint, backlinks, orphan detection,
* embedding, and sync into a single command that keeps the brain healthy
* and compounding overnight.
*
* Phases:
* 1. Lint & Fix auto-fix LLM artifacts, placeholder dates, broken citations
* 2. Backlinks detect and create missing back-links between pages
* 3. Orphan Sweep surface pages with no inbound links (thin/disconnected)
* 4. Embed re-embed stale content so search stays fresh
* 5. Sync sync repo changes to the database index
*
* Usage:
* gbrain dream # full dream cycle
* gbrain dream --dry-run # preview all fixes without writing
* gbrain dream --json # structured JSON report
* gbrain dream --phase lint # run only one phase
* gbrain dream --phase backlinks
* gbrain dream --phase orphans
* gbrain dream --phase embed
* gbrain dream --phase sync
* gbrain dream --skip-embed # skip embedding (faster, for testing)
* gbrain dream --skip-sync # skip sync phase
*/
import type { BrainEngine } from '../core/engine.ts';
import { createProgress, startHeartbeat, type ProgressReporter } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
// ── Types ──────────────────────────────────────────────────────────
export interface PhaseResult {
phase: string;
status: 'ok' | 'warn' | 'fail' | 'skipped';
duration_ms: number;
summary: string;
details?: Record<string, unknown>;
}
export interface DreamReport {
timestamp: string;
duration_ms: number;
phases: PhaseResult[];
brain_dir: string | null;
totals: {
lint_fixes: number;
backlinks_added: number;
orphans_found: number;
pages_embedded: number;
pages_synced: number;
};
}
// ── Helpers ─────────────────────────────────────────────────────────
function findRepoRoot(): string | null {
// Walk up from cwd looking for a .git directory
let dir = process.cwd();
for (let i = 0; i < 10; i++) {
if (existsSync(join(dir, '.git'))) return dir;
const parent = join(dir, '..');
if (parent === dir) break;
dir = parent;
}
// Check common locations
for (const candidate of ['/data/brain', './brain']) {
if (existsSync(candidate) && existsSync(join(candidate, '.git'))) {
return candidate;
}
}
return null;
}
function parseArgs(args: string[]) {
return {
json: args.includes('--json'),
dryRun: args.includes('--dry-run'),
skipEmbed: args.includes('--skip-embed'),
skipSync: args.includes('--skip-sync'),
phase: (() => {
const idx = args.indexOf('--phase');
return idx !== -1 ? args[idx + 1] : null;
})(),
dir: (() => {
const idx = args.indexOf('--dir');
return idx !== -1 ? args[idx + 1] : null;
})(),
};
}
async function timePhase<T>(
name: string,
fn: () => Promise<T>,
progress: ProgressReporter,
): Promise<{ result: T; duration_ms: number }> {
progress.start(name);
const start = performance.now();
const result = await fn();
const duration_ms = Math.round(performance.now() - start);
progress.finish(`${name} done (${(duration_ms / 1000).toFixed(1)}s)`);
return { result, duration_ms };
}
// ── Phase Runners ───────────────────────────────────────────────────
async function runLintPhase(brainDir: string, dryRun: boolean): Promise<PhaseResult> {
try {
// Use the library-level lint function
const { runLintCore } = await import('./lint.ts');
const result = await runLintCore({
target: brainDir,
fix: !dryRun,
dryRun,
});
const fixed = result.total_fixed ?? 0;
const issues = result.total_issues ?? 0;
return {
phase: 'lint',
status: issues > 0 ? 'warn' : 'ok',
duration_ms: 0,
summary: dryRun
? `${issues} issues found (dry run, no fixes applied)`
: `${fixed} fixes applied, ${Math.max(0, issues - fixed)} remaining`,
details: { issues, fixed, pages_scanned: result.pages_scanned },
};
} catch {
// Fallback: shell out to the lint CLI
const { execSync } = await import('child_process');
try {
const fixFlag = dryRun ? '--fix --dry-run' : '--fix';
const output = execSync(
`bun run ${join(import.meta.dir, '..', 'cli.ts')} lint "${brainDir}" ${fixFlag} --json`,
{ encoding: 'utf-8', timeout: 120_000 },
);
const data = JSON.parse(output);
const issues = data.totalIssues ?? data.issues?.length ?? 0;
const fixed = data.totalFixed ?? data.fixed ?? 0;
return {
phase: 'lint',
status: issues > 0 ? 'warn' : 'ok',
duration_ms: 0,
summary: dryRun
? `${issues} issues found (dry run)`
: `${fixed} fixes applied, ${Math.max(0, issues - fixed)} remaining`,
details: { issues, fixed },
};
} catch (e: any) {
// lint exits non-zero when issues found — parse stdout
const stdout = e.stdout || '';
try {
const data = JSON.parse(stdout);
const issues = data.totalIssues ?? data.issues?.length ?? 0;
const fixed = data.totalFixed ?? data.fixed ?? 0;
return {
phase: 'lint',
status: 'warn',
duration_ms: 0,
summary: `${fixed} fixes, ${Math.max(0, issues - fixed)} remaining`,
details: { issues, fixed },
};
} catch {
return {
phase: 'lint',
status: 'fail',
duration_ms: 0,
summary: `Lint failed: ${e.message?.slice(0, 100)}`,
};
}
}
}
}
async function runBacklinksPhase(brainDir: string, dryRun: boolean): Promise<PhaseResult> {
const { execSync } = await import('child_process');
const subcmd = dryRun ? 'fix --dry-run' : 'fix';
try {
const output = execSync(
`bun run ${join(import.meta.dir, '..', 'cli.ts')} check-backlinks ${subcmd} --dir "${brainDir}" --json`,
{ encoding: 'utf-8', timeout: 120_000 },
);
const data = JSON.parse(output);
const added = data.fixed ?? data.created ?? data.added ?? 0;
const gaps = data.gaps ?? data.total ?? 0;
return {
phase: 'backlinks',
status: gaps > 0 ? 'warn' : 'ok',
duration_ms: 0,
summary: dryRun
? `${gaps} missing back-links found (dry run)`
: `${added} back-links created, ${Math.max(0, gaps - added)} remaining`,
details: { gaps, added },
};
} catch (e: any) {
const stdout = e.stdout || '';
try {
const data = JSON.parse(stdout);
const added = data.fixed ?? data.created ?? data.added ?? 0;
const gaps = data.gaps ?? data.total ?? 0;
return {
phase: 'backlinks',
status: 'warn',
duration_ms: 0,
summary: `${added} back-links created, ${Math.max(0, gaps - added)} gaps`,
details: { gaps, added },
};
} catch {
return {
phase: 'backlinks',
status: 'fail',
duration_ms: 0,
summary: `Backlinks failed: ${(e.message || '').slice(0, 100)}`,
};
}
}
}
async function runOrphansPhase(): Promise<PhaseResult> {
try {
const { findOrphans } = await import('./orphans.ts');
const result = await findOrphans(false);
const count = result?.total_orphans ?? 0;
// Group by domain
const domains: Record<string, number> = {};
for (const o of result?.orphans ?? []) {
const d = o.domain || 'unknown';
domains[d] = (domains[d] || 0) + 1;
}
return {
phase: 'orphans',
status: count > 20 ? 'warn' : 'ok',
duration_ms: 0,
summary: `${count} orphan pages (no inbound links)`,
details: { count, by_domain: domains },
};
} catch (e: any) {
// Fallback: shell out
const { execSync } = await import('child_process');
try {
const output = execSync(
`bun run ${join(import.meta.dir, '..', 'cli.ts')} orphans --json`,
{ encoding: 'utf-8', timeout: 60_000 },
);
const data = JSON.parse(output);
const count = data.total ?? data.orphans?.length ?? 0;
return {
phase: 'orphans',
status: count > 20 ? 'warn' : 'ok',
duration_ms: 0,
summary: `${count} orphan pages`,
details: { count },
};
} catch {
return {
phase: 'orphans',
status: 'fail',
duration_ms: 0,
summary: `Orphan check failed: ${(e.message || '').slice(0, 100)}`,
};
}
}
}
async function runEmbedPhase(engine: BrainEngine): Promise<PhaseResult> {
const { execSync } = await import('child_process');
try {
const output = execSync(
`bun run ${join(import.meta.dir, '..', 'cli.ts')} embed --stale --json`,
{ encoding: 'utf-8', timeout: 300_000 },
);
const data = JSON.parse(output);
const embedded = data.embedded ?? data.count ?? 0;
return {
phase: 'embed',
status: 'ok',
duration_ms: 0,
summary: `${embedded} stale pages re-embedded`,
details: { embedded },
};
} catch (e: any) {
const stdout = e.stdout || '';
try {
const data = JSON.parse(stdout);
const embedded = data.embedded ?? data.count ?? 0;
return {
phase: 'embed',
status: 'ok',
duration_ms: 0,
summary: `${embedded} pages re-embedded`,
details: { embedded },
};
} catch {
return {
phase: 'embed',
status: 'fail',
duration_ms: 0,
summary: `Embed failed: ${(e.message || '').slice(0, 100)}`,
};
}
}
}
async function runSyncPhase(engine: BrainEngine, brainDir: string): Promise<PhaseResult> {
const { execSync } = await import('child_process');
try {
const output = execSync(
`bun run ${join(import.meta.dir, '..', 'cli.ts')} sync --repo "${brainDir}" --no-pull`,
{ encoding: 'utf-8', timeout: 300_000 },
);
// Parse sync output for page count
const match = output.match(/(\d+)\s+page/);
const pages = match ? parseInt(match[1], 10) : 0;
return {
phase: 'sync',
status: 'ok',
duration_ms: 0,
summary: `Synced${pages ? ` (${pages} pages)` : ''}`,
details: { pages },
};
} catch (e: any) {
return {
phase: 'sync',
status: 'fail',
duration_ms: 0,
summary: `Sync failed: ${(e.message || '').slice(0, 100)}`,
};
}
}
// ── Main ────────────────────────────────────────────────────────────
export async function runDream(engine: BrainEngine | null, args: string[]) {
const opts = parseArgs(args);
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
const heartbeat = startHeartbeat(progress, 5_000);
const brainDir = opts.dir ?? findRepoRoot();
const phases: PhaseResult[] = [];
const start = performance.now();
if (!opts.json) {
console.log('🌙 Dream cycle starting...\n');
}
const shouldRun = (phase: string) => !opts.phase || opts.phase === phase;
try {
// Phase 1: Lint & Fix
if (shouldRun('lint') && brainDir) {
const { result, duration_ms } = await timePhase('lint', () => runLintPhase(brainDir, opts.dryRun), progress);
result.duration_ms = duration_ms;
phases.push(result);
if (!opts.json) {
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
console.log(`${icon} Lint: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
}
}
// Phase 2: Backlinks
if (shouldRun('backlinks') && brainDir) {
const { result, duration_ms } = await timePhase('backlinks', () => runBacklinksPhase(brainDir, opts.dryRun), progress);
result.duration_ms = duration_ms;
phases.push(result);
if (!opts.json) {
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
console.log(`${icon} Backlinks: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
}
}
// Phase 3: Orphan Sweep (requires DB)
if (shouldRun('orphans') && engine) {
const { result, duration_ms } = await timePhase('orphans', () => runOrphansPhase(), progress);
result.duration_ms = duration_ms;
phases.push(result);
if (!opts.json) {
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
console.log(`${icon} Orphans: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
}
}
// Phase 4: Embed stale content (requires DB)
if (shouldRun('embed') && !opts.skipEmbed && engine) {
const { result, duration_ms } = await timePhase('embed', () => runEmbedPhase(engine), progress);
result.duration_ms = duration_ms;
phases.push(result);
if (!opts.json) {
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
console.log(`${icon} Embed: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
}
} else if (shouldRun('embed') && opts.skipEmbed) {
phases.push({ phase: 'embed', status: 'skipped', duration_ms: 0, summary: 'Skipped (--skip-embed)' });
}
// Phase 5: Sync
if (shouldRun('sync') && !opts.skipSync && brainDir) {
const { result, duration_ms } = await timePhase('sync', () => runSyncPhase(engine, brainDir), progress);
result.duration_ms = duration_ms;
phases.push(result);
if (!opts.json) {
const icon = result.status === 'ok' ? '✅' : result.status === 'warn' ? '⚠️' : '❌';
console.log(`${icon} Sync: ${result.summary} (${(duration_ms / 1000).toFixed(1)}s)`);
}
} else if (shouldRun('sync') && opts.skipSync) {
phases.push({ phase: 'sync', status: 'skipped', duration_ms: 0, summary: 'Skipped (--skip-sync)' });
}
const totalMs = Math.round(performance.now() - start);
// Build report
const report: DreamReport = {
timestamp: new Date().toISOString(),
duration_ms: totalMs,
phases,
brain_dir: brainDir,
totals: {
lint_fixes: (phases.find(p => p.phase === 'lint')?.details?.fixed as number) ?? 0,
backlinks_added: (phases.find(p => p.phase === 'backlinks')?.details?.added as number) ?? 0,
orphans_found: (phases.find(p => p.phase === 'orphans')?.details?.count as number) ?? 0,
pages_embedded: (phases.find(p => p.phase === 'embed')?.details?.embedded as number) ?? 0,
pages_synced: (phases.find(p => p.phase === 'sync')?.details?.pages as number) ?? 0,
},
};
if (opts.json) {
console.log(JSON.stringify(report, null, 2));
} else {
const failed = phases.filter(p => p.status === 'fail').length;
const warned = phases.filter(p => p.status === 'warn').length;
console.log(`\n🌙 Dream cycle complete in ${(totalMs / 1000).toFixed(1)}s`);
if (failed > 0) {
console.log(` ${failed} phase(s) failed — check output above`);
} else if (warned > 0) {
console.log(` ${warned} phase(s) have warnings — brain is getting healthier`);
} else {
console.log(' All phases clean — brain is healthy 🧠');
}
}
return report;
} finally {
clearInterval(heartbeat);
}
}
+5 -34
View File
@@ -2,8 +2,6 @@ import type { BrainEngine } from '../core/engine.ts';
import { embedBatch } from '../core/embedding.ts';
import type { ChunkInput } from '../core/types.ts';
import { chunkText } from '../core/chunkers/recursive.ts';
import { createProgress, type ProgressReporter } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export interface EmbedOpts {
/** Embed ALL pages (every chunk). */
@@ -14,13 +12,6 @@ export interface EmbedOpts {
slugs?: string[];
/** Embed a single page. */
slug?: string;
/**
* Optional progress callback. Called after each page. CLI wrappers
* supply a reporter.tick()-backed implementation; Minion handlers
* supply a job.updateProgress()-backed one so per-job progress lives
* in the DB where `gbrain jobs get` can read it.
*/
onProgress?: (done: number, total: number, embedded: number) => void;
}
/**
@@ -38,7 +29,7 @@ export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promis
return;
}
if (opts.all || opts.stale) {
await embedAll(engine, !!opts.stale, opts.onProgress);
await embedAll(engine, !!opts.stale);
return;
}
if (opts.slug) {
@@ -67,24 +58,9 @@ export async function runEmbed(engine: BrainEngine, args: string[]) {
opts = { slug };
}
// CLI path: wire a reporter so --progress-json / --quiet / TTY rendering
// all work. Minion handlers call runEmbedCore directly with their own
// onProgress (see jobs.ts).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
let progressStarted = false;
opts.onProgress = (done, total, _embedded) => {
if (!progressStarted) {
progress.start('embed.pages', total);
progressStarted = true;
}
progress.tick(1);
};
try {
await runEmbedCore(engine, opts);
if (progressStarted) progress.finish();
} catch (e) {
if (progressStarted) progress.finish();
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
@@ -141,11 +117,7 @@ async function embedPage(engine: BrainEngine, slug: string) {
console.log(`${slug}: embedded ${toEmbed.length} chunks`);
}
async function embedAll(
engine: BrainEngine,
staleOnly: boolean,
onProgress?: (done: number, total: number, embedded: number) => void,
) {
async function embedAll(engine: BrainEngine, staleOnly: boolean) {
const pages = await engine.listPages({ limit: 100000 });
let total = 0;
let embedded = 0;
@@ -169,7 +141,7 @@ async function embedAll(
if (toEmbed.length === 0) {
processed++;
onProgress?.(processed, pages.length, embedded);
process.stdout.write(`\r ${processed}/${pages.length} pages, ${embedded} chunks embedded`);
return;
}
@@ -196,7 +168,7 @@ async function embedAll(
total += toEmbed.length;
processed++;
onProgress?.(processed, pages.length, embedded);
process.stdout.write(`\r ${processed}/${pages.length} pages, ${embedded} chunks embedded`);
}
// Sliding worker pool: N workers share a queue and each pulls the
@@ -215,6 +187,5 @@ async function embedAll(
const numWorkers = Math.min(CONCURRENCY, pages.length);
await Promise.all(Array.from({ length: numWorkers }, () => worker()));
// Stdout summary preserved for scripts/tests that grep for counts.
console.log(`Embedded ${embedded} chunks across ${pages.length} pages`);
console.log(`\n\nEmbedded ${embedded} chunks across ${pages.length} pages`);
}
+3 -14
View File
@@ -50,28 +50,17 @@ export async function runEvalCommand(engine: BrainEngine, args: string[]): Promi
const k = opts.k ?? 5;
const configA = buildConfig(opts, 'a');
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
if (opts.configB || opts.configBPath) {
// A/B comparison mode
const configB = buildConfig(opts, 'b');
progress.start('eval.ab', qrels.length * 2);
const onProgress = (_done: number, _total: number, q: string) => progress.tick(1, q);
const [reportA, reportB] = await Promise.all([
runEval(engine, qrels, configA, k, { onProgress }),
runEval(engine, qrels, configB, k, { onProgress }),
runEval(engine, qrels, configA, k),
runEval(engine, qrels, configB, k),
]);
progress.finish();
printABTable(reportA, reportB, k);
} else {
// Single-run mode
progress.start('eval.single', qrels.length);
const report = await runEval(engine, qrels, configA, k, {
onProgress: (_done, _total, q) => progress.tick(1, q),
});
progress.finish();
const report = await runEval(engine, qrels, configA, k);
printSingleTable(report);
}
}
+4 -10
View File
@@ -2,8 +2,6 @@ import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { serializeMarkdown } from '../core/markdown.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export async function runExport(engine: BrainEngine, args: string[]) {
const dirIdx = args.indexOf('--dir');
@@ -12,10 +10,6 @@ export async function runExport(engine: BrainEngine, args: string[]) {
const pages = await engine.listPages({ limit: 100000 });
console.log(`Exporting ${pages.length} pages to ${outDir}/`);
// Progress on stderr so stdout stays clean for scripts parsing counts.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('export.pages', pages.length);
let exported = 0;
for (const page of pages) {
@@ -47,10 +41,10 @@ export async function runExport(engine: BrainEngine, args: string[]) {
}
exported++;
progress.tick();
if (exported % 100 === 0) {
process.stdout.write(`\r ${exported}/${pages.length} exported`);
}
}
progress.finish();
// Stdout summary preserved so scripts that grep for "Exported N pages" keep working.
console.log(`Exported ${exported} pages to ${outDir}/`);
console.log(`\nExported ${exported} pages to ${outDir}/`);
}
+12 -25
View File
@@ -26,8 +26,6 @@ import {
extractFrontmatterLinks,
type UnresolvedFrontmatterRef,
} from '../core/link-extraction.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
// Batch size for addLinksBatch / addTimelineEntriesBatch.
// Postgres bind-parameter limit is 65535. Links use 4 cols/row → 16K hard ceiling;
@@ -417,12 +415,6 @@ async function extractLinksFromDir(
const files = walkMarkdownFiles(brainDir);
const allSlugs = new Set(files.map(f => f.relPath.replace('.md', '')));
// Progress stream on stderr (separate from the action-events --json writes
// to stdout, which tests grep for). Rate-gated; respects global --quiet /
// --progress-json flags.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.links_fs', files.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
// Without this, the same link extracted from N files would print N times in --dry-run.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -462,10 +454,11 @@ async function extractLinksFromDir(
}
}
} catch { /* skip unreadable */ }
progress.tick(1);
if (jsonMode && !dryRun && (i % 100 === 0 || i === files.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_links', done: i + 1, total: files.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
@@ -479,9 +472,6 @@ async function extractTimelineFromDir(
): Promise<{ created: number; pages: number }> {
const files = walkMarkdownFiles(brainDir);
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.timeline_fs', files.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -520,10 +510,11 @@ async function extractTimelineFromDir(
}
}
} catch { /* skip unreadable */ }
progress.tick(1);
if (jsonMode && !dryRun && (i % 100 === 0 || i === files.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_timeline', done: i + 1, total: files.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
@@ -595,9 +586,6 @@ async function extractLinksFromDB(
const slugList = Array.from(allSlugs);
let processed = 0, created = 0;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.links_db', slugList.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -673,10 +661,11 @@ async function extractLinksFromDB(
}
}
processed++;
progress.tick(1);
if (jsonMode && !dryRun && (processed % 500 === 0 || i === slugList.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_links_db', done: processed, total: slugList.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
@@ -710,9 +699,6 @@ async function extractTimelineFromDB(
const slugList = Array.from(allSlugs);
let processed = 0, created = 0;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.timeline_db', slugList.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -767,10 +753,11 @@ async function extractTimelineFromDB(
}
}
processed++;
progress.tick(1);
if (jsonMode && !dryRun && (processed % 500 === 0 || i === slugList.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_timeline_db', done: processed, total: slugList.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
+4 -9
View File
@@ -4,8 +4,6 @@ import { createHash } from 'crypto';
import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { humanSize } from '../core/file-resolver.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
/** Size threshold: files >= 100 MB use TUS resumable upload */
const SIZE_THRESHOLD = 100 * 1024 * 1024;
@@ -308,14 +306,13 @@ async function syncFiles(dir?: string) {
let uploaded = 0;
let skipped = 0;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('files.sync', files.length);
for (let i = 0; i < files.length; i++) {
const filePath = files[i];
const relativePath = relative(dir, filePath);
progress.tick(1);
if ((i + 1) % 50 === 0 || i === files.length - 1) {
process.stdout.write(`\r ${i + 1}/${files.length} processed, ${uploaded} uploaded, ${skipped} skipped`);
}
const hash = fileHash(filePath);
const filename = basename(filePath);
@@ -346,9 +343,7 @@ async function syncFiles(dir?: string) {
uploaded++;
}
progress.finish();
// Stdout summary preserved for scripts/tests that grep for it.
console.log(`Files sync complete: ${uploaded} uploaded, ${skipped} skipped (unchanged)`);
console.log(`\n\nFiles sync complete: ${uploaded} uploaded, ${skipped} skipped (unchanged)`);
}
async function verifyFiles() {
+15 -61
View File
@@ -5,8 +5,6 @@ import { cpus, totalmem, homedir } from 'os';
import type { BrainEngine } from '../core/engine.ts';
import { importFile } from '../core/import-file.ts';
import { loadConfig } from '../core/config.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
function defaultWorkers(): number {
const cpuCount = cpus().length;
@@ -19,16 +17,7 @@ function defaultWorkers(): number {
return Math.min(byPool, byCpu, byMem);
}
/** Bug 9 — surface per-file failures so callers (performFullSync) can gate state advances. */
export interface RunImportResult {
imported: number;
skipped: number;
errors: number;
chunksCreated: number;
failures: Array<{ path: string; error: string }>;
}
export async function runImport(engine: BrainEngine, args: string[], opts: { commit?: string } = {}): Promise<RunImportResult> {
export async function runImport(engine: BrainEngine, args: string[]) {
const noEmbed = args.includes('--no-embed');
const fresh = args.includes('--fresh');
const jsonOutput = args.includes('--json');
@@ -80,15 +69,14 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
let chunksCreated = 0;
const importedSlugs: string[] = [];
const errorCounts: Record<string, number> = {};
const failures: Array<{ path: string; error: string }> = []; // Bug 9
const startTime = Date.now();
// Progress on stderr so stdout stays clean for the final summary / --json payload.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('import.files', files.length);
function tickProgress() {
progress.tick(1, `imported=${imported} skipped=${skipped} errors=${errors}`);
function logProgress() {
const elapsed = (Date.now() - startTime) / 1000;
const rate = elapsed > 0 ? Math.round(processed / elapsed) : 0;
const remaining = rate > 0 ? Math.round((files.length - processed) / rate) : 0;
const pct = Math.round((processed / files.length) * 100);
console.log(`[gbrain import] ${processed}/${files.length} (${pct}%) | ${rate} files/sec | imported: ${imported} | skipped: ${skipped} | errors: ${errors} | ETA: ${remaining}s`);
}
async function processFile(eng: BrainEngine, filePath: string) {
@@ -103,8 +91,6 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
skipped++;
if (result.error && result.error !== 'unchanged') {
console.error(` Skipped ${relativePath}: ${result.error}`);
// Bug 9 — non-"unchanged" skips carry a real error reason.
failures.push({ path: relativePath, error: result.error });
}
}
} catch (e: unknown) {
@@ -118,11 +104,10 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
errors++;
skipped++;
failures.push({ path: relativePath, error: msg });
}
processed++;
tickProgress();
if (processed % 100 === 0 || processed === files.length) {
logProgress();
// Save checkpoint every 100 files — track completed file set, not just a counter
if (processed % 100 === 0) {
try {
@@ -150,15 +135,10 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
} else {
const { PostgresEngine } = await import('../core/postgres-engine.ts');
const { resolvePoolSize } = await import('../core/db.ts');
// Default per-worker pool is 2 (small, parallel import case). Users on
// constrained poolers (e.g. Supabase port 6543) can cap below this via
// GBRAIN_POOL_SIZE=1.
const workerPoolSize = Math.min(2, resolvePoolSize(2));
const workerEngines = await Promise.all(
Array.from({ length: actualWorkers }, async () => {
const eng = new PostgresEngine();
await eng.connect({ database_url: config!.database_url!, poolSize: workerPoolSize });
await eng.connect({ database_url: config!.database_url!, poolSize: 2 });
return eng;
})
);
@@ -182,8 +162,6 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
}
progress.finish();
// Error summary
for (const [err, count] of Object.entries(errorCounts)) {
if (count > 5) {
@@ -220,41 +198,17 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
summary: `Imported ${imported} pages, ${skipped} skipped, ${chunksCreated} chunks`,
});
// Import → sync continuity: write sync checkpoint if this is a git repo.
// Bug 9 — gate last_commit on "no failures" so import doesn't silently
// stomp on the sync bookmark when parsing broke. We still write
// last_run + repo_path either way (those are progress indicators).
let gitHead: string | null = null;
// Import → sync continuity: write sync checkpoint if this is a git repo
try {
if (existsSync(join(dir, '.git'))) {
gitHead = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
const head = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
await engine.setConfig('sync.last_commit', head);
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', dir);
}
} catch {
// Not a git repo or git not available
// Not a git repo or git not available, skip checkpoint
}
if (gitHead) {
// Record failures into the central JSONL so doctor can surface them.
// Use gitHead as the commit so a later sync can tell "same broken
// state as last time" from "new broken state."
if (failures.length > 0) {
const { recordSyncFailures } = await import('../core/sync.ts');
recordSyncFailures(failures, gitHead);
}
if (failures.length === 0) {
await engine.setConfig('sync.last_commit', gitHead);
} else {
console.error(
`\nImport completed with ${failures.length} failure(s). ` +
`sync.last_commit NOT advanced — re-run 'gbrain sync' to retry, or ` +
`'gbrain sync --skip-failed' to acknowledge and move past them.`,
);
}
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', dir);
}
return { imported, skipped, errors, chunksCreated, failures };
}
export function collectMarkdownFiles(dir: string): string[] {
+73 -79
View File
@@ -107,39 +107,36 @@ async function initPGLite(opts: { jsonOutput: boolean; apiKey: string | null; cu
console.log(`Setting up local brain with PGLite (no server needed)...`);
const engine = await createEngine({ engine: 'pglite' });
try {
await engine.connect({ database_path: dbPath, engine: 'pglite' });
await engine.initSchema();
await engine.connect({ database_path: dbPath, engine: 'pglite' });
await engine.initSchema();
const config: GBrainConfig = {
engine: 'pglite',
database_path: dbPath,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
const config: GBrainConfig = {
engine: 'pglite',
database_path: dbPath,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
const stats = await engine.getStats();
const stats = await engine.getStats();
await engine.disconnect();
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'pglite', path: dbPath, pages: stats.page_count }));
} else {
console.log(`\nBrain ready at ${dbPath}`);
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'pglite', path: dbPath, pages: stats.page_count }));
} else {
console.log(`\nBrain ready at ${dbPath}`);
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
if (stats.page_count > 0) {
console.log('');
console.log('When you outgrow local: gbrain migrate --to supabase');
reportModStatus();
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
} finally {
try { await engine.disconnect(); } catch { /* best-effort */ }
console.log('');
console.log('When you outgrow local: gbrain migrate --to supabase');
reportModStatus();
}
}
@@ -160,67 +157,64 @@ async function initPostgres(opts: { databaseUrl: string; jsonOutput: boolean; ap
console.log('Connecting to database...');
const engine = await createEngine({ engine: 'postgres' });
try {
try {
await engine.connect({ database_url: databaseUrl });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (databaseUrl.includes('supabase.co') && (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT'))) {
console.error('Connection failed. Supabase direct connections (db.*.supabase.co:5432) are IPv6 only.');
console.error('Use the Session pooler connection string instead (port 6543).');
}
throw e;
await engine.connect({ database_url: databaseUrl });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (databaseUrl.includes('supabase.co') && (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT'))) {
console.error('Connection failed. Supabase direct connections (db.*.supabase.co:5432) are IPv6 only.');
console.error('Use the Session pooler connection string instead (port 6543).');
}
throw e;
}
// Check and auto-create pgvector extension
try {
const conn = (engine as any).sql || (await import('../core/db.ts')).getConnection();
const ext = await conn`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
if (ext.length === 0) {
console.log('pgvector extension not found. Attempting to create...');
try {
await conn`CREATE EXTENSION IF NOT EXISTS vector`;
console.log('pgvector extension created successfully.');
} catch {
console.error('Could not auto-create pgvector extension. Run manually in SQL Editor:');
console.error(' CREATE EXTENSION vector;');
// Throw so the outer finally runs engine.disconnect() before we die.
throw new Error('pgvector extension missing');
}
// Check and auto-create pgvector extension
try {
const conn = (engine as any).sql || (await import('../core/db.ts')).getConnection();
const ext = await conn`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
if (ext.length === 0) {
console.log('pgvector extension not found. Attempting to create...');
try {
await conn`CREATE EXTENSION IF NOT EXISTS vector`;
console.log('pgvector extension created successfully.');
} catch {
console.error('Could not auto-create pgvector extension. Run manually in SQL Editor:');
console.error(' CREATE EXTENSION vector;');
await engine.disconnect();
process.exit(1);
}
} catch {
// Non-fatal
}
} catch {
// Non-fatal
}
console.log('Running schema migration...');
await engine.initSchema();
console.log('Running schema migration...');
await engine.initSchema();
const config: GBrainConfig = {
engine: 'postgres',
database_url: databaseUrl,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
console.log('Config saved to ~/.gbrain/config.json');
const config: GBrainConfig = {
engine: 'postgres',
database_url: databaseUrl,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
console.log('Config saved to ~/.gbrain/config.json');
const stats = await engine.getStats();
const stats = await engine.getStats();
await engine.disconnect();
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'postgres', pages: stats.page_count }));
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'postgres', pages: stats.page_count }));
} else {
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
reportModStatus();
console.log('Next: gbrain import <dir>');
}
} finally {
try { await engine.disconnect(); } catch { /* best-effort */ }
reportModStatus();
}
}
-9
View File
@@ -361,14 +361,8 @@ async function cmdAuto(args: string[]): Promise<void> {
let bucketErr = 0;
let pagesProcessed = 0;
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
try {
const allSlugs = [...(await engine.getAllSlugs())].sort();
const toScan = allSlugs.filter(s => !seen.has(s));
progress.start('integrity.auto', toScan.length);
for (const slug of allSlugs) {
if (pagesProcessed >= limit) break;
if (seen.has(slug)) continue;
@@ -377,7 +371,6 @@ async function cmdAuto(args: string[]): Promise<void> {
if (!page) continue;
pagesProcessed++;
progress.tick(1, slug);
// Bare-tweet handling
if (!skipTweet) {
@@ -463,8 +456,6 @@ async function cmdAuto(args: string[]): Promise<void> {
}
}
progress.finish();
// Summary
console.log('');
console.log(`=== integrity auto summary${dryRun ? ' (DRY RUN)' : ''} ===`);
+15 -179
View File
@@ -57,10 +57,8 @@ export async function runJobs(engine: BrainEngine, args: string[]): Promise<void
USAGE
gbrain jobs submit <name> [--params JSON] [--follow] [--priority N]
[--delay Nms] [--max-attempts N] [--max-stalled N]
[--backoff-type fixed|exponential] [--backoff-delay Nms]
[--backoff-jitter 0..1] [--timeout-ms Nms]
[--idempotency-key K] [--queue Q] [--dry-run]
[--delay Nms] [--max-attempts N] [--queue Q]
[--dry-run]
gbrain jobs list [--status S] [--queue Q] [--limit N]
gbrain jobs get <id>
gbrain jobs cancel <id>
@@ -70,18 +68,6 @@ USAGE
gbrain jobs stats
gbrain jobs smoke
gbrain jobs work [--queue Q] [--concurrency N]
HANDLER TYPES (built in)
sync Pull and embed new pages from the repo
embed (Re-)embed pages; --params '{"slug":...}' or '{"all":true}'
lint Run page linter; --params '{"dir":"...","fix":true}'
import Bulk import markdown; --params '{"dir":"..."}'
extract Extract links + timeline entries; '{"mode":"all"}'
backlinks Check or fix back-links; '{"action":"fix"}'
autopilot-cycle One autopilot pass (sync+extract+embed+backlinks)
shell Run a command or argv. Requires GBRAIN_ALLOW_SHELL_JOBS=1
on the worker. Params: {cmd?, argv?, cwd, env?}.
See: docs/guides/minions-shell-jobs.md
`);
return;
}
@@ -106,25 +92,6 @@ HANDLER TYPES (built in)
const priority = parseInt(parseFlag(args, '--priority') ?? '0', 10);
const delay = parseInt(parseFlag(args, '--delay') ?? '0', 10);
const maxAttempts = parseInt(parseFlag(args, '--max-attempts') ?? '3', 10);
const maxStalledRaw = parseFlag(args, '--max-stalled');
const maxStalled = maxStalledRaw !== undefined ? parseInt(maxStalledRaw, 10) : undefined;
// v0.13.1 field audit: expose retry/backoff/timeout/idempotency knobs so
// users can tune Minions behavior without dropping into TypeScript.
const backoffTypeRaw = parseFlag(args, '--backoff-type');
const backoffType = backoffTypeRaw === 'fixed' || backoffTypeRaw === 'exponential'
? backoffTypeRaw
: undefined;
const backoffDelayRaw = parseFlag(args, '--backoff-delay');
const backoffDelay = backoffDelayRaw !== undefined ? parseInt(backoffDelayRaw, 10) : undefined;
const backoffJitterRaw = parseFlag(args, '--backoff-jitter');
const backoffJitter = backoffJitterRaw !== undefined ? parseFloat(backoffJitterRaw) : undefined;
const timeoutMsRaw = parseFlag(args, '--timeout-ms');
const timeoutMs = timeoutMsRaw !== undefined ? parseInt(timeoutMsRaw, 10) : undefined;
if (timeoutMsRaw !== undefined && (isNaN(timeoutMs!) || timeoutMs! <= 0)) {
console.error('Error: --timeout-ms must be a positive integer (milliseconds)');
process.exit(1);
}
const idempotencyKey = parseFlag(args, '--idempotency-key');
const queueName = parseFlag(args, '--queue') ?? 'default';
const dryRun = hasFlag(args, '--dry-run');
const follow = hasFlag(args, '--follow');
@@ -135,12 +102,6 @@ HANDLER TYPES (built in)
console.log(` Queue: ${queueName}`);
console.log(` Priority: ${priority}`);
console.log(` Max attempts: ${maxAttempts}`);
if (maxStalled !== undefined) console.log(` Max stalled: ${maxStalled}`);
if (backoffType) console.log(` Backoff type: ${backoffType}`);
if (backoffDelay !== undefined) console.log(` Backoff delay: ${backoffDelay}ms`);
if (backoffJitter !== undefined) console.log(` Backoff jitter: ${backoffJitter}`);
if (timeoutMs !== undefined) console.log(` Timeout: ${timeoutMs}ms`);
if (idempotencyKey) console.log(` Idempotency key: ${idempotencyKey}`);
if (delay > 0) console.log(` Delay: ${delay}ms`);
console.log(` Data: ${JSON.stringify(data)}`);
return;
@@ -153,56 +114,12 @@ HANDLER TYPES (built in)
process.exit(1);
}
// The CLI path is a trusted submitter. Pass {allowProtectedSubmit: true}
// ONLY for protected names, not blanket-set for every submission, so any
// future protected name forces explicit opt-in at the call site.
const { isProtectedJobName } = await import('../core/minions/protected-names.ts');
const trusted = isProtectedJobName(name) ? { allowProtectedSubmit: true } : undefined;
const job = await queue.add(name, data, {
priority,
delay: delay > 0 ? delay : undefined,
max_attempts: maxAttempts,
max_stalled: maxStalled,
backoff_type: backoffType,
backoff_delay: backoffDelay,
backoff_jitter: backoffJitter,
timeout_ms: timeoutMs,
idempotency_key: idempotencyKey,
queue: queueName,
}, trusted);
// Submission audit log (operational trace, not forensic insurance).
try {
const { logShellSubmission } = await import('../core/minions/handlers/shell-audit.ts');
if (name.trim() === 'shell') {
logShellSubmission({
caller: 'cli',
remote: false,
job_id: job.id,
cwd: typeof data.cwd === 'string' ? data.cwd : '',
cmd_display: typeof data.cmd === 'string' ? data.cmd.slice(0, 80) : undefined,
argv_display: Array.isArray(data.argv)
? (data.argv as unknown[]).filter((a): a is string => typeof a === 'string').map((a) => a.slice(0, 80))
: undefined,
});
}
} catch { /* audit failures never block submission */ }
// Starvation warning (DX polish). Fire for every non-`--follow` shell submit
// regardless of the submitter's own `GBRAIN_ALLOW_SHELL_JOBS` — the submitter
// env is a weak proxy for the worker env (they may run on different machines),
// so the warning remains useful any time the job might sit in 'waiting'.
if (!follow && name.trim() === 'shell') {
process.stderr.write(
`\n⚠ Shell jobs require GBRAIN_ALLOW_SHELL_JOBS=1 on the worker process.\n` +
` Your job was queued (id=${job.id}) but will sit in 'waiting' until a\n` +
` worker with the env flag starts. To run now:\n\n` +
` GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \\\n` +
` --params '...' --follow\n\n` +
` Or start a persistent worker (Postgres only — PGLite uses --follow):\n\n` +
` GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work\n\n`,
);
}
});
if (follow) {
console.log(`Job #${job.id} submitted (${name}). Executing inline...`);
@@ -378,8 +295,6 @@ HANDLER TYPES (built in)
process.exit(1);
}
const sigkillRescue = hasFlag(args, '--sigkill-rescue');
const worker = new MinionWorker(engine, { queue: 'smoke', pollInterval: 100 });
worker.register('noop', async () => ({ ok: true, at: new Date().toISOString() }));
@@ -397,64 +312,22 @@ HANDLER TYPES (built in)
await workerPromise;
const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(2);
if (final?.status !== 'completed') {
if (final?.status === 'completed') {
const cfg = (await import('../core/config.ts')).loadConfig();
const engineLabel = cfg?.engine ?? 'unknown';
console.log(`SMOKE PASS — Minions healthy in ${elapsedSec}s (engine: ${engineLabel})`);
if (engineLabel === 'pglite') {
console.log('Note: the `gbrain jobs work` daemon requires Postgres. PGLite');
console.log('supports inline execution only (`submit --follow`).');
}
try { await queue.removeJob(job.id); } catch { /* non-fatal cleanup */ }
process.exit(0);
} else {
console.error(`SMOKE FAIL — job #${job.id} status: ${final?.status ?? 'timeout'} (${elapsedSec}s elapsed)`);
if (final?.error_text) console.error(` Error: ${final.error_text}`);
process.exit(1);
}
// --sigkill-rescue: regression case for #219. Simulates a SIGKILL
// mid-flight by directly manipulating lock_until via handleStalled.
// Verifies that with the v0.13.1 schema default (max_stalled=5), a
// stalled job is REQUEUED rather than dead-lettered on first stall.
// Full subprocess-level SIGKILL lives in test/e2e/minions.test.ts.
if (sigkillRescue) {
const rescueJob = await queue.add('noop', {}, { queue: 'smoke' });
// Transition to active with a past lock_until, mimicking a worker
// that claimed and then got SIGKILL'd mid-run.
await engine.executeRaw(
`UPDATE minion_jobs
SET status='active',
lock_token='smoke-sigkill-rescue',
lock_until=now() - interval '1 minute',
started_at=now() - interval '2 minute',
attempts_started = attempts_started + 1
WHERE id=$1`,
[rescueJob.id]
);
const result = await queue.handleStalled();
const afterStall = await queue.getJob(rescueJob.id);
if (afterStall?.status === 'dead') {
console.error(
`SMOKE FAIL (--sigkill-rescue) — job #${rescueJob.id} was dead-lettered on first stall. ` +
`This is the #219 regression: schema default max_stalled should rescue, not dead-letter. ` +
`handleStalled: ${JSON.stringify(result)}`
);
process.exit(1);
}
if (afterStall?.status !== 'waiting') {
console.error(
`SMOKE FAIL (--sigkill-rescue) — unexpected status after stall: ${afterStall?.status}. ` +
`Expected 'waiting' (rescued). handleStalled: ${JSON.stringify(result)}`
);
process.exit(1);
}
try { await queue.removeJob(rescueJob.id); } catch { /* non-fatal cleanup */ }
}
const cfg = (await import('../core/config.ts')).loadConfig();
const engineLabel = cfg?.engine ?? 'unknown';
const tag = sigkillRescue ? ' + SIGKILL rescue' : '';
console.log(`SMOKE PASS — Minions healthy${tag} in ${elapsedSec}s (engine: ${engineLabel})`);
if (engineLabel === 'pglite') {
console.log('Note: the `gbrain jobs work` daemon requires Postgres. PGLite');
console.log('supports inline execution only (`submit --follow`).');
}
try { await queue.removeJob(job.id); } catch { /* non-fatal cleanup */ }
process.exit(0);
break;
}
case 'work': {
@@ -511,20 +384,11 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
worker.register('embed', async (job) => {
const { runEmbedCore } = await import('./embed.ts');
// Primary Minion progress channel is job.updateProgress (DB-backed,
// readable via `gbrain jobs get <id>`). Stderr from the worker daemon
// only emits coarse job-start / job-done lines; per-page detail lives
// in the DB. Per Codex review #20.
await runEmbedCore(engine, {
slug: typeof job.data.slug === 'string' ? job.data.slug : undefined,
slugs: Array.isArray(job.data.slugs) ? (job.data.slugs as string[]) : undefined,
all: !!job.data.all,
stale: job.data.all ? false : (job.data.stale !== false),
onProgress: (done, total, embedded) => {
// Fire-and-forget: progress updates are best-effort and must not
// block the worker loop.
job.updateProgress({ done, total, embedded, phase: 'embed.pages' }).catch(() => {});
},
});
return { embedded: true };
});
@@ -589,30 +453,14 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
const steps: Record<string, unknown> = {};
const failed: string[] = [];
// Bug 8 — Between phases, yield to the event loop. The worker's lock
// renewal runs on a timer (src/core/minions/worker.ts); without a
// periodic yield, long CPU-bound phases starve the renewal callback
// and the job gets killed by the stalled-sweeper. A single
// `await new Promise(r => setImmediate(r))` gives the timer a chance
// to fire. The per-phase body is async+await already, so each phase
// internally yields on its own I/O boundaries — this is a belt for
// the gap between phases.
//
// Follow-up (deferred to v0.15): thread ctx.signal / ctx.shutdownSignal
// through each core fn so mid-phase cancellation works on huge brains.
const yieldToLoop = () => new Promise<void>(r => setImmediate(r));
try { steps.sync = await performSync(engine, { repoPath, noEmbed: true }); }
catch (e) { steps.sync = { error: e instanceof Error ? e.message : String(e) }; failed.push('sync'); }
await yieldToLoop();
try { steps.extract = await runExtractCore(engine, { mode: 'all', dir: repoPath }); }
catch (e) { steps.extract = { error: e instanceof Error ? e.message : String(e) }; failed.push('extract'); }
await yieldToLoop();
try { await runEmbedCore(engine, { stale: true }); steps.embed = { embedded: true }; }
catch (e) { steps.embed = { error: e instanceof Error ? e.message : String(e) }; failed.push('embed'); }
await yieldToLoop();
try { steps.backlinks = await runBacklinksCore({ action: 'fix', dir: repoPath }); }
catch (e) { steps.backlinks = { error: e instanceof Error ? e.message : String(e) }; failed.push('backlinks'); }
@@ -622,16 +470,4 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
}
return { partial: false, steps };
});
// Shell handler: registered ONLY when GBRAIN_ALLOW_SHELL_JOBS=1 is set on the
// worker process. Default-closed; opt-in per-host. Without the flag, shell
// jobs submitted via CLI insert rows but no worker claims them (they sit in
// 'waiting' — the CLI prints a starvation warning for that case).
if (process.env.GBRAIN_ALLOW_SHELL_JOBS === '1') {
const { shellHandler } = await import('../core/minions/handlers/shell.ts');
worker.register('shell', shellHandler);
process.stderr.write('[minion worker] shell handler enabled (GBRAIN_ALLOW_SHELL_JOBS=1)\n');
} else {
process.stderr.write('[minion worker] shell handler disabled (set GBRAIN_ALLOW_SHELL_JOBS=1 to enable)\n');
}
}
-9
View File
@@ -268,17 +268,10 @@ export async function runLint(args: string[]) {
const isSingleFile = statSync(target).isFile();
const pages = isSingleFile ? [target] : collectPages(target);
// Progress on stderr. Stdout keeps the per-issue human output it always had.
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('lint.pages', pages.length);
for (const page of pages) {
const content = readFileSync(page, 'utf-8');
const relPath = isSingleFile ? page : relative(target, page);
const issues = lintContent(content, relPath);
progress.tick(1);
if (issues.length === 0) continue;
console.log(`\n${relPath}:`);
@@ -299,8 +292,6 @@ export async function runLint(args: string[]) {
}
}
progress.finish();
// Re-run core for the aggregate counts (cheap; re-parses contents but
// produces canonical numbers for the summary line).
const result = await runLintCore({ target, fix: doFix, dryRun });
+4 -10
View File
@@ -14,8 +14,6 @@ import type { EngineConfig } from '../core/types.ts';
import { homedir } from 'os';
import { join } from 'path';
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
interface MigrateOpts {
targetEngine: 'postgres' | 'pglite';
@@ -148,9 +146,6 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
console.log(`Migrating ${pagesToMigrate.length} pages (${allPages.length} total, ${completedSet.size} already done)...`);
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('migrate.copy_pages', pagesToMigrate.length);
let migrated = 0;
for (const page of pagesToMigrate) {
// Copy page
@@ -208,21 +203,20 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
manifest!.completed_slugs.push(page.slug);
saveManifest(manifest!);
migrated++;
progress.tick(1, page.slug);
if (migrated % 50 === 0 || migrated === pagesToMigrate.length) {
console.log(` Progress: ${migrated}/${pagesToMigrate.length} pages`);
}
}
progress.finish();
// Copy links (after all pages exist in target)
console.log('Copying links...');
progress.start('migrate.copy_links', allPages.length);
for (const page of allPages) {
const links = await sourceEngine.getLinks(page.slug);
for (const link of links) {
await targetEngine.addLink(link.from_slug, link.to_slug, link.context, link.link_type);
}
progress.tick(1);
}
progress.finish();
// Copy config (selective)
const configKeys = ['embedding_model', 'embedding_dimensions', 'chunk_strategy'];
-2
View File
@@ -16,7 +16,6 @@ import { v0_12_0 } from './v0_12_0.ts';
import { v0_12_2 } from './v0_12_2.ts';
import { v0_13_0 } from './v0_13_0.ts';
import { v0_13_1 } from './v0_13_1.ts';
import { v0_14_0 } from './v0_14_0.ts';
export const migrations: Migration[] = [
v0_11_0,
@@ -24,7 +23,6 @@ export const migrations: Migration[] = [
v0_12_2,
v0_13_0,
v0_13_1,
v0_14_0,
];
/** Look up a migration by exact version string. */
+17 -8
View File
@@ -23,10 +23,8 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, lstatSync, statSync, realpathSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { execSync } from 'child_process';
import { childGlobalFlags } from '../../core/cli-options.ts';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { savePreferences, loadPreferences } from '../../core/preferences.ts';
// Bug 3 — appendCompletedMigration moved to the runner (apply-migrations.ts).
import { savePreferences, loadPreferences, appendCompletedMigration } from '../../core/preferences.ts';
import { promptLine } from '../../core/cli-util.ts';
import { VERSION } from '../../version.ts';
@@ -61,7 +59,7 @@ export interface PendingHostWorkEntry {
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 60_000, env: process.env });
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 60_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -443,11 +441,22 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const f = phaseFInstall(opts);
phases.push(f);
// Bug 3 — Phase G (record in completed.jsonl) moved to the runner. The
// runner in apply-migrations.ts persists the result after orchestrator
// returns, so we just decide the status here.
// Phase G: record in completed.jsonl. Status depends on whether any
// host work remains pending AND whether the install phase succeeded.
const status: 'complete' | 'partial' = (pending_host_work > 0) ? 'partial' : 'complete';
phases.push({ name: 'record', status: opts.dryRun ? 'skipped' : 'complete', detail: `status=${status} (ledger write in runner)` });
if (!opts.dryRun) {
appendCompletedMigration({
version: '0.11.0',
status,
mode,
files_rewritten,
autopilot_installed: f.status === 'complete',
install_target: undefined, // install target is decided inside autopilot --install
...(status === 'partial' ? { apply_migrations_pending: true } : {}),
});
}
phases.push({ name: 'record', status: opts.dryRun ? 'skipped' : 'complete', detail: `status=${status}` });
// Post-run: print pending-host-work summary if anything needs host action.
if (pending_host_work > 0) {
+11 -6
View File
@@ -32,8 +32,7 @@
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { childGlobalFlags } from '../../core/cli-options.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
import { appendCompletedMigration } from '../../core/preferences.ts';
// ── Phase A — Schema ────────────────────────────────────────
@@ -43,7 +42,7 @@ function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
// 10-minute budget. Migrations v8/v9 dedup with helper-index should be sub-second
// even on 80K-duplicate brains, but the outer wall-clock cap shouldn't be the
// failure mode (the prior 60s ceiling tripped Garry's production upgrade).
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -93,7 +92,7 @@ function phaseCBackfillLinks(opts: OrchestratorOpts): OrchestratorPhaseResult {
// --source db is idempotent: the UNIQUE constraint on
// (from_page_id, to_page_id, link_type) and ON CONFLICT DO NOTHING
// make re-runs cheap. Empty brains return 0/0 quickly.
execSync('gbrain extract links --source db' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain extract links --source db', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'backfill_links', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -104,7 +103,7 @@ function phaseCBackfillLinks(opts: OrchestratorOpts): OrchestratorPhaseResult {
function phaseDBackfillTimeline(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'backfill_timeline', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain extract timeline --source db' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain extract timeline --source db', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'backfill_timeline', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -226,7 +225,13 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
}
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
// Ledger write lives in the runner now (Bug 3).
if (status !== 'failed') {
try {
appendCompletedMigration({ version: '0.12.0', status: status as 'complete' | 'partial' });
} catch {
// Recording is best-effort.
}
}
return {
version: '0.12.0',
status,
+10 -14
View File
@@ -22,17 +22,14 @@
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { childGlobalFlags } from '../../core/cli-options.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
import { appendCompletedMigration } from '../../core/preferences.ts';
// ── Phase A — Schema ────────────────────────────────────────
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
// Propagate global progress flags so the child shows the same mode the
// parent orchestrator is running in.
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 60_000, env: process.env });
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 60_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -45,8 +42,7 @@ function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
function phaseBRepair(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'jsonb_repair', status: 'skipped', detail: 'dry-run' };
try {
// stdio: 'inherit' — child's stderr progress streams straight through.
execSync('gbrain repair-jsonb' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain repair-jsonb', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'jsonb_repair', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -59,14 +55,8 @@ function phaseBRepair(opts: OrchestratorOpts): OrchestratorPhaseResult {
function phaseCVerify(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'verify', status: 'skipped', detail: 'dry-run' };
try {
// Explicit stdio discipline: we must parse JSON off child.stdout, so
// pipe stdout but let child.stderr (progress) pass straight through.
// Any accidental stdout progress from the child would break JSON.parse
// (per Codex review #12). NOTE: we deliberately do NOT pass
// --progress-json here — this child is parsed, not watched.
const out = execSync('gbrain repair-jsonb --dry-run --json', {
encoding: 'utf-8', timeout: 60_000, env: process.env,
stdio: ['ignore', 'pipe', 'inherit'],
});
const parsed = JSON.parse(out) as { total_repaired?: number; engine?: string };
const remaining = parsed.total_repaired ?? 0;
@@ -114,7 +104,13 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
}
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
// Ledger write lives in the runner now (Bug 3).
if (status !== 'failed') {
try {
appendCompletedMigration({ version: '0.12.2', status: status as 'complete' | 'partial' });
} catch {
// Recording is best-effort.
}
}
return {
version: '0.12.2',
status,
+17 -13
View File
@@ -27,8 +27,7 @@
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts). The
// orchestrator returns its result and the runner persists it.
import { appendCompletedMigration } from '../../core/preferences.ts';
// ── Phase A — Schema ────────────────────────────────────────
//
@@ -36,18 +35,17 @@ import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhase
// and swaps the unique constraint. Schema build time on 46K pages is
// ~10s (ALTER + index builds). Bumped timeout accounts for slow Supabase
// links (v0.12.1 pattern — migrations can time out on the 60s default).
//
// Shell out to the canonical `gbrain` shim on PATH (`/usr/local/bin/gbrain`
// by default). An earlier revision resolved via the active Node/Bun runtime
// binary, but on bun-installed trees that binary is `bun` — the spawned
// `bun extract ...` gets reinterpreted as `bun run extract` and crashes the
// upgrade mid-migration. The shim is already the canonical wrapper; trust
// it. Regression guarded by test/migrations-v0_13_0.test.ts.
// Use the CURRENTLY-RUNNING binary path (not `gbrain` off $PATH). After
// `gbrain upgrade` rewrites the binary, a bare `gbrain` could resolve to
// an older installed copy via alias shadowing or stale PATH cache. The
// active process.execPath is the one that loaded THIS migration module,
// so recursing into it is always the right binary.
const GBRAIN = process.execPath;
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync(`${GBRAIN} init --migrate-only`, { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -64,7 +62,7 @@ function phaseBBackfill(opts: OrchestratorOpts): OrchestratorPhaseResult {
// `--include-frontmatter` is the v0.13 flag that enables the canonical
// frontmatter link extractor. Default-OFF in the CLI for back-compat;
// the migration explicitly opts in because this is the canonical backfill.
execSync('gbrain extract links --source db --include-frontmatter', {
execSync(`${GBRAIN} extract links --source db --include-frontmatter`, {
stdio: 'inherit',
timeout: 1_800_000, // 30 min hard cap; typical 2-5 min on 46K pages
env: process.env,
@@ -89,7 +87,7 @@ function phaseCVerify(opts: OrchestratorOpts): OrchestratorPhaseResult {
// docs-only brains, and brains with no entity pages legitimately
// produce 0. Phase B's own stdout shows `Links: created N` which is
// the authoritative signal — user sees it during upgrade.
const out = execSync('gbrain call get_stats', {
const out = execSync(`${GBRAIN} call get_stats`, {
encoding: 'utf-8', timeout: 60_000, env: process.env,
});
const parsed = JSON.parse(out) as { link_count?: number; page_count?: number };
@@ -138,7 +136,13 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
}
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
// Ledger write lives in the runner now (Bug 3).
if (status !== 'failed') {
try {
appendCompletedMigration({ version: '0.13.0', status: status as 'complete' | 'partial' });
} catch {
// Recording is best-effort.
}
}
return {
version: '0.13.0',
status,
+19 -2
View File
@@ -42,7 +42,7 @@ import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhase
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
import type { BrainEngine } from '../../core/engine.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
import { appendCompletedMigration } from '../../core/preferences.ts';
const ROLLBACK_DIR = join(homedir(), '.gbrain', 'migrations');
const ROLLBACK_FILE = join(ROLLBACK_DIR, 'v0_13_1-rollback.jsonl');
@@ -233,7 +233,24 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const anyFailed = phases.some(p => p.status === 'failed');
const status: OrchestratorResult['status'] = anyFailed ? 'partial' : 'complete';
// Bug 3 — ledger write lives in the runner now.
if (!opts.dryRun && status === 'complete') {
try {
appendCompletedMigration({
version: '0.13.1',
completed_at: new Date().toISOString(),
status: 'complete',
phases: phases.map(p => ({ name: p.name, status: p.status })),
files_rewritten: filesRewritten,
});
} catch (e) {
// Recording failure is non-fatal; migration still ran.
phases.push({
name: 'record',
status: 'failed',
detail: e instanceof Error ? e.message : String(e),
});
}
}
return {
version: '0.13.1',
-180
View File
@@ -1,180 +0,0 @@
/**
* v0.14.0 migration shell-jobs adoption + autopilot cooperative fix.
*
* Ships two phases:
*
* A. Schema: `ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 3`.
* New installs already get the bumped default from schema-embedded.ts +
* pglite-schema.ts. This ALTER is for existing brains where the table
* was created under v0.13.x (default 1). Idempotent running twice is
* a no-op because the default is a table-level attribute, not per-row.
* Existing rows keep their stored max_stalled value; only rows created
* after the ALTER pick up the new default.
*
* B. Pending-host-work ping: emit one entry to
* ~/.gbrain/migrations/pending-host-work.jsonl so the host agent knows
* to read skills/migrations/v0.14.0.md (shell-jobs adoption, autopilot
* cooperative handler wiring, GBRAIN_POOL_SIZE doc). Idempotent the
* write checks for an existing entry before appending.
*
* Ledger writes live in the runner (Bug 3). This orchestrator returns its
* result; apply-migrations.ts persists.
*/
import { existsSync, readFileSync, mkdirSync, appendFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
import type { BrainEngine } from '../../core/engine.ts';
// Resolve HOME at CALL time, not module-load time — Bun caches os.homedir()
// and ignores later HOME mutations, which breaks test isolation and scripted
// installs. Match the preferences.ts pattern.
function resolveHome(): string { return process.env.HOME || homedir(); }
function pendingHostWorkDir(): string { return join(resolveHome(), '.gbrain', 'migrations'); }
function pendingHostWorkPath(): string { return join(pendingHostWorkDir(), 'pending-host-work.jsonl'); }
// ---------------------------------------------------------------------------
// Phase A — schema: bump minion_jobs.max_stalled default 1 → 3
// ---------------------------------------------------------------------------
async function phaseASchema(opts: OrchestratorOpts): Promise<{ result: OrchestratorPhaseResult; engine: BrainEngine | null }> {
if (opts.dryRun) {
return { result: { name: 'schema', status: 'skipped', detail: 'dry-run' }, engine: null };
}
try {
const config = loadConfig();
if (!config) {
return {
result: { name: 'schema', status: 'skipped', detail: 'no brain configured (run gbrain init first)' },
engine: null,
};
}
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
try {
// Both Postgres and PGLite accept this ALTER. Idempotent at the
// table level — setting the default to 3 twice is fine.
await engine.executeRaw('ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 3');
} catch (e) {
// If minion_jobs doesn't exist yet (brand new install), the schema
// file already has the new default, so this is moot. Skip instead of
// fail.
const msg = e instanceof Error ? e.message : String(e);
if (/does not exist|no such table|relation .* does not exist/i.test(msg)) {
return {
result: { name: 'schema', status: 'skipped', detail: 'minion_jobs not yet created (fresh install)' },
engine,
};
}
throw e;
}
return { result: { name: 'schema', status: 'complete' }, engine };
} catch (e) {
return {
result: { name: 'schema', status: 'failed', detail: e instanceof Error ? e.message : String(e) },
engine: null,
};
}
}
// ---------------------------------------------------------------------------
// Phase B — emit pending-host-work entry for the v0.14.0 skill
// ---------------------------------------------------------------------------
interface PendingHostWorkEntry {
migration: string;
ts: string;
skill: string;
reason: string;
}
function existingEntryForVersion(version: string): boolean {
const p = pendingHostWorkPath();
if (!existsSync(p)) return false;
try {
const raw = readFileSync(p, 'utf-8');
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as PendingHostWorkEntry;
if (obj.migration === version) return true;
} catch { /* skip malformed */ }
}
} catch { /* read error */ }
return false;
}
function phaseBHostWork(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) {
return { name: 'host-work', status: 'skipped', detail: 'dry-run' };
}
try {
if (existingEntryForVersion('0.14.0')) {
return { name: 'host-work', status: 'skipped', detail: 'already recorded' };
}
mkdirSync(pendingHostWorkDir(), { recursive: true });
const entry: PendingHostWorkEntry = {
migration: '0.14.0',
ts: new Date().toISOString(),
skill: 'skills/migrations/v0.14.0.md',
reason: 'shell-jobs adoption + autopilot cooperative wiring',
};
appendFileSync(pendingHostWorkPath(), JSON.stringify(entry) + '\n');
return { name: 'host-work', status: 'complete', detail: pendingHostWorkPath() };
} catch (e) {
return { name: 'host-work', status: 'failed', detail: e instanceof Error ? e.message : String(e) };
}
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
const phases: OrchestratorPhaseResult[] = [];
const { result: schemaRes, engine } = await phaseASchema(opts);
phases.push(schemaRes);
try {
const hostRes = phaseBHostWork(opts);
phases.push(hostRes);
} finally {
if (engine) {
try { await engine.disconnect(); } catch { /* best-effort */ }
}
}
const anyFailed = phases.some(p => p.status === 'failed');
const status: OrchestratorResult['status'] = anyFailed ? 'partial' : 'complete';
return {
version: '0.14.0',
status,
phases,
pending_host_work: phases.some(p => p.name === 'host-work' && p.status === 'complete') ? 1 : 0,
};
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
export const v0_14_0: Migration = {
version: '0.14.0',
featurePitch: {
headline: 'Shell jobs + autopilot cooperative handler + max_stalled default bump.',
description:
'v0.14.0 unlocks `shell` as a Minion job type (gated by GBRAIN_ALLOW_SHELL_JOBS=1 ' +
'on the worker). The autopilot-cycle handler now yields to the event loop ' +
'between phases so lock renewal fires on huge brains. The minion_jobs.max_stalled ' +
'default is bumped 1→3 so one lock-lost tick no longer dead-letters a job. ' +
'Host-specific skill doc: skills/migrations/v0.14.0.md.',
},
orchestrator,
};
+6 -23
View File
@@ -14,8 +14,6 @@
import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
// --- Types ---
@@ -123,28 +121,13 @@ export async function queryOrphanPages(): Promise<{ slug: string; title: string;
* Returns structured OrphanResult with totals.
*/
export async function findOrphans(includePseudo: boolean = false): Promise<OrphanResult> {
// The NOT EXISTS anti-join over pages × links can take seconds on 50K-page
// brains. Heartbeat every second so agents see the scan is alive. Keyset
// pagination was considered and rejected: without an index on
// links.to_page_id it does no useful work. Adding that index is a
// follow-up (v0.14.3 schema migration).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('orphans.scan');
const stopHb = startHeartbeat(progress, 'scanning pages for missing inbound links…');
let allOrphans: { slug: string; title: string; domain: string | null }[];
let total: number;
try {
allOrphans = await queryOrphanPages();
const allOrphans = await queryOrphanPages();
const totalPages = allOrphans.length; // pages with no inbound links
// Count total pages in DB for the summary line
const sql = db.getConnection();
const [{ count: totalPagesCount }] = await sql`SELECT count(*)::int AS count FROM pages`;
total = Number(totalPagesCount);
} finally {
stopHb();
progress.finish();
}
const _totalPages = allOrphans.length; // pages with no inbound links (preserved for ref)
// Count total pages in DB for the summary line
const sql = db.getConnection();
const [{ count: totalPagesCount }] = await sql`SELECT count(*)::int AS count FROM pages`;
const total = Number(totalPagesCount);
const filtered = includePseudo
? allOrphans
+13 -31
View File
@@ -31,8 +31,6 @@
import { loadConfig, toEngineConfig } from '../core/config.ts';
import type { EngineConfig } from '../core/types.ts';
import * as db from '../core/db.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
interface RepairTarget {
table: string;
@@ -99,44 +97,28 @@ export async function repairJsonb(opts: RepairOpts = { dryRun: false }): Promise
await db.connect(engineCfg);
const sql = db.getConnection();
// Progress on stderr only. Stdout is reserved for the JSON summary that
// migrations/v0_12_2.ts parses via JSON.parse — stray progress lines on
// stdout would break the orchestrator (per Codex review #12).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('repair_jsonb.run', TARGETS.length);
for (const t of TARGETS) {
const phase = `repair_jsonb.${t.table}.${t.column}`;
progress.heartbeat(phase);
// Heartbeat the caller while each UPDATE runs (minutes on 50K-row tables).
const stopHb = startHeartbeat(progress, `${t.table}.${t.column}`);
let repaired = 0;
try {
if (opts.dryRun) {
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${t.table} WHERE jsonb_typeof(${t.column}) = 'string'`,
);
repaired = (rows[0] as { n: number }).n;
} else {
const rows = await sql.unsafe(
`UPDATE ${t.table}
SET ${t.column} = (${t.column} #>> '{}')::jsonb
WHERE jsonb_typeof(${t.column}) = 'string'
RETURNING 1`,
);
repaired = rows.length;
}
} finally {
stopHb();
if (opts.dryRun) {
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${t.table} WHERE jsonb_typeof(${t.column}) = 'string'`,
);
repaired = (rows[0] as { n: number }).n;
} else {
const rows = await sql.unsafe(
`UPDATE ${t.table}
SET ${t.column} = (${t.column} #>> '{}')::jsonb
WHERE jsonb_typeof(${t.column}) = 'string'
RETURNING 1`,
);
repaired = rows.length;
}
progress.tick(1, `${t.table}.${t.column}=${repaired}`);
result.per_target.push({ table: t.table, column: t.column, rows_repaired: repaired });
result.total_repaired += repaired;
}
progress.finish();
return result;
}
+1 -4
View File
@@ -18,7 +18,6 @@
import { execFileSync } from 'child_process';
import { VERSION } from '../version.ts';
import { getCliOptions } from '../core/cli-options.ts';
/**
* Resolve the gbrain binary + args for spawning subcommands from
@@ -208,9 +207,7 @@ Exit codes:
return;
}
// --quiet is parsed as a global flag in src/cli.ts (and stripped from argv
// before reaching here); honor it via the CliOptions singleton.
const quiet = getCliOptions().quiet;
const quiet = args.includes('--quiet');
const report = buildReport();
if (!quiet) {
+33 -163
View File
@@ -3,20 +3,11 @@ import { execFileSync } from 'child_process';
import { join, relative } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { importFile } from '../core/import-file.ts';
import {
buildSyncManifest,
isSyncable,
pathToSlug,
recordSyncFailures,
unacknowledgedSyncFailures,
acknowledgeSyncFailures,
} from '../core/sync.ts';
import { buildSyncManifest, isSyncable, pathToSlug } from '../core/sync.ts';
import type { SyncManifest } from '../core/sync.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export interface SyncResult {
status: 'up_to_date' | 'synced' | 'first_sync' | 'dry_run' | 'blocked_by_failures';
status: 'up_to_date' | 'synced' | 'first_sync' | 'dry_run';
fromCommit: string | null;
toCommit: string;
added: number;
@@ -25,7 +16,6 @@ export interface SyncResult {
renamed: number;
chunksCreated: number;
pagesAffected: string[];
failedFiles?: number; // count of parse failures (Bug 9)
}
export interface SyncOpts {
@@ -35,10 +25,6 @@ export interface SyncOpts {
noPull?: boolean;
noEmbed?: boolean;
noExtract?: boolean;
/** Bug 9 — acknowledge + skip past current failure set (CLI --skip-failed). */
skipFailed?: boolean;
/** Bug 9 — re-attempt unacknowledged failures explicitly (CLI --retry-failed). */
retryFailed?: boolean;
}
function git(repoPath: string, ...args: string[]): string {
@@ -192,43 +178,29 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
let chunksCreated = 0;
const start = Date.now();
// Per-file progress on stderr so agents see each step of a big sync.
// Phases: sync.deletes, sync.renames, sync.imports.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// Process deletes first (prevents slug conflicts)
if (filtered.deleted.length > 0) {
progress.start('sync.deletes', filtered.deleted.length);
for (const path of filtered.deleted) {
const slug = pathToSlug(path);
await engine.deletePage(slug);
pagesAffected.push(slug);
progress.tick(1, slug);
}
progress.finish();
for (const path of filtered.deleted) {
const slug = pathToSlug(path);
await engine.deletePage(slug);
pagesAffected.push(slug);
}
// Process renames (updateSlug preserves page_id, chunks, embeddings)
if (filtered.renamed.length > 0) {
progress.start('sync.renames', filtered.renamed.length);
for (const { from, to } of filtered.renamed) {
const oldSlug = pathToSlug(from);
const newSlug = pathToSlug(to);
try {
await engine.updateSlug(oldSlug, newSlug);
} catch {
// Slug doesn't exist or collision, treat as add
}
// Reimport at new path (picks up content changes)
const filePath = join(repoPath, to);
if (existsSync(filePath)) {
const result = await importFile(engine, filePath, to, { noEmbed });
if (result.status === 'imported') chunksCreated += result.chunks;
}
pagesAffected.push(newSlug);
progress.tick(1, newSlug);
for (const { from, to } of filtered.renamed) {
const oldSlug = pathToSlug(from);
const newSlug = pathToSlug(to);
try {
await engine.updateSlug(oldSlug, newSlug);
} catch {
// Slug doesn't exist or collision, treat as add
}
progress.finish();
// Reimport at new path (picks up content changes)
const filePath = join(repoPath, to);
if (existsSync(filePath)) {
const result = await importFile(engine, filePath, to, { noEmbed });
if (result.status === 'imported') chunksCreated += result.chunks;
}
pagesAffected.push(newSlug);
}
// Process adds and modifies.
@@ -241,77 +213,23 @@ export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<
// ep_poll whenever the diff crosses the old > 10 threshold that used to
// trigger the outer wrap. Per-file atomicity is also the right granularity:
// one file's failure should not roll back the others' successful imports.
//
// v0.15.2: per-file progress on stderr via the shared reporter.
// Bug 9: per-file failures captured in `failedFiles` so the caller can
// gate `sync.last_commit` advancement and record recoverable errors.
const failedFiles: Array<{ path: string; error: string; line?: number }> = [];
const addsAndMods = [...filtered.added, ...filtered.modified];
if (addsAndMods.length > 0) {
progress.start('sync.imports', addsAndMods.length);
for (const path of addsAndMods) {
const filePath = join(repoPath, path);
if (!existsSync(filePath)) {
progress.tick(1, `skip:${path}`);
continue;
for (const path of [...filtered.added, ...filtered.modified]) {
const filePath = join(repoPath, path);
if (!existsSync(filePath)) continue;
try {
const result = await importFile(engine, filePath, path, { noEmbed });
if (result.status === 'imported') {
chunksCreated += result.chunks;
pagesAffected.push(result.slug);
}
try {
const result = await importFile(engine, filePath, path, { noEmbed });
if (result.status === 'imported') {
chunksCreated += result.chunks;
pagesAffected.push(result.slug);
} else if (result.status === 'skipped' && (result as any).error) {
// importFile returned a non-throw skip with a reason.
failedFiles.push({ path, error: String((result as any).error) });
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error(` Warning: skipped ${path}: ${msg}`);
failedFiles.push({ path, error: msg });
}
progress.tick(1, path);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error(` Warning: skipped ${path}: ${msg}`);
}
progress.finish();
}
const elapsed = Date.now() - start;
// Bug 9 — gate the sync bookmark on success. If any per-file parse
// failed, record it to ~/.gbrain/sync-failures.jsonl and DO NOT advance
// sync.last_commit. The next sync re-walks the same diff and re-attempts
// the failed files. Escape hatches: --skip-failed acknowledges the
// current set, --retry-failed re-parses before running the normal sync.
if (failedFiles.length > 0) {
recordSyncFailures(failedFiles, headCommit);
if (!opts.skipFailed) {
console.error(
`\nSync blocked: ${failedFiles.length} file(s) failed to parse. ` +
`Fix the YAML frontmatter in the files above and re-run, or use ` +
`'gbrain sync --skip-failed' to acknowledge and move on.`,
);
// Update last_run + repo_path (progress on infra) but NOT last_commit.
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', repoPath);
return {
status: 'blocked_by_failures',
fromCommit: lastCommit,
toCommit: headCommit,
added: filtered.added.length,
modified: filtered.modified.length,
deleted: filtered.deleted.length,
renamed: filtered.renamed.length,
chunksCreated,
pagesAffected,
failedFiles: failedFiles.length,
};
}
// --skip-failed: acknowledge the now-recorded set and proceed.
const acked = acknowledgeSyncFailures();
if (acked > 0) {
console.error(` Acknowledged ${acked} failure(s) and advancing past them.`);
}
}
// Update sync state AFTER all changes succeed
await engine.setConfig('sync.last_commit', headCommit);
await engine.setConfig('sync.last_run', new Date().toISOString());
@@ -370,34 +288,7 @@ async function performFullSync(
const { runImport } = await import('./import.ts');
const importArgs = [repoPath];
if (opts.noEmbed) importArgs.push('--no-embed');
const result = await runImport(engine, importArgs, { commit: headCommit });
// Bug 9 — gate the full-sync bookmark on success. runImport already
// writes its own sync.last_commit conditionally (import.ts), but
// performFullSync is called on first-sync + force-full paths where
// the sync module owns the last_commit write. Respect the same gate.
if (result.failures.length > 0) {
recordSyncFailures(result.failures, headCommit);
if (!opts.skipFailed) {
console.error(
`\nFull sync blocked: ${result.failures.length} file(s) failed. ` +
`Fix the YAML in those files and re-run, or use '--skip-failed'.`,
);
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', repoPath);
return {
status: 'blocked_by_failures',
fromCommit: null,
toCommit: headCommit,
added: 0, modified: 0, deleted: 0, renamed: 0,
chunksCreated: result.chunksCreated,
pagesAffected: [],
failedFiles: result.failures.length,
};
}
const acked = acknowledgeSyncFailures();
if (acked > 0) console.error(` Acknowledged ${acked} failure(s) and advancing past them.`);
}
await runImport(engine, importArgs);
// Persist sync state so next sync is incremental (C1 fix: was missing)
await engine.setConfig('sync.last_commit', headCommit);
@@ -431,24 +322,8 @@ export async function runSync(engine: BrainEngine, args: string[]) {
const full = args.includes('--full');
const noPull = args.includes('--no-pull');
const noEmbed = args.includes('--no-embed');
const skipFailed = args.includes('--skip-failed');
const retryFailed = args.includes('--retry-failed');
const opts: SyncOpts = { repoPath, dryRun, full, noPull, noEmbed, skipFailed, retryFailed };
// Bug 9 — --retry-failed: before running normal sync, clear acknowledgment
// flags so the sync picks them up as fresh work. The actual re-attempt
// happens inside the regular incremental/full loop because once the commit
// pointer is behind the failures, the diff naturally revisits them.
if (retryFailed) {
const failures = unacknowledgedSyncFailures();
if (failures.length === 0) {
console.log('No unacknowledged sync failures to retry.');
} else {
console.log(`Retrying ${failures.length} previously-failed file(s)...`);
// Don't acknowledge them yet — they must succeed to clear.
}
}
const opts: SyncOpts = { repoPath, dryRun, full, noPull, noEmbed };
if (!watch) {
const result = await performSync(engine, opts);
@@ -496,10 +371,5 @@ function printSyncResult(result: SyncResult) {
break;
case 'dry_run':
break; // already printed in performSync
case 'blocked_by_failures':
console.log(`Sync BLOCKED at ${result.toCommit.slice(0, 8)}: ${result.failedFiles ?? 0} file(s) failed to parse.`);
console.log(` See ~/.gbrain/sync-failures.jsonl for details, or run 'gbrain doctor'.`);
console.log(` Fix the files then re-run 'gbrain sync', or 'gbrain sync --skip-failed' to move on.`);
break;
}
}
+4 -9
View File
@@ -56,16 +56,11 @@ export async function runUpgrade(args: string[]) {
// Save old version for post-upgrade migration detection
saveUpgradeState(oldVersion, newVersion);
// Run post-upgrade feature discovery (reads migration files from the NEW binary).
// Timeout bumped 300s → 1800s (30 min) in v0.15.2 because v0.12.0 graph
// backfill on 50K+ brains regularly exceeded the old ceiling. The heartbeat
// wiring added in v0.15.2 makes the long wait observable; a hard 300s
// cap would still kill legit migrations mid-run. Override via
// GBRAIN_POST_UPGRADE_TIMEOUT_MS env var.
const postUpgradeTimeoutMs = Number(
process.env.GBRAIN_POST_UPGRADE_TIMEOUT_MS || 1_800_000,
);
// Timeout bumped 30s → 300s because runPostUpgrade now tail-calls
// apply-migrations, which can do long work (schema, smoke, host-rewrite,
// autopilot install) on a v0.11.0→v0.11.1 jump. Codex H7.
try {
execSync('gbrain post-upgrade', { stdio: 'inherit', timeout: postUpgradeTimeoutMs });
execSync('gbrain post-upgrade', { stdio: 'inherit', timeout: 300_000 });
} catch (e) {
// post-upgrade is best-effort, don't fail the upgrade. BUT leave a
// trail so `gbrain doctor` can surface it and give the user a clear
+18 -86
View File
@@ -136,67 +136,13 @@ function extractTriggers(skillContent: string): string[] {
.filter(Boolean);
}
/**
* Scan for inlined cross-cutting rules that should reference convention
* files. Each pattern can list multiple valid delegation targets e.g.,
* notability rules live in both `conventions/quality.md` and
* `_brain-filing-rules.md`, and referencing either counts as delegation.
*/
export interface CrossCuttingPattern {
pattern: RegExp;
conventions: string[];
label: string;
}
export const CROSS_CUTTING_PATTERNS: CrossCuttingPattern[] = [
{ pattern: /iron\s*law.*back-?link/i,
conventions: ['conventions/quality.md'],
label: 'Iron Law back-linking' },
{ pattern: /citation.*format.*\[Source:/i,
conventions: ['conventions/quality.md'],
label: 'citation format rules' },
{ pattern: /notability.*gate/i,
conventions: ['conventions/quality.md', '_brain-filing-rules.md'],
label: 'notability gate' },
/** Scan for inlined cross-cutting rules that should reference convention files. */
const CROSS_CUTTING_PATTERNS = [
{ pattern: /iron\s*law.*back-?link/i, convention: 'conventions/quality.md', label: 'Iron Law back-linking' },
{ pattern: /citation.*format.*\[Source:/i, convention: 'conventions/quality.md', label: 'citation format rules' },
{ pattern: /notability.*gate/i, convention: 'conventions/quality.md', label: 'notability gate' },
];
/** Proximity window (lines) within which a delegation reference suppresses
* a DRY match. Typical skill section is 20-30 lines; 40 covers header +
* section without leaking across document-length files. */
export const DRY_PROXIMITY_LINES = 40;
export interface DelegationRef {
convention: string; // normalized relative path, e.g., 'conventions/quality.md'
line: number; // 1-indexed line number of the reference
}
/**
* Extract delegation references from skill content. Recognizes three shapes:
* 1. `> **Convention:** ... \`skills/<path>\` ...`
* 2. `> **Filing rule:** ... \`skills/<path>\` ...`
* 3. Inline backtick `\`skills/conventions/*.md\`` or
* `\`skills/_brain-filing-rules.md\``
*
* Paths are normalized by stripping the leading `skills/` so they match the
* `conventions` field of CROSS_CUTTING_PATTERNS.
*/
export function extractDelegationTargets(content: string): DelegationRef[] {
const refs: DelegationRef[] = [];
const lines = content.split('\n');
// Match backtick-wrapped skills/ paths that point at a known delegation
// target. Scoped to conventions/ subtree and _brain-filing-rules.md.
const pathRe = /`skills\/((?:conventions\/[^`]+\.md)|(?:_brain-filing-rules\.md))`/g;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
pathRe.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = pathRe.exec(line)) !== null) {
refs.push({ convention: m[1], line: i + 1 });
}
}
return refs;
}
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
@@ -371,34 +317,24 @@ export function checkResolvable(skillsDir: string): ResolvableReport {
}
}
// 5. DRY detection — inlined cross-cutting rules.
// A match is suppressed when the skill references one of the pattern's
// accepted convention files within DRY_PROXIMITY_LINES lines of the match.
// This catches the common case where a skill delegates at a section
// header but still contains prose mentioning the rule by name.
// 5. DRY detection — inlined cross-cutting rules
for (const skill of manifest) {
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) continue;
try {
const content = readFileSync(skillPath, 'utf-8');
const delegations = extractDelegationTargets(content);
for (const { pattern, conventions, label } of CROSS_CUTTING_PATTERNS) {
const globalRe = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = [...content.matchAll(globalRe)];
for (const m of matches) {
const matchLine = content.slice(0, m.index ?? 0).split('\n').length;
const suppressed = delegations.some(
d => conventions.includes(d.convention) && Math.abs(d.line - matchLine) <= DRY_PROXIMITY_LINES
);
if (suppressed) continue;
issues.push({
type: 'dry_violation',
severity: 'warning',
skill: skill.name,
message: `Skill '${skill.name}' inlines ${label} instead of delegating to a convention file`,
action: `Replace inlined rules with a reference to one of: ${conventions.join(', ')}`,
});
break; // one issue per pattern per skill
for (const { pattern, convention, label } of CROSS_CUTTING_PATTERNS) {
if (pattern.test(content)) {
// Check if the skill also references the convention file
if (!content.includes(convention)) {
issues.push({
type: 'dry_violation',
severity: 'warning',
skill: skill.name,
message: `Skill '${skill.name}' inlines ${label} instead of referencing '${convention}'`,
action: `Replace inlined rules with a reference to '${convention}'`,
});
}
}
}
} catch {
@@ -418,7 +354,3 @@ export function checkResolvable(skillsDir: string): ResolvableReport {
},
};
}
// Re-export auto-fix so callers have one canonical entry point.
export { autoFixDryViolations } from './dry-fix.ts';
export type { AutoFixOptions, AutoFixReport, FixOutcome } from './dry-fix.ts';
-147
View File
@@ -1,147 +0,0 @@
/**
* Global CLI flags parsed before command dispatch.
*
* Keeping this separate from per-command flag parsing so that
* `gbrain --progress-json doctor` works: the global flag is stripped
* before cli.ts looks at argv[0] for the subcommand.
*
* Threading: every command handler receives a resolved CliOptions object.
* Shared-operation handlers see the same values via OperationContext.cliOpts.
*/
import type { ProgressOptions } from './progress.ts';
export interface CliOptions {
quiet: boolean;
progressJson: boolean;
progressInterval: number; // ms
}
export const DEFAULT_CLI_OPTIONS: CliOptions = {
quiet: false,
progressJson: false,
progressInterval: 1000,
};
/**
* Parse recognized global flags from the front / anywhere in argv and return
* the resolved options plus the remaining argv (with global flags stripped).
*
* Recognized:
* --quiet
* --progress-json
* --progress-interval=<ms>
* --progress-interval <ms> (space-separated form)
*
* Unknown flags are passed through unchanged per-command parsers see them.
*/
export function parseGlobalFlags(argv: string[]): { cliOpts: CliOptions; rest: string[] } {
const cliOpts: CliOptions = { ...DEFAULT_CLI_OPTIONS };
const rest: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--quiet') {
cliOpts.quiet = true;
continue;
}
if (a === '--progress-json') {
cliOpts.progressJson = true;
continue;
}
if (a === '--progress-interval' && i + 1 < argv.length) {
const next = argv[i + 1];
const parsed = parseInterval(next);
if (parsed !== null) {
cliOpts.progressInterval = parsed;
i++;
continue;
}
// not a number — let per-command parser handle; pass through
rest.push(a);
continue;
}
if (a.startsWith('--progress-interval=')) {
const val = a.slice('--progress-interval='.length);
const parsed = parseInterval(val);
if (parsed !== null) {
cliOpts.progressInterval = parsed;
continue;
}
rest.push(a);
continue;
}
rest.push(a);
}
return { cliOpts, rest };
}
function parseInterval(s: string): number | null {
const n = Number(s);
if (!Number.isFinite(n) || n < 0) return null;
return Math.floor(n);
}
/**
* Map resolved CliOptions to ProgressOptions for createProgress().
*
* Mode resolution:
* --quiet 'quiet'
* --progress-json 'json'
* otherwise 'auto' (TTY: human-\r, non-TTY: human-plain)
*
* Agents that want structured events on a non-TTY stream must pass
* --progress-json explicitly. Non-TTY default is plain human lines so
* shell pipelines don't suddenly see JSON noise.
*/
export function cliOptsToProgressOptions(cliOpts: CliOptions): ProgressOptions {
if (cliOpts.quiet) return { mode: 'quiet' };
if (cliOpts.progressJson) return { mode: 'json', minIntervalMs: cliOpts.progressInterval };
return { mode: 'auto', minIntervalMs: cliOpts.progressInterval };
}
// ---------------------------------------------------------------------------
// Module-level singleton (set once by cli.ts after parsing global flags; read
// by any bulk command that wants to construct a reporter). Same pattern as
// Commander's `program.opts()`. Also threaded into OperationContext for
// shared ops that run under the MCP server (which sets its own defaults).
// ---------------------------------------------------------------------------
let activeCliOptions: CliOptions = { ...DEFAULT_CLI_OPTIONS };
export function setCliOptions(opts: CliOptions): void {
activeCliOptions = { ...opts };
}
export function getCliOptions(): CliOptions {
return activeCliOptions;
}
/**
* Reset singleton to defaults. Only used by tests.
*/
export function _resetCliOptionsForTest(): void {
activeCliOptions = { ...DEFAULT_CLI_OPTIONS };
}
/**
* Build the global-flag suffix to append to child `gbrain …` subprocess
* commands so children inherit the parent's progress-mode.
*
* Returns a string ready to concat onto an execSync command string, with
* a leading space when non-empty. E.g. " --progress-json --quiet".
*
* Empty string when nothing to propagate (so the child's behavior is
* unchanged for the common no-flag case).
*/
export function childGlobalFlags(cliOpts?: CliOptions): string {
const opts = cliOpts ?? activeCliOptions;
const parts: string[] = [];
if (opts.quiet) parts.push('--quiet');
if (opts.progressJson) parts.push('--progress-json');
if (opts.progressInterval !== DEFAULT_CLI_OPTIONS.progressInterval) {
parts.push(`--progress-interval=${opts.progressInterval}`);
}
return parts.length > 0 ? ' ' + parts.join(' ') : '';
}
+1 -37
View File
@@ -1,24 +1,8 @@
import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'fs';
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import type { EngineConfig } from './types.ts';
/**
* Where is the active DB URL coming from? Pure introspection, no connection
* attempt. Used by `gbrain doctor --fast` so the user gets a precise message
* instead of the misleading "No database configured" when GBRAIN_DATABASE_URL
* (or DATABASE_URL) is actually set.
*
* Precedence matches loadConfig(): env vars win over config-file URL. Returns
* null only when NO source provides a URL at all.
*/
export type DbUrlSource =
| 'env:GBRAIN_DATABASE_URL'
| 'env:DATABASE_URL'
| 'config-file'
| 'config-file-path' // PGLite: config file present, no URL but database_path set
| null;
// Lazy-evaluated to avoid calling homedir() at module scope (breaks in serverless/bundled environments)
function getConfigDir() { return join(homedir(), '.gbrain'); }
function getConfigPath() { return join(getConfigDir(), 'config.json'); }
@@ -86,23 +70,3 @@ export function configDir(): string {
export function configPath(): string {
return join(configDir(), 'config.json');
}
/**
* Introspect where the active DB URL would come from if we tried to connect.
* Never throws, never connects. Env vars take precedence (matches loadConfig).
*/
export function getDbUrlSource(): DbUrlSource {
if (process.env.GBRAIN_DATABASE_URL) return 'env:GBRAIN_DATABASE_URL';
if (process.env.DATABASE_URL) return 'env:DATABASE_URL';
if (!existsSync(configPath())) return null;
try {
const raw = readFileSync(configPath(), 'utf-8');
const parsed = JSON.parse(raw) as Partial<GBrainConfig>;
if (parsed.database_url) return 'config-file';
if (parsed.database_path) return 'config-file-path';
return null;
} catch {
// Config file exists but is unreadable/malformed — treat as null source.
return null;
}
}
+3 -79
View File
@@ -5,72 +5,6 @@ import { SCHEMA_SQL } from './schema-embedded.ts';
let sql: ReturnType<typeof postgres> | null = null;
let connectedUrl: string | null = null;
/**
* Default pool size for Postgres connections. Users on the Supabase transaction
* pooler (port 6543) or any multi-tenant pooler can lower this to avoid
* MaxClients errors when `gbrain upgrade` spawns subprocesses that each open
* their own pool. Set `GBRAIN_POOL_SIZE=2` (or similar) before the command.
*/
const DEFAULT_POOL_SIZE_FALLBACK = 10;
/**
* Supabase PgBouncer transaction-mode convention: port 6543 routes through
* PgBouncer, which recycles the backend connection between queries and
* invalidates per-client prepared-statement caches. On that port postgres.js
* defaults (prepare=true) surface as `prepared statement "..." does not exist`
* under sustained load and silently drop rows during sync.
*
* This is a heuristic, not a protocol guarantee. A direct-Postgres server
* deliberately bound to 6543 will also get `prepare: false`; the
* `GBRAIN_PREPARE=true` env var (or `?prepare=true` on the URL) is the
* documented escape hatch.
*/
const AUTO_DETECT_PORTS = new Set(['6543']);
/**
* Decide whether to force `prepare: true`/`false` on the postgres.js client.
*
* Precedence:
* 1. `GBRAIN_PREPARE` env var (`true`/`1` or `false`/`0`)
* 2. `?prepare=true|false` query param on the URL
* 3. Auto-detect: port 6543 `false`
* 4. Default: `undefined` (caller omits the option; postgres.js default stands)
*
* Returns `boolean | undefined`. `undefined` is meaningful callers MUST
* omit the `prepare` key entirely in that case rather than passing
* `undefined` through to `postgres(url, {prepare: undefined})`.
*/
export function resolvePrepare(url: string): boolean | undefined {
const envPrepare = process.env.GBRAIN_PREPARE;
if (envPrepare === 'false' || envPrepare === '0') return false;
if (envPrepare === 'true' || envPrepare === '1') return true;
try {
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
const urlPrepare = parsed.searchParams.get('prepare');
if (urlPrepare === 'false') return false;
if (urlPrepare === 'true') return true;
if (AUTO_DETECT_PORTS.has(parsed.port)) {
return false;
}
} catch {
// URL parse failure — fall through to default
}
return undefined;
}
export function resolvePoolSize(explicit?: number): number {
if (typeof explicit === 'number' && explicit > 0) return explicit;
const raw = process.env.GBRAIN_POOL_SIZE;
if (raw) {
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return DEFAULT_POOL_SIZE_FALLBACK;
}
export function getConnection(): ReturnType<typeof postgres> {
if (!sql) {
throw new GBrainError(
@@ -101,25 +35,15 @@ export async function connect(config: EngineConfig): Promise<void> {
}
try {
const prepare = resolvePrepare(url);
const opts: Record<string, unknown> = {
max: resolvePoolSize(),
sql = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
types: {
// Register pgvector type
bigint: postgres.BigInt,
},
};
if (typeof prepare === 'boolean') {
opts.prepare = prepare;
if (!prepare) {
console.warn(
'[gbrain] Prepared statements disabled (PgBouncer transaction-mode convention on port 6543). Override with GBRAIN_PREPARE=true if your pooler runs in session mode.',
);
}
}
sql = postgres(url, opts);
});
// Test connection
await sql`SELECT 1`;
-382
View File
@@ -1,382 +0,0 @@
/**
* dry-fix.ts Auto-repair DRY violations surfaced by checkResolvable().
*
* Called by `gbrain doctor --fix`. Scans every skill in the manifest, locates
* matches of CROSS_CUTTING_PATTERNS, expands each match to its block
* boundary, and replaces the block with a `> **Convention:** ...` reference
* line. Writes are guarded:
* - working-tree-dirty skip (preserves git-as-backup contract)
* - inside code fence skip (don't mangle example prose)
* - already delegated skip (idempotent re-runs)
* - multi-match skip (ambiguous; manual edit required)
*
* Dry-run mode returns proposed edits without writing to disk.
*/
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { execFileSync } from 'child_process';
import {
CROSS_CUTTING_PATTERNS,
DRY_PROXIMITY_LINES,
extractDelegationTargets,
type CrossCuttingPattern,
} from './check-resolvable.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AutoFixOptions {
dryRun?: boolean;
}
export type FixStatus = 'applied' | 'proposed' | 'skipped' | 'error';
export type SkipReason =
| 'working_tree_dirty'
| 'no_git_backup'
| 'inside_code_fence'
| 'already_delegated'
| 'ambiguous_multiple_matches'
| 'block_is_callout'
| 'file_missing'
| 'read_error'
| 'write_error';
export interface FixOutcome {
skill: string;
skillPath: string; // absolute
patternLabel: string;
status: FixStatus;
reason?: SkipReason | string;
before?: string; // snippet (the expanded block)
after?: string; // replacement line
}
export interface AutoFixReport {
fixed: FixOutcome[]; // applied writes (or proposals in dryRun)
skipped: FixOutcome[]; // skips and errors
}
// ---------------------------------------------------------------------------
// Block-expansion strategy map
// ---------------------------------------------------------------------------
export type BlockShape = 'bullet' | 'blockquote' | 'paragraph';
export interface Block {
startLine: number; // 0-indexed inclusive
endLine: number; // 0-indexed inclusive
}
/** Detect which block shape the line at `lineIdx` belongs to. */
export function detectBlockShape(lines: string[], lineIdx: number): BlockShape {
const line = lines[lineIdx] ?? '';
if (/^(\s*)(?:[-*]\s|\d+\.\s)/.test(line)) return 'bullet';
if (/^>\s/.test(line)) return 'blockquote';
return 'paragraph';
}
/** Expand a bullet item: start at the bullet line, end at the next sibling
* or shallower bullet (sub-bullets included). */
export function expandBullet(lines: string[], lineIdx: number): Block | null {
const line = lines[lineIdx] ?? '';
const indentMatch = line.match(/^(\s*)(?:[-*]\s|\d+\.\s)/);
if (!indentMatch) return null;
const baseIndent = indentMatch[1].length;
// Walk up to find the start of THIS bullet (in case match is on a
// continuation line of a multi-line bullet).
let start = lineIdx;
while (start > 0) {
const prev = lines[start - 1];
const prevIsBullet = /^(\s*)(?:[-*]\s|\d+\.\s)/.test(prev);
const prevIndent = prev.match(/^(\s*)/)?.[1].length ?? 0;
if (prevIsBullet && prevIndent <= baseIndent) break;
if (prev.trim() === '') break;
start--;
}
// Walk down: continue until a bullet at <= baseIndent (sibling or
// shallower), a blank line, or end of file.
let end = lineIdx;
for (let i = lineIdx + 1; i < lines.length; i++) {
const l = lines[i];
if (l.trim() === '') break;
const isBullet = /^(\s*)(?:[-*]\s|\d+\.\s)/.test(l);
const indent = l.match(/^(\s*)/)?.[1].length ?? 0;
if (isBullet && indent <= baseIndent) break;
end = i;
}
return { startLine: start, endLine: end };
}
/** Expand a blockquote: contiguous `>` lines. Returns null if the block is
* itself a `> **Convention:**` or `> **Filing rule:**` callout (don't
* rewrite a reference into a reference). */
export function expandBlockquote(lines: string[], lineIdx: number): Block | null {
if (!/^>\s/.test(lines[lineIdx] ?? '')) return null;
let start = lineIdx;
while (start > 0 && /^>\s/.test(lines[start - 1])) start--;
let end = lineIdx;
while (end + 1 < lines.length && /^>\s/.test(lines[end + 1])) end++;
const firstLine = lines[start] ?? '';
if (/\*\*(?:Convention|Filing rule):\*\*/.test(firstLine)) {
return null; // this IS a delegation callout already
}
return { startLine: start, endLine: end };
}
/** Expand a paragraph: previous blank line → next blank line. */
export function expandParagraph(lines: string[], lineIdx: number): Block | null {
let start = lineIdx;
while (start > 0 && lines[start - 1].trim() !== '') start--;
let end = lineIdx;
while (end + 1 < lines.length && lines[end + 1].trim() !== '') end++;
return { startLine: start, endLine: end };
}
export const expanders: Record<BlockShape, (lines: string[], lineIdx: number) => Block | null> = {
bullet: expandBullet,
blockquote: expandBlockquote,
paragraph: expandParagraph,
};
// ---------------------------------------------------------------------------
// Guards
// ---------------------------------------------------------------------------
/** True when the match offset sits inside a fenced code block (``` ... ```).
* Counts triple-backtick fences at line starts. Odd count = inside. */
export function isInsideCodeFence(content: string, offset: number): boolean {
const before = content.slice(0, offset);
const fenceRe = /^```/gm;
const fenceCount = (before.match(fenceRe) || []).length;
return fenceCount % 2 === 1;
}
export type WorkingTreeStatus = 'clean' | 'dirty' | 'not_a_repo';
/** Check the git state of a skill file. Three distinct outcomes callers
* must NOT conflate "not a repo" with "clean", because the auto-fix
* contract is "git is the backup" and writing to a file outside any repo
* destroys user data with no recovery path.
*
* `execFileSync` with array args bypasses the shell entirely, so paths
* with odd characters from a manifest can't inject commands. */
export function getWorkingTreeStatus(skillPath: string): WorkingTreeStatus {
try {
const out = execFileSync('git', ['status', '--porcelain', '--', skillPath], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
cwd: dirname(skillPath),
});
return out.trim().length > 0 ? 'dirty' : 'clean';
} catch {
// git exits 128 when not inside a repo; treat any non-zero the same.
return 'not_a_repo';
}
}
/** Legacy wrapper. Callers that need to distinguish not_a_repo from clean
* should use getWorkingTreeStatus() directly. */
export function isWorkingTreeDirty(skillPath: string): boolean {
return getWorkingTreeStatus(skillPath) === 'dirty';
}
// ---------------------------------------------------------------------------
// Manifest loading (duplicated from check-resolvable.ts to avoid exporting
// that internal helper — kept in sync via tests)
// ---------------------------------------------------------------------------
interface ManifestEntry {
name: string;
path: string;
}
function loadManifest(skillsDir: string): ManifestEntry[] {
const manifestPath = join(skillsDir, 'manifest.json');
if (!existsSync(manifestPath)) return [];
try {
const content = JSON.parse(readFileSync(manifestPath, 'utf-8'));
return content.skills || [];
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Main function
// ---------------------------------------------------------------------------
/**
* Auto-repair DRY violations across every skill in the manifest.
*
* @param skillsDir path to the `skills/` directory
* @param opts.dryRun if true, do not write; return proposed edits
*/
export function autoFixDryViolations(
skillsDir: string,
opts: AutoFixOptions = {}
): AutoFixReport {
const fixed: FixOutcome[] = [];
const skipped: FixOutcome[] = [];
const manifest = loadManifest(skillsDir);
for (const skill of manifest) {
const skillPath = join(skillsDir, skill.path);
if (!existsSync(skillPath)) {
// Manifest-present but file-missing is already reported by
// checkResolvable as 'missing_file'; don't double-report here.
continue;
}
let content: string;
try {
content = readFileSync(skillPath, 'utf-8');
} catch (e: any) {
skipped.push({
skill: skill.name,
skillPath,
patternLabel: '(all)',
status: 'error',
reason: 'read_error',
});
continue;
}
// Compute delegations fresh per pattern — a prior applied fix inserts
// a new Convention callout that should inform later patterns'
// idempotency checks.
let delegations = extractDelegationTargets(content);
for (const cut of CROSS_CUTTING_PATTERNS) {
const outcome = attemptFix(skill.name, skillPath, content, delegations, cut, opts);
if (!outcome) continue;
if (outcome.status === 'applied' || outcome.status === 'proposed') {
fixed.push(outcome);
if (outcome.status === 'applied') {
try {
content = readFileSync(skillPath, 'utf-8');
delegations = extractDelegationTargets(content);
} catch {
break;
}
}
} else {
skipped.push(outcome);
}
}
}
return { fixed, skipped };
}
function attemptFix(
skillName: string,
skillPath: string,
content: string,
delegations: ReturnType<typeof extractDelegationTargets>,
cut: CrossCuttingPattern,
opts: AutoFixOptions
): FixOutcome | null {
const base = {
skill: skillName,
skillPath,
patternLabel: cut.label,
};
// Find ALL matches first (for multi-match detection).
const globalRe = new RegExp(
cut.pattern.source,
cut.pattern.flags.includes('g') ? cut.pattern.flags : cut.pattern.flags + 'g'
);
const matches = [...content.matchAll(globalRe)];
if (matches.length === 0) return null;
if (matches.length > 1) {
return { ...base, status: 'skipped', reason: 'ambiguous_multiple_matches' };
}
const m = matches[0];
const offset = m.index ?? 0;
if (isInsideCodeFence(content, offset)) {
return { ...base, status: 'skipped', reason: 'inside_code_fence' };
}
// Compute match line (1-indexed) to evaluate idempotency.
// Use the same proximity window as the detector (DRY_PROXIMITY_LINES)
// so the fixer can't re-fire on blocks the detector already suppresses.
const matchLine = content.slice(0, offset).split('\n').length;
const alreadyDelegated = delegations.some(
d => cut.conventions.includes(d.convention) && Math.abs(d.line - matchLine) <= DRY_PROXIMITY_LINES
);
if (alreadyDelegated) {
return { ...base, status: 'skipped', reason: 'already_delegated' };
}
const treeStatus = getWorkingTreeStatus(skillPath);
if (treeStatus === 'dirty') {
return { ...base, status: 'skipped', reason: 'working_tree_dirty' };
}
if (treeStatus === 'not_a_repo') {
// File isn't tracked by git — writing would destroy the user's only
// copy with no rollback path. Refuse.
return { ...base, status: 'skipped', reason: 'no_git_backup' };
}
// Expand to block boundary.
const lines = content.split('\n');
const lineIdx = matchLine - 1; // 0-indexed
const shape = detectBlockShape(lines, lineIdx);
const expander = expanders[shape];
const block = expander(lines, lineIdx);
if (!block) {
return { ...base, status: 'skipped', reason: 'block_is_callout' };
}
// Build replacement line.
const canonical = cut.conventions[0];
const replacement = `> **Convention:** See \`skills/${canonical}\` for ${cut.label}.`;
// Splice: replace lines[startLine..endLine] with [replacement].
const before = lines.slice(0, block.startLine).join('\n');
const originalBlock = lines.slice(block.startLine, block.endLine + 1).join('\n');
const after = lines.slice(block.endLine + 1).join('\n');
// Preserve structure: one newline between sections, preserve the file's
// trailing newline if the original had one (POSIX convention).
const parts: string[] = [];
if (before.length > 0) parts.push(before);
parts.push(replacement);
if (after.length > 0) parts.push(after);
let next = parts.join('\n');
if (content.endsWith('\n') && !next.endsWith('\n')) {
next += '\n';
}
if (opts.dryRun) {
return {
...base,
status: 'proposed',
before: originalBlock,
after: replacement,
};
}
try {
writeFileSync(skillPath, next, 'utf-8');
} catch {
return { ...base, status: 'error', reason: 'write_error' };
}
return {
...base,
status: 'applied',
before: originalBlock,
after: replacement,
};
}
+1 -14
View File
@@ -32,19 +32,7 @@ export async function embed(text: string): Promise<Float32Array> {
return result[0];
}
export interface EmbedBatchOptions {
/**
* Optional callback fired after each 100-item sub-batch completes.
* CLI wrappers tick a reporter; Minion handlers can call
* job.updateProgress here instead of hooking the per-page callback.
*/
onBatchComplete?: (done: number, total: number) => void;
}
export async function embedBatch(
texts: string[],
options: EmbedBatchOptions = {},
): Promise<Float32Array[]> {
export async function embedBatch(texts: string[]): Promise<Float32Array[]> {
const truncated = texts.map(t => t.slice(0, MAX_CHARS));
const results: Float32Array[] = [];
@@ -53,7 +41,6 @@ export async function embedBatch(
const batch = truncated.slice(i, i + BATCH_SIZE);
const batchResults = await embedBatchWithRetry(batch);
results.push(...batchResults);
options.onBatchComplete?.(results.length, truncated.length);
}
return results;
-3
View File
@@ -50,9 +50,6 @@ export function clampSearchLimit(limit: number | undefined, defaultLimit = 20, c
}
export interface BrainEngine {
/** Discriminator: lets migrations and other consumers branch on engine kind without instanceof + dynamic imports. */
readonly kind: 'postgres' | 'pglite';
// Lifecycle
connect(config: EngineConfig): Promise<void>;
disconnect(): Promise<void>;
+1 -4
View File
@@ -146,13 +146,11 @@ export async function enrichEntity(
/**
* Enrich multiple entities with throttling between each.
* config.onProgress is called after each entity so callers can stream
* progress to a reporter (CLI) or job.updateProgress (Minion).
*/
export async function enrichEntities(
engine: BrainEngine,
requests: EnrichmentRequest[],
config?: { throttle?: boolean; onProgress?: (done: number, total: number, name: string) => void },
config?: { throttle?: boolean },
): Promise<EnrichmentResult[]> {
const results: EnrichmentResult[] = [];
for (const req of requests) {
@@ -161,7 +159,6 @@ export async function enrichEntities(
}
const result = await enrichEntity(engine, req);
results.push(result);
config?.onProgress?.(results.length, requests.length, req.name);
}
return results;
}
+19 -92
View File
@@ -17,20 +17,7 @@ import { slugifyPath } from './sync.ts';
interface Migration {
version: number;
name: string;
/** Engine-agnostic SQL. Used when `sqlFor` is absent. Set to '' for handler-only or sqlFor-only migrations. */
sql: string;
/**
* Engine-specific SQL. If present, overrides `sql` for the matching engine.
* Needed when Postgres wants CONCURRENTLY but PGLite can't honor it.
*/
sqlFor?: { postgres?: string; pglite?: string };
/**
* When false, the runner does NOT wrap the SQL in `engine.transaction()`.
* Required for `CREATE INDEX CONCURRENTLY` (which Postgres refuses inside a transaction).
* Enforced Postgres-only; ignored on PGLite (PGLite has no concurrent writers anyway).
* Defaults to true.
*/
transaction?: boolean;
handler?: (engine: BrainEngine) => Promise<void>;
}
@@ -115,7 +102,7 @@ export const MIGRATIONS: Migration[] = [
backoff_delay INTEGER NOT NULL DEFAULT 1000,
backoff_jitter REAL NOT NULL DEFAULT 0.2,
stalled_counter INTEGER NOT NULL DEFAULT 0,
max_stalled INTEGER NOT NULL DEFAULT 5,
max_stalled INTEGER NOT NULL DEFAULT 1,
lock_token TEXT,
lock_until TIMESTAMPTZ,
delay_until TIMESTAMPTZ,
@@ -368,8 +355,9 @@ export const MIGRATIONS: Migration[] = [
// midnight rollover in the user's TZ naturally creates a new row instead of
// mutating yesterday's. reserved_usd and committed_usd track reservations
// vs actuals so process death between reserve() and commit()/rollback()
// can be cleaned up by TTL scan. Rollback: DROP TABLE (regenerable from
// resolver call logs; no durable product data lives here).
// can be cleaned up by TTL scan. status and reserved_at exist for that
// reclaim path. Rollback: DROP TABLE (budget is regenerable from resolver
// call logs; no durable product data lives here).
sql: `
CREATE TABLE IF NOT EXISTS budget_ledger (
scope TEXT NOT NULL,
@@ -400,6 +388,16 @@ export const MIGRATIONS: Migration[] = [
version: 13,
name: 'minion_quiet_hours_stagger',
// Adds quiet-hours gating + deterministic stagger to Minions.
//
// quiet_hours (JSONB): {start, end, tz, policy} — checked at claim
// time by the worker, not at dispatch. A queued job inside its quiet
// window is released back to 'waiting' and claimed again outside the
// window. 'skip' policy drops the event, 'defer' re-queues.
// stagger_key (TEXT): hashed to a minute-slot offset so jobs with the
// same key don't collide when a cron boundary fires. Optional; NULL
// = no stagger. The hash lives in application code (deterministic,
// ensures same key always lands on same slot) so the column is
// just the key.
sql: `
ALTER TABLE minion_jobs ADD COLUMN IF NOT EXISTS quiet_hours JSONB;
ALTER TABLE minion_jobs ADD COLUMN IF NOT EXISTS stagger_key TEXT;
@@ -407,65 +405,6 @@ export const MIGRATIONS: Migration[] = [
ON minion_jobs(stagger_key) WHERE stagger_key IS NOT NULL;
`,
},
{
version: 14,
name: 'pages_updated_at_index',
// v0.14.1 (fix wave): fixes the 14.6s "list pages newest-first" seqscan on 31k+ row brains.
// Original report: https://github.com/garrytan/gbrain/issues/170 (PR #215).
//
// Engine-aware via handler (not SQL): Postgres uses CREATE INDEX CONCURRENTLY
// to avoid the write-blocking SHARE lock on `pages`. CONCURRENTLY refuses to
// run inside a transaction AND postgres.js's multi-statement `.unsafe()` wraps
// in an implicit transaction, so the handler runs each statement as a separate
// call. A failed CONCURRENTLY leaves an invalid index with the target name;
// the handler pre-drops any invalid remnant via pg_index.indisvalid. PGLite
// has no concurrent writers, so plain CREATE is safe.
sql: '',
handler: async (engine) => {
if (engine.kind === 'postgres') {
await engine.runMigration(
14,
`DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = 'idx_pages_updated_at_desc' AND NOT i.indisvalid
) THEN
EXECUTE 'DROP INDEX CONCURRENTLY IF EXISTS idx_pages_updated_at_desc';
END IF;
END $$;`
);
await engine.runMigration(
14,
`CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pages_updated_at_desc
ON pages (updated_at DESC);`
);
} else {
await engine.runMigration(
14,
`CREATE INDEX IF NOT EXISTS idx_pages_updated_at_desc
ON pages (updated_at DESC);`
);
}
},
},
{
version: 15,
name: 'minion_jobs_max_stalled_default_5',
// v0.14.1 (fix wave): fixes https://github.com/garrytan/gbrain/issues/219
// Shipped default was 1 — first stall = dead-letter, contradicting the
// "SIGKILL rescued" claim. New default 5. UPDATE backfills existing non-
// terminal rows so upgrading brains don't keep dead-lettering queued work.
// Statuses come from MinionJobStatus in types.ts. Row locks serialize
// against claim()'s FOR UPDATE SKIP LOCKED — race-safe. Idempotent.
sql: `
ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 5;
UPDATE minion_jobs
SET max_stalled = 5
WHERE status IN ('waiting','active','delayed','waiting-children','paused')
AND max_stalled < 5;
`,
},
];
export const LATEST_VERSION = MIGRATIONS.length > 0
@@ -479,23 +418,11 @@ export async function runMigrations(engine: BrainEngine): Promise<{ applied: num
let applied = 0;
for (const m of MIGRATIONS) {
if (m.version > current) {
// Pick SQL: engine-specific `sqlFor` wins over engine-agnostic `sql`.
const sql = m.sqlFor?.[engine.kind] ?? m.sql;
if (sql) {
const useTransaction = m.transaction !== false;
// Non-transactional path is Postgres-only: `CREATE INDEX CONCURRENTLY`
// refuses to run inside a transaction. PGLite has no concurrent
// writers, so even if a migration sets transaction:false we wrap it
// anyway (harmless; keeps behavior consistent).
if (useTransaction || engine.kind === 'pglite') {
await engine.transaction(async (tx) => {
await tx.runMigration(m.version, sql);
});
} else {
// Postgres + transaction:false → direct execution, no BEGIN/COMMIT.
await engine.runMigration(m.version, sql);
}
// SQL migration (transactional)
if (m.sql) {
await engine.transaction(async (tx) => {
await tx.runMigration(m.version, m.sql);
});
}
// Application-level handler (runs outside transaction for flexibility)
-75
View File
@@ -1,75 +0,0 @@
/**
* Shell-job submission audit log (operational trace, NOT forensic insurance).
*
* Writes a JSONL line per shell-job submission to `~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl`
* (ISO week rotation, override via `GBRAIN_AUDIT_DIR`). Best-effort: write failures go
* to stderr and never block submission, which means a disk-full attacker could silently
* disable the trail. CHANGELOG calls this out honestly: it's for debugging "what did
* this cron submit last Tuesday?", not for security-critical forensics.
*
* Never logs `env` values (may contain secrets). Does log `cmd` and `argv` truncated to
* 80 chars for cmd / stored as JSON array for argv the command text itself can contain
* inline tokens (`curl -H 'Authorization: Bearer ...'`) and the guide explicitly tells
* operators to put secrets in `env:` instead of embedding them in the command line.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
export interface ShellAuditEvent {
ts: string;
caller: 'cli' | 'mcp';
remote: boolean;
job_id: number;
cwd: string;
cmd_display?: string; // first 80 chars of cmd; may contain inline tokens
argv_display?: string[]; // each arg truncated individually to preserve separation
}
/** Compute `shell-jobs-YYYY-Www.jsonl` using ISO-8601 week numbering.
*
* Year-boundary edge: 2027-01-01 is ISO week 53 of year 2026, so the correct
* filename is `shell-jobs-2026-W53.jsonl`. This matches the ISO week standard
* (week containing the first Thursday of the year is W1; week containing Dec 28
* is always W52 or W53 of that year).
*/
export function computeAuditFilename(now: Date = new Date()): string {
// Copy date and move to nearest Thursday (ISO week anchor).
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const dayNum = (d.getUTCDay() + 6) % 7; // Mon=0, Sun=6
d.setUTCDate(d.getUTCDate() - dayNum + 3); // shift to Thursday
const isoYear = d.getUTCFullYear();
const firstThursday = new Date(Date.UTC(isoYear, 0, 4));
const firstThursdayDayNum = (firstThursday.getUTCDay() + 6) % 7;
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstThursdayDayNum + 3);
const weekNum = Math.round((d.getTime() - firstThursday.getTime()) / (7 * 86400000)) + 1;
const ww = String(weekNum).padStart(2, '0');
return `shell-jobs-${isoYear}-W${ww}.jsonl`;
}
/** Resolve the audit dir. Honors `GBRAIN_AUDIT_DIR` for container/sandbox deployments
* where `$HOME` is read-only. Defaults to `~/.gbrain/audit/`. */
export function resolveAuditDir(): string {
const override = process.env.GBRAIN_AUDIT_DIR;
if (override && override.trim().length > 0) return override;
return path.join(os.homedir(), '.gbrain', 'audit');
}
export function logShellSubmission(event: Omit<ShellAuditEvent, 'ts'>): void {
const dir = resolveAuditDir();
const filename = computeAuditFilename();
const fullPath = path.join(dir, filename);
const line = JSON.stringify({ ...event, ts: new Date().toISOString() }) + '\n';
try {
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(fullPath, line, { encoding: 'utf8' });
} catch (err) {
// Best-effort: log to stderr and keep going. A disk-full or EACCES attacker
// can silently disable this trail, which is why CHANGELOG calls it an
// operational trace, not forensic insurance.
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[shell-audit] write failed (${msg}); submission continues\n`);
}
}
-311
View File
@@ -1,311 +0,0 @@
/**
* `shell` job handler.
*
* Runs an arbitrary shell command or argv vector as a child process under the
* Minions worker. Purpose: move deterministic cron scripts (API fetch, token
* refresh, scrape + write) off the LLM gateway so they don't consume an Opus
* session each time.
*
* Security (both gates must pass):
* 1. `MinionQueue.add()` rejects name='shell' unless the caller explicitly
* opts in via `trusted.allowProtectedSubmit`. CLI path and the `submit_job`
* operation (when `ctx.remote === false`) set the flag. MCP callers don't.
* 2. This handler only registers when `process.env.GBRAIN_ALLOW_SHELL_JOBS === '1'`.
* Default: off. Without the flag the worker's `registeredNames` excludes
* shell and queued jobs stay in 'waiting'.
*
* Env model (honest): the child process receives a small allowlist (PATH, HOME,
* USER, LANG, TZ, NODE_ENV) merged with caller-supplied `job.data.env`. This
* prevents the accidental `$OPENAI_API_KEY` interpolation footgun. It does NOT
* sandbox filesystem reads a shell script can `cat ~/.env` or any file the
* worker can read. The operator picks a safe `cwd`; that's the trust boundary.
*
* Shutdown: the handler listens to BOTH `ctx.signal` (timeout/cancel/lock-loss)
* and `ctx.shutdownSignal` (worker process SIGTERM). Either triggers the same
* kill sequence: SIGTERM 5s grace SIGKILL. Non-shell handlers ignore
* `shutdownSignal` so deploy restarts don't interrupt them mid-flight.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder';
import * as path from 'node:path';
import type { MinionJobContext } from '../types.ts';
import { UnrecoverableError } from '../types.ts';
/** Environment variables passed through to shell children by default. Callers
* that need additional keys (e.g. a specific API token for a cron) must name
* them explicitly in `job.data.env`. Named keys override this allowlist. */
const SHELL_ENV_ALLOWLIST = ['PATH', 'HOME', 'USER', 'LANG', 'TZ', 'NODE_ENV'] as const;
/** Max bytes retained from stdout/stderr. Output exceeding these caps is
* truncated with a `[truncated N bytes]` marker. UTF-8-safe via StringDecoder. */
const STDOUT_TAIL_MAX_BYTES = 64 * 1024;
const STDERR_TAIL_MAX_BYTES = 16 * 1024;
/** Grace period between SIGTERM and SIGKILL. Well-behaved scripts catch SIGTERM,
* flush state, exit cleanly; non-behaving scripts get reaped. */
const KILL_GRACE_MS = 5000;
export interface ShellJobParams {
/** Shell command. Spawned via `/bin/sh -c cmd`. Exactly one of cmd or argv is required. */
cmd?: string;
/** Argv vector. Spawned directly without a shell. Exactly one of cmd or argv is required. */
argv?: string[];
/** Working directory. REQUIRED, must be an absolute path. The operator chooses
* this; it's the trust boundary for what files the script can read/write. */
cwd: string;
/** Additional env vars to pass to the child. Merged on top of SHELL_ENV_ALLOWLIST. */
env?: Record<string, string>;
}
export interface ShellJobResult {
exit_code: number;
stdout_tail: string;
stderr_tail: string;
duration_ms: number;
pid: number;
}
/** Validate and narrow `job.data` to ShellJobParams. Throws UnrecoverableError
* for misshapen input validation failures are not retry-worthy. */
function validateParams(data: Record<string, unknown>): ShellJobParams {
const hasCmd = typeof data.cmd === 'string' && data.cmd.length > 0;
const hasArgv = Array.isArray(data.argv) && data.argv.length > 0;
if (hasCmd && hasArgv) {
throw new UnrecoverableError(
'shell: specify exactly one of cmd or argv (see: docs/guides/minions-shell-jobs.md#errors)',
);
}
if (!hasCmd && !hasArgv) {
throw new UnrecoverableError(
'shell: specify exactly one of cmd or argv (see: docs/guides/minions-shell-jobs.md#errors)',
);
}
if (hasArgv) {
const argvOk = (data.argv as unknown[]).every((a) => typeof a === 'string');
if (!argvOk) {
throw new UnrecoverableError(
'shell: argv must be an array of strings (see: docs/guides/minions-shell-jobs.md#errors)',
);
}
}
if (typeof data.cwd !== 'string' || data.cwd.length === 0) {
throw new UnrecoverableError(
'shell: cwd is required and must be an absolute path (see: docs/guides/minions-shell-jobs.md#errors)',
);
}
if (!path.isAbsolute(data.cwd)) {
throw new UnrecoverableError(
'shell: cwd is required and must be an absolute path (see: docs/guides/minions-shell-jobs.md#errors)',
);
}
if (data.env !== undefined) {
if (typeof data.env !== 'object' || data.env === null || Array.isArray(data.env)) {
throw new UnrecoverableError(
'shell: env must be an object of string values (see: docs/guides/minions-shell-jobs.md#errors)',
);
}
for (const v of Object.values(data.env as Record<string, unknown>)) {
if (typeof v !== 'string') {
throw new UnrecoverableError(
'shell: env values must all be strings (see: docs/guides/minions-shell-jobs.md#errors)',
);
}
}
}
return {
cmd: hasCmd ? (data.cmd as string) : undefined,
argv: hasArgv ? (data.argv as string[]) : undefined,
cwd: data.cwd,
env: (data.env as Record<string, string> | undefined),
};
}
/** Build the child process env: SHELL_ENV_ALLOWLIST picked from process.env,
* overlaid with caller-supplied `job.data.env`. Prevents accidental leak of
* OPENAI_API_KEY / DATABASE_URL / etc. into user-authored scripts. */
function buildChildEnv(override: Record<string, string> | undefined): Record<string, string> {
const env: Record<string, string> = {};
for (const key of SHELL_ENV_ALLOWLIST) {
const v = process.env[key];
if (typeof v === 'string') env[key] = v;
}
if (override) {
for (const [k, v] of Object.entries(override)) env[k] = v;
}
return env;
}
/** Bounded-length UTF-8-safe tail buffer. Accumulates bytes via StringDecoder
* so the last `maxBytes` of output is character-safe (no split multibyte chars).
* On truncation, the emitted string is prefixed with `[truncated N bytes]`. */
class TailBuffer {
private decoder = new StringDecoder('utf8');
private body = '';
private bodyBytes = 0;
private truncatedBytes = 0;
constructor(private readonly maxBytes: number) {}
append(chunk: Buffer): void {
const str = this.decoder.write(chunk);
if (str.length === 0) return;
this.body += str;
this.bodyBytes = Buffer.byteLength(this.body, 'utf8');
this.compactIfOver();
}
private compactIfOver(): void {
if (this.bodyBytes <= this.maxBytes) return;
// We need to keep only the trailing maxBytes. Byte-slicing mid-character is
// unsafe; instead, find the highest character offset whose byte length from
// that point is <= maxBytes. Linear-scan from the end over grapheme-safe
// codepoints is good enough at 64KB scales.
const targetByteSize = this.maxBytes;
// Fast path: if body is all ASCII (1 byte per char), byteLength === length.
if (this.body.length === this.bodyBytes) {
const drop = this.bodyBytes - targetByteSize;
this.truncatedBytes += drop;
this.body = this.body.slice(drop);
this.bodyBytes = targetByteSize;
return;
}
// Slow path: find a character boundary that lands just under maxBytes.
// Scan from the end; accumulate bytes per codepoint.
let tailBytes = 0;
let cut = this.body.length;
for (let i = this.body.length - 1; i >= 0; i--) {
const code = this.body.codePointAt(i);
const cpBytes = code === undefined ? 0
: code < 0x80 ? 1
: code < 0x800 ? 2
: code < 0x10000 ? 3
: 4;
if (tailBytes + cpBytes > targetByteSize) break;
tailBytes += cpBytes;
cut = i;
}
const droppedBytes = this.bodyBytes - tailBytes;
this.truncatedBytes += droppedBytes;
this.body = this.body.slice(cut);
this.bodyBytes = tailBytes;
}
done(): string {
const tail = this.decoder.end();
if (tail.length > 0) {
this.body += tail;
this.bodyBytes = Buffer.byteLength(this.body, 'utf8');
this.compactIfOver();
}
if (this.truncatedBytes === 0) return this.body;
return `[truncated ${this.truncatedBytes} bytes]\n${this.body}`;
}
}
/** The shell handler itself. */
export async function shellHandler(ctx: MinionJobContext): Promise<ShellJobResult> {
const params = validateParams(ctx.data);
const env = buildChildEnv(params.env);
const startedAt = Date.now();
let proc: ChildProcess;
try {
if (params.cmd) {
// Absolute /bin/sh — not 'sh' — so a caller-supplied env with a poisoned
// PATH can't redirect to a different shell binary.
proc = spawn('/bin/sh', ['-c', params.cmd], {
cwd: params.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
} else {
const argv = params.argv!;
proc = spawn(argv[0], argv.slice(1), {
cwd: params.cwd,
env,
stdio: ['ignore', 'pipe', 'pipe'],
});
}
} catch (err) {
// Spawn-phase failure (e.g. cwd doesn't exist when using '/bin/sh' directly).
// Retryable.
throw err instanceof Error ? err : new Error(String(err));
}
const pid = proc.pid ?? -1;
const stdoutTail = new TailBuffer(STDOUT_TAIL_MAX_BYTES);
const stderrTail = new TailBuffer(STDERR_TAIL_MAX_BYTES);
proc.stdout?.on('data', (c: Buffer) => stdoutTail.append(c));
proc.stderr?.on('data', (c: Buffer) => stderrTail.append(c));
// Wire BOTH signals to the kill sequence. `ctx.signal` fires on timeout /
// cancel / lock-loss; `ctx.shutdownSignal` fires only on worker SIGTERM/SIGINT.
// Shell handler needs both — a deploy restart shouldn't leave children running
// past the 30s worker cleanup race.
let killTimer: ReturnType<typeof setTimeout> | null = null;
let killReason = '';
const onAbort = (label: string) => () => {
if (killTimer !== null) return; // already started
killReason = label;
if (!proc.killed) {
try { proc.kill('SIGTERM'); } catch { /* proc already exited */ }
}
killTimer = setTimeout(() => {
if (!proc.killed) {
try { proc.kill('SIGKILL'); } catch { /* already exited */ }
}
}, KILL_GRACE_MS);
};
const sigAbort = onAbort('signal');
const shutdownAbort = onAbort('shutdown');
ctx.signal.addEventListener('abort', sigAbort);
ctx.shutdownSignal.addEventListener('abort', shutdownAbort);
// Fire immediately if either already aborted before wiring
if (ctx.signal.aborted) sigAbort();
if (ctx.shutdownSignal.aborted) shutdownAbort();
const exitCode: number = await new Promise((resolve, reject) => {
proc.on('error', (err) => {
reject(err);
});
proc.on('exit', (code, signal) => {
// Node maps signal-terminated exits to a 128+N code convention; we use
// whichever is defined.
if (code !== null) resolve(code);
else if (signal === 'SIGTERM') resolve(143);
else if (signal === 'SIGKILL') resolve(137);
else resolve(-1);
});
}).finally(() => {
if (killTimer !== null) clearTimeout(killTimer);
ctx.signal.removeEventListener('abort', sigAbort);
ctx.shutdownSignal.removeEventListener('abort', shutdownAbort);
});
const duration_ms = Date.now() - startedAt;
const stdout_tail = stdoutTail.done();
const stderr_tail = stderrTail.done();
// If we sent SIGTERM/SIGKILL in response to an abort, surface that as the
// error rather than the exit code — clearer for debugging. Worker catch
// handles retry/dead classification.
if (killReason === 'signal' || killReason === 'shutdown') {
const err = new Error(
`aborted: ${killReason === 'shutdown' ? 'shutdown' : (ctx.signal.reason as Error)?.message || 'signal'}`,
);
throw err;
}
if (exitCode !== 0) {
throw new Error(
`exit ${exitCode}: ${stderr_tail.slice(-500)}`,
);
}
return { exit_code: exitCode, stdout_tail, stderr_tail, duration_ms, pid };
}
-20
View File
@@ -1,20 +0,0 @@
/**
* Protected job names side-effect-free constant module.
*
* Names in this set require an explicit `trusted.allowProtectedSubmit: true` opt-in
* when passed to `MinionQueue.add()`. The CLI path and the `submit_job` operation
* (when `ctx.remote === false`) set the flag; MCP callers never do. Defense-in-depth
* against in-process handlers that programmatically submit a shell child via
* `queue.add('shell', ...)`.
*
* This file must stay pure no imports from handlers, no filesystem, no env reads.
* Queue core imports it; if this module grew side effects, every queue user would
* pay them at module load.
*/
export const PROTECTED_JOB_NAMES: ReadonlySet<string> = new Set(['shell']);
/** Check a job name against the protected set. Normalizes whitespace first. */
export function isProtectedJobName(name: string): boolean {
return PROTECTED_JOB_NAMES.has(name.trim());
}
+14 -51
View File
@@ -15,16 +15,6 @@ import type {
} from './types.ts';
import { rowToMinionJob, rowToInboxMessage, rowToAttachment } from './types.ts';
import { validateAttachment } from './attachments.ts';
import { isProtectedJobName } from './protected-names.ts';
/** Options for opting into protected-job-name submission. Passed as a separate
* 4th arg to `MinionQueue.add()` (NOT folded into `opts`) so user-spread
* `{...userOpts}` payloads can't accidentally carry the trust flag. */
export interface TrustedSubmitOpts {
/** When true, allow submission of names in PROTECTED_JOB_NAMES (currently 'shell').
* Set only by the CLI path and by `submit_job` when `ctx.remote === false`. */
allowProtectedSubmit?: boolean;
}
const MIGRATION_VERSION = 7;
@@ -65,25 +55,10 @@ export class MinionQueue {
* to 'waiting-children' atomically. Idempotency_key dedups via PG unique
* partial index; same key returns the existing row (no second insert).
*/
async add(
name: string,
data?: Record<string, unknown>,
opts?: Partial<MinionJobInput>,
trusted?: TrustedSubmitOpts,
): Promise<MinionJob> {
// Normalize first so the protected-name check and the insert use the same
// canonical form. Without the trim-before-check, `queue.add(' shell ', ...)`
// would evade the guard and insert a job literally named 'shell'.
const jobName = (name || '').trim();
if (jobName.length === 0) {
async add(name: string, data?: Record<string, unknown>, opts?: Partial<MinionJobInput>): Promise<MinionJob> {
if (!name || name.trim().length === 0) {
throw new Error('Job name cannot be empty');
}
if (isProtectedJobName(jobName) && !trusted?.allowProtectedSubmit) {
throw new Error(
`protected job name '${jobName}' requires CLI or operation-local submitter ` +
`(pass {allowProtectedSubmit: true} as the 4th arg to MinionQueue.add)`,
);
}
await this.ensureSchema();
const childStatus: MinionJobStatus = opts?.delay ? 'delayed' : 'waiting';
@@ -134,35 +109,24 @@ export class MinionQueue {
// 3. Insert child. Use ON CONFLICT for idempotency; if a concurrent submit
// raced past the fast-path SELECT, the unique index catches it here.
// v13 quiet_hours + stagger_key always present (null fallback; schema
// stores NULL). v15 max_stalled is conditional: provided values get
// clamped to [1, 100] and included in the INSERT; omitted values
// skip the column so the schema DEFAULT (5 as of v0.14.1) kicks in.
// Keeps the app layer from hardcoding the schema default constant.
const hasMaxStalled = opts?.max_stalled !== undefined && opts.max_stalled !== null;
const clampedMaxStalled = hasMaxStalled
? Math.max(1, Math.min(100, Math.floor(opts!.max_stalled as number)))
: null;
const baseCols = `name, queue, status, priority, data, max_attempts, backoff_type,
// v12 adds quiet_hours + stagger_key passed through from opts.
const insertSql = opts?.idempotency_key
? `INSERT INTO minion_jobs (name, queue, status, priority, data, max_attempts, backoff_type,
backoff_delay, backoff_jitter, delay_until, parent_job_id, on_child_fail,
depth, max_children, timeout_ms, remove_on_complete, remove_on_fail, idempotency_key,
quiet_hours, stagger_key`;
const baseVals = `$1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::jsonb, $20`;
const cols = hasMaxStalled ? `${baseCols}, max_stalled` : baseCols;
const vals = hasMaxStalled ? `${baseVals}, $21` : baseVals;
const insertSql = opts?.idempotency_key
? `INSERT INTO minion_jobs (${cols})
VALUES (${vals})
quiet_hours, stagger_key)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::jsonb, $20)
ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING
RETURNING *`
: `INSERT INTO minion_jobs (${cols})
VALUES (${vals})
: `INSERT INTO minion_jobs (name, queue, status, priority, data, max_attempts, backoff_type,
backoff_delay, backoff_jitter, delay_until, parent_job_id, on_child_fail,
depth, max_children, timeout_ms, remove_on_complete, remove_on_fail, idempotency_key,
quiet_hours, stagger_key)
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::jsonb, $20)
RETURNING *`;
const params: unknown[] = [
jobName,
const params = [
name.trim(),
opts?.queue ?? 'default',
childStatus,
opts?.priority ?? 0,
@@ -183,7 +147,6 @@ export class MinionQueue {
opts?.quiet_hours ?? null,
opts?.stagger_key ?? null,
];
if (hasMaxStalled) params.push(clampedMaxStalled);
const inserted = await tx.executeRaw<Record<string, unknown>>(insertSql, params);
+1 -12
View File
@@ -103,12 +103,6 @@ export interface MinionJobInput {
backoff_type?: BackoffType;
backoff_delay?: number;
backoff_jitter?: number;
/**
* Max number of stall windows before dead-letter. Default is the schema
* default (5 as of v0.13.1). Clamped to [1, 100] on insert values
* outside that range are silently coerced. See migration v13.
*/
max_stalled?: number;
delay?: number; // ms delay before eligible
parent_job_id?: number;
on_child_fail?: ChildFailPolicy;
@@ -165,13 +159,8 @@ export interface MinionJobContext {
name: string;
data: Record<string, unknown>;
attempts_made: number;
/** AbortSignal for cooperative cancellation (fires on timeout, cancel, pause, or lock loss). */
/** AbortSignal for cooperative cancellation (fires on pause or lock loss). */
signal: AbortSignal;
/** AbortSignal that fires only on worker process SIGTERM/SIGINT. Handlers sensitive
* to deploy restarts (e.g. the shell handler, which must run a SIGTERM 5s SIGKILL
* sequence on its child) listen to this in addition to `signal`. Most handlers can
* ignore it workers give them the full 30s cleanup race to finish naturally. */
shutdownSignal: AbortSignal;
/** Update structured progress (not just 0-100). */
updateProgress(progress: unknown): Promise<void>;
/** Accumulate token usage for this job. */
+8 -36
View File
@@ -49,13 +49,6 @@ export class MinionWorker {
private inFlight = new Map<number, InFlightJob>();
private workerId = randomUUID();
/** Fires only on worker process SIGTERM/SIGINT. Handlers that need to run
* shutdown-specific cleanup (e.g. shell handler's SIGTERMSIGKILL sequence on
* its child) subscribe via `ctx.shutdownSignal`. Separated from the per-job
* abort controller so non-shell handlers don't get cancelled mid-flight on
* deploy restart they still get the full 30s cleanup race instead. */
private shutdownAbort = new AbortController();
private opts: Required<MinionWorkerOpts>;
constructor(
@@ -95,16 +88,10 @@ export class MinionWorker {
await this.queue.ensureSchema();
this.running = true;
// Graceful shutdown. Fires shutdownAbort so handlers subscribed to
// `ctx.shutdownSignal` (currently: shell handler) can run their own cleanup
// BEFORE the 30s cleanup race expires. Non-shell handlers ignore shutdown
// and keep running — they get the full 30s window.
// Graceful shutdown
const shutdown = () => {
console.log('Minion worker shutting down...');
this.running = false;
if (!this.shutdownAbort.signal.aborted) {
this.shutdownAbort.abort(new Error('shutdown'));
}
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
@@ -259,7 +246,7 @@ export class MinionWorker {
if (!renewed) {
console.warn(`Lock lost for job ${job.id}, aborting execution`);
clearInterval(lockTimer);
abort.abort(new Error('lock-lost'));
abort.abort();
}
}, this.opts.lockDuration / 2);
@@ -273,7 +260,7 @@ export class MinionWorker {
timeoutTimer = setTimeout(() => {
if (!abort.signal.aborted) {
console.warn(`Job ${job.id} (${job.name}) hit per-job timeout (${job.timeout_ms}ms), aborting`);
abort.abort(new Error('timeout'));
abort.abort();
}
}, job.timeout_ms);
}
@@ -300,18 +287,13 @@ export class MinionWorker {
return;
}
// Build job context with per-job AbortSignal + shared shutdown signal.
// Most handlers only care about `signal` (timeout / cancel / lock-loss).
// `shutdownSignal` is separate: fires only on worker process SIGTERM/SIGINT.
// Handlers that need to run cleanup before worker exit (shell handler's
// SIGTERM→5s→SIGKILL on its child) subscribe to shutdownSignal too.
// Build job context with per-job AbortSignal
const context: MinionJobContext = {
id: job.id,
name: job.name,
data: job.data,
attempts_made: job.attempts_made,
signal: abort.signal,
shutdownSignal: this.shutdownAbort.signal,
updateProgress: async (progress: unknown) => {
await this.queue.updateProgress(job.id, lockToken, progress);
},
@@ -361,23 +343,13 @@ export class MinionWorker {
} catch (err) {
clearInterval(lockTimer);
// If the per-job abort fired, derive the reason from signal.reason (set
// by whichever site aborted: 'timeout' / 'cancel' / 'lock-lost'). We call
// failJob unconditionally — the DB match on status='active' + lock_token
// makes it idempotent: if another path (handleTimeouts, cancelJob, stall)
// already flipped status, our call no-ops cleanly. The prior silent-return
// left jobs stranded in 'active' until a secondary sweep, breaking
// timeout/cancel contracts downstream callers rely on.
let errorText: string;
// If aborted (paused or lock lost), don't try to fail the job
if (abort.signal.aborted) {
const reason = abort.signal.reason instanceof Error
? abort.signal.reason.message
: String(abort.signal.reason || 'aborted');
errorText = `aborted: ${reason}`;
} else {
errorText = err instanceof Error ? err.message : String(err);
console.log(`Job ${job.id} (${job.name}) aborted (paused or lock lost)`);
return;
}
const errorText = err instanceof Error ? err.message : String(err);
const isUnrecoverable = err instanceof UnrecoverableError;
const attemptsExhausted = job.attempts_made + 1 >= job.max_attempts;
+5 -30
View File
@@ -167,13 +167,6 @@ export interface OperationContext {
* When unset, operations MUST default to the stricter (remote=true) behavior.
*/
remote?: boolean;
/**
* Resolved global CLI options (--quiet / --progress-json / --progress-interval).
* CLI callers populate this from `getCliOptions()`. MCP / library callers
* may leave it undefined consumers default to quiet/no-progress for
* background work.
*/
cliOpts?: { quiet: boolean; progressJson: boolean; progressInterval: number };
}
export interface Operation {
@@ -1054,44 +1047,26 @@ const file_url: Operation = {
const submit_job: Operation = {
name: 'submit_job',
description: 'Submit a background job to the Minions queue. Built-in types: sync, embed, lint, import, extract, backlinks, autopilot-cycle. The `shell` type is CLI-only and rejected over MCP.',
description: 'Submit a background job to the Minions queue',
params: {
name: { type: 'string', required: true, description: 'Job type (sync, embed, lint, import, extract, backlinks, autopilot-cycle; shell is CLI-only)' },
name: { type: 'string', required: true, description: 'Job type (sync, embed, lint, import)' },
data: { type: 'object', description: 'Job payload (JSON)' },
queue: { type: 'string', description: 'Queue name (default: "default")' },
priority: { type: 'number', description: 'Priority (0 = highest, default: 0)' },
max_attempts: { type: 'number', description: 'Max retry attempts (default: 3)' },
delay: { type: 'number', description: 'Delay in ms before eligible' },
timeout_ms: { type: 'number', description: 'Per-job wall-clock timeout in ms; aborted job goes to dead' },
},
mutating: true,
handler: async (ctx, p) => {
const name = typeof p.name === 'string' ? p.name.trim() : '';
if (ctx.dryRun) return { dry_run: true, action: 'submit_job', name };
// Submit-side MCP guard: reject protected job names from untrusted callers
// BEFORE we touch the DB. This is the first of the two security layers
// (the second is MinionQueue.add's check). Independent of the worker-side
// GBRAIN_ALLOW_SHELL_JOBS env flag — even if that flag is on, MCP callers
// cannot submit protected-type jobs.
const { isProtectedJobName } = await import('./minions/protected-names.ts');
if (ctx.remote && isProtectedJobName(name)) {
throw new OperationError('permission_denied', `'${name}' jobs cannot be submitted over MCP (CLI-only for security)`);
}
if (ctx.dryRun) return { dry_run: true, action: 'submit_job', name: p.name };
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
// Trusted flag set only when this is a local (non-remote) submission. When
// remote=true, the guard above has already thrown for protected names, so
// passing undefined here is safe for any non-protected name that slips by.
const trusted = !ctx.remote && isProtectedJobName(name) ? { allowProtectedSubmit: true } : undefined;
return queue.add(name, (p.data as Record<string, unknown>) || {}, {
return queue.add(p.name as string, (p.data as Record<string, unknown>) || {}, {
queue: (p.queue as string) || 'default',
priority: (p.priority as number) || 0,
max_attempts: (p.max_attempts as number) || 3,
delay: (p.delay as number) || undefined,
timeout_ms: (p.timeout_ms as number) || undefined,
}, trusted);
});
},
};
+9 -49
View File
@@ -24,7 +24,6 @@ import { validateSlug, contentHash, rowToPage, rowToChunk, rowToSearchResult } f
type PGLiteDB = PGlite;
export class PGLiteEngine implements BrainEngine {
readonly kind = 'pglite' as const;
private _db: PGLiteDB | null = null;
private _lock: LockHandle | null = null;
@@ -44,32 +43,10 @@ export class PGLiteEngine implements BrainEngine {
throw new Error('Could not acquire PGLite lock. Another gbrain process is using the database.');
}
try {
this._db = await PGlite.create({
dataDir,
extensions: { vector, pg_trgm },
});
} catch (err) {
// v0.13.1: any PGLite.create() failure becomes actionable. Most commonly
// this is the macOS 26.3 WASM bug (#223). We deliberately do NOT suggest
// "missing migrations" as a cause — migrations run AFTER create(), so a
// create-time abort has nothing to do with them. Nest the original error
// message so debugging isn't erased.
const original = err instanceof Error ? err.message : String(err);
const wrapped = new Error(
`PGLite failed to initialize its WASM runtime.\n` +
` This is most commonly the macOS 26.3 WASM bug: https://github.com/garrytan/gbrain/issues/223\n` +
` Run \`gbrain doctor\` for a full diagnosis.\n` +
` Original error: ${original}`
);
// Release the lock so a fresh process can try again; leaking the lock
// here turns a recoverable init error into a stuck-brain state.
if (this._lock?.acquired) {
try { await releaseLock(this._lock); } catch { /* ignore cleanup error */ }
this._lock = null;
}
throw wrapped;
}
this._db = await PGlite.create({
dataDir,
extensions: { vector, pg_trgm },
});
}
async disconnect(): Promise<void> {
@@ -504,12 +481,7 @@ export class PGLiteEngine implements BrainEngine {
)
SELECT DISTINCT g.slug, g.title, g.type, g.depth,
coalesce(
-- jsonb_agg(DISTINCT ...) collapses duplicate (to_slug, link_type)
-- edges that originate from different provenance (markdown body
-- vs frontmatter vs auto-extracted). Presentation-only dedup;
-- the links table still preserves every provenance row. See
-- plan Bug 6/10.
(SELECT jsonb_agg(DISTINCT jsonb_build_object('to_slug', p3.slug, 'link_type', l2.link_type))
(SELECT jsonb_agg(jsonb_build_object('to_slug', p3.slug, 'link_type', l2.link_type))
FROM links l2
JOIN pages p3 ON p3.id = l2.to_page_id
WHERE l2.from_page_id = g.id),
@@ -878,8 +850,6 @@ export class PGLiteEngine implements BrainEngine {
(SELECT count(*) FROM pages p
WHERE p.updated_at < (SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id)
) as stale_pages,
-- Bug 11 orphan = islanded (no inbound AND no outbound).
-- See BrainHealth.orphan_pages docstring; docs updated to match this.
(SELECT count(*) FROM pages p
WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)
@@ -920,14 +890,10 @@ export class PGLiteEngine implements BrainEngine {
const timelineCoverageDensity = pageCount > 0 ? Math.min(pagesWithTimeline / pageCount, 1) : 0;
const noOrphans = pageCount > 0 ? 1 - (orphanPages / pageCount) : 1;
const noDeadLinks = pageCount > 0 ? 1 - Math.min(deadLinks / pageCount, 1) : 1;
// Bug 11 — per-component points. Sum equals brainScore by construction
// so `doctor` can render a breakdown that adds up to the total.
const embedCoverageScore = pageCount === 0 ? 0 : Math.round(embedCoverage * 35);
const linkDensityScore = pageCount === 0 ? 0 : Math.round(linkDensity * 25);
const timelineCoverageScore = pageCount === 0 ? 0 : Math.round(timelineCoverageDensity * 15);
const noOrphansScore = pageCount === 0 ? 0 : Math.round(noOrphans * 15);
const noDeadLinksScore = pageCount === 0 ? 0 : Math.round(noDeadLinks * 10);
const brainScore = embedCoverageScore + linkDensityScore + timelineCoverageScore + noOrphansScore + noDeadLinksScore;
const brainScore = pageCount === 0 ? 0 : Math.round(
(embedCoverage * 0.35 + linkDensity * 0.25 + timelineCoverageDensity * 0.15 +
noOrphans * 0.15 + noDeadLinks * 0.10) * 100
);
return {
page_count: pageCount,
@@ -936,18 +902,12 @@ export class PGLiteEngine implements BrainEngine {
orphan_pages: orphanPages,
missing_embeddings: Number(r.missing_embeddings),
brain_score: brainScore,
dead_links: deadLinks,
link_coverage: Number(r.link_coverage),
timeline_coverage: Number(r.timeline_coverage),
most_connected: (connected as { slug: string; link_count: number }[]).map(c => ({
slug: c.slug,
link_count: Number(c.link_count),
})),
embed_coverage_score: embedCoverageScore,
link_density_score: linkDensityScore,
timeline_coverage_score: timelineCoverageScore,
no_orphans_score: noOrphansScore,
no_dead_links_score: noDeadLinksScore,
};
}
+1 -1
View File
@@ -185,7 +185,7 @@ CREATE TABLE IF NOT EXISTS minion_jobs (
backoff_delay INTEGER NOT NULL DEFAULT 1000,
backoff_jitter REAL NOT NULL DEFAULT 0.2,
stalled_counter INTEGER NOT NULL DEFAULT 0,
max_stalled INTEGER NOT NULL DEFAULT 5,
max_stalled INTEGER NOT NULL DEFAULT 1,
lock_token TEXT,
lock_until TIMESTAMPTZ,
delay_until TIMESTAMPTZ,
+13 -45
View File
@@ -20,7 +20,6 @@ import * as db from './db.ts';
import { validateSlug, contentHash, rowToPage, rowToChunk, rowToSearchResult, parseEmbedding, tryParseEmbedding } from './utils.ts';
export class PostgresEngine implements BrainEngine {
readonly kind = 'postgres' as const;
private _sql: ReturnType<typeof postgres> | null = null;
// Instance connection (for workers) or fall back to module global (backward compat)
@@ -32,27 +31,15 @@ export class PostgresEngine implements BrainEngine {
// Lifecycle
async connect(config: EngineConfig & { poolSize?: number }): Promise<void> {
if (config.poolSize) {
// Instance-level connection for worker isolation. resolvePoolSize lets
// GBRAIN_POOL_SIZE cap below the caller's requested size when set — the
// env var is a user escape hatch, so it wins.
// Instance-level connection for worker isolation
const url = config.database_url;
if (!url) throw new GBrainError('No database URL', 'database_url is missing', 'Provide --url');
const size = Math.min(config.poolSize, db.resolvePoolSize(config.poolSize));
// Honor PgBouncer transaction-mode detection on worker-instance pools too.
// Without this, `gbrain jobs work` against a Supabase pooler URL hits
// "prepared statement does not exist" under load just like the module
// singleton did before v0.15.4.
const prepare = db.resolvePrepare(url);
const opts: Record<string, unknown> = {
max: size,
this._sql = postgres(url, {
max: config.poolSize,
idle_timeout: 20,
connect_timeout: 10,
types: { bigint: postgres.BigInt },
};
if (typeof prepare === 'boolean') {
opts.prepare = prepare;
}
this._sql = postgres(url, opts);
});
await this._sql`SELECT 1`;
} else {
// Module-level singleton (backward compat for CLI main engine)
@@ -553,14 +540,7 @@ export class PostgresEngine implements BrainEngine {
)
SELECT DISTINCT g.slug, g.title, g.type, g.depth,
coalesce(
-- jsonb_agg(DISTINCT ...) collapses duplicate (to_slug, link_type)
-- edges that originate from different provenance (markdown body
-- vs frontmatter vs auto-extracted). The underlying links table
-- preserves every row with its origin_page_id / link_source
-- the dedup is presentation-only for the legacy traverseGraph
-- aggregation. traversePaths has its own in-memory dedup at a
-- different layer. See plan Bug 6/10.
(SELECT jsonb_agg(DISTINCT jsonb_build_object('to_slug', p3.slug, 'link_type', l2.link_type))
(SELECT jsonb_agg(jsonb_build_object('to_slug', p3.slug, 'link_type', l2.link_type))
FROM links l2
JOIN pages p3 ON p3.id = l2.to_page_id
WHERE l2.from_page_id = g.id),
@@ -913,12 +893,9 @@ export class PostgresEngine implements BrainEngine {
async getHealth(): Promise<BrainHealth> {
const sql = this.sql;
// Bug 11 doc-drift fix — orphan_pages means "islanded" (no inbound AND
// no outbound links), aligning both engines with the user-facing
// definition. The type comment previously said "no inbound" but the
// SQL required both — docs now match code so users can trust the
// number. A hub page that links out to many but has no back-references
// is working as intended, not an orphan.
// dead_links omitted (always 0 under ON DELETE CASCADE on link FKs).
// orphan_pages now matches PGLite definition: no inbound links (regardless of outbound).
// stale_pages aligned to PGLite definition (page updated_at < latest timeline entry).
const [h] = await sql`
WITH entity_pages AS (
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
@@ -966,16 +943,13 @@ export class PostgresEngine implements BrainEngine {
// brain_score: 0-100 weighted average
const linkDensity = pageCount > 0 ? Math.min(linkCount / pageCount, 1) : 0;
const timelineCoverageWhole = pageCount > 0 ? Math.min(pagesWithTimeline / pageCount, 1) : 0;
const timelineCoverage = pageCount > 0 ? Math.min(pagesWithTimeline / pageCount, 1) : 0;
const noOrphans = pageCount > 0 ? 1 - (orphanPages / pageCount) : 1;
const noDeadLinks = pageCount > 0 ? 1 - Math.min(deadLinks / pageCount, 1) : 1;
// Per-component points. Sum equals brainScore by construction.
const embedCoverageScore = pageCount === 0 ? 0 : Math.round(embedCoverage * 35);
const linkDensityScore = pageCount === 0 ? 0 : Math.round(linkDensity * 25);
const timelineCoverageScore = pageCount === 0 ? 0 : Math.round(timelineCoverageWhole * 15);
const noOrphansScore = pageCount === 0 ? 0 : Math.round(noOrphans * 15);
const noDeadLinksScore = pageCount === 0 ? 0 : Math.round(noDeadLinks * 10);
const brainScore = embedCoverageScore + linkDensityScore + timelineCoverageScore + noOrphansScore + noDeadLinksScore;
const brainScore = pageCount === 0 ? 0 : Math.round(
(embedCoverage * 0.35 + linkDensity * 0.25 + timelineCoverage * 0.15 +
noOrphans * 0.15 + noDeadLinks * 0.10) * 100
);
return {
page_count: pageCount,
@@ -984,18 +958,12 @@ export class PostgresEngine implements BrainEngine {
orphan_pages: orphanPages,
missing_embeddings: Number(h.missing_embeddings),
brain_score: brainScore,
dead_links: deadLinks,
link_coverage: Number(h.link_coverage),
timeline_coverage: Number(h.timeline_coverage),
most_connected: (connected as { slug: string; link_count: number }[]).map(c => ({
slug: c.slug,
link_count: Number(c.link_count),
})),
embed_coverage_score: embedCoverageScore,
link_density_score: linkDensityScore,
timeline_coverage_score: timelineCoverageScore,
no_orphans_score: noOrphansScore,
no_dead_links_score: noDeadLinksScore,
};
}
+3 -26
View File
@@ -33,23 +33,12 @@ export interface Preferences {
export interface CompletedMigrationEntry {
version: string;
ts?: string;
/**
* - `complete` orchestrator finished cleanly. Terminal state; future
* runs no-op this version unless `retry` is appended.
* - `partial` orchestrator ran but reported missed phases; re-run is
* expected. Attempt cap (3 consecutive partials without a `complete`
* or `retry` between them) triggers the "wedged" skip in the runner.
* - `retry` explicit reset marker written by `--force-retry`.
* Clears a wedge without faking success; the next upgrade treats the
* version as fresh again.
*/
status: 'complete' | 'partial' | 'retry';
status: 'complete' | 'partial';
mode?: MinionMode;
files_rewritten?: number;
autopilot_installed?: boolean;
install_target?: string;
apply_migrations_pending?: boolean;
phases?: Array<{ name: string; status: string; detail?: string }>;
[key: string]: unknown;
}
@@ -114,20 +103,8 @@ export function savePreferences(prefs: Preferences): void {
*/
export function appendCompletedMigration(entry: CompletedMigrationEntry): void {
if (!entry.version) throw new Error('appendCompletedMigration: version required');
if (entry.status !== 'complete' && entry.status !== 'partial' && entry.status !== 'retry') {
throw new Error(`appendCompletedMigration: status must be 'complete', 'partial', or 'retry', got "${entry.status}"`);
}
// Bug 3 — idempotency guard. If the most recent existing entry for this
// version is already 'complete' and we're about to write another
// 'complete', skip. This protects against accidental double-writes
// during the Bug 3 runner-owned-ledger transition (old orchestrator
// code paths and new runner path shouldn't both write).
if (entry.status === 'complete') {
const existing = loadCompletedMigrations();
const prior = existing.filter(e => e.version === entry.version);
if (prior.length > 0 && prior[prior.length - 1].status === 'complete') {
return; // no-op — already terminal
}
if (entry.status !== 'complete' && entry.status !== 'partial') {
throw new Error(`appendCompletedMigration: status must be 'complete' or 'partial', got "${entry.status}"`);
}
const full: CompletedMigrationEntry = {
ts: new Date().toISOString(),
-477
View File
@@ -1,477 +0,0 @@
/**
* Bulk-action progress reporter.
*
* Single source of truth for per-object progress on long-running binaries
* (doctor, embed, sync, extract, etc.). Writes to stderr so stdout stays
* clean for data / JSON output that agents parse.
*
* Modes:
* auto (default): isTTY ? human-\r : human-plain one-line-per-event
* human: force human rendering
* json: emit one JSON object per line (see schema below)
* quiet: no output
*
* JSON event schema (stable from v0.15.2, additive only):
* {"event":"start","phase":"<snake.dot.path>","total"?:N,"ts":"<iso>"}
* {"event":"tick","phase":"...","done":N,"total"?:N,"pct"?:F,"elapsed_ms":N,"eta_ms"?:N,"ts":"..."}
* {"event":"heartbeat","phase":"...","note":"<str>","elapsed_ms":N,"ts":"..."}
* {"event":"finish","phase":"...","done"?:N,"total"?:N,"elapsed_ms":N,"ts":"..."}
* {"event":"abort","phase":"...","reason":"<SIGINT|SIGTERM>","elapsed_ms":N,"ts":"..."}
*
* Rules:
* - phase uses snake_case dot-separated machine-stable names.
* - total/pct/eta_ms are omitted when total is unknown (no fake totals).
* - stdout is NEVER written to. Data output stays a separate concern.
*
* See docs/progress-events.md for the full reference.
*/
export type ProgressMode = 'auto' | 'human' | 'json' | 'quiet';
export interface ProgressOptions {
mode?: ProgressMode;
stream?: NodeJS.WritableStream; // default process.stderr
minIntervalMs?: number; // default 1000
minItems?: number; // default: max(10, Math.ceil((total||1000)/100))
}
export interface ProgressReporter {
start(phase: string, total?: number): void;
tick(n?: number, note?: string): void;
heartbeat(note: string): void;
finish(note?: string): void;
child(phase: string, total?: number): ProgressReporter;
}
// ---------------------------------------------------------------------------
// Singleton signal coordinator
// ---------------------------------------------------------------------------
// Per Codex review #28/#29: one process-level SIGINT/SIGTERM handler, tracking
// every live reporter. Per-instance handlers would leak listeners and interfere
// with command-level handlers (e.g. shell-handler abort in jobs.ts).
//
// We never call process.exit() or swallow the signal — we just emit abort
// events for live phases, then remove ourselves so the user's own handlers
// (or the default Node behavior) run as usual.
interface LivePhase {
reporter: PhaseState;
abort: (reason: string) => void;
}
const liveReporters = new Set<LivePhase>();
let signalHandlerInstalled = false;
function installSignalHandler(): void {
if (signalHandlerInstalled) return;
signalHandlerInstalled = true;
const onSignal = (reason: 'SIGINT' | 'SIGTERM') => {
// Copy to array so abort() can mutate liveReporters during iteration.
const snapshot = Array.from(liveReporters);
for (const entry of snapshot) {
try {
entry.abort(reason);
} catch {
/* best-effort */
}
}
};
// once() so we don't block user handlers or double-fire.
process.once('SIGINT', () => onSignal('SIGINT'));
process.once('SIGTERM', () => onSignal('SIGTERM'));
}
// ---------------------------------------------------------------------------
// Mode resolution
// ---------------------------------------------------------------------------
function resolveMode(mode: ProgressMode, stream: NodeJS.WritableStream): 'human-tty' | 'human-plain' | 'json' | 'quiet' {
if (mode === 'quiet') return 'quiet';
if (mode === 'json') return 'json';
const isTty = (stream as { isTTY?: boolean }).isTTY === true;
if (mode === 'human') return isTty ? 'human-tty' : 'human-plain';
// auto
return isTty ? 'human-tty' : 'human-plain';
}
// ---------------------------------------------------------------------------
// Stream write with EPIPE defense (sync throw path AND 'error' event path).
// ---------------------------------------------------------------------------
const brokenStreams = new WeakSet<NodeJS.WritableStream>();
function safeWrite(stream: NodeJS.WritableStream, chunk: string): void {
if (brokenStreams.has(stream)) return;
try {
stream.write(chunk, (err) => {
if (err) brokenStreams.add(stream);
});
} catch {
brokenStreams.add(stream);
}
}
// Attach one 'error' listener per stream so async EPIPE marks it broken.
const errorListenersAttached = new WeakSet<NodeJS.WritableStream>();
function attachErrorListener(stream: NodeJS.WritableStream): void {
if (errorListenersAttached.has(stream)) return;
errorListenersAttached.add(stream);
// 'error' on a raw tty/pipe is rare, but EPIPE can surface this way.
(stream as NodeJS.EventEmitter).on?.('error', () => {
brokenStreams.add(stream);
});
}
// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------
function renderHumanLine(phase: string, done: number | undefined, total: number | undefined, note: string | undefined): string {
const parts: string[] = [`[${phase}]`];
if (typeof done === 'number') {
if (typeof total === 'number' && total > 0) {
const pct = Math.floor((done / total) * 100);
parts.push(`${done}/${total} (${pct}%)`);
} else {
parts.push(`${done}`);
}
}
if (note) parts.push(note);
return parts.join(' ');
}
function nowIso(): string {
return new Date().toISOString();
}
// ---------------------------------------------------------------------------
// Phase state (per start/finish lifecycle of one reporter instance)
// ---------------------------------------------------------------------------
interface PhaseState {
phase: string;
total?: number;
done: number;
startedAt: number;
lastEmitMs: number;
lastDoneEmitted: number;
heartbeatTimer?: ReturnType<typeof setInterval>;
live: LivePhase | null; // membership in liveReporters for signal cleanup
}
// ---------------------------------------------------------------------------
// Reporter factory
// ---------------------------------------------------------------------------
interface ReporterInternal extends ProgressReporter {
_phasePath: string[]; // for child phase path composition
}
class Reporter implements ReporterInternal {
_phasePath: string[];
private stream: NodeJS.WritableStream;
private renderMode: 'human-tty' | 'human-plain' | 'json' | 'quiet';
private minIntervalMs: number;
private minItemsOverride?: number;
private state: PhaseState | null = null;
constructor(parentPath: string[], opts: Required<Omit<ProgressOptions, 'stream' | 'minIntervalMs' | 'minItems'>> & {
stream: NodeJS.WritableStream;
minIntervalMs: number;
minItems?: number;
}) {
this._phasePath = parentPath;
this.stream = opts.stream;
this.renderMode = resolveMode(opts.mode, opts.stream);
this.minIntervalMs = opts.minIntervalMs;
this.minItemsOverride = opts.minItems;
if (this.renderMode !== 'quiet') {
attachErrorListener(this.stream);
installSignalHandler();
}
}
private defaultMinItems(total?: number): number {
if (this.minItemsOverride !== undefined) return this.minItemsOverride;
const base = total && total > 0 ? total : 1000;
return Math.max(10, Math.ceil(base / 100));
}
private emitJson(obj: Record<string, unknown>): void {
safeWrite(this.stream, JSON.stringify(obj) + '\n');
}
private emitHumanLine(line: string): void {
if (this.renderMode === 'human-tty') {
// \r rewrite: clear-to-EOL then carriage-return-positioned line.
safeWrite(this.stream, `\r\x1b[2K${line}`);
} else {
safeWrite(this.stream, line + '\n');
}
}
private finalizeHumanLine(): void {
// When a TTY phase ends, move to a new line so subsequent output doesn't overwrite.
if (this.renderMode === 'human-tty') safeWrite(this.stream, '\n');
}
private phaseName(localPhase: string): string {
return [...this._phasePath, localPhase].join('.');
}
start(localPhase: string, total?: number): void {
// Auto-finish prior phase if caller forgot.
if (this.state) this.finish();
const phase = this.phaseName(localPhase);
const now = Date.now();
const s: PhaseState = {
phase,
total,
done: 0,
startedAt: now,
lastEmitMs: now,
lastDoneEmitted: 0,
live: null,
};
this.state = s;
// Register with signal coordinator.
const live: LivePhase = {
reporter: s,
abort: (reason) => this.abortFromSignal(reason),
};
liveReporters.add(live);
s.live = live;
if (this.renderMode === 'quiet') return;
if (this.renderMode === 'json') {
const obj: Record<string, unknown> = { event: 'start', phase, ts: nowIso() };
if (typeof total === 'number') obj.total = total;
this.emitJson(obj);
} else {
this.emitHumanLine(renderHumanLine(phase, undefined, total, 'start'));
}
}
tick(n: number = 1, note?: string): void {
const s = this.state;
if (!s) return;
s.done += n;
if (this.renderMode === 'quiet') return;
const now = Date.now();
const sinceEmit = now - s.lastEmitMs;
const itemsSinceEmit = s.done - s.lastDoneEmitted;
const minItems = this.defaultMinItems(s.total);
const isFinalTick = s.total !== undefined && s.done >= s.total;
// Emit if: time-gate passed, OR enough items since last emit, OR this is the final tick.
const shouldEmit = sinceEmit >= this.minIntervalMs || itemsSinceEmit >= minItems || isFinalTick;
if (!shouldEmit) return;
s.lastEmitMs = now;
s.lastDoneEmitted = s.done;
const elapsedMs = now - s.startedAt;
if (this.renderMode === 'json') {
const obj: Record<string, unknown> = {
event: 'tick',
phase: s.phase,
done: s.done,
elapsed_ms: elapsedMs,
ts: nowIso(),
};
if (typeof s.total === 'number' && s.total > 0) {
obj.total = s.total;
obj.pct = Math.round((s.done / s.total) * 1000) / 10; // one decimal
if (s.done > 0) {
const msPerItem = elapsedMs / s.done;
const remaining = Math.max(0, s.total - s.done);
obj.eta_ms = Math.round(msPerItem * remaining);
}
}
if (note) obj.note = note;
this.emitJson(obj);
} else {
this.emitHumanLine(renderHumanLine(s.phase, s.done, s.total, note));
}
}
heartbeat(note: string): void {
const s = this.state;
if (!s) return;
if (this.renderMode === 'quiet') return;
const now = Date.now();
const elapsedMs = now - s.startedAt;
if (this.renderMode === 'json') {
this.emitJson({
event: 'heartbeat',
phase: s.phase,
note,
elapsed_ms: elapsedMs,
ts: nowIso(),
});
} else {
this.emitHumanLine(renderHumanLine(s.phase, undefined, undefined, note));
}
}
finish(note?: string): void {
const s = this.state;
if (!s) return;
if (s.heartbeatTimer) {
clearInterval(s.heartbeatTimer);
s.heartbeatTimer = undefined;
}
if (s.live) {
liveReporters.delete(s.live);
s.live = null;
}
if (this.renderMode !== 'quiet') {
const elapsedMs = Date.now() - s.startedAt;
if (this.renderMode === 'json') {
const obj: Record<string, unknown> = {
event: 'finish',
phase: s.phase,
elapsed_ms: elapsedMs,
ts: nowIso(),
};
if (s.done > 0) obj.done = s.done;
if (typeof s.total === 'number') obj.total = s.total;
if (note) obj.note = note;
this.emitJson(obj);
} else {
this.emitHumanLine(renderHumanLine(s.phase, s.done > 0 ? s.done : undefined, s.total, note ?? 'done'));
this.finalizeHumanLine();
}
}
this.state = null;
}
private abortFromSignal(reason: string): void {
const s = this.state;
if (!s) return;
if (s.heartbeatTimer) {
clearInterval(s.heartbeatTimer);
s.heartbeatTimer = undefined;
}
if (this.renderMode !== 'quiet') {
const elapsedMs = Date.now() - s.startedAt;
if (this.renderMode === 'json') {
this.emitJson({
event: 'abort',
phase: s.phase,
reason,
elapsed_ms: elapsedMs,
ts: nowIso(),
});
} else {
this.emitHumanLine(renderHumanLine(s.phase, s.done > 0 ? s.done : undefined, s.total, `aborted (${reason})`));
this.finalizeHumanLine();
}
}
if (s.live) {
liveReporters.delete(s.live);
s.live = null;
}
this.state = null;
}
child(localPhase: string, _total?: number): ProgressReporter {
// Children inherit mode, stream, rate settings. The child's prefix path
// is the parent's currently-active FULL phase (if any) plus the local
// child-name passed here, so child.start('file1') renders as
// '<parent-phase>.<child-name>.file1'. If parent has no active phase,
// fall back to parent's own prefix.
const childPath = this.state
? [this.state.phase, localPhase]
: [...this._phasePath, localPhase];
const child = new Reporter(childPath, {
mode: this.modeForChildren(),
stream: this.stream,
minIntervalMs: this.minIntervalMs,
minItems: this.minItemsOverride,
});
return child;
}
/**
* Expose a heartbeat timer to external callers. The reporter owns the timer
* so we can guarantee cleanup on finish/abort. Caller uses the returned
* stopper in a try/finally. Internal helper the canonical user API is:
*
* p.start('phase');
* const stop = startHeartbeat(p, 'still scanning…');
* try { await slowWork(); } finally { stop(); p.finish(); }
*/
// modeForChildren preserves the fully-resolved mode (so a parent in 'json'
// doesn't re-evaluate TTY for children — they inherit the explicit mode).
private modeForChildren(): ProgressMode {
switch (this.renderMode) {
case 'human-tty':
case 'human-plain':
return 'human';
case 'json':
return 'json';
case 'quiet':
return 'quiet';
}
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function createProgress(opts: ProgressOptions = {}): ProgressReporter {
const stream = opts.stream ?? process.stderr;
return new Reporter([], {
mode: opts.mode ?? 'auto',
stream,
minIntervalMs: opts.minIntervalMs ?? 1000,
minItems: opts.minItems,
});
}
/**
* Starts a 1000ms interval that fires p.heartbeat(note). Returns a stop
* function to call in finally. Safe to stop twice.
*
* Use for single long-running queries where there's no iteration to tick.
*/
export function startHeartbeat(p: ProgressReporter, note: string, intervalMs = 1000): () => void {
const timer = setInterval(() => {
try {
p.heartbeat(note);
} catch {
/* reporter may be finished; ignore */
}
}, intervalMs);
let stopped = false;
return () => {
if (stopped) return;
stopped = true;
clearInterval(timer);
};
}
// Test-only hook so we can assert one signal handler across many reporters.
// Not part of the public API; used by test/progress.test.ts.
export function __liveReporterCountForTest(): number {
return liveReporters.size;
}
export function __signalHandlerInstalledForTest(): boolean {
return signalHandlerInstalled;
}
+1 -3
View File
@@ -28,8 +28,6 @@ CREATE TABLE IF NOT EXISTS pages (
CREATE INDEX IF NOT EXISTS idx_pages_type ON pages(type);
CREATE INDEX IF NOT EXISTS idx_pages_frontmatter ON pages USING GIN(frontmatter);
CREATE INDEX IF NOT EXISTS idx_pages_trgm ON pages USING GIN(title gin_trgm_ops);
-- v0.13.1 #170: avoids 14.6s seqscan on large brains when listing pages newest-first.
CREATE INDEX IF NOT EXISTS idx_pages_updated_at_desc ON pages (updated_at DESC);
-- ============================================================
-- content_chunks: chunked content with embeddings
@@ -282,7 +280,7 @@ CREATE TABLE IF NOT EXISTS minion_jobs (
backoff_delay INTEGER NOT NULL DEFAULT 1000,
backoff_jitter REAL NOT NULL DEFAULT 0.2,
stalled_counter INTEGER NOT NULL DEFAULT 0,
max_stalled INTEGER NOT NULL DEFAULT 5,
max_stalled INTEGER NOT NULL DEFAULT 1,
lock_token TEXT,
lock_until TIMESTAMPTZ,
delay_until TIMESTAMPTZ,
-12
View File
@@ -161,27 +161,17 @@ export function ndcgAtK(hits: string[], grades: Map<string, number>, k: number):
* Run a full evaluation of one search configuration against all qrels.
* Returns an EvalReport with per-query and mean metrics.
*/
export interface RunEvalOptions {
/**
* Optional per-query progress callback. Called after each qrel finishes.
* CLI wrappers pass a reporter.tick()-backed implementation; no-op otherwise.
*/
onProgress?: (done: number, total: number, query: string) => void;
}
export async function runEval(
engine: BrainEngine,
qrels: EvalQrel[],
config: EvalConfig,
k = 5,
options: RunEvalOptions = {},
): Promise<EvalReport> {
const strategy = config.strategy ?? 'hybrid';
const limit = config.limit ?? Math.max(k * 2, 10);
const queryResults: QueryResult[] = [];
let done = 0;
for (const qrel of qrels) {
const hits = await runQuery(engine, qrel.query, strategy, config, limit);
@@ -196,8 +186,6 @@ export async function runEval(
mrr: mrr(hits, relevantSet),
ndcg_at_k: ndcgAtK(hits, gradesMap, k),
});
done++;
options.onProgress?.(done, qrels.length, qrel.query);
}
return {
-124
View File
@@ -133,127 +133,3 @@ export function pathToSlug(filePath: string, repoPrefix?: string): string {
if (repoPrefix) slug = `${repoPrefix}/${slug}`;
return slug.toLowerCase();
}
// ─────────────────────────────────────────────────────────────────
// Sync failure tracking — Bug 9
// ─────────────────────────────────────────────────────────────────
//
// When a sync run catches a per-file parse error (YAML with unquoted
// colons, malformed frontmatter, etc.), we record it here instead of just
// logging and moving on. Three goals:
// 1. Gate the sync.last_commit bookmark advance in all three sync paths
// (incremental, full/runImport, `gbrain import` git continuity).
// 2. Give users a visible record of what failed, with the commit hash
// they can use to re-attempt after fixing the source file.
// 3. Let `gbrain sync --skip-failed` acknowledge a known-bad set so
// repos with many broken files aren't permanently stuck.
import { existsSync as _existsSync, readFileSync as _readFileSync, appendFileSync as _appendFileSync, mkdirSync as _mkdirSync } from 'fs';
import { join as _joinPath } from 'path';
import { homedir as _homedir } from 'os';
import { createHash as _createHash } from 'crypto';
export interface SyncFailure {
path: string;
error: string;
commit: string;
line?: number;
ts: string;
acknowledged?: boolean;
acknowledged_at?: string;
}
function _failuresDir(): string {
return _joinPath(_homedir(), '.gbrain');
}
export function syncFailuresPath(): string {
return _joinPath(_failuresDir(), 'sync-failures.jsonl');
}
function _hashError(msg: string): string {
return _createHash('sha256').update(msg).digest('hex').slice(0, 12);
}
function _dedupKey(f: { path: string; commit: string; error: string }): string {
return `${f.path}|${f.commit}|${_hashError(f.error)}`;
}
/**
* Read the failures JSONL, skipping malformed lines with a warning to stderr.
* Returns empty array if the file doesn't exist.
*/
export function loadSyncFailures(): SyncFailure[] {
const path = syncFailuresPath();
if (!_existsSync(path)) return [];
const raw = _readFileSync(path, 'utf-8');
const out: SyncFailure[] = [];
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
out.push(JSON.parse(trimmed) as SyncFailure);
} catch {
console.warn(`[sync-failures] skipping malformed line: ${trimmed.slice(0, 120)}`);
}
}
return out;
}
/**
* Append failure entries to the JSONL. Dedups by (path, commit, error-hash)
* the same file failing with the same error on the same commit writes ONCE
* to the log, not once per sync run.
*/
export function recordSyncFailures(
failures: Array<{ path: string; error: string; line?: number }>,
commit: string,
): void {
if (failures.length === 0) return;
const existing = loadSyncFailures();
const seen = new Set(existing.map(f => _dedupKey(f)));
_mkdirSync(_failuresDir(), { recursive: true });
const now = new Date().toISOString();
for (const f of failures) {
const entry: SyncFailure = {
path: f.path,
error: f.error,
commit,
line: f.line,
ts: now,
};
if (seen.has(_dedupKey(entry))) continue;
_appendFileSync(syncFailuresPath(), JSON.stringify(entry) + '\n');
seen.add(_dedupKey(entry));
}
}
/**
* Mark all unacknowledged failures as acknowledged. Used by
* `gbrain sync --skip-failed`. Returns the number newly acknowledged.
*
* We do not delete acknowledged entries stay as historical record so
* doctor can still show them under a "previously skipped" bucket.
*/
export function acknowledgeSyncFailures(): number {
const entries = loadSyncFailures();
if (entries.length === 0) return 0;
const now = new Date().toISOString();
let changed = 0;
const updated = entries.map(e => {
if (e.acknowledged) return e;
changed++;
return { ...e, acknowledged: true, acknowledged_at: now };
});
if (changed === 0) return 0;
_mkdirSync(_failuresDir(), { recursive: true });
const fd = require('fs').writeFileSync;
fd(syncFailuresPath(), updated.map(e => JSON.stringify(e)).join('\n') + '\n');
return changed;
}
/** Return only unacknowledged failures. */
export function unacknowledgedSyncFailures(): SyncFailure[] {
return loadSyncFailures().filter(f => !f.acknowledged);
}
+2 -31
View File
@@ -181,46 +181,17 @@ export interface BrainHealth {
page_count: number;
embed_coverage: number;
stale_pages: number;
/**
* Islanded pages zero inbound AND zero outbound links. A hub page
* that has references out but no back-references is NOT an orphan under
* this definition (it's working as intended as an index). The metric
* aims at "pages I forgot to connect to anything", not the stricter
* graph-theory "no inbound" definition. Both engines share this
* semantics after Bug 11 doc-drift fix.
*/
/** Pages with zero inbound links. Definition aligned across PGLite and Postgres. */
orphan_pages: number;
missing_embeddings: number;
/**
* Composite quality score, 0-100. Weighted sum of five components: embed
* coverage, link density, timeline coverage, orphan avoidance, dead-link
* avoidance. See the per-component *_score fields below for breakdown.
*/
/** Composite quality score (0-10). Computed from coverage, staleness, orphans. */
brain_score: number;
/**
* Number of links whose to_page_id no longer resolves to a page. Under
* `ON DELETE CASCADE` this is always 0, but malformed data or direct SQL
* DELETEs can produce dangling references.
*/
dead_links: number;
/** Fraction of entity pages (person/company) with >= 1 inbound link. */
link_coverage: number;
/** Fraction of entity pages (person/company) with >= 1 structured timeline entry. */
timeline_coverage: number;
/** Top 5 entities by total link count (in + out). */
most_connected: Array<{ slug: string; link_count: number }>;
/**
* Per-component contribution to brain_score. Sum equals brain_score by
* construction. Displayed by `gbrain doctor` when brain_score < 100.
* Field names are distinct from the entity-scoped link_coverage /
* timeline_coverage above to avoid semantic collision (these reflect
* whole-brain measures used in the score formula).
*/
embed_coverage_score: number; // 0-35
link_density_score: number; // 0-25
timeline_coverage_score: number; // 0-15
no_orphans_score: number; // 0-15
no_dead_links_score: number; // 0-10
}
// Ingest log
+1 -3
View File
@@ -24,8 +24,6 @@ CREATE TABLE IF NOT EXISTS pages (
CREATE INDEX IF NOT EXISTS idx_pages_type ON pages(type);
CREATE INDEX IF NOT EXISTS idx_pages_frontmatter ON pages USING GIN(frontmatter);
CREATE INDEX IF NOT EXISTS idx_pages_trgm ON pages USING GIN(title gin_trgm_ops);
-- v0.13.1 #170: avoids 14.6s seqscan on large brains when listing pages newest-first.
CREATE INDEX IF NOT EXISTS idx_pages_updated_at_desc ON pages (updated_at DESC);
-- ============================================================
-- content_chunks: chunked content with embeddings
@@ -278,7 +276,7 @@ CREATE TABLE IF NOT EXISTS minion_jobs (
backoff_delay INTEGER NOT NULL DEFAULT 1000,
backoff_jitter REAL NOT NULL DEFAULT 0.2,
stalled_counter INTEGER NOT NULL DEFAULT 0,
max_stalled INTEGER NOT NULL DEFAULT 5,
max_stalled INTEGER NOT NULL DEFAULT 1,
lock_token TEXT,
lock_until TIMESTAMPTZ,
delay_until TIMESTAMPTZ,
+6 -7
View File
@@ -104,9 +104,8 @@ describe('buildPlan — diff against completed + installed VERSION', () => {
expect(plan.pending.map(m => m.version)).toContain('0.11.0');
// Future migrations (registered but newer than installed VERSION) land in
// skippedFuture until the binary catches up. v0.13.0 = frontmatter graph
// (master), v0.13.1 = Knowledge Runtime grandfather, v0.14.0 = shell
// jobs + autopilot cooperative.
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.0', '0.12.2', '0.13.0', '0.13.1', '0.14.0']);
// (master), v0.13.1 = Knowledge Runtime grandfather (this branch).
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.0', '0.12.2', '0.13.0', '0.13.1']);
});
test('already applied → v0.11.0 lands in `applied` bucket, not pending', () => {
@@ -142,10 +141,10 @@ describe('buildPlan — diff against completed + installed VERSION', () => {
const idx = indexCompleted([]);
const plan = buildPlan(idx, '0.12.0');
expect(plan.pending.map(m => m.version)).toContain('0.11.0');
// v0.12.2, v0.13.0, v0.13.1, and v0.14.0 were added later; installed=0.12.0
// means they belong in skippedFuture, not pending. v0.11.0 and v0.12.0
// stay pending despite being ≤ installed — that is the H9 invariant.
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.2', '0.13.0', '0.13.1', '0.14.0']);
// v0.12.2, v0.13.0, and v0.13.1 were added later; installed=0.12.0 means
// they belong in skippedFuture, not pending. v0.11.0 and v0.12.0 stay
// pending despite being ≤ installed — that is the H9 invariant.
expect(plan.skippedFuture.map(m => m.version)).toEqual(['0.12.2', '0.13.0', '0.13.1']);
});
test('--migration filter narrows to one version', () => {
+28 -58
View File
@@ -1,83 +1,53 @@
/**
* Tests for resolveGbrainCliPath() picks the right executable to supervise
* as the Minions worker child.
*
* Iron rule (regression guard for Bug 4, v0.14.0 upgrade night): the resolver
* must NEVER return a `.ts` path. TypeScript source files are not executable;
* spawning them fails with EACCES and autopilot silently loses its worker.
* Earlier versions short-circuited on `argv[1].endsWith('/cli.ts')`, which
* caused the bug. The canonical resolution is the `gbrain` shim on PATH.
* as the Minions worker child. Codex caught that the earlier plan's use of
* process.execPath is wrong on source installs (points at the Bun runtime,
* not `gbrain`).
*/
import { describe, test, expect } from 'bun:test';
import { resolveGbrainCliPath } from '../src/commands/autopilot.ts';
describe('resolveGbrainCliPath', () => {
test('returns a non-empty string or throws with a clear install hint', () => {
test('returns a non-empty string', () => {
// Whatever the test environment is (bun run ...), the resolver should
// find *something* — either argv[1] (cli.ts entry), execPath (compiled
// binary), or `which gbrain`. If none of those work, it throws; in
// test, argv[1] is the test runner path which usually ends in .ts, so
// the first branch or the `which` fallback catches it.
let path: string;
try {
path = resolveGbrainCliPath();
} catch (e) {
// Machine without gbrain on PATH and no compiled binary: throw is
// expected. The error message must point the user at the install step.
expect((e as Error).message).toMatch(/PATH|resolve/i);
// If we throw, that means neither argv[1] nor execPath nor $PATH has
// gbrain — on a machine without gbrain installed, this is expected.
expect((e as Error).message).toContain('resolve');
return;
}
expect(typeof path).toBe('string');
expect(path.length).toBeGreaterThan(0);
});
test('NEVER returns a path ending in .ts (regression guard — Bug 4)', () => {
// Simulate the exact production break: bun-source install puts
// `/path/to/src/cli.ts` in argv[1]. The resolver must not hand that back.
const origArg1 = process.argv[1];
const origExec = (process as { execPath?: string }).execPath;
process.argv[1] = '/some/project/src/cli.ts';
try {
const path = resolveGbrainCliPath();
// Either we got a real executable (shim on PATH from the test machine)
// or the throw path fires. Either way, the return value is never .ts.
expect(path.endsWith('.ts')).toBe(false);
expect(path.endsWith('.tsx')).toBe(false);
} catch (e) {
expect((e as Error).message).toMatch(/PATH|resolve/i);
} finally {
process.argv[1] = origArg1;
if (origExec) (process as { execPath?: string }).execPath = origExec;
}
});
test('shim on PATH wins over argv[1]=cli.ts', () => {
// If `which gbrain` resolves (most dev machines), the resolver should
// return that shim path, not argv[1]=cli.ts. This is the canonical
// install shape.
const origArg1 = process.argv[1];
process.argv[1] = '/some/project/src/cli.ts';
try {
const path = resolveGbrainCliPath();
// On a machine where `which gbrain` resolves, path ends in /gbrain.
// On a machine without, we throw. Both outcomes prove the resolver
// did not short-circuit on the .ts suffix.
expect(path.endsWith('/cli.ts')).toBe(false);
} catch (e) {
expect((e as Error).message).toMatch(/PATH|resolve/i);
} finally {
process.argv[1] = origArg1;
}
});
test('accepts argv[1]=/gbrain when shim is absent (compiled binary)', () => {
// If the machine has neither shim nor compiled exec, but argv[1]
// happens to be a literal /gbrain path (direct invocation), accept it.
const origArg1 = process.argv[1];
test('accepts /gbrain suffix (compiled binary)', () => {
// Simulate compiled-binary detection by setting argv[1] to /usr/local/bin/gbrain
const orig = process.argv[1];
process.argv[1] = '/usr/local/bin/gbrain';
try {
const path = resolveGbrainCliPath();
// On a machine with `which gbrain`, we get the shim. On a machine
// without, argv[1] fallback fires. Either way the result is valid.
expect(path.endsWith('/gbrain') || path.endsWith('\\gbrain.exe')).toBe(true);
expect(path).toBe('/usr/local/bin/gbrain');
} finally {
process.argv[1] = origArg1;
process.argv[1] = orig;
}
});
test('accepts /cli.ts suffix (source install)', () => {
const orig = process.argv[1];
process.argv[1] = '/some/path/src/cli.ts';
try {
const path = resolveGbrainCliPath();
expect(path).toBe('/some/path/src/cli.ts');
} finally {
process.argv[1] = orig;
}
});
});
-140
View File
@@ -1,140 +0,0 @@
/**
* Bug 11 brain_score needs a breakdown + orphan_pages metric is wrong.
*
* Assertions:
* 1. getHealth() returns the new *_score breakdown fields.
* 2. Breakdown fields sum to brain_score by construction.
* 3. orphan_pages counts pages with zero INBOUND links, regardless of
* whether they have outbound links (was: required both).
* 4. BrainHealth type now carries dead_links.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
for (const t of ['links', 'content_chunks', 'timeline_entries', 'raw_data', 'tags', 'page_versions', 'ingest_log', 'pages']) {
await (engine as any).db.exec(`DELETE FROM ${t}`);
}
});
describe('Bug 11 — brain_score breakdown sums to total', () => {
test('empty brain returns zero score with all breakdown fields present', async () => {
const h = await engine.getHealth();
expect(h.brain_score).toBe(0);
expect(h.embed_coverage_score).toBe(0);
expect(h.link_density_score).toBe(0);
expect(h.timeline_coverage_score).toBe(0);
expect(h.no_orphans_score).toBe(0);
expect(h.no_dead_links_score).toBe(0);
// dead_links is now on the type.
expect(h.dead_links).toBe(0);
});
test('breakdown fields always sum to brain_score', async () => {
// Seed a small graph — some pages, some links, some embeds.
for (const slug of ['a', 'b', 'c']) {
await engine.putPage(slug, { type: 'note', title: slug, compiled_truth: `content of ${slug}`, frontmatter: {} });
}
const h = await engine.getHealth();
const sum =
h.embed_coverage_score +
h.link_density_score +
h.timeline_coverage_score +
h.no_orphans_score +
h.no_dead_links_score;
expect(sum).toBe(h.brain_score);
});
test('brain_score caps at 100', async () => {
const h = await engine.getHealth();
expect(h.brain_score).toBeGreaterThanOrEqual(0);
expect(h.brain_score).toBeLessThanOrEqual(100);
});
});
describe('Bug 11 — orphan_pages is "no inbound links"', () => {
test('a page with outbound-only links is NOT an orphan', async () => {
// Hub page: links out to three others, but nothing links back to it.
// Previous (buggy) behavior: hub counted as orphan because it had no
// inbound links (correct) AND the old query also required no outbound.
await engine.putPage('hub', { type: 'note', title: 'Hub', compiled_truth: 'index', frontmatter: {} });
await engine.putPage('leaf1', { type: 'note', title: 'L1', compiled_truth: 'x', frontmatter: {} });
await engine.putPage('leaf2', { type: 'note', title: 'L2', compiled_truth: 'y', frontmatter: {} });
await engine.putPage('leaf3', { type: 'note', title: 'L3', compiled_truth: 'z', frontmatter: {} });
const hubId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='hub'`)).rows[0].id;
for (const target of ['leaf1', 'leaf2', 'leaf3']) {
const tid = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug=$1`, [target])).rows[0].id;
await (engine as any).db.query(
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'mentions')`,
[hubId, tid],
);
}
const h = await engine.getHealth();
// hub has outbound, no inbound → NOT orphan (under the fixed definition).
// leaf1/2/3 have inbound from hub → NOT orphan.
// So orphan_pages should be 0.
expect(h.orphan_pages).toBe(0);
});
test('a page with no links at all IS an orphan', async () => {
await engine.putPage('loner', { type: 'note', title: 'Loner', compiled_truth: 'alone', frontmatter: {} });
const h = await engine.getHealth();
expect(h.orphan_pages).toBe(1);
});
test('a page with inbound links only is NOT an orphan', async () => {
await engine.putPage('sink', { type: 'note', title: 'Sink', compiled_truth: 'target', frontmatter: {} });
await engine.putPage('source', { type: 'note', title: 'Source', compiled_truth: 'origin', frontmatter: {} });
const sinkId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='sink'`)).rows[0].id;
const srcId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='source'`)).rows[0].id;
await (engine as any).db.query(
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'mentions')`,
[srcId, sinkId],
);
const h = await engine.getHealth();
// sink has 1 inbound (from source) → not orphan.
// source has no inbound (but has outbound) → not orphan under new definition.
expect(h.orphan_pages).toBe(0);
});
});
describe('Bug 11 — doctor renders brain_score breakdown', () => {
test('doctor source contains brain_score breakdown rendering', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
expect(source).toContain('brain_score');
expect(source).toContain('embed_coverage_score');
expect(source).toContain('link_density_score');
expect(source).toContain('no_orphans_score');
expect(source).toContain('no_dead_links_score');
});
});
describe('Bug 11 — BrainHealth type shape', () => {
test('type includes dead_links + breakdown scores', async () => {
const typesSource = await Bun.file(new URL('../src/core/types.ts', import.meta.url)).text();
expect(typesSource).toContain('dead_links: number');
expect(typesSource).toContain('embed_coverage_score: number');
expect(typesSource).toContain('link_density_score: number');
expect(typesSource).toContain('timeline_coverage_score: number');
expect(typesSource).toContain('no_orphans_score: number');
expect(typesSource).toContain('no_dead_links_score: number');
// The stale "(0-10)" comment must be corrected to 0-100.
expect(typesSource).toContain('0-100');
});
});
-98
View File
@@ -1,98 +0,0 @@
import { describe, test, expect } from "bun:test";
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { buildLlmsFiles } from "../scripts/build-llms";
import { SECTIONS, FULL_SIZE_BUDGET } from "../scripts/llms-config";
const repoRoot = join(import.meta.dir, "..");
describe("build-llms generator", () => {
// Case 1 — every config path resolves on disk. Catches rename-induced 404s.
test("every configured path exists on disk", () => {
for (const section of SECTIONS) {
for (const entry of section.entries) {
const abs = join(repoRoot, entry.path);
expect(existsSync(abs), `missing: ${entry.path}`).toBe(true);
const st = statSync(abs);
if (entry.path.endsWith("/")) {
expect(st.isDirectory(), `${entry.path} should be a directory`).toBe(true);
} else {
expect(st.isFile(), `${entry.path} should be a file`).toBe(true);
}
}
}
});
// Case 2 — generator is idempotent. Run twice in-memory, compare byte-for-byte.
test("generator output is deterministic across runs", () => {
const first = buildLlmsFiles();
const second = buildLlmsFiles();
expect(second.llmsTxt).toBe(first.llmsTxt);
expect(second.llmsFullTxt).toBe(first.llmsFullTxt);
});
// Case 3 — llms.txt spec shape per llmstxt.org: H1 + blockquote + required H2s.
test("llms.txt follows llmstxt.org spec shape", () => {
const { llmsTxt } = buildLlmsFiles();
const lines = llmsTxt.split("\n");
expect(lines[0], "first line must be H1").toBe("# GBrain");
// Blockquote summary on line 2 or 3 (spec allows blank line after H1).
const hasEarlyBlockquote =
lines.slice(1, 4).some((line) => line.startsWith("> "));
expect(hasEarlyBlockquote, "needs > blockquote summary near top").toBe(true);
// Required H2 sections for GBrain's user need (config/debug/migration).
expect(llmsTxt).toContain("## Core entry points");
expect(llmsTxt).toContain("## Configuration");
expect(llmsTxt).toContain("## Debugging");
expect(llmsTxt).toContain("## Migrations");
});
// Case 4 — checked-in files match generator output. Catches "forgot to rerun
// generator" before ship. If this fails in CI, run `bun run build:llms` and
// commit the result.
test("committed llms.txt + llms-full.txt match current generator output", () => {
const { llmsTxt, llmsFullTxt } = buildLlmsFiles();
const committedLlms = readFileSync(join(repoRoot, "llms.txt"), "utf8");
const committedFull = readFileSync(join(repoRoot, "llms-full.txt"), "utf8");
const helpMsg =
"Run `bun run build:llms` and commit the updated output before shipping.";
expect(committedLlms, helpMsg).toBe(llmsTxt);
expect(committedFull, helpMsg).toBe(llmsFullTxt);
});
// Case 5 — content contract. Prevents silent removal of critical sections or
// entries from llms-config.ts. Catches "someone deleted the Debugging section."
test("content contract: llms.txt references required entry points", () => {
const { llmsTxt } = buildLlmsFiles();
expect(llmsTxt).toContain("skills/RESOLVER.md");
expect(llmsTxt).toContain("INSTALL_FOR_AGENTS.md");
expect(llmsTxt).toContain("AGENTS.md");
expect(llmsTxt).toContain("CLAUDE.md");
});
test("content contract: AGENTS.md mirrors README + INSTALL_FOR_AGENTS install path", () => {
const agents = readFileSync(join(repoRoot, "AGENTS.md"), "utf8");
expect(agents).toContain("CLAUDE.md");
expect(agents).toContain("skills/RESOLVER.md");
expect(agents).toContain("INSTALL_FOR_AGENTS.md");
expect(agents).toContain("llms.txt");
// Trust boundary is the non-obvious security concept agents need up-front.
expect(agents.toLowerCase()).toContain("trust boundary");
});
test("llms-full.txt stays within size budget", () => {
const { llmsFullTxt } = buildLlmsFiles();
const bytes = Buffer.byteLength(llmsFullTxt, "utf8");
expect(
bytes,
`llms-full.txt is ${bytes} bytes (budget ${FULL_SIZE_BUDGET}). Add includeInFull: false to large entries.`,
).toBeLessThan(FULL_SIZE_BUDGET);
});
});
+1 -137
View File
@@ -1,12 +1,6 @@
import { describe, test, expect } from "bun:test";
import { join } from "path";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import {
checkResolvable,
parseResolverEntries,
extractDelegationTargets,
} from "../src/core/check-resolvable.ts";
import { checkResolvable, parseResolverEntries } from "../src/core/check-resolvable.ts";
const SKILLS_DIR = join(import.meta.dir, "..", "skills");
@@ -132,133 +126,3 @@ describe("checkResolvable — real skills directory", () => {
expect(report.summary.reachable + report.summary.unreachable).toBe(report.summary.total_skills);
});
});
// ---------------------------------------------------------------------------
// DRY detection — proximity-based suppression
// ---------------------------------------------------------------------------
function makeSkillsFixture(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "gbrain-dry-"));
// Minimal RESOLVER.md and manifest.json so checkResolvable doesn't bail.
const skillNames = Object.keys(files);
const resolverRows = skillNames.map(n => `| "${n}" | \`skills/${n}/SKILL.md\` |`).join("\n");
writeFileSync(join(dir, "RESOLVER.md"), `## Test\n| Trigger | Skill |\n|-----|-----|\n${resolverRows}\n`);
writeFileSync(
join(dir, "manifest.json"),
JSON.stringify({ skills: skillNames.map(n => ({ name: n, path: `${n}/SKILL.md` })) }, null, 2)
);
for (const [name, body] of Object.entries(files)) {
mkdirSync(join(dir, name), { recursive: true });
// Skill conformance tests (elsewhere) check for frontmatter + triggers;
// checkResolvable itself only needs the body.
const frontmatter = `---\nname: ${name}\ndescription: test\ntriggers:\n - "${name}"\n---\n`;
writeFileSync(join(dir, name, "SKILL.md"), frontmatter + body);
}
return dir;
}
describe("extractDelegationTargets", () => {
test("parses > **Convention:** callouts", () => {
const refs = extractDelegationTargets(
"> **Convention:** See `skills/conventions/quality.md` for citation rules.\n"
);
expect(refs).toEqual([{ convention: "conventions/quality.md", line: 1 }]);
});
test("parses > **Filing rule:** callouts", () => {
const refs = extractDelegationTargets(
"> **Filing rule:** Read `skills/_brain-filing-rules.md` before any new page.\n"
);
expect(refs).toEqual([{ convention: "_brain-filing-rules.md", line: 1 }]);
});
test("parses inline backtick references", () => {
const refs = extractDelegationTargets(
"some prose.\nSee `skills/conventions/quality.md` for details.\n"
);
expect(refs).toEqual([{ convention: "conventions/quality.md", line: 2 }]);
});
test("ignores backticks pointing outside known delegation targets", () => {
const refs = extractDelegationTargets(
"See `skills/random/README.md` for unrelated notes.\n"
);
expect(refs).toHaveLength(0);
});
test("handles frontmatter-only skill (no body matches)", () => {
const refs = extractDelegationTargets("---\nname: foo\n---\n");
expect(refs).toHaveLength(0);
});
});
describe("DRY detection — checkResolvable", () => {
let dir: string;
afterEachCleanup(() => dir && rmSync(dir, { recursive: true, force: true }));
test("flags inlined notability rule with no reference", () => {
dir = makeSkillsFixture({
bad: "# BadSkill\n\nCheck the notability gate every time.\n",
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(1);
expect(dry[0].skill).toBe("bad");
});
test("suppresses DRY when > **Convention:** callout points at quality.md (notability)", () => {
dir = makeSkillsFixture({
good: `# GoodSkill\n\n> **Convention:** See \`skills/conventions/quality.md\` for rules.\n\nCheck the notability gate.\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(0);
});
test("suppresses DRY when _brain-filing-rules.md is referenced for notability", () => {
dir = makeSkillsFixture({
good: `# GoodSkill\n\n> **Filing rule:** Read \`skills/_brain-filing-rules.md\`.\n\nCheck the notability gate.\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(0);
});
test("does NOT suppress when reference is >40 lines from the match", () => {
const filler = Array(50).fill("padding paragraph with no match.").join("\n");
dir = makeSkillsFixture({
distant: `> **Convention:** See \`skills/conventions/quality.md\`.\n\n${filler}\n\nCheck the notability gate now.\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(1);
});
test("DOES suppress when reference is ~30 lines from the match", () => {
const filler = Array(20).fill("padding paragraph with no match.").join("\n");
dir = makeSkillsFixture({
near: `> **Convention:** See \`skills/conventions/quality.md\`.\n\n${filler}\n\nCheck the notability gate now.\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry).toHaveLength(0);
});
test("iron-law pattern does NOT accept _brain-filing-rules.md as delegation", () => {
// iron-law's only accepted target is conventions/quality.md
dir = makeSkillsFixture({
filing: `> **Filing rule:** Read \`skills/_brain-filing-rules.md\`.\n\n## Iron Law: Back-Linking (MANDATORY)\n`,
});
const report = checkResolvable(dir);
const dry = report.issues.filter(i => i.type === "dry_violation");
expect(dry.length).toBeGreaterThanOrEqual(1);
});
});
// bun:test has no beforeEach/afterEach at module scope cleanly interacting
// with closures; a small helper keeps cleanup readable and per-test.
function afterEachCleanup(fn: () => void) {
const { afterEach } = require("bun:test");
afterEach(fn);
}
-161
View File
@@ -1,161 +0,0 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { parseGlobalFlags, cliOptsToProgressOptions, DEFAULT_CLI_OPTIONS, setCliOptions, getCliOptions, _resetCliOptionsForTest } from '../src/core/cli-options.ts';
describe('parseGlobalFlags', () => {
test('empty argv → defaults, empty rest', () => {
const r = parseGlobalFlags([]);
expect(r.cliOpts).toEqual(DEFAULT_CLI_OPTIONS);
expect(r.rest).toEqual([]);
});
test('strips --quiet from argv and sets quiet=true', () => {
// Per-command handlers that historically parsed their own --quiet
// (skillpack-check) now read the resolved CliOptions singleton via
// getCliOptions() — see src/core/cli-options.ts.
const r = parseGlobalFlags(['--quiet', 'doctor', '--fast']);
expect(r.cliOpts.quiet).toBe(true);
expect(r.cliOpts.progressJson).toBe(false);
expect(r.rest).toEqual(['doctor', '--fast']);
});
test('strips --progress-json from argv', () => {
const r = parseGlobalFlags(['--progress-json', 'doctor']);
expect(r.cliOpts.progressJson).toBe(true);
expect(r.rest).toEqual(['doctor']);
});
test('--progress-interval=500 form', () => {
const r = parseGlobalFlags(['--progress-interval=500', 'embed']);
expect(r.cliOpts.progressInterval).toBe(500);
expect(r.rest).toEqual(['embed']);
});
test('--progress-interval 500 space-separated form', () => {
const r = parseGlobalFlags(['--progress-interval', '500', 'embed']);
expect(r.cliOpts.progressInterval).toBe(500);
expect(r.rest).toEqual(['embed']);
});
test('global flag interleaved mid-argv still stripped', () => {
const r = parseGlobalFlags(['doctor', '--progress-json', '--fast']);
expect(r.cliOpts.progressJson).toBe(true);
expect(r.rest).toEqual(['doctor', '--fast']);
});
test('invalid --progress-interval value passes through (per-command parser can handle it)', () => {
const r = parseGlobalFlags(['--progress-interval=abc', 'doctor']);
// Unparseable value → leave the flag in rest, default interval kept.
expect(r.cliOpts.progressInterval).toBe(DEFAULT_CLI_OPTIONS.progressInterval);
expect(r.rest).toEqual(['--progress-interval=abc', 'doctor']);
});
test('negative --progress-interval rejected', () => {
const r = parseGlobalFlags(['--progress-interval=-1', 'doctor']);
expect(r.cliOpts.progressInterval).toBe(DEFAULT_CLI_OPTIONS.progressInterval);
expect(r.rest).toContain('--progress-interval=-1');
});
test('unknown flags pass through unchanged', () => {
const r = parseGlobalFlags(['doctor', '--fast', '--json', '--foo=bar']);
expect(r.rest).toEqual(['doctor', '--fast', '--json', '--foo=bar']);
expect(r.cliOpts).toEqual(DEFAULT_CLI_OPTIONS);
});
test('all global flags combined', () => {
const r = parseGlobalFlags(['--quiet', '--progress-json', '--progress-interval=250', 'sync']);
expect(r.cliOpts).toEqual({ quiet: true, progressJson: true, progressInterval: 250 });
expect(r.rest).toEqual(['sync']);
});
});
describe('getCliOptions / setCliOptions singleton', () => {
test('defaults when never set', () => {
_resetCliOptionsForTest();
expect(getCliOptions()).toEqual(DEFAULT_CLI_OPTIONS);
});
test('setCliOptions applies + getCliOptions returns a copy', () => {
_resetCliOptionsForTest();
setCliOptions({ quiet: false, progressJson: true, progressInterval: 250 });
expect(getCliOptions().progressJson).toBe(true);
expect(getCliOptions().progressInterval).toBe(250);
});
});
describe('cli.ts global-flag stripping (integration)', () => {
const CLI = join(import.meta.dir, '..', 'src', 'cli.ts');
test('gbrain --progress-json --version works (global flag stripped before dispatch)', () => {
const res = spawnSync('bun', [CLI, '--progress-json', '--version'], {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
});
expect(res.status).toBe(0);
expect(res.stdout).toContain('gbrain ');
});
test('gbrain --quiet --progress-interval=500 version works (flags interleaved, all stripped)', () => {
const res = spawnSync('bun', [CLI, '--quiet', '--progress-interval=500', 'version'], {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
});
expect(res.status).toBe(0);
expect(res.stdout).toContain('gbrain ');
});
});
describe('CLI integration: progress streams to the right channel', () => {
const CLI = join(import.meta.dir, '..', 'src', 'cli.ts');
test('gbrain --progress-json --version emits only the version on stdout', () => {
// `version` is a single-shot command that goes through the main()
// dispatch path. We want to confirm --progress-json doesn't force
// stray progress onto stdout for commands that don't use a reporter.
const res = spawnSync('bun', [CLI, '--progress-json', '--version'], {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
});
expect(res.status).toBe(0);
expect(res.stdout.trim()).toMatch(/^gbrain /);
// No JSON progress object should end up on stdout.
expect(res.stdout).not.toContain('"event":"start"');
});
test('gbrain --quiet skillpack-check returns exit code with no stdout', () => {
// Regression guard for the flag-collision that skillpack-check hit
// when --quiet briefly passed through argv. Now it reads the singleton.
const res = spawnSync('bun', [CLI, '--quiet', 'skillpack-check'], {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
});
// Exit may be 0 or 1 depending on whether a brain is configured;
// what matters is stdout stays empty.
expect(res.stdout).toBe('');
});
});
describe('cliOptsToProgressOptions', () => {
test('--quiet → quiet mode', () => {
const opts = cliOptsToProgressOptions({ quiet: true, progressJson: false, progressInterval: 1000 });
expect(opts.mode).toBe('quiet');
});
test('--progress-json → json mode with interval', () => {
const opts = cliOptsToProgressOptions({ quiet: false, progressJson: true, progressInterval: 500 });
expect(opts.mode).toBe('json');
expect(opts.minIntervalMs).toBe(500);
});
test('defaults → auto mode', () => {
const opts = cliOptsToProgressOptions(DEFAULT_CLI_OPTIONS);
expect(opts.mode).toBe('auto');
expect(opts.minIntervalMs).toBe(1000);
});
test('quiet takes priority over progressJson', () => {
const opts = cliOptsToProgressOptions({ quiet: true, progressJson: true, progressInterval: 1000 });
expect(opts.mode).toBe('quiet');
});
});
-101
View File
@@ -1,101 +0,0 @@
/**
* CLI integration tests for `gbrain doctor --fix` / `--dry-run`.
* Spawns the actual CLI against tmpdir skill fixtures to prove the
* arg-parsing wiring and stdout/file-state contract hold end-to-end.
*/
import { describe, test, expect, afterEach } from "bun:test";
import { join } from "path";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "fs";
import { tmpdir } from "os";
import { spawnSync, execSync } from "child_process";
const CLI = join(import.meta.dir, "..", "src", "cli.ts");
const REPO_ROOT = join(import.meta.dir, "..");
let fixtures: string[] = [];
afterEach(() => {
for (const f of fixtures) {
try { rmSync(f, { recursive: true, force: true }); } catch { /* ignore */ }
}
fixtures = [];
});
function makeGitFixture(skills: Record<string, string>): string {
// doctor finds repo root by looking for skills/RESOLVER.md — so wrap the
// fixture in a dir with skills/ inside and a RESOLVER.md stub.
const root = mkdtempSync(join(tmpdir(), "gbrain-doctorfix-"));
fixtures.push(root);
const skillsDir = join(root, "skills");
mkdirSync(skillsDir, { recursive: true });
const names = Object.keys(skills);
const rows = names.map(n => `| "${n}" | \`skills/${n}/SKILL.md\` |`).join("\n");
writeFileSync(
join(skillsDir, "RESOLVER.md"),
`## Test\n| Trigger | Skill |\n|-----|-----|\n${rows}\n`
);
writeFileSync(
join(skillsDir, "manifest.json"),
JSON.stringify({ skills: names.map(n => ({ name: n, path: `${n}/SKILL.md` })) }, null, 2)
);
for (const [name, body] of Object.entries(skills)) {
mkdirSync(join(skillsDir, name), { recursive: true });
const fm = `---\nname: ${name}\ndescription: test\ntriggers:\n - "${name}"\n---\n`;
writeFileSync(join(skillsDir, name, "SKILL.md"), fm + body);
}
execSync("git init --quiet", { cwd: root });
execSync("git config user.email t@t", { cwd: root });
execSync("git config user.name t", { cwd: root });
execSync("git add -A && git commit --quiet -m init", { cwd: root });
return root;
}
function runDoctor(cwd: string, args: string[]): { stdout: string; stderr: string; status: number } {
const res = spawnSync("bun", [CLI, "doctor", "--fast", ...args], {
cwd,
encoding: "utf-8",
env: { ...process.env, NO_COLOR: "1" },
});
return { stdout: res.stdout, stderr: res.stderr, status: res.status ?? -1 };
}
describe("gbrain doctor --fix CLI integration", () => {
test("--fix --dry-run proposes a fix and does not write", () => {
const root = makeGitFixture({
demo: "## Iron Law: Back-Linking (MANDATORY)\n\nbody paragraph.\n",
});
const before = readFileSync(join(root, "skills", "demo", "SKILL.md"), "utf-8");
const { stdout } = runDoctor(root, ["--fix", "--dry-run"]);
expect(stdout).toContain("[PROPOSED]");
expect(stdout).toContain("Iron Law back-linking");
expect(stdout).toContain("Run without --dry-run to apply.");
const after = readFileSync(join(root, "skills", "demo", "SKILL.md"), "utf-8");
expect(after).toBe(before);
});
test("--fix applies, subsequent --fast run shows no DRY violation for fixed pattern", () => {
const root = makeGitFixture({
demo: "## Iron Law: Back-Linking (MANDATORY)\n\nbody.\n",
});
const { stdout: fixOut } = runDoctor(root, ["--fix"]);
expect(fixOut).toContain("[APPLIED]");
const updated = readFileSync(join(root, "skills", "demo", "SKILL.md"), "utf-8");
expect(updated).toContain("> **Convention:** See `skills/conventions/quality.md`");
expect(updated).not.toContain("## Iron Law: Back-Linking");
// Re-run --fast (not --fix) — commit the fix first so the dirty guard
// doesn't fire and we're testing detection cleanly.
execSync("git add -A && git commit --quiet -m fixup", { cwd: root });
const { stdout: checkOut } = runDoctor(root, ["--json"]);
const dryCount = (checkOut.match(/"type":"dry_violation"/g) || []).length;
expect(dryCount).toBe(0);
});
test("--fix with nothing to fix prints no-op message", () => {
const root = makeGitFixture({
clean: "# CleanSkill\n\nNo cross-cutting patterns here.\n",
});
const { stdout } = runDoctor(root, ["--fix"]);
expect(stdout).toContain("no DRY violations to repair");
});
});
+3 -35
View File
@@ -36,41 +36,9 @@ describe('doctor command', () => {
test('runDoctor accepts null engine for filesystem-only mode', async () => {
const { runDoctor } = await import('../src/commands/doctor.ts');
// runDoctor should accept null engine — it runs filesystem checks only.
// Signature is (engine, args, dbSource?) — third param is optional and
// used by --fast to distinguish "no config" from "user skipped DB check".
// Function.length counts required params only (JS ignores ?-marked).
expect(runDoctor.length).toBeGreaterThanOrEqual(2);
expect(runDoctor.length).toBeLessThanOrEqual(3);
});
// Bug 7 — --fast should differentiate "no config anywhere" from "user
// chose --fast with GBRAIN_DATABASE_URL / config-file URL present".
test('getDbUrlSource reflects GBRAIN_DATABASE_URL env var', async () => {
const { getDbUrlSource } = await import('../src/core/config.ts');
const orig = process.env.GBRAIN_DATABASE_URL;
const origAlt = process.env.DATABASE_URL;
try {
process.env.GBRAIN_DATABASE_URL = 'postgresql://test@localhost/x';
expect(getDbUrlSource()).toBe('env:GBRAIN_DATABASE_URL');
delete process.env.GBRAIN_DATABASE_URL;
process.env.DATABASE_URL = 'postgresql://test@localhost/x';
expect(getDbUrlSource()).toBe('env:DATABASE_URL');
} finally {
if (orig === undefined) delete process.env.GBRAIN_DATABASE_URL;
else process.env.GBRAIN_DATABASE_URL = orig;
if (origAlt === undefined) delete process.env.DATABASE_URL;
else process.env.DATABASE_URL = origAlt;
}
});
test('doctor --fast emits source-specific message when URL present', async () => {
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
// The source-aware message must reference the variable name so users
// know where their URL is coming from.
expect(source).toContain('Skipping DB checks (--fast mode, URL present from');
// The null-source fallback must still mention both config + env paths.
expect(source).toContain('GBRAIN_DATABASE_URL');
// runDoctor should accept null engine — it runs filesystem checks only
// We can't call it directly (it calls process.exit), but we verify the signature
expect(runDoctor.length).toBe(2); // engine, args
});
// v0.12.2 reliability wave — doctor detects JSONB double-encode + truncated
-101
View File
@@ -1,101 +0,0 @@
import { describe, test, expect } from 'bun:test';
import { existsSync } from 'fs';
import { execSync } from 'child_process';
import { join } from 'path';
const CLI = join(import.meta.dir, '..', 'src', 'cli.ts');
const BUN = 'bun run';
// CI may not have a brain dir or DB — detect environment
const HAS_BRAIN = existsSync('/data/brain/.git');
const BRAIN_DIR = HAS_BRAIN ? '/data/brain' : null;
function gbrain(args: string, timeout = 30_000): string {
try {
return execSync(`${BUN} ${CLI} ${args} 2>/dev/null`, {
encoding: 'utf-8',
timeout,
env: { ...process.env, BUN_INSTALL: '/root/.bun', PATH: `/root/.bun/bin:${process.env.PATH}` },
}).trim();
} catch (e: any) {
return (e.stdout || '').trim();
}
}
function parseJsonOutput(output: string): any {
try { return JSON.parse(output); } catch {}
const lines = output.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('{')) {
try { return JSON.parse(lines.slice(i).join('\n')); } catch {}
}
}
return null;
}
// ── Tests that always work (no brain or DB needed) ──
describe('Dream Command — CLI Registration', () => {
test('dream appears in help text', () => {
const help = gbrain('--help');
expect(help).toContain('dream');
expect(help).toContain('Nightly dream cycle');
});
});
describe('Dream Command — Source File', () => {
test('dream.ts exists', () => {
expect(existsSync(join(import.meta.dir, '..', 'src', 'commands', 'dream.ts'))).toBe(true);
});
test('exports runDream function', async () => {
const mod = await import('../src/commands/dream.ts');
expect(typeof mod.runDream).toBe('function');
});
test('DreamReport type structure is correct', async () => {
const mod = await import('../src/commands/dream.ts');
expect(mod.runDream).toBeTruthy();
// Verify the module compiles without errors
});
});
// ── Tests that require a brain directory ──
describe('Dream Command — Integration', () => {
const skipReason = !HAS_BRAIN ? '(skip) no brain dir at /data/brain' : undefined;
test('dream command runs lint phase', () => {
if (!HAS_BRAIN) return; // skip in CI
const output = gbrain(`dream --phase lint --dry-run --json --dir ${BRAIN_DIR}`, 30_000);
expect(output).not.toContain('Unknown command');
const report = parseJsonOutput(output);
expect(report).toBeTruthy();
expect(report.phases.length).toBe(1);
expect(report.phases[0].phase).toBe('lint');
expect(report.timestamp).toBeTruthy();
expect(report.duration_ms).toBeGreaterThanOrEqual(0);
expect(typeof report.totals.lint_fixes).toBe('number');
}, 60_000);
test('dream command runs backlinks phase', () => {
if (!HAS_BRAIN) return;
const output = gbrain(`dream --phase backlinks --dry-run --json --dir ${BRAIN_DIR}`, 30_000);
const report = parseJsonOutput(output);
expect(report).toBeTruthy();
expect(report.phases.length).toBe(1);
expect(report.phases[0].phase).toBe('backlinks');
}, 60_000);
test('--json returns valid DreamReport', () => {
if (!HAS_BRAIN) return;
const output = gbrain(`dream --phase lint --dry-run --json --dir ${BRAIN_DIR}`, 30_000);
const report = parseJsonOutput(output);
expect(report).toBeTruthy();
expect(report.timestamp).toBeTruthy();
expect(report.duration_ms).toBeGreaterThanOrEqual(0);
expect(Array.isArray(report.phases)).toBe(true);
expect(report.totals).toBeTruthy();
expect(report.brain_dir).toBeTruthy();
}, 60_000);
});
-284
View File
@@ -1,284 +0,0 @@
import { describe, test, expect, afterEach } from "bun:test";
import { join } from "path";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "fs";
import { tmpdir } from "os";
import { execSync } from "child_process";
import {
autoFixDryViolations,
isInsideCodeFence,
detectBlockShape,
expandBullet,
expandBlockquote,
expandParagraph,
} from "../src/core/dry-fix.ts";
// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------
let fixtures: string[] = [];
afterEach(() => {
for (const f of fixtures) {
try { rmSync(f, { recursive: true, force: true }); } catch { /* ignore */ }
}
fixtures = [];
});
function makeSkillsFixture(files: Record<string, string>, opts: { gitInit?: boolean } = {}): string {
const dir = mkdtempSync(join(tmpdir(), "gbrain-dryfix-"));
fixtures.push(dir);
const skillNames = Object.keys(files);
writeFileSync(
join(dir, "manifest.json"),
JSON.stringify({ skills: skillNames.map(n => ({ name: n, path: `${n}/SKILL.md` })) }, null, 2)
);
for (const [name, body] of Object.entries(files)) {
mkdirSync(join(dir, name), { recursive: true });
writeFileSync(join(dir, name, "SKILL.md"), body);
}
if (opts.gitInit) {
execSync("git init --quiet", { cwd: dir });
execSync("git config user.email test@test", { cwd: dir });
execSync("git config user.name test", { cwd: dir });
execSync("git add -A && git commit --quiet -m init", { cwd: dir });
}
return dir;
}
// ---------------------------------------------------------------------------
// Pure function tests: expanders and guards
// ---------------------------------------------------------------------------
describe("detectBlockShape", () => {
test("bullet with dash", () => {
expect(detectBlockShape(["- a bullet"], 0)).toBe("bullet");
});
test("bullet with numeric", () => {
expect(detectBlockShape(["1. numbered"], 0)).toBe("bullet");
});
test("indented bullet", () => {
expect(detectBlockShape([" - nested"], 0)).toBe("bullet");
});
test("blockquote", () => {
expect(detectBlockShape(["> quoted"], 0)).toBe("blockquote");
});
test("paragraph default", () => {
expect(detectBlockShape(["plain text"], 0)).toBe("paragraph");
});
});
describe("expandBullet", () => {
test("single-line bullet", () => {
const lines = ["before", "", "- single bullet", "", "after"];
const block = expandBullet(lines, 2);
expect(block).toEqual({ startLine: 2, endLine: 2 });
});
test("bullet with sub-bullets", () => {
const lines = [
"- top-level bullet",
" - sub one",
" - sub two",
"- next sibling",
];
const block = expandBullet(lines, 0);
expect(block).toEqual({ startLine: 0, endLine: 2 });
});
test("stops at blank line", () => {
const lines = ["- item", "continuation", "", "- next"];
const block = expandBullet(lines, 0);
expect(block).toEqual({ startLine: 0, endLine: 1 });
});
});
describe("expandBlockquote", () => {
test("contiguous quote lines", () => {
const lines = ["> line 1", "> line 2", "not quote"];
const block = expandBlockquote(lines, 0);
expect(block).toEqual({ startLine: 0, endLine: 1 });
});
test("returns null for Convention callout (don't rewrite reference)", () => {
const lines = ["> **Convention:** See `skills/conventions/quality.md`."];
expect(expandBlockquote(lines, 0)).toBeNull();
});
test("returns null for Filing rule callout", () => {
const lines = ["> **Filing rule:** Read `skills/_brain-filing-rules.md`."];
expect(expandBlockquote(lines, 0)).toBeNull();
});
});
describe("expandParagraph", () => {
test("expands to blank boundaries", () => {
const lines = ["", "line a", "line b", "", "other"];
const block = expandParagraph(lines, 1);
expect(block).toEqual({ startLine: 1, endLine: 2 });
});
test("handles start of file", () => {
const lines = ["first line", "second", ""];
const block = expandParagraph(lines, 0);
expect(block).toEqual({ startLine: 0, endLine: 1 });
});
});
describe("isInsideCodeFence", () => {
test("inside fenced block", () => {
const content = "pre\n```\nfenced notability gate\n```\npost\n";
const offset = content.indexOf("fenced notability");
expect(isInsideCodeFence(content, offset)).toBe(true);
});
test("outside fenced block", () => {
const content = "notability gate\n```\ncode\n```\n";
const offset = content.indexOf("notability");
expect(isInsideCodeFence(content, offset)).toBe(false);
});
test("after closed fence (regression guard)", () => {
const content = "```\nexample\n```\n\nreal notability gate here\n";
const offset = content.indexOf("real notability");
expect(isInsideCodeFence(content, offset)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Integration tests: autoFixDryViolations
// ---------------------------------------------------------------------------
describe("autoFixDryViolations", () => {
test("replaces paragraph-form heading (Iron Law)", () => {
const dir = makeSkillsFixture({
a: "# A\n\n## Iron Law: Back-Linking (MANDATORY)\n\nbody text.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
expect(report.fixed).toHaveLength(1);
expect(report.fixed[0].status).toBe("applied");
const updated = readFileSync(join(dir, "a", "SKILL.md"), "utf-8");
expect(updated).toContain("> **Convention:** See `skills/conventions/quality.md`");
expect(updated).not.toContain("## Iron Law: Back-Linking");
});
test("replaces bullet-item inlined rule", () => {
const dir = makeSkillsFixture({
b: "# B\n\n- First\n- Check the notability gate before creating a page\n- Last\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
expect(report.fixed).toHaveLength(1);
const updated = readFileSync(join(dir, "b", "SKILL.md"), "utf-8");
expect(updated).toContain("> **Convention:** See `skills/conventions/quality.md`");
expect(updated).toContain("- First"); // surrounding bullets preserved
expect(updated).toContain("- Last");
});
test("does NOT rewrite a Convention callout (block_is_callout)", () => {
const dir = makeSkillsFixture({
c: "> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking rules.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
// proximity suppression means no violation to fix in the first place
expect(report.fixed).toHaveLength(0);
});
test("skips match inside fenced code block", () => {
const dir = makeSkillsFixture({
d: "# D\n\nExample:\n```\n## Iron Law: Back-Linking (MANDATORY)\n```\ntext.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "inside_code_fence");
expect(sk).toBeDefined();
expect(report.fixed).toHaveLength(0);
});
test("skips when pattern matches more than once", () => {
const dir = makeSkillsFixture({
e: "## Iron Law: Back-Linking (MANDATORY)\n\nThe Iron Law Back-Link applies to every entity.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "ambiguous_multiple_matches");
expect(sk).toBeDefined();
});
test("skips when delegation already within 10 lines (idempotent)", () => {
const dir = makeSkillsFixture({
f: "> **Convention:** See `skills/conventions/quality.md`.\n\nCheck the notability gate.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "already_delegated");
expect(sk).toBeDefined();
expect(report.fixed).toHaveLength(0);
});
test("skips when working tree is dirty", () => {
const dir = makeSkillsFixture({
g: "## Iron Law: Back-Linking (MANDATORY)\n\nbody.\n",
}, { gitInit: true });
// dirty the file: add another line post-commit
const p = join(dir, "g", "SKILL.md");
writeFileSync(p, readFileSync(p, "utf-8") + "\nextra edit\n");
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "working_tree_dirty");
expect(sk).toBeDefined();
// file unchanged
expect(readFileSync(p, "utf-8")).toContain("## Iron Law: Back-Linking");
});
test("refuses to write when skill is NOT inside a git repo (no_git_backup)", () => {
// no gitInit — writing would destroy user data with no rollback
const dir = makeSkillsFixture({
ng: "## Iron Law: Back-Linking (MANDATORY)\n\nbody.\n",
}, { gitInit: false });
const p = join(dir, "ng", "SKILL.md");
const before = readFileSync(p, "utf-8");
const report = autoFixDryViolations(dir);
const sk = report.skipped.find(s => s.reason === "no_git_backup");
expect(sk).toBeDefined();
expect(report.fixed).toHaveLength(0);
expect(readFileSync(p, "utf-8")).toBe(before);
});
test("preserves trailing newline when block is at EOF", () => {
const dir = makeSkillsFixture({
eof: "## Iron Law: Back-Linking (MANDATORY)\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
expect(report.fixed).toHaveLength(1);
const after = readFileSync(join(dir, "eof", "SKILL.md"), "utf-8");
expect(after.endsWith("\n")).toBe(true);
});
test("dry-run mode does not write files", () => {
const dir = makeSkillsFixture({
h: "# H\n\n## Iron Law: Back-Linking (MANDATORY)\n\nbody.\n",
}, { gitInit: true });
const before = readFileSync(join(dir, "h", "SKILL.md"), "utf-8");
const report = autoFixDryViolations(dir, { dryRun: true });
expect(report.fixed).toHaveLength(1);
expect(report.fixed[0].status).toBe("proposed");
const after = readFileSync(join(dir, "h", "SKILL.md"), "utf-8");
expect(after).toBe(before);
});
test("ENOENT on skill file does not crash", () => {
const dir = makeSkillsFixture({
i: "## Iron Law: Back-Linking (MANDATORY)\n",
}, { gitInit: true });
// remove the skill file after fixture creation but before fix runs
rmSync(join(dir, "i", "SKILL.md"));
const report = autoFixDryViolations(dir);
// file_missing is silently skipped (already reported as missing_file elsewhere)
expect(report.fixed).toHaveLength(0);
});
test("notability gate accepts _brain-filing-rules.md as delegation", () => {
const dir = makeSkillsFixture({
j: "> **Filing rule:** Read `skills/_brain-filing-rules.md`.\n\nCheck the notability gate.\n",
}, { gitInit: true });
const report = autoFixDryViolations(dir);
// suppressed by proximity + filing-rule delegation
expect(report.fixed).toHaveLength(0);
});
});
-117
View File
@@ -1,117 +0,0 @@
/**
* E2E doctor --progress-json streaming.
*
* Spawns the real CLI against a real Postgres+pgvector instance. Asserts:
* - stderr contains one JSON event per DB check (start + heartbeats)
* - stdout stays clean of progress (agents that parse stdout don't see
* progress garbage mixed with the check results)
*
* Tier 1 (no API keys). Requires DATABASE_URL or .env.testing.
* Run: DATABASE_URL=... bun test test/e2e/doctor-progress.test.ts
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'child_process';
import { join } from 'path';
import {
hasDatabase, setupDB, teardownDB, importFixtures,
} from './helpers.ts';
const skip = !hasDatabase();
const describeE2E = skip ? describe.skip : describe;
const CLI = join(import.meta.dir, '..', '..', 'src', 'cli.ts');
describeE2E('gbrain doctor --progress-json (E2E)', () => {
beforeAll(async () => {
await setupDB();
// Seed a handful of pages so the DB checks have something to scan.
await importFixtures();
});
afterAll(async () => {
await teardownDB();
});
test('stderr has JSONL progress events, stdout stays clean', () => {
const res = spawnSync('bun', [CLI, '--progress-json', 'doctor', '--json'], {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
timeout: 30_000,
});
// Even if some checks warn, doctor runs to completion. Failures would
// exit non-zero, which is OK — we're testing progress wiring.
// Require that some output happened on both streams.
expect(res.stderr.length).toBeGreaterThan(0);
expect(res.stdout.length).toBeGreaterThan(0);
// Parse stderr as JSONL. Extract every line that looks like a JSON
// object; tolerate stray non-JSON lines (warnings, dependency noise).
const lines = res.stderr.split('\n').filter(l => l.trim().startsWith('{'));
const events: Array<Record<string, unknown>> = [];
for (const line of lines) {
try {
events.push(JSON.parse(line));
} catch {
// Not a progress event — could be a legacy stderr logger line.
}
}
expect(events.length).toBeGreaterThan(0);
// We expect at least one 'start' for doctor.db_checks.
const starts = events.filter(e => e.event === 'start');
const phases = starts.map(e => e.phase);
expect(phases).toContain('doctor.db_checks');
// We expect at least one 'finish' for it too.
const finishes = events.filter(e => e.event === 'finish');
expect(finishes.some(e => e.phase === 'doctor.db_checks')).toBe(true);
// Every event has the canonical schema (event, phase, ts).
for (const ev of events) {
expect(typeof ev.event).toBe('string');
expect(typeof ev.phase).toBe('string');
expect(typeof ev.ts).toBe('string');
}
// Stdout should be doctor's --json payload (array of checks) and nothing
// that looks like a progress event. Parse it as JSON to ensure no stray
// progress-line pollution on stdout.
const parsed = JSON.parse(res.stdout);
expect(Array.isArray(parsed.checks) || Array.isArray(parsed)).toBe(true);
});
test('default (no --progress-json) writes human-plain progress to stderr only', () => {
const res = spawnSync('bun', [CLI, 'doctor'], {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
timeout: 30_000,
});
// Stdout may contain the check summary (human-readable) but should NOT
// contain `[doctor.db_checks]` — that's stderr territory.
expect(res.stdout).not.toContain('[doctor.db_checks]');
// Stderr should contain the phase bracket marker at least once.
// Skip assertion if the DB had no pages and doctor short-circuits fast.
if (res.stderr.length > 0) {
expect(res.stderr).toContain('doctor.db_checks');
}
});
test('--quiet suppresses progress entirely', () => {
const res = spawnSync('bun', [CLI, '--quiet', 'doctor'], {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
timeout: 30_000,
});
// With --quiet the reporter emits no start/finish/tick lines on stderr.
// Stderr may still contain warnings/errors from doctor's own logger,
// just no progress phases.
expect(res.stderr).not.toContain('[doctor.db_checks]');
expect(res.stderr).not.toContain('"event":"start"');
});
});
+17 -17
View File
@@ -121,14 +121,13 @@ describeE2E('E2E: v0.11.0 orchestrator against live Postgres', () => {
expect(prefs.set_at).toBeTruthy();
expect(prefs.set_in_version).toBeTruthy();
// Bug 3 (v0.14.2) — orchestrator no longer writes completed.jsonl.
// The runner (apply-migrations.ts) persists the result after the
// orchestrator returns. A direct orchestrator call in E2E leaves the
// ledger empty; the runner path is tested separately in
// test/apply-migrations.test.ts + test/migration-resume.test.ts.
// Phase G: completed.jsonl has one entry for v0.11.0.
const completed = loadCompletedMigrations();
expect(completed.length).toBeGreaterThanOrEqual(1);
const v0110Entries = completed.filter(e => e.version === '0.11.0');
expect(v0110Entries.length).toBe(0);
expect(v0110Entries.length).toBe(1);
expect(['complete', 'partial']).toContain(v0110Entries[0].status!);
expect(v0110Entries[0].mode).toBe('pain_triggered');
// Phase F is skipped per COMMON_OPTS — autopilot should NOT have been
// installed on this host.
@@ -143,13 +142,15 @@ describeE2E('E2E: v0.11.0 orchestrator against live Postgres', () => {
const second = await v0_11_0.orchestrator(COMMON_OPTS);
expect(['complete', 'partial']).toContain(second.status);
// Bug 3 (v0.14.2) — orchestrator does not write completed.jsonl, so
// repeated direct invocations don't accumulate ledger entries. Assert
// the preferences state stays stable (the real idempotency signal for
// this orchestrator is "running again doesn't corrupt preferences").
expect(loadPreferences().minion_mode).toBe('pain_triggered');
// completed.jsonl accumulates entries per run (each run appends one).
// The runtime semantics for resume are governed by the diff rule in
// apply-migrations; here we just assert the orchestrator itself doesn't
// blow up or produce different results on a second run.
const completed = loadCompletedMigrations();
expect(completed.filter(e => e.version === '0.11.0').length).toBe(0);
const v0110 = completed.filter(e => e.version === '0.11.0');
expect(v0110.length).toBeGreaterThanOrEqual(2);
// Preferences should be stable (same mode, unchanged content).
expect(loadPreferences().minion_mode).toBe('pain_triggered');
}, 90_000);
test('host rewrite: builtin handlers auto-rewritten, non-builtins queued as JSONL TODOs', async () => {
@@ -232,16 +233,15 @@ describeE2E('E2E: v0.11.0 orchestrator against live Postgres', () => {
// Orchestrator re-running on a partial → should succeed (schema apply
// and smoke are idempotent; prefs are preserved from the partial
// record; host-rewrite runs its safe-skip pass). Per Bug 3 (v0.14.2),
// the orchestrator itself doesn't append to completed.jsonl — the
// runner does. The stopgap's partial entry stays unchanged here.
// record; host-rewrite runs its safe-skip pass; completed appends a
// new status:"complete" row).
const result = await v0_11_0.orchestrator(COMMON_OPTS);
expect(['complete', 'partial']).toContain(result.status);
const completed = loadCompletedMigrations();
const v0110 = completed.filter(e => e.version === '0.11.0');
// Just the stopgap partial — orchestrator doesn't add its own entry.
expect(v0110.length).toBe(1);
// 1 partial (stopgap) + 1 post-orchestrator entry.
expect(v0110.length).toBe(2);
expect(v0110[0].status).toBe('partial');
expect(v0110[0].source).toBe('fix-v0.11.0.sh');
}, 90_000);
-135
View File
@@ -1,135 +0,0 @@
/**
* E2E Minions Shell Handler Tests exercises the full lifecycle against real
* Postgres: submit worker claims spawn result status flip.
*
* Unit tests in test/minions-shell.test.ts cover the handler in detail
* (validation, env allowlist, abort, SIGTERM grace, audit log). These E2E
* tests prove the wiring against real Postgres works end-to-end.
*
* Run: DATABASE_URL=... bun test test/e2e/minions-shell.test.ts
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { hasDatabase, setupDB, teardownDB, getConn, getEngine } from './helpers.ts';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import { MinionQueue } from '../../src/core/minions/queue.ts';
import { MinionWorker } from '../../src/core/minions/worker.ts';
import { shellHandler } from '../../src/core/minions/handlers/shell.ts';
import { runMigrations } from '../../src/core/migrate.ts';
const skip = !hasDatabase();
const describeE2E = skip ? describe.skip : describe;
if (skip) {
console.log('Skipping E2E minions shell tests (DATABASE_URL not set)');
}
async function makeEngine(): Promise<PostgresEngine> {
const url = process.env.DATABASE_URL!;
const e = new PostgresEngine();
await e.connect({ engine: 'postgres', database_url: url, poolSize: 4 });
return e;
}
async function waitTerminal(queue: MinionQueue, id: number, timeoutMs = 15000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const j = await queue.getJob(id);
if (j && ['completed', 'failed', 'dead', 'cancelled'].includes(j.status)) return j.status;
await new Promise((r) => setTimeout(r, 100));
}
const j = await queue.getJob(id);
throw new Error(`job ${id} did not reach terminal state in ${timeoutMs}ms; last status=${j?.status}`);
}
describeE2E('E2E: Minions shell handler', () => {
beforeAll(async () => {
await setupDB();
await runMigrations(getEngine());
});
afterAll(async () => {
await teardownDB();
});
beforeEach(async () => {
const conn = getConn();
await conn.unsafe(`TRUNCATE minion_attachments, minion_inbox, minion_jobs RESTART IDENTITY CASCADE`);
});
test('CLI submit → worker claims → shell runs → completes', async () => {
const engine = await makeEngine();
try {
const queue = new MinionQueue(engine);
const job = await queue.add('shell',
{ cmd: 'echo hello', cwd: '/tmp' },
{},
{ allowProtectedSubmit: true },
);
expect(job.name).toBe('shell');
const worker = new MinionWorker(engine, { pollInterval: 100, lockDuration: 30000 });
worker.register('shell', shellHandler);
const runPromise = worker.start();
try {
// 20s tolerates DB warmup variance when run after other E2E files
const status = await waitTerminal(queue, job.id, 20000);
expect(status).toBe('completed');
const final = await queue.getJob(job.id);
expect((final!.result as any).exit_code).toBe(0);
expect((final!.result as any).stdout_tail).toBe('hello\n');
} finally {
worker.stop();
await runPromise;
}
} finally {
await engine.disconnect();
}
}, 45000);
test('MinionQueue.add("shell",...) without trusted arg → throws (defense-in-depth)', async () => {
const engine = await makeEngine();
try {
const queue = new MinionQueue(engine);
await expect(queue.add('shell', { cmd: 'echo ok', cwd: '/tmp' })).rejects.toThrow(/protected job name/);
// Whitespace bypass defense (Codex #1)
await expect(queue.add(' shell ', { cmd: 'echo ok', cwd: '/tmp' })).rejects.toThrow(/protected job name/);
} finally {
await engine.disconnect();
}
});
test('submit_job with ctx.remote=true rejects shell (MCP guard)', async () => {
const engine = await makeEngine();
try {
// Invoke submit_job operation directly with remote=true
const { operations } = await import('../../src/core/operations.ts');
const submitJob = operations.find((op: { name: string }) => op.name === 'submit_job')!;
await expect(
submitJob.handler(
{ engine, remote: true, dryRun: false } as any,
{ name: 'shell', data: { cmd: 'echo hi', cwd: '/tmp' } },
),
).rejects.toThrow(/permission_denied|cannot be submitted over MCP/i);
} finally {
await engine.disconnect();
}
});
test('submit_job with ctx.remote=false allows shell (CLI path)', async () => {
const engine = await makeEngine();
try {
const { operations } = await import('../../src/core/operations.ts');
const submitJob = operations.find((op: { name: string }) => op.name === 'submit_job')!;
const result = await submitJob.handler(
{ engine, remote: false, dryRun: false } as any,
{ name: 'shell', data: { cmd: 'echo hi', cwd: '/tmp' } },
);
expect((result as any).name).toBe('shell');
expect((result as any).status).toBe('waiting');
} finally {
await engine.disconnect();
}
});
});
-152
View File
@@ -79,112 +79,6 @@ describe('migrations v8 + v9 — structural guard for helper-index fix', () => {
});
});
// v0.14.1 — fix wave structural assertions (migrations renumbered from v12/v13 to
// v14/v15 after master merged budget_ledger (v12) + minion_quiet_hours_stagger (v13)).
describe('migrate v14 — pages_updated_at_index (handler-based, engine-aware)', () => {
const v14 = MIGRATIONS.find(m => m.version === 14);
test('v14 exists and uses a handler (not pure SQL) for engine-aware branching', () => {
expect(v14).toBeDefined();
expect(v14!.name).toBe('pages_updated_at_index');
expect(typeof v14!.handler).toBe('function');
expect(v14!.sql).toBe('');
});
test('v14 handler source contains CONCURRENTLY + invalid-index cleanup for Postgres branch', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/core/migrate.ts', 'utf-8');
const v14Start = src.indexOf("name: 'pages_updated_at_index'");
expect(v14Start).toBeGreaterThan(-1);
const v14Block = src.slice(v14Start, v14Start + 3000);
expect(v14Block).toContain('pg_index');
expect(v14Block).toContain('indisvalid');
expect(v14Block).toContain('DROP INDEX CONCURRENTLY IF EXISTS idx_pages_updated_at_desc');
expect(v14Block).toContain('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_pages_updated_at_desc');
// Order within the handler body: DROP IF EXISTS must precede CREATE IF NOT EXISTS,
// so a failed prior CONCURRENTLY build is cleaned before re-create. Anchor on the
// explicit "IF EXISTS" / "IF NOT EXISTS" phrases so the header doc-comment
// (which mentions both unqualified) doesn't fool the ordering assertion.
const dropIdx = v14Block.indexOf('DROP INDEX CONCURRENTLY IF EXISTS');
const createIdx = v14Block.indexOf('CREATE INDEX CONCURRENTLY IF NOT EXISTS');
expect(dropIdx).toBeLessThan(createIdx);
expect(v14Block).toContain('engine.kind');
});
});
describe('migrate v15 — minion_jobs_max_stalled_default_5', () => {
const v15 = MIGRATIONS.find(m => m.version === 15);
test('v15 exists and alters max_stalled default to 5', () => {
expect(v15).toBeDefined();
expect(v15!.name).toBe('minion_jobs_max_stalled_default_5');
expect(v15!.sql).toContain('ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 5');
});
test('v15 backfill UPDATE targets the correct non-terminal statuses', () => {
const sql = v15!.sql;
expect(sql).toContain(`'waiting'`);
expect(sql).toContain(`'active'`);
expect(sql).toContain(`'delayed'`);
expect(sql).toContain(`'waiting-children'`);
expect(sql).toContain(`'paused'`);
expect(sql).not.toContain(`'completed'`);
expect(sql).not.toContain(`'dead'`);
expect(sql).not.toContain(`'cancelled'`);
expect(sql).not.toContain(`'claimed'`);
expect(sql).not.toContain(`'running'`);
expect(sql).not.toContain(`'stalled'`);
});
test('v15 UPDATE clause has the < 5 guard so idempotent re-runs are no-ops', () => {
expect(v15!.sql).toContain('max_stalled < 5');
});
});
describe('migrate — runner behavioral (v14 handler + v15 backfill)', () => {
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
test('v14 created idx_pages_updated_at_desc on PGLite via handler branch', async () => {
const rows = await (engine as any).db.query(
`SELECT indexname FROM pg_indexes WHERE indexname = 'idx_pages_updated_at_desc'`
);
expect(rows.rows.length).toBe(1);
});
test('v15 backfilled any max_stalled=1 rows (smoke: schema default is 5)', async () => {
await (engine as any).db.exec(
`INSERT INTO minion_jobs (name, queue, status, max_stalled) VALUES ('test', 'default', 'waiting', 1)`
);
await (engine as any).db.exec(
`UPDATE minion_jobs SET max_stalled = 5
WHERE status IN ('waiting','active','delayed','waiting-children','paused')
AND max_stalled < 5`
);
const rows = await (engine as any).db.query(
`SELECT max_stalled FROM minion_jobs WHERE name = 'test'`
);
expect((rows.rows[0] as any).max_stalled).toBe(5);
await (engine as any).db.exec(
`UPDATE minion_jobs SET max_stalled = 5
WHERE status IN ('waiting','active','delayed','waiting-children','paused')
AND max_stalled < 5`
);
const rows2 = await (engine as any).db.query(
`SELECT max_stalled FROM minion_jobs WHERE name = 'test'`
);
expect((rows2.rows[0] as any).max_stalled).toBe(5);
});
});
describe('migrate: v8 (links_dedup) regression — must be fast on 1K duplicate rows', () => {
let engine: PGLiteEngine;
@@ -307,49 +201,3 @@ describe('migrate: v9 (timeline_dedup_index) regression — must be fast on 1K d
expect(helperIdx.length).toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────
// resolvePoolSize — GBRAIN_POOL_SIZE env override
// ─────────────────────────────────────────────────────────────────
//
// Guards the Bug 2 fix: users on constrained poolers (Supabase port 6543)
// must be able to cap the pool size via GBRAIN_POOL_SIZE. The default
// (10) is unchanged when the env var is unset.
describe('resolvePoolSize — env var + explicit override', () => {
const { resolvePoolSize } = require('../src/core/db.ts');
const original = process.env.GBRAIN_POOL_SIZE;
afterAll(() => {
if (original === undefined) delete process.env.GBRAIN_POOL_SIZE;
else process.env.GBRAIN_POOL_SIZE = original;
});
test('returns 10 default when unset and no explicit override', () => {
delete process.env.GBRAIN_POOL_SIZE;
expect(resolvePoolSize()).toBe(10);
});
test('reads GBRAIN_POOL_SIZE as an integer', () => {
process.env.GBRAIN_POOL_SIZE = '2';
expect(resolvePoolSize()).toBe(2);
process.env.GBRAIN_POOL_SIZE = '5';
expect(resolvePoolSize()).toBe(5);
});
test('ignores invalid GBRAIN_POOL_SIZE values', () => {
process.env.GBRAIN_POOL_SIZE = 'not-a-number';
expect(resolvePoolSize()).toBe(10);
process.env.GBRAIN_POOL_SIZE = '0';
expect(resolvePoolSize()).toBe(10);
process.env.GBRAIN_POOL_SIZE = '-1';
expect(resolvePoolSize()).toBe(10);
});
test('explicit argument wins over env + default', () => {
delete process.env.GBRAIN_POOL_SIZE;
expect(resolvePoolSize(3)).toBe(3);
process.env.GBRAIN_POOL_SIZE = '7';
expect(resolvePoolSize(3)).toBe(3);
});
});
-170
View File
@@ -1,170 +0,0 @@
/**
* Bug 3 regression migration resume semantics.
*
* Covers:
* - statusForVersion prefers 'complete' over 'partial' (never regresses).
* - Three consecutive 'partial' entries flip a migration to 'wedged'.
* - 'retry' marker resets the counter; next run treats it as fresh.
* - appendCompletedMigration no-ops on double 'complete' (idempotency).
*
* Infrastructure: point HOME at a tmpdir so the ledger writes don't
* stomp the real ~/.gbrain/migrations/completed.jsonl.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
let tmpHome: string;
const originalHome = process.env.HOME;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-migration-resume-'));
process.env.HOME = tmpHome;
});
afterEach(() => {
if (originalHome) process.env.HOME = originalHome;
else delete process.env.HOME;
try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('Bug 3 — statusForVersion semantics', () => {
test("complete wins over partial regardless of order", async () => {
const { __testing } = await import('../src/commands/apply-migrations.ts');
const idx = __testing.indexCompleted([
{ version: '0.13.0', status: 'complete' },
{ version: '0.13.0', status: 'partial' },
] as any);
expect(__testing.statusForVersion('0.13.0', idx)).toBe('complete');
const idx2 = __testing.indexCompleted([
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'complete' },
] as any);
expect(__testing.statusForVersion('0.13.0', idx2)).toBe('complete');
});
test('two consecutive partials stay at partial', async () => {
const { __testing } = await import('../src/commands/apply-migrations.ts');
const idx = __testing.indexCompleted([
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'partial' },
] as any);
expect(__testing.statusForVersion('0.13.0', idx)).toBe('partial');
});
test('three consecutive partials flip to wedged', async () => {
const { __testing } = await import('../src/commands/apply-migrations.ts');
const idx = __testing.indexCompleted([
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'partial' },
] as any);
expect(__testing.statusForVersion('0.13.0', idx)).toBe('wedged');
});
test("retry marker resets the counter", async () => {
const { __testing } = await import('../src/commands/apply-migrations.ts');
const idx = __testing.indexCompleted([
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'retry' },
] as any);
// After 'retry', the version is pending (fresh start).
expect(__testing.statusForVersion('0.13.0', idx)).toBe('pending');
});
test('complete after wedge is still complete (terminal)', async () => {
const { __testing } = await import('../src/commands/apply-migrations.ts');
const idx = __testing.indexCompleted([
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'retry' },
{ version: '0.13.0', status: 'complete' },
] as any);
expect(__testing.statusForVersion('0.13.0', idx)).toBe('complete');
});
});
describe('Bug 3 — appendCompletedMigration idempotency', () => {
test('writing complete when last entry is already complete is a no-op', async () => {
const { appendCompletedMigration, loadCompletedMigrations } = await import('../src/core/preferences.ts');
appendCompletedMigration({ version: '9.9.9', status: 'complete' });
const first = loadCompletedMigrations().filter(e => e.version === '9.9.9');
expect(first.length).toBe(1);
appendCompletedMigration({ version: '9.9.9', status: 'complete' });
const second = loadCompletedMigrations().filter(e => e.version === '9.9.9');
expect(second.length).toBe(1);
});
test('partial always appends (needed for attempt-cap counter)', async () => {
const { appendCompletedMigration, loadCompletedMigrations } = await import('../src/core/preferences.ts');
appendCompletedMigration({ version: '9.9.9', status: 'partial' });
appendCompletedMigration({ version: '9.9.9', status: 'partial' });
const entries = loadCompletedMigrations().filter(e => e.version === '9.9.9');
expect(entries.length).toBe(2);
});
test("'retry' status is accepted", async () => {
const { appendCompletedMigration, loadCompletedMigrations } = await import('../src/core/preferences.ts');
appendCompletedMigration({ version: '9.9.9', status: 'retry' } as any);
const entries = loadCompletedMigrations().filter(e => e.version === '9.9.9');
expect(entries.length).toBe(1);
expect(entries[0].status).toBe('retry');
});
});
describe('Bug 3 — orchestrator no longer writes the ledger directly', () => {
test('v0_13_0 does not import appendCompletedMigration', async () => {
const source = await Bun.file(new URL('../src/commands/migrations/v0_13_0.ts', import.meta.url)).text();
expect(source).not.toContain('import { appendCompletedMigration }');
});
test('v0_13_1 does not import appendCompletedMigration', async () => {
const source = await Bun.file(new URL('../src/commands/migrations/v0_13_1.ts', import.meta.url)).text();
expect(source).not.toContain('import { appendCompletedMigration }');
});
test('v0_12_0 does not import appendCompletedMigration', async () => {
const source = await Bun.file(new URL('../src/commands/migrations/v0_12_0.ts', import.meta.url)).text();
expect(source).not.toContain('import { appendCompletedMigration }');
});
test('v0_12_2 does not import appendCompletedMigration', async () => {
const source = await Bun.file(new URL('../src/commands/migrations/v0_12_2.ts', import.meta.url)).text();
expect(source).not.toContain('import { appendCompletedMigration }');
});
test('v0_11_0 does not import appendCompletedMigration', async () => {
const source = await Bun.file(new URL('../src/commands/migrations/v0_11_0.ts', import.meta.url)).text();
// Import statement should not reference appendCompletedMigration; the
// old call site is replaced with a comment.
expect(source).not.toMatch(/import .*appendCompletedMigration.*from/);
});
test('apply-migrations.ts runner writes the ledger', async () => {
const source = await Bun.file(new URL('../src/commands/apply-migrations.ts', import.meta.url)).text();
expect(source).toContain("import { loadCompletedMigrations, appendCompletedMigration");
expect(source).toContain("appendCompletedMigration({");
expect(source).toContain("'retry'");
expect(source).toContain('--force-retry');
expect(source).toContain('MAX_CONSECUTIVE_PARTIALS');
});
});
describe('Bug 3 — buildPlan surfaces wedged migrations', () => {
test('wedged bucket exists in the plan', async () => {
const { __testing } = await import('../src/commands/apply-migrations.ts');
const idx = __testing.indexCompleted([
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'partial' },
{ version: '0.13.0', status: 'partial' },
] as any);
const plan = __testing.buildPlan(idx, '0.13.0', '0.13.0'); // filter to just this version
expect(plan.wedged.length).toBe(1);
expect(plan.wedged[0].version).toBe('0.13.0');
expect(plan.pending.length).toBe(0);
expect(plan.partial.length).toBe(0);
});
});
-79
View File
@@ -1,79 +0,0 @@
/**
* Tests for the v0.13.0 frontmatter relationship indexing migration.
*
* Iron rule (regression guard for Bug 1, v0.14.0 upgrade night): phase
* handlers must shell out to the bare string `gbrain`, NOT to
* `process.execPath`. On bun-installed trees execPath is the bun runtime;
* `bun extract ...` gets interpreted as `bun run extract` and the upgrade
* crashes mid-migration. The canonical shim on PATH is the right target.
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const SRC_PATH = join(__dirname, '..', 'src', 'commands', 'migrations', 'v0_13_0.ts');
describe('v0.13.0 — Frontmatter relationship indexing migration', () => {
test('registered in the TS migration registry', async () => {
const { migrations, getMigration } = await import('../src/commands/migrations/index.ts');
const versions = migrations.map(m => m.version);
expect(versions).toContain('0.13.0');
const m = getMigration('0.13.0');
expect(m).not.toBeNull();
expect(typeof m!.orchestrator).toBe('function');
});
test('phase functions exported for unit testing', async () => {
const { __testing } = await import('../src/commands/migrations/v0_13_0.ts');
expect(typeof __testing.phaseASchema).toBe('function');
expect(typeof __testing.phaseBBackfill).toBe('function');
expect(typeof __testing.phaseCVerify).toBe('function');
});
test('dry-run skips all side-effect phases', async () => {
const { v0_13_0 } = await import('../src/commands/migrations/v0_13_0.ts');
const result = await v0_13_0.orchestrator({ yes: true, dryRun: true });
expect(result.version).toBe('0.13.0');
for (const phase of result.phases) {
expect(phase.status).toBe('skipped');
expect(phase.detail).toBe('dry-run');
}
});
// ── Regression guards (Bug 1) ──────────────────────────────
test('source does NOT reference process.execPath (Bug 1 regression)', () => {
// process.execPath on a bun install is the bun runtime itself, so
// `${process.execPath} extract` becomes `bun run extract` and dies.
// See v0.14.0 upgrade-night postmortem.
const src = readFileSync(SRC_PATH, 'utf-8');
expect(src).not.toContain('process.execPath');
});
test('source does NOT build commands from a GBRAIN constant (Bug 1 regression)', () => {
// Earlier revisions used `const GBRAIN = process.execPath` and built
// commands as `${GBRAIN} extract ...`. The constant was the vector.
const src = readFileSync(SRC_PATH, 'utf-8');
expect(src).not.toMatch(/const\s+GBRAIN\s*=/);
expect(src).not.toMatch(/\$\{GBRAIN\}/);
});
test('phase commands invoke bare `gbrain` shell-out (Bug 1 fix)', () => {
const src = readFileSync(SRC_PATH, 'utf-8');
// All three phases shell out to bare `gbrain` so the canonical shim
// on PATH wins. This is the shape v0_12_0 has always used.
expect(src).toContain("execSync('gbrain init --migrate-only'");
expect(src).toContain("execSync('gbrain extract links --source db --include-frontmatter'");
expect(src).toContain("execSync('gbrain call get_stats'");
});
test('phase commands never reference `bun` or `.ts` paths (Bug 1 regression)', () => {
// Belt-and-suspenders: even if someone reintroduces a runtime-path
// helper, they must not produce `bun ...` or `<path>.ts` as the spawn
// target.
const src = readFileSync(SRC_PATH, 'utf-8');
expect(src).not.toMatch(/execSync\([^)]*\bbun\b/);
expect(src).not.toMatch(/execSync\([^)]*\.ts/);
});
});
-107
View File
@@ -1,107 +0,0 @@
/**
* Bug 5 + Bug 8 v0_14_0 orchestrator regression.
*
* The migration ships:
* - Phase A (schema): ALTER minion_jobs.max_stalled SET DEFAULT 3
* - Phase B (host-work): append skill-ping entry to
* ~/.gbrain/migrations/pending-host-work.jsonl
*
* Both phases are idempotent re-running the migration is a no-op after
* the first successful pass.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
let tmpHome: string;
const originalHome = process.env.HOME;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-v0_14_0-'));
process.env.HOME = tmpHome;
});
afterEach(() => {
if (originalHome) process.env.HOME = originalHome;
else delete process.env.HOME;
try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
});
describe('Bug 5 + Bug 8 — v0_14_0 module shape', () => {
test('v0_14_0 is registered in migrations/index.ts', async () => {
const { migrations } = await import('../src/commands/migrations/index.ts');
const m = migrations.find(x => x.version === '0.14.0');
expect(m).toBeDefined();
expect(m!.featurePitch.headline).toBeTruthy();
});
test('v0_14_0 does NOT write the ledger directly', async () => {
const source = await Bun.file(new URL('../src/commands/migrations/v0_14_0.ts', import.meta.url)).text();
expect(source).not.toContain('appendCompletedMigration');
});
test('orchestrator returns complete when phase A is skipped (no config)', async () => {
const { v0_14_0 } = await import('../src/commands/migrations/v0_14_0.ts');
// No loadConfig() backing → phaseASchema reports skipped (no brain).
// Phase B still emits the host-work ping.
const result = await v0_14_0.orchestrator({
yes: true,
dryRun: false,
noAutopilotInstall: true,
});
expect(['complete', 'partial']).toContain(result.status);
expect(result.version).toBe('0.14.0');
const hostWork = result.phases.find(p => p.name === 'host-work');
expect(hostWork).toBeDefined();
});
});
describe('Bug 5 — Phase B host-work entry dedup', () => {
test('first run writes the entry, second run is a skip', async () => {
const { v0_14_0 } = await import('../src/commands/migrations/v0_14_0.ts');
const first = await v0_14_0.orchestrator({ yes: true, dryRun: false, noAutopilotInstall: true });
const hostPath = join(tmpHome, '.gbrain', 'migrations', 'pending-host-work.jsonl');
expect(existsSync(hostPath)).toBe(true);
const beforeLines = readFileSync(hostPath, 'utf-8').split('\n').filter(l => l.trim()).length;
expect(beforeLines).toBe(1);
// Second run — Phase B should skip, not duplicate.
await v0_14_0.orchestrator({ yes: true, dryRun: false, noAutopilotInstall: true });
const afterLines = readFileSync(hostPath, 'utf-8').split('\n').filter(l => l.trim()).length;
expect(afterLines).toBe(1);
const entry = JSON.parse(readFileSync(hostPath, 'utf-8').split('\n')[0]);
expect(entry.migration).toBe('0.14.0');
expect(entry.skill).toBe('skills/migrations/v0.14.0.md');
});
test('dry-run writes nothing', async () => {
const { v0_14_0 } = await import('../src/commands/migrations/v0_14_0.ts');
await v0_14_0.orchestrator({ yes: true, dryRun: true, noAutopilotInstall: true });
const hostPath = join(tmpHome, '.gbrain', 'migrations', 'pending-host-work.jsonl');
expect(existsSync(hostPath)).toBe(false);
});
});
describe('Bug 8 — max_stalled default bumped in schema files', () => {
// v0.14.2 bumped schema default 1 -> 3 via Bug 8. v0.14.3 (#219 fix wave) further
// bumps to 5 for extra flaky-deploy headroom, plus adds UPDATE backfill of
// non-terminal rows via migration v15. These structural assertions track the
// current schema source state (not historical).
test('schema-embedded.ts has max_stalled DEFAULT 5', async () => {
const source = await Bun.file(new URL('../src/core/schema-embedded.ts', import.meta.url)).text();
expect(source).toContain('max_stalled INTEGER NOT NULL DEFAULT 5');
});
test('pglite-schema.ts has max_stalled DEFAULT 5', async () => {
const source = await Bun.file(new URL('../src/core/pglite-schema.ts', import.meta.url)).text();
expect(source).toContain('max_stalled INTEGER NOT NULL DEFAULT 5');
});
test('schema.sql has max_stalled DEFAULT 5', async () => {
const source = await Bun.file(new URL('../src/schema.sql', import.meta.url)).text();
expect(source).toContain('max_stalled INTEGER NOT NULL DEFAULT 5');
});
});
-342
View File
@@ -1,342 +0,0 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { MinionQueue } from '../src/core/minions/queue.ts';
import { UnrecoverableError } from '../src/core/minions/types.ts';
import type { MinionJobContext } from '../src/core/minions/types.ts';
import { shellHandler } from '../src/core/minions/handlers/shell.ts';
import { computeAuditFilename, resolveAuditDir, logShellSubmission } from '../src/core/minions/handlers/shell-audit.ts';
import { isProtectedJobName, PROTECTED_JOB_NAMES } from '../src/core/minions/protected-names.ts';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
let engine: PGLiteEngine;
let queue: MinionQueue;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({ databaseUrl: '' });
await engine.initSchema();
queue = new MinionQueue(engine);
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await engine.executeRaw('DELETE FROM minion_jobs');
});
// Build a minimal MinionJobContext for unit tests. Real worker provides this;
// here we mock it so the handler can be exercised without spinning up Postgres.
function makeCtx(
data: Record<string, unknown>,
opts: { signal?: AbortSignal; shutdownSignal?: AbortSignal } = {},
): MinionJobContext {
return {
id: 1,
name: 'shell',
data,
attempts_made: 0,
signal: opts.signal ?? new AbortController().signal,
shutdownSignal: opts.shutdownSignal ?? new AbortController().signal,
updateProgress: async () => {},
updateTokens: async () => {},
log: async () => {},
isActive: async () => true,
readInbox: async () => [],
};
}
// ---- protected-names ---------------------------------------------------------
describe('protected-names', () => {
test('shell is protected', () => {
expect(isProtectedJobName('shell')).toBe(true);
expect(PROTECTED_JOB_NAMES.has('shell')).toBe(true);
});
test('normalization: whitespace is trimmed before check', () => {
expect(isProtectedJobName(' shell ')).toBe(true);
expect(isProtectedJobName('\tshell\n')).toBe(true);
});
test('case-sensitive: Shell is NOT protected', () => {
expect(isProtectedJobName('Shell')).toBe(false);
expect(isProtectedJobName('SHELL')).toBe(false);
});
test('non-protected names pass through', () => {
expect(isProtectedJobName('sync')).toBe(false);
expect(isProtectedJobName('embed')).toBe(false);
expect(isProtectedJobName('')).toBe(false);
});
});
// ---- MinionQueue.add trusted guard ------------------------------------------
describe('MinionQueue.add protected-name guard', () => {
test('add("shell", ...) without trusted arg throws', async () => {
expect(queue.add('shell', { cmd: 'echo', cwd: '/tmp' })).rejects.toThrow(/protected job name/);
});
test('add("shell", ..., opts, {allowProtectedSubmit:true}) succeeds', async () => {
const job = await queue.add('shell', { cmd: 'echo', cwd: '/tmp' }, undefined, { allowProtectedSubmit: true });
expect(job.name).toBe('shell');
expect(job.status).toBe('waiting');
});
// Whitespace bypass defense (Codex #1)
test('add(" shell ", ...) without trusted arg throws (whitespace bypass defense)', async () => {
expect(queue.add(' shell ', { cmd: 'echo', cwd: '/tmp' })).rejects.toThrow(/protected job name/);
});
test('add(" shell ", ...) with trusted arg inserts normalized name "shell"', async () => {
const job = await queue.add(' shell ', { cmd: 'echo', cwd: '/tmp' }, undefined, { allowProtectedSubmit: true });
expect(job.name).toBe('shell');
});
test('add("Shell", ...) is treated as non-protected (case-sensitive)', async () => {
const job = await queue.add('Shell', {});
expect(job.name).toBe('Shell');
expect(job.status).toBe('waiting');
});
// Regression: non-protected names unaffected (Codex iron-rule)
test('REGRESSION: add("sync", ...) without trusted arg still succeeds', async () => {
const job = await queue.add('sync', { full: true });
expect(job.name).toBe('sync');
expect(job.status).toBe('waiting');
});
test('REGRESSION: trusted flag does NOT bypass empty-name check', async () => {
expect(queue.add('', {}, undefined, { allowProtectedSubmit: true })).rejects.toThrow(/cannot be empty/);
});
});
// ---- Shell handler: validation ----------------------------------------------
describe('shell handler: validation', () => {
test('both cmd and argv → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo', argv: ['echo'], cwd: '/tmp' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('neither cmd nor argv → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cwd: '/tmp' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('cwd missing → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo ok' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('cwd not absolute → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo ok', cwd: 'relative/path' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('argv non-array (string) → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ argv: 'echo ok', cwd: '/tmp' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('argv with non-string entries → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ argv: ['echo', 42], cwd: '/tmp' }));
expect(p).rejects.toThrow(UnrecoverableError);
});
test('env with non-string values → UnrecoverableError', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo', cwd: '/tmp', env: { FOO: 42 } }));
expect(p).rejects.toThrow(UnrecoverableError);
});
});
// ---- Shell handler: spawn + output ------------------------------------------
describe('shell handler: spawn', () => {
test('cmd happy path: echo ok → exit 0, stdout captured', async () => {
const res = await shellHandler(makeCtx({ cmd: 'echo ok', cwd: '/tmp' })) as any;
expect(res.exit_code).toBe(0);
expect(res.stdout_tail).toBe('ok\n');
expect(res.stderr_tail).toBe('');
expect(typeof res.duration_ms).toBe('number');
expect(res.duration_ms).toBeGreaterThanOrEqual(0);
expect(typeof res.pid).toBe('number');
});
test('argv happy path: ["echo","hi"] → exit 0, stdout "hi\\n"', async () => {
const res = await shellHandler(makeCtx({ argv: ['echo', 'hi'], cwd: '/tmp' })) as any;
expect(res.exit_code).toBe(0);
expect(res.stdout_tail).toBe('hi\n');
});
test('non-zero exit → Error with stderr in message', async () => {
const p = shellHandler(makeCtx({ cmd: 'echo fail 1>&2; exit 7', cwd: '/tmp' }));
await expect(p).rejects.toThrow(/exit 7/);
});
test('argv with bogus binary → Error (retryable)', async () => {
const p = shellHandler(makeCtx({ argv: ['gbrain-nonexistent-binary-xyz'], cwd: '/tmp' }));
// spawn emits 'error' on ENOENT
await expect(p).rejects.toThrow();
});
test('result shape includes all declared keys', async () => {
const res = await shellHandler(makeCtx({ cmd: 'echo ok', cwd: '/tmp' })) as any;
expect(Object.keys(res).sort()).toEqual(['duration_ms', 'exit_code', 'pid', 'stderr_tail', 'stdout_tail']);
});
});
// ---- Shell handler: env allowlist -------------------------------------------
describe('shell handler: env allowlist', () => {
test('process env leak prevention: a faux secret is NOT in child env', async () => {
const saved = process.env.SHELL_TEST_SECRET;
process.env.SHELL_TEST_SECRET = 'should-not-leak';
try {
const res = await shellHandler(makeCtx({
cmd: 'echo "secret=${SHELL_TEST_SECRET:-EMPTY}"',
cwd: '/tmp',
})) as any;
expect(res.stdout_tail).toBe('secret=EMPTY\n');
} finally {
if (saved === undefined) delete process.env.SHELL_TEST_SECRET;
else process.env.SHELL_TEST_SECRET = saved;
}
});
test('PATH is inherited from worker', async () => {
const res = await shellHandler(makeCtx({
cmd: 'echo "path=$PATH"',
cwd: '/tmp',
})) as any;
expect(res.stdout_tail.startsWith('path=')).toBe(true);
expect(res.stdout_tail.length).toBeGreaterThan('path=\n'.length);
});
test('caller-supplied env key is added', async () => {
const res = await shellHandler(makeCtx({
cmd: 'echo "val=$MY_CUSTOM"',
cwd: '/tmp',
env: { MY_CUSTOM: 'hello' },
})) as any;
expect(res.stdout_tail).toBe('val=hello\n');
});
test('caller-supplied env can override allowlisted key (PATH)', async () => {
const res = await shellHandler(makeCtx({
cmd: 'echo "path=$PATH"',
cwd: '/tmp',
env: { PATH: '/custom/bin' },
})) as any;
expect(res.stdout_tail).toBe('path=/custom/bin\n');
});
});
// ---- Shell handler: abort --------------------------------------------------
describe('shell handler: abort', () => {
test('ctx.signal.abort triggers SIGTERM and handler throws aborted', async () => {
const ac = new AbortController();
const promise = shellHandler(makeCtx(
{ cmd: 'sleep 30', cwd: '/tmp' },
{ signal: ac.signal },
));
// Give spawn a beat to start
setTimeout(() => ac.abort(new Error('cancel')), 50);
await expect(promise).rejects.toThrow(/aborted/);
});
test('ctx.shutdownSignal.abort also triggers kill', async () => {
const shutdownCtl = new AbortController();
const promise = shellHandler(makeCtx(
{ cmd: 'sleep 30', cwd: '/tmp' },
{ shutdownSignal: shutdownCtl.signal },
));
setTimeout(() => shutdownCtl.abort(new Error('shutdown')), 50);
await expect(promise).rejects.toThrow(/aborted/);
});
test('pre-aborted signal → immediate kill', async () => {
const ac = new AbortController();
ac.abort(new Error('cancel'));
const promise = shellHandler(makeCtx(
{ cmd: 'sleep 30', cwd: '/tmp' },
{ signal: ac.signal },
));
await expect(promise).rejects.toThrow(/aborted/);
});
});
// ---- shell-audit: ISO-week filename ----------------------------------------
describe('shell-audit: computeAuditFilename', () => {
test('2027-01-01 is ISO week 53 of 2026', () => {
expect(computeAuditFilename(new Date('2027-01-01T12:00:00Z'))).toBe('shell-jobs-2026-W53.jsonl');
});
test('2026-12-28 (Monday) is ISO week 53 of 2026', () => {
expect(computeAuditFilename(new Date('2026-12-28T12:00:00Z'))).toBe('shell-jobs-2026-W53.jsonl');
});
test('2027-01-04 (Monday) is ISO week 1 of 2027', () => {
expect(computeAuditFilename(new Date('2027-01-04T12:00:00Z'))).toBe('shell-jobs-2027-W01.jsonl');
});
test('2026-04-19 (mid-year reference)', () => {
const f = computeAuditFilename(new Date('2026-04-19T00:00:00Z'));
expect(f).toMatch(/^shell-jobs-2026-W\d{2}\.jsonl$/);
});
});
// ---- shell-audit: write path -----------------------------------------------
describe('shell-audit: write', () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-audit-test-'));
process.env.GBRAIN_AUDIT_DIR = tmpDir;
});
afterAll(() => {
delete process.env.GBRAIN_AUDIT_DIR;
});
test('GBRAIN_AUDIT_DIR env override resolves to the custom dir', () => {
expect(resolveAuditDir()).toBe(tmpDir);
});
test('writes a JSONL line; creates dir if missing', () => {
const inner = path.join(tmpDir, 'nested-not-yet-created');
process.env.GBRAIN_AUDIT_DIR = inner;
logShellSubmission({
caller: 'cli', remote: false, job_id: 42, cwd: '/tmp', cmd_display: 'echo ok',
});
const files = fs.readdirSync(inner);
expect(files.length).toBe(1);
const content = fs.readFileSync(path.join(inner, files[0]), 'utf8').trim();
const parsed = JSON.parse(content);
expect(parsed.caller).toBe('cli');
expect(parsed.job_id).toBe(42);
expect(parsed.cmd_display).toBe('echo ok');
expect(parsed.ts).toBeDefined();
});
test('argv_display stored as JSON array (Codex #11)', () => {
logShellSubmission({
caller: 'cli', remote: false, job_id: 1, cwd: '/tmp',
argv_display: ['node', 'script.mjs', '--date', '2026-04-18'],
});
const files = fs.readdirSync(tmpDir);
const content = fs.readFileSync(path.join(tmpDir, files[0]), 'utf8').trim();
const parsed = JSON.parse(content);
expect(Array.isArray(parsed.argv_display)).toBe(true);
expect(parsed.argv_display).toEqual(['node', 'script.mjs', '--date', '2026-04-18']);
});
test('does NOT log env values', () => {
logShellSubmission({
caller: 'cli', remote: false, job_id: 1, cwd: '/tmp', cmd_display: 'echo ok',
});
const files = fs.readdirSync(tmpDir);
const content = fs.readFileSync(path.join(tmpDir, files[0]), 'utf8');
expect(content).not.toContain('env');
});
test('write failure (EACCES) is non-blocking', () => {
// Point at a read-only target. /dev/null is not a directory.
process.env.GBRAIN_AUDIT_DIR = '/dev/null/not-a-dir';
// Should not throw — failures go to stderr.
expect(() => logShellSubmission({
caller: 'cli', remote: false, job_id: 1, cwd: '/tmp',
})).not.toThrow();
});
});
// ---- shell handler: UTF-8-safe output truncation ---------------------------
describe('shell handler: output truncation', () => {
test('stdout > 64KB is truncated and marker is prepended', async () => {
// Emit ~100KB of stdout to force truncation
const res = await shellHandler(makeCtx({
cmd: `yes ok | head -c 100000`,
cwd: '/tmp',
})) as any;
expect(res.exit_code).toBe(0);
expect(res.stdout_tail).toMatch(/^\[truncated \d+ bytes\]/);
expect(res.stdout_tail.length).toBeGreaterThan(0);
// Tail must contain characters we emitted
expect(res.stdout_tail).toContain('ok');
});
});
-104
View File
@@ -270,110 +270,6 @@ describe('MinionQueue: Stall Detection', () => {
});
});
// --- v0.13.1 #219 — max_stalled default + input surface ---
describe('MinionQueue: v0.13.1 max_stalled schema default (#219)', () => {
test('job submitted with no explicit max_stalled uses schema default of 5', async () => {
const job = await queue.add('noop', {});
expect(job.max_stalled).toBe(5);
});
test('default=5 rescues across 4 consecutive stalls, dead-letters on the 5th', async () => {
const job = await queue.add('noop', {});
// Job starts at max_stalled=5 (schema default).
for (let i = 0; i < 4; i++) {
await queue.claim(`tok-${i}`, 30000, 'default', ['noop']);
await engine.executeRaw(
"UPDATE minion_jobs SET lock_until = now() - interval '1 second' WHERE id = $1",
[job.id]
);
const { requeued, dead } = await queue.handleStalled();
expect(dead.length).toBe(0);
expect(requeued.length).toBe(1);
expect(requeued[0].stalled_counter).toBe(i + 1);
}
// 5th stall = dead (5+1 >= 5 = wait, actually handleStalled gate is stalled_counter + 1 >= max_stalled).
// With stalled_counter now at 4, next stall: 4+1=5 >= 5 = dead.
await queue.claim('tok-final', 30000, 'default', ['noop']);
await engine.executeRaw(
"UPDATE minion_jobs SET lock_until = now() - interval '1 second' WHERE id = $1",
[job.id]
);
const { dead } = await queue.handleStalled();
expect(dead.length).toBe(1);
expect(dead[0].status).toBe('dead');
});
});
describe('MinionQueue: v0.13.1 MinionJobInput.max_stalled plumbing', () => {
test('honored end-to-end when provided', async () => {
const job = await queue.add('noop', {}, { max_stalled: 10 });
expect(job.max_stalled).toBe(10);
});
test('clamps input > 100 to 100', async () => {
const job = await queue.add('noop', {}, { max_stalled: 9999 });
expect(job.max_stalled).toBe(100);
});
test('clamps input < 1 to 1', async () => {
const job = await queue.add('noop', {}, { max_stalled: 0 });
expect(job.max_stalled).toBe(1);
});
test('clamps negative input to 1', async () => {
const job = await queue.add('noop', {}, { max_stalled: -5 });
expect(job.max_stalled).toBe(1);
});
test('non-integer inputs are floored before clamp', async () => {
const job = await queue.add('noop', {}, { max_stalled: 7.9 });
expect(job.max_stalled).toBe(7);
});
test('undefined leaves schema default intact (5)', async () => {
const job = await queue.add('noop', {}, { max_stalled: undefined });
expect(job.max_stalled).toBe(5);
});
});
describe('MinionQueue: v0.13.1 live-queue rescue regression (#219)', () => {
test('a row at max_stalled=1 is rescued by v13 backfill', async () => {
// Simulate a pre-v0.13.1 brain that inserted a row at the old default.
const job = await queue.add('noop', {});
await engine.executeRaw('UPDATE minion_jobs SET max_stalled = 1 WHERE id = $1', [job.id]);
// Run the v13 backfill UPDATE directly (matches migrate.ts v13 body).
await engine.executeRaw(
`UPDATE minion_jobs SET max_stalled = 5
WHERE status IN ('waiting','active','delayed','waiting-children','paused')
AND max_stalled < 5`
);
const refetched = await queue.getJob(job.id);
expect(refetched!.max_stalled).toBe(5);
});
test('backfill does not touch terminal-status rows', async () => {
const job = await queue.add('noop', {});
// Mark completed and set max_stalled=1 (simulating historical data).
await engine.executeRaw(
`UPDATE minion_jobs SET status = 'completed', max_stalled = 1, finished_at = now() WHERE id = $1`,
[job.id]
);
await engine.executeRaw(
`UPDATE minion_jobs SET max_stalled = 5
WHERE status IN ('waiting','active','delayed','waiting-children','paused')
AND max_stalled < 5`
);
const refetched = await queue.getJob(job.id);
// Terminal rows intentionally untouched; historical data stays as-is.
expect(refetched!.max_stalled).toBe(1);
});
});
// --- Dependencies (5 tests) ---
describe('MinionQueue: Dependencies', () => {
-37
View File
@@ -891,40 +891,3 @@ describe('PGLiteEngine: getHealth graph metrics', () => {
expect(h2.orphan_pages).toBe(1);
});
});
// ─────────────────────────────────────────────────────────────────
// v0.13.1 — PGLite.create() error-wrap (structural guard for #223)
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: v0.13.1 error-wrap on connect() (#223)', () => {
test('pglite-engine.ts source contains the wrap with #223 hint and nested original error', async () => {
const { readFileSync } = await import('fs');
const src = readFileSync('src/core/pglite-engine.ts', 'utf-8');
// Structural: the try/catch block must wrap PGlite.create() (the actual
// abort site, NOT engine-factory.ts). The error message must name the
// issue and suggest gbrain doctor. Must NOT suggest "missing migrations"
// as a cause (that was conflating #218 and #223 — migrations run AFTER
// create()).
expect(src).toContain('this._db = await PGlite.create');
expect(src).toContain('https://github.com/garrytan/gbrain/issues/223');
expect(src).toContain('gbrain doctor');
expect(src).toContain('Original error:');
// Regression guard: the user-visible error MESSAGE must not re-introduce
// the misleading "missing migrations" hint. (A source comment explaining
// *why* we removed it is fine — match only inside the wrapped Error body.)
const wrapStart = src.indexOf('const wrapped = new Error(');
expect(wrapStart).toBeGreaterThan(-1);
const wrapEnd = src.indexOf(');', wrapStart);
const errBody = src.slice(wrapStart, wrapEnd);
expect(errBody).not.toContain('missing migrations');
expect(errBody).not.toContain('apply-migrations');
});
});
// ─────────────────────────────────────────────────────────────────
// v0.13.1 — Engine kind discriminator
// ─────────────────────────────────────────────────────────────────
describe('PGLiteEngine: v0.13.1 kind discriminator', () => {
test('exposes readonly kind = pglite', () => {
expect(engine.kind).toBe('pglite');
});
});
-15
View File
@@ -65,21 +65,6 @@ describe('postgres-engine / search path timeout isolation', () => {
expect(vector).toMatch(/SET\s+LOCAL\s+statement_timeout/);
});
test('connect() with poolSize honors resolvePrepare (PgBouncer regression guard)', () => {
// Regression: worker-instance pools were NOT honoring the prepare decision
// before v0.15.4. Module singleton connect() in db.ts was fixed by #284 but
// PostgresEngine.connect({poolSize}) (the branch used by `gbrain jobs work`)
// silently ignored it — agents running background work against Supabase
// pooler URLs still hit `prepared statement "..." does not exist` under
// load. Source-level grep is enough: runtime mocking of postgres.js's
// tagged-template interface is painful under bun ESM and the wiring is
// simple enough that if `resolvePrepare` name appears and a conditional
// `prepare` key appears in the options literal, the wire-up is live.
const stripped = stripComments(SRC);
expect(stripped).toMatch(/db\.resolvePrepare\s*\(\s*url\s*\)/);
expect(stripped).toMatch(/typeof\s+prepare\s*===\s*['"]boolean['"]/);
});
test('neither search method clears the timeout with `SET statement_timeout = 0`', () => {
// The reset-to-zero pattern was the other half of the leak: if SET
// LOCAL is in play, COMMIT handles the reset and an explicit
-260
View File
@@ -1,260 +0,0 @@
import { describe, test, expect } from 'bun:test';
import { PassThrough } from 'node:stream';
import { createProgress, startHeartbeat, __liveReporterCountForTest, __signalHandlerInstalledForTest } from '../src/core/progress.ts';
/** Collect everything a reporter writes into a string. */
function sink(isTTY = false): { stream: PassThrough & { isTTY?: boolean }; read: () => string } {
const s = new PassThrough() as PassThrough & { isTTY?: boolean };
s.isTTY = isTTY;
const chunks: string[] = [];
s.on('data', (c) => chunks.push(c.toString('utf8')));
return { stream: s, read: () => chunks.join('') };
}
function parseJsonl(raw: string): Record<string, unknown>[] {
return raw
.split('\n')
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l));
}
describe('progress reporter', () => {
test('auto mode: non-TTY → human-plain (NOT JSON)', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'auto', stream, minIntervalMs: 0, minItems: 1 });
p.start('scan', 3);
p.tick();
p.tick();
p.tick();
p.finish();
const out = read();
// plain lines, no JSON
expect(out).not.toContain('"event"');
expect(out).toContain('[scan]');
expect(out).toContain('1/3');
expect(out).toContain('3/3');
});
test('auto mode: TTY → human-\\r (carriage return, no newline between ticks)', () => {
const { stream, read } = sink(true);
const p = createProgress({ mode: 'auto', stream, minIntervalMs: 0, minItems: 1 });
p.start('scan', 2);
p.tick();
p.tick();
p.finish();
const out = read();
// TTY path uses \r + clear-line escape; final newline on finish.
expect(out).toContain('\r');
expect(out).toContain('[scan]');
});
test('json mode emits one JSON object per line with schema', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
p.start('doctor.jsonb_integrity', 4);
p.tick(1, 'pages.frontmatter');
p.tick(1, 'raw_data.data');
p.finish();
const events = parseJsonl(read());
expect(events.length).toBeGreaterThanOrEqual(3);
expect(events[0]).toMatchObject({ event: 'start', phase: 'doctor.jsonb_integrity', total: 4 });
expect(events[0].ts).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(events[1]).toMatchObject({ event: 'tick', phase: 'doctor.jsonb_integrity', done: 1, total: 4 });
expect(events[1].pct).toBe(25);
expect(typeof events[1].elapsed_ms).toBe('number');
expect(events[events.length - 1]).toMatchObject({ event: 'finish', phase: 'doctor.jsonb_integrity' });
});
test('quiet mode emits nothing', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'quiet', stream });
p.start('scan', 10);
p.tick();
p.heartbeat('hello');
p.finish();
expect(read()).toBe('');
});
test('tick() time-gated: calls inside minIntervalMs collapse to one emit', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 5000, minItems: 999999 });
p.start('scan', 100);
// Rapid ticks — should not emit intermediate 'tick' events (only the final one if eq total).
for (let i = 0; i < 10; i++) p.tick();
const events = parseJsonl(read());
const ticks = events.filter((e) => e.event === 'tick');
// 10 ticks, total=100, final-tick-on-complete heuristic doesn't apply (done < total).
// Time-gated + item-gated should suppress all.
expect(ticks.length).toBe(0);
p.finish();
});
test('tick() item-gated: minItems threshold emits after N items', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 999999, minItems: 50 });
p.start('scan', 1000);
for (let i = 0; i < 100; i++) p.tick();
p.finish();
const events = parseJsonl(read());
const ticks = events.filter((e) => e.event === 'tick');
// 100 ticks with minItems=50 ⇒ expect ~2 emits
expect(ticks.length).toBeGreaterThanOrEqual(1);
expect(ticks.length).toBeLessThanOrEqual(3);
});
test('final tick emits regardless of gating when done === total', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 999999, minItems: 999999 });
p.start('scan', 3);
p.tick();
p.tick();
p.tick(); // this one hits done===total, must emit
p.finish();
const events = parseJsonl(read());
const ticks = events.filter((e) => e.event === 'tick');
expect(ticks.length).toBe(1);
expect(ticks[0]).toMatchObject({ done: 3, total: 3 });
});
test('start(phase) with no total → ticks omit pct/eta_ms', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
p.start('unknown_size_scan'); // no total
p.tick();
p.finish();
const events = parseJsonl(read());
const tick = events.find((e) => e.event === 'tick')!;
expect(tick).toBeDefined();
expect(tick.total).toBeUndefined();
expect(tick.pct).toBeUndefined();
expect(tick.eta_ms).toBeUndefined();
expect(tick.done).toBe(1);
});
test('heartbeat() emits without bumping done', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
p.start('slow_query');
p.heartbeat('still scanning…');
p.heartbeat('still scanning…');
p.finish();
const events = parseJsonl(read());
const hb = events.filter((e) => e.event === 'heartbeat');
expect(hb.length).toBe(2);
expect(hb[0]).toMatchObject({ phase: 'slow_query', note: 'still scanning…' });
// No 'done' field on heartbeat.
expect(hb[0].done).toBeUndefined();
});
test('child() composes phase path with dots', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
p.start('sync');
const c = p.child('import');
c.start('file1', 1);
c.tick();
c.finish();
p.finish();
const events = parseJsonl(read());
const startEvents = events.filter((e) => e.event === 'start');
const phases = startEvents.map((e) => e.phase);
expect(phases).toContain('sync');
expect(phases).toContain('sync.import.file1');
});
test('child.finish() does not close parent', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
p.start('sync');
const c = p.child('import');
c.start('batch1', 1);
c.tick();
c.finish();
// Parent still alive — another tick should work.
// (parent.tick requires a started phase; start was called on 'sync'.)
p.tick(1, 'after-child');
p.finish();
const events = parseJsonl(read());
const finishes = events.filter((e) => e.event === 'finish');
const finishPhases = finishes.map((e) => e.phase);
expect(finishPhases).toContain('sync.import.batch1');
expect(finishPhases).toContain('sync');
});
test('EPIPE sync throw is swallowed; subsequent writes are no-ops', () => {
const brokenStream = {
isTTY: false,
write: () => {
throw Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
},
on: () => {},
} as unknown as NodeJS.WritableStream;
const p = createProgress({ mode: 'json', stream: brokenStream, minIntervalMs: 0, minItems: 1 });
// Must not throw.
expect(() => {
p.start('scan', 3);
p.tick();
p.tick();
p.finish();
}).not.toThrow();
});
test("EPIPE stream 'error' event marks stream broken", () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
p.start('scan', 2);
p.tick();
// Simulate async EPIPE via error event.
stream.emit('error', Object.assign(new Error('EPIPE'), { code: 'EPIPE' }));
// Subsequent calls must not throw.
expect(() => {
p.tick();
p.finish();
}).not.toThrow();
// We did get at least the pre-error emissions.
expect(read()).toContain('"event":"start"');
});
test('only one process-level signal handler installed across many reporters', () => {
// Baseline: one handler already installed by prior tests in this file.
const installedBefore = __signalHandlerInstalledForTest();
const { stream } = sink(false);
for (let i = 0; i < 50; i++) {
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
p.start(`phase_${i}`, 1);
p.finish();
}
// After 50 reporter lifecycles, still exactly one handler and zero leaked live entries.
expect(__signalHandlerInstalledForTest()).toBe(installedBefore || true);
expect(__liveReporterCountForTest()).toBe(0);
});
test('startHeartbeat() fires heartbeats and stop() clears', async () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
p.start('slow_query');
const stop = startHeartbeat(p, 'still running…', 20);
await new Promise((r) => setTimeout(r, 85));
stop();
p.finish();
const events = parseJsonl(read());
const hb = events.filter((e) => e.event === 'heartbeat');
// Expect ~4 heartbeats in 85ms at 20ms interval, tolerate jitter.
expect(hb.length).toBeGreaterThanOrEqual(2);
expect(hb.length).toBeLessThanOrEqual(6);
});
test('finish without prior start is a no-op (no crash)', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream });
expect(() => p.finish()).not.toThrow();
expect(read()).toBe('');
});
test('tick without prior start is a no-op (no crash)', () => {
const { stream, read } = sink(false);
const p = createProgress({ mode: 'json', stream });
expect(() => p.tick()).not.toThrow();
expect(read()).toBe('');
});
});
-75
View File
@@ -1,75 +0,0 @@
/**
* resolvePrepare precedence tests.
*
* The helper in src/core/db.ts decides whether to force `prepare: true|false`
* on the postgres.js client, or leave it unset (postgres.js default). The
* decision matters: on Supabase PgBouncer (port 6543) prepared statements
* break under load, but forcing `prepare: false` on direct Postgres loses
* plan-cache performance. Precedence ordering (env URL query port
* auto-detect default) is enforced here so future edits to resolvePrepare
* cannot silently reshuffle the precedence and reintroduce the bug.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import { resolvePrepare } from '../src/core/db.ts';
describe('resolvePrepare', () => {
afterEach(() => {
delete process.env.GBRAIN_PREPARE;
});
test('returns false for Supabase pooler port 6543', () => {
expect(resolvePrepare('postgresql://user:pass@host:6543/db')).toBe(false);
});
test('returns undefined for direct Postgres port 5432', () => {
expect(resolvePrepare('postgresql://user:pass@host:5432/db')).toBeUndefined();
});
test('returns undefined for default port (no port specified)', () => {
expect(resolvePrepare('postgresql://user:pass@host/db')).toBeUndefined();
});
test('respects ?prepare=false in URL', () => {
expect(
resolvePrepare('postgresql://user:pass@host:5432/db?prepare=false'),
).toBe(false);
});
test('respects ?prepare=true in URL even on port 6543', () => {
expect(
resolvePrepare('postgresql://user:pass@host:6543/db?prepare=true'),
).toBe(true);
});
test('GBRAIN_PREPARE=false overrides everything', () => {
process.env.GBRAIN_PREPARE = 'false';
expect(
resolvePrepare('postgresql://user:pass@host:5432/db?prepare=true'),
).toBe(false);
});
test('GBRAIN_PREPARE=true overrides auto-detect on 6543', () => {
process.env.GBRAIN_PREPARE = 'true';
expect(resolvePrepare('postgresql://user:pass@host:6543/db')).toBe(true);
});
test('GBRAIN_PREPARE=0 is falsy', () => {
process.env.GBRAIN_PREPARE = '0';
expect(resolvePrepare('postgresql://user:pass@host:6543/db')).toBe(false);
});
test('returns undefined for malformed URL', () => {
expect(resolvePrepare('not-a-url')).toBeUndefined();
});
test('handles postgres:// scheme (no ql)', () => {
expect(resolvePrepare('postgres://user:pass@host:6543/db')).toBe(false);
});
test('handles URL with encoded special chars in password', () => {
expect(
resolvePrepare('postgresql://user:p%40ss%24word@host:6543/db'),
).toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show More