mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* v0.28 schema: takes + synthesis_evidence (v31) + access_tokens.permissions (v32)
Migration v31 adds the takes table (typed/weighted/attributed claims) and
synthesis_evidence (provenance for `gbrain think` outputs). Page-scoped via
page_id FK (slug isn't unique alone in v0.18+ multi-source). HNSW partial
index on embedding for active rows. ON DELETE CASCADE on synthesis_evidence
so deleting a source take cascades the provenance row.
Migration v32 adds access_tokens.permissions JSONB with safe-default
backfill (`{"takes_holders":["world"]}`). Default keeps non-world holders
hidden from MCP-bound tokens until the operator explicitly grants access
via the v0.28 auth permissions CLI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 engine: addTakesBatch, listTakes, searchTakes/Vector, supersede, resolve, synthesis_evidence
Extends BrainEngine with the takes domain object. Both engines implement the
same surface; PGLite uses manual `$N` placeholders, Postgres uses postgres-js
unnest() — same shape as addLinksBatch and addTimelineEntriesBatch.
Methods:
- addTakesBatch (upsert via ON CONFLICT (page_id, row_num) DO UPDATE)
- listTakes (filter by holder/kind/active/resolved, takesHoldersAllowList
for MCP-bound calls, sortBy weight/since_date/created_at)
- searchTakes / searchTakesVector (pg_trgm + cosine; honor allow-list)
- countStaleTakes / listStaleTakes (mirror countStaleChunks pattern;
embedding column intentionally omitted from listStale payload)
- updateTake (mutable fields only; throws TAKE_ROW_NOT_FOUND)
- supersedeTake (transactional: insert new at next row_num, mark old
active=false, set superseded_by; throws TAKE_RESOLVED_IMMUTABLE on
resolved bets)
- resolveTake (sets resolved_*; throws TAKE_ALREADY_RESOLVED on re-resolve;
resolution is immutable per Codex P1 #13 fold)
- addSynthesisEvidence (provenance persist; ON CONFLICT DO NOTHING)
- getTakeEmbeddings (parallel to getEmbeddingsByChunkIds)
Types live in src/core/engine.ts adjacent to LinkBatchInput. Page-scoped
via page_id (slug not unique in v0.18+ multi-source). PageType gains
'synthesis'. takeRowToTake mapper in utils.ts handles Date → ISO string
normalization.
Tests: test/takes-engine.test.ts — 16 cases against PGLite covering
upsert/list/filter/search happy paths, takesHoldersAllowList isolation,
the four invariant errors (TAKE_ROW_NOT_FOUND, TAKES_WEIGHT_CLAMPED,
TAKE_RESOLVED_IMMUTABLE, TAKE_ALREADY_RESOLVED), supersede flow, resolve
metadata round-trip, FK CASCADE on synthesis_evidence when source take
deletes. All pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 model-config: unified resolveModel with 6-tier precedence + alias resolution
Replaces every hardcoded `claude-*-X` and per-phase `dream.<phase>.model`
config key with a single resolver. Hierarchy:
1. CLI flag (--model)
2. New-key config (e.g. models.dream.synthesize)
3. Old-key config (deprecated dream.synthesize.model, dream.patterns.model)
— read with stderr deprecation warning, one-per-process
4. Global default (models.default)
5. Env var (GBRAIN_MODEL or caller-supplied)
6. Hardcoded fallback
Aliases (`opus`, `sonnet`, `haiku`, `gemini`, `gpt`) resolve at the end so
any tier can use a short name. User-defined `models.aliases.<name>` config
overrides built-ins. Cycle-safe (depth 2 break). Unknown alias passes
through unchanged so users can pass full provider IDs without registering.
When new-key + old-key are BOTH set (Codex P1 #11 fix), new-key wins and
stderr warns "deprecated config X ignored; Y is set and wins". When only
old-key is set, it's honored with a softer "rename to Y before v0.30"
warning. Both warnings emit once per (key, process) — a Set memo prevents
log spam in long-running daemons.
Migrated call sites: synthesize.ts (model + verdictModel), patterns.ts
(model). subagent.ts and search/expansion.ts to be migrated later in v0.28
(staying compatible until then).
Tests: test/model-config.test.ts — 11 cases pinning the 6-tier ordering,
alias resolution + cycle break, deprecated-key warning emit-once, and
unknown-alias pass-through. All pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 takes-fence: parser/renderer/upserter + chunker strip (privacy P0 fix)
src/core/takes-fence.ts — pure functions for the fenced markdown surface:
- parseTakesFence(body) — extracts ParsedTake[] from `<!--- gbrain:takes:begin/end -->`
blocks. Strict on canonical form, lenient on hand-edits with warnings
(TAKES_FENCE_UNBALANCED, TAKES_TABLE_MALFORMED, TAKES_ROW_NUM_COLLISION).
Strikethrough `~~claim~~` → active=false; date ranges `since → until`
split into sinceDate/untilDate.
- renderTakesFence(takes) — round-trip safe with parseTakesFence.
- upsertTakeRow(body, row) — append-only per CEO-D6 + eng-D9. Creates a
fresh `## Takes` section if no fence present. row_num is monotonic
(max + 1, never gap-filled — keeps cross-page refs and synthesis_evidence
stable forever).
- supersedeRow(body, oldRow, replacement) — strikes through old row's claim
AND appends the new row at end. Both rows preserved in markdown for
git-blame archaeology.
- stripTakesFence(body) — removes the fenced block entirely. Used by the
chunker so takes content lives ONLY in the takes table.
Codex P0 #3 fix: src/core/chunkers/recursive.ts now calls stripTakesFence()
before computing chunk boundaries. Without this, page chunks would contain
the rendered takes table and the per-token MCP allow-list would be
bypassed at the index layer (token bound to takes_holders=['world'] would
see garry's hunches via page hits). Doctor's takes_fence_chunk_leak check
(plan-side) asserts no chunk contains the begin marker.
Tests: 15 cases covering canonical parse, strikethrough, date range, fence
unbalanced detection, malformed-row skip + warning, row_num collision
detection, round-trip render, append-only upsert into existing fence,
fresh-section creation, monotonic row_num under hand-edit gaps, supersede
flow, stripTakesFence verifying takes content removed AND surrounding
prose preserved. Existing chunker tests still pass (15 + 15 = 30).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 page-lock: PID-liveness file lock for atomic markdown read-modify-write
src/core/page-lock.ts — per-page file lock at
~/.gbrain/page-locks/<sha256-of-slug>.lock so two concurrent `gbrain takes
add` calls or `takes seed --refresh` from autopilot can't race on the
same `<slug>.md` read-modify-write. Eng-review fold: reuses the v0.17
cycle.lock pattern (mtime + PID liveness) but per-slug.
Differences from cycle.ts's lock:
- SHA-256 of slug for safe filenames (slashes, unicode, etc.)
- Same-pid + fresh mtime = LIVE (cycle.ts assumes one lock per process and
reclaims same-pid; page-lock allows concurrent locks for DIFFERENT slugs
in one process). mtime expiry still rescues post-crash leftovers.
- 5-min TTL (vs cycle's 30 min — page edits are short)
- `withPageLock(slug, fn)` convenience wrapper with default 30s timeout
API:
- acquirePageLock(slug, opts) → handle | null (poll-with-timeout)
- handle.refresh() / handle.release() (idempotent — only releases if pid matches)
- withPageLock(slug, fn, opts) — acquire + run + release-in-finally
Tests: 10 cases — fresh acquire, live holder returns null, stale-mtime
reclaim, dead-PID reclaim, refresh updates timestamp, foreign-pid release
is no-op, withPageLock callback runs and releases on success/failure,
timeout-throws when held, SHA-256 filename safety for slashes/unicode.
All pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 extract-takes: dual-path phase (fs|db) + since/until_date as TEXT
src/core/cycle/extract-takes.ts — new phase that materializes the takes
table from fenced markdown blocks. Two paths mirror src/commands/extract.ts:
- extractTakesFromFs: walk *.md under repoPath, parse fences, batch upsert
- extractTakesFromDb: iterate engine.getAllSlugs(), parse each page's
compiled_truth+timeline, batch upsert (mutation-immune snapshot iteration)
Single dispatcher extractTakes(opts) routes by source. Honors:
- slugs filter for incremental re-extract (pipes from sync→extract)
- dryRun: count would-be upserts, write nothing
- rebuild: DELETE FROM takes WHERE page_id = $1 before re-insert (clean
slate when markdown is canonical and DB has drifted)
Schema fix: since_date/until_date were DATE in the original v31 migration.
Spec uses partial dates ('2017-01', '2026-04-29 → 2026-06') that Postgres
DATE rejects. Changed to TEXT in both the Postgres and PGLite blocks so
parser-rendered ranges round-trip cleanly. Loses the ability to do
date-range arithmetic in SQL, but date math on opinion timelines is
out of scope for v0.28 anyway. utils.ts dateOrNull now annotated as
v0.28 TEXT-aware.
Migration v31 has not been deployed yet (this branch is the v0.28 release
candidate), so the type swap is free. No data migration needed.
Tests: test/extract-takes.test.ts — 5 cases against PGLite covering full
walk + fence-skip on no-fence pages, takes-table populated post-extract,
incremental slugs filter, dry-run no-write, rebuild=true clears + re-inserts
ad-hoc rows. test/takes-engine.test.ts (16), test/takes-fence.test.ts (15)
all still pass — 36/36 takes tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 takes CLI: list, search, add, update, supersede, resolve
src/commands/takes.ts — surfaces the engine methods + takes-fence library
through a single `gbrain takes <subcommand>` entrypoint:
takes <slug> list with filters + sort
takes search "<query>" pg_trgm keyword search across all takes
takes add <slug> --claim ... ... append (markdown + DB, atomic via lock)
takes update <slug> --row N ... mutable-fields update (markdown + DB)
takes supersede <slug> --row N ... strikethrough old + append new
takes resolve <slug> --row N --outcome record bet resolution (immutable)
Markdown is canonical. Every mutate command:
1. acquires the per-page file lock (withPageLock)
2. re-reads the .md file
3. applies the edit via takes-fence (upsertTakeRow / supersedeRow)
4. writes the .md file back
5. mirrors to the DB via the engine method
6. releases the lock (auto via finally)
Resolve currently writes only to DB — surfacing resolved_* in the markdown
table is deferred to v0.29 (the takes-fence renderer's column set is
fixed at # | claim | kind | who | weight | since | source per spec).
Wired into src/cli.ts dispatch + CLI_ONLY allowlist. Help text follows the
project convention (orphans/embed/extract pattern). --dir flag overrides
sync.repo_path config when working outside the configured brain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 MCP + auth: takes_list / takes_search / think ops + per-token allow-list
OperationContext gains takesHoldersAllowList — server-side filter for
takes.holder field threaded from access_tokens.permissions through dispatch
into the engine SQL. Closes Codex P0 #3 at the dispatch layer (chunker
strip already closed the page-content side in the previous commit).
src/core/operations.ts — three new ops:
- takes_list: lists takes with holder/kind/active/resolved filters; honors
ctx.takesHoldersAllowList for MCP-bound calls
- takes_search: pg_trgm keyword search; honors allow-list
- think: op surface registered (returns not_implemented envelope until
Lane D's pipeline lands). Remote callers cannot save/take per Codex P1 #7.
src/mcp/dispatch.ts — DispatchOpts.takesHoldersAllowList threads into
buildOperationContext.
src/mcp/http-transport.ts — validateToken now reads
access_tokens.permissions.takes_holders, defaults to ['world'] when the
column is absent or malformed (default-deny on private hunches).
auth.takesHoldersAllowList passed to dispatchToolCall.
src/mcp/server.ts (stdio) — defaults to takesHoldersAllowList: ['world']
since stdio has no per-token auth. Operators wanting full visibility use
`gbrain call <op>` directly (sets remote=false).
src/commands/auth.ts — `gbrain auth create <name> --takes-holders w,g,b`
flag persists the per-token list; new `auth permissions <name>
set-takes-holders <list>` updates an existing token.
Tests: test/takes-mcp-allowlist.test.ts — 8 cases against PGLite proving
the threading: local-CLI sees all holders, ['world'] returns only public,
['world','garry'] returns 2/3, no-overlap returns empty (no fallback),
search honors allow-list, remote save/take on think rejected with
not_implemented envelope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28.0: ship-prep — VERSION, CHANGELOG, migration orchestrator, skill
Closes the v0.28 ship-prep cycle. Bumps VERSION + package.json + bun.lock
to 0.28.0. v0_28_0 migration orchestrator runs three idempotent phases on
upgrade:
- Schema verify: asserts schema_version >= 32 (migrations v31 + v32 already
applied by the schema runner during gbrain upgrade); fails clean if not.
- Backfill takes: inline runs `extractTakes(engine, { source: 'db' })` so
any pre-existing fenced takes tables in markdown populate the takes
index. Idempotent; ON CONFLICT DO UPDATE keeps the table in sync.
- Re-chunk TODO: queues a pending-host-work entry asking the host agent
to re-import pages with takes content so the v0.28 chunker-strip rule
(Codex P0 #3 fix) applies retroactively. Pages imported under v0.28+
already have takes content stripped from chunks at index time; this
TODO catches up legacy pages.
skills/migrations/v0.28.0.md — agent-readable upgrade guide. Walks
through doctor verification, deprecated-key migration, MCP token
visibility configuration, and a "try the takes layer" smoke test.
CHANGELOG.md — v0.28.0 release-summary in the GStack voice (no AI
vocabulary, no em dashes, real numbers from git diff stat) + the
mandatory "To take advantage of v0.28.0" block + itemized changes by
subsystem (schema, engine, markdown surface, model config, MCP+auth,
CLI, tests, accepted risks).
Final test sweep: 65/65 v0.28 tests pass across 6 files. typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 think pipeline: gather → sanitize → synthesize → cite-render → CLI
src/core/think/sanitize.ts — prompt-injection defense for take claims:
14 jailbreak patterns (ignore-prior, role-jailbreak, close-take tag,
DAN, system-prompt overrides, eval-shell hooks) plus structural framing
(takes wrapped in <take id="..."> tags the model is told to treat as
DATA). Length-cap at 500 chars. Renders evidence blocks for the prompt.
src/core/think/prompt.ts — system prompt + structured-output schema.
Hard rules: cite every claim, mark hunches/low-weight explicitly,
surface conflicts (never silently pick), surface gaps. JSON schema
with answer + citations[] + gaps[]. Prompt adapts to anchor / time
window / save flag.
src/core/think/cite-render.ts — structured citations + regex fallback
(Codex P1 #4 fold). normalizeStructuredCitations validates the model's
structured output; parseInlineCitations is the body-scan fallback when
the model omits the structured field. resolveCitations dispatches and
records CITATIONS_REGEX_FALLBACK warning when used.
src/core/think/gather.ts — 4-stream parallel retrieval:
1. hybridSearch (pages, existing primitive)
2. searchTakes (keyword, pg_trgm)
3. searchTakesVector (vector, when embedQuestion fn supplied)
4. traversePaths (graph, when --anchor set)
RRF fusion (k=60). Each stream wrapped in try/catch — partial gather
beats no synthesis. Honors takesHoldersAllowList for MCP-bound calls.
src/core/think/index.ts — runThink orchestrator + persistSynthesis:
INTENT (regex classify) → GATHER → render evidence blocks → resolveModel
('models.think' → 'models.default' → GBRAIN_MODEL → opus) → LLM call
(injectable client) → JSON parse with code-fence + fallback strip →
resolveCitations → ThinkResult. persistSynthesis writes a synthesis
page + synthesis_evidence rows (page_id resolved per slug; page-level
citations skip evidence). Degrades gracefully without ANTHROPIC_API_KEY.
Round-loop scaffolding in place (rounds=1 only path exercised in v0.28).
src/commands/think.ts — `gbrain think "<question>"` CLI. Flag parsing
strips --anchor, --rounds, --save, --take, --model, --since, --until,
--json. Local CLI = remote=false, so save/take honored. Human-readable
output by default; --json for agent consumption.
operations.ts — `think` op now calls runThink (was a not_implemented
stub). Remote callers can't save/take per Codex P1 #7. Returns full
ThinkResult plus saved_slug + evidence_inserted.
cli.ts — wired into dispatch + CLI_ONLY allowlist.
Tests: test/think-pipeline.test.ts — 18 cases against PGLite covering
sanitize patterns, structural rendering, citation parsing (structured +
regex fallback + dedup + invalid-slug rejection), gather streams +
allow-list filter, full pipeline with stub client, malformed-LLM
fallback path, no-API-key graceful degradation, persistSynthesis writes
page + evidence rows. All pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 dream phases: auto-think + drift + budget meter (Codex P1 #10 fold)
src/core/anthropic-pricing.ts — USD/1M-tokens map for Claude 4.7 family
plus older aliases. estimateMaxCostUsd returns null on unpriced models so
the meter caller can warn-once and bypass the gate.
src/core/cycle/budget-meter.ts — cumulative cost ledger. Each submit
estimates max-cost from (model + estimatedInputTokens + maxOutputTokens),
accumulates per-cycle, refuses next submit when projected > cap. Codex
P1 #10 fold: non-Anthropic models (gemini, gpt) bypass with one stderr
warn per process and `unpriced=true` on the result. Budget=0 disables
the gate. Audit trail at ~/.gbrain/audit/dream-budget-YYYY-Www.jsonl.
src/core/cycle/auto-think.ts — auto_think dream phase. Reads
dream.auto_think.{enabled,questions,max_per_cycle,budget,cooldown_days,
auto_commit}. Iterates configured questions through runThink with the
BudgetMeter pre-checking each submit. Cooldown timestamp written ONLY on
success (matches v0.23 synthesize pattern — retries after partial
failures pick back up). When auto_commit=true, persists synthesis pages
via persistSynthesis. Default-disabled.
src/core/cycle/drift.ts — drift dream phase scaffold. Reads
dream.drift.{enabled,lookback_days,budget,auto_update}. Surfaces takes
in the soft band (weight 0.3-0.85, unresolved) that have recent timeline
evidence on the same page. v0.28 ships the orchestration; the LLM judge
that proposes weight adjustments lands in v0.29. modelId + meter wired
now so the ledger captures gate state for callers that opt in.
Tests:
- test/budget-meter.test.ts (7 cases) — pricing-map coverage, allow path,
cumulative-deny, budget=0 disabled, unpriced bypass+warn-once, ledger
captures all events, ISO-week filename branch.
- test/auto-think-phase.test.ts (9 cases) — auto_think enable/skip,
questions empty, success → cooldown ts written, cooldown blocks rerun,
budget exhausted → partial. drift not_enabled, soft-band candidate
detection, complete + dry-run paths.
All pass. Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 e2e Postgres: takes engine + extract + MCP allow-list (12 cases)
test/e2e/takes-postgres.test.ts — full v0.28 takes pipeline against real
Postgres (gated on DATABASE_URL). 12 cases:
- addTakesBatch upsert via unnest() bind path (Postgres-specific)
- listTakes filters: holder, kind, sort=weight, takesHoldersAllowList
- searchTakes pg_trgm + allow-list filter
- supersedeTake transactional path (BEGIN/COMMIT semantics)
- resolveTake immutability — second resolve throws TAKE_ALREADY_RESOLVED
- synthesis_evidence FK CASCADE on take delete
- countStaleTakes + listStaleTakes filter active+null
- extractTakesFromDb populates takes from fenced markdown
- MCP dispatch with takesHoldersAllowList=['world'] returns only world
- MCP dispatch local-CLI path returns all holders
- MCP dispatch takes_search honors allow-list
- think op forces remote_persisted_blocked even for save+take
postgres-engine.ts: addTakesBatch boolean[] serialization fix.
postgres-js auto-detects element type from JS arrays; for booleans it
mis-detects as scalar. Cast through text[] (`'true' | 'false'`) then
SQL-cast to boolean[] — same pattern other batch methods rely on for
type-stable bind shapes.
test/e2e/helpers.ts: setupDB now (a) tolerates non-existent tables in
TRUNCATE (for fresh DBs where v31 hasn't yet created takes/synthesis_evidence)
and (b) calls engine.initSchema() to actually run migrations.
test/takes-mcp-allowlist.test.ts: updated 2 think-op cases to match
Lane D's landed pipeline. They previously asserted not_implemented
envelopes; now they assert remote_persisted_blocked + NO_ANTHROPIC_API_KEY
graceful-degrade behavior.
Run: DATABASE_URL=postgres://localhost:5435/gbrain_test bun test test/e2e/takes-postgres.test.ts
Result: 12/12 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 dream phases: local DreamPhaseResult type (avoid premature CyclePhase enum extension)
cycle.ts's PhaseResult is shaped {phase, status, summary, details} with a
narrow PhaseStatus enum ('ok'|'warn'|'fail'|'skipped') and CyclePhase enum
that doesn't yet include 'auto_think'/'drift'. The phases ship standalone
in v0.28 (cycle.ts dispatcher integration is v0.28.x); using PhaseResult
forced premature enum extension.
Introduces DreamPhaseResult exported from auto-think.ts:
{ name: 'auto_think'|'drift'; status: 'complete'|'partial'|'failed'|'skipped';
detail: string; totals?: Record<string,number>; duration_ms: number }
drift.ts re-exports the same type. When v0.28.x wires the dispatcher, the
adapter at the call site can map DreamPhaseResult → PhaseResult cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 e2e: access_tokens.permissions JSONB end-to-end (5 cases)
test/e2e/auth-permissions.test.ts — closes the v0.28 token-allow-list
verification loop against real Postgres. Exercises:
- Migration v32 default backfill: new tokens created without a permissions
column get {takes_holders: ["world"]} via the schema DEFAULT clause.
- Explicit ["world","garry"] → dispatch.takes_list filters to those
holders only; brain hunches stay hidden from this token.
- ["world"] default-deny token → takes_search hits filtered to public claims.
- {} permissions row (operator tampered) gracefully defaults to ["world"]
via the HTTP transport's validateToken parsing.
- revoked_at IS NOT NULL → token excluded from active token query.
Avoids the postgres-js JSONB double-encode trap (CLAUDE.md memory): pass
the object directly to executeRaw, no JSON.stringify, no ::jsonb cast.
All 5 pass against pgvector/pgvector:pg16 on port 5435. Combined v0.28
test sweep: 116/116 across 11 files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28 e2e: chunker takes-strip integration test (Codex P0 #3 verification)
test/e2e/chunker-takes-strip.test.ts — verifies the chunker actually
strips fenced takes content end-to-end through the import pipeline.
This is the Codex P0 #3 fix's verification path: takes content lives
ONLY in the takes table for retrieval, never duplicated in
content_chunks where the per-token MCP allow-list cannot reach.
5 cases:
- chunkText (unit) output never contains TAKES_FENCE_BEGIN/END markers
- chunkText output never contains fenced claim text
- chunkText output retains non-fence prose (no over-stripping)
- importFromContent end-to-end: imported page has chunks but none
contain fenced content
- takes_fence_chunk_leak doctor invariant: zero rows globally where
chunk_text matches `<!--- gbrain:takes:%`
Final v0.28 test sweep:
121 pass, 0 fail, 336 expect() calls, 12 files
Coverage: schema migrations, engine methods (PGLite + Postgres),
takes-fence parser, page-lock, extract phase, takes CLI engine
surface, model config 6-tier resolver, MCP+auth allow-list,
think pipeline (gather + sanitize + cite-render + synthesize),
auto-think + drift + budget meter, JSONB end-to-end, chunker
strip integration. ~95% of v0.28 surface area covered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix CI: apply-migrations skippedFuture arrays + http-transport SQL mock
Two CI failures from PR #563:
test/apply-migrations.test.ts (2 fails) — `buildPlan` tests assert exact
skippedFuture arrays at fixed installed-version stamps. Adding v0.28.0 to
the migration registry means it shows up in skippedFuture when the test
runs at installed=0.11.1 / installed=0.12.0. Append '0.28.0' to both
hardcoded arrays.
test/http-transport.test.ts (8 fails) — the FakeEngine mock string-prefix
matches `SELECT id, name FROM access_tokens` to return a row. v0.28's
validateToken now selects `SELECT id, name, permissions FROM access_tokens`
to read the per-token takes_holders allow-list. Mock returned [] on the
new query → validateToken treated every token as invalid → 401.
Fix: mock now matches both query shapes. validTokens row gets a default
`{takes_holders: ['world']}` permission injected when caller didn't
supply one (mirrors the migration v33 column DEFAULT). Updated
FakeEngineConfig type to allow tests to pass explicit permissions.
Verification:
bun test test/apply-migrations.test.ts → 18/18 pass
bun test test/http-transport.test.ts → 24/24 pass
bun run typecheck → clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix CI: add scope annotations to v0.28 ops (takes_list/takes_search/think)
test/oauth.test.ts enforces an invariant from master's v0.26 OAuth landing:
every Operation must have `scope: 'read' | 'write' | 'admin'`, and any op
flagged `mutating: true` must be 'write' or 'admin'. My v0.28 ops were added
before master shipped v0.26 + the new invariant; the merge surfaced the gap.
Annotations:
- takes_list → read
- takes_search → read
- think → write (mutating: true; --save persists synthesis page)
Verification:
bun test test/oauth.test.ts → 42/42 pass
bun run typecheck → clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* v0.28.2 feat: remote-source MCP + scope hierarchy + whoami (#690)
* refactor(core): extract SSRF helpers from integrations.ts to core/url-safety.ts
src/core/git-remote.ts (next commit) needs isInternalUrl etc. but importing
from src/commands/ would invert the layering boundary (no existing
src/core/ file imports from src/commands/). Extract the SSRF helpers
(parseOctet, hostnameToOctets, isPrivateIpv4, isInternalUrl) into a new
src/core/url-safety.ts and have integrations.ts re-export for backward
compat. test/integrations.test.ts continues to pass without changes (110
existing tests, 214 expects).
Why this matters for v0.28: the upcoming sources --url feature reuses
this SSRF gate for git-clone URL validation. Codex review caught that
re-rolling weaker URL classification would regress on the IPv6/v4-mapped/
metadata/CGNAT bypass forms that integrations.ts already handles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(core): add git-remote module — SSRF-defensive clone/pull + state probe
New src/core/git-remote.ts (~210 lines) for v0.28's remote-source feature:
- GIT_SSRF_FLAGS exported const: -c http.followRedirects=false,
-c protocol.file.allow=never, -c protocol.ext.allow=never,
--no-recurse-submodules. Single source of truth shared by cloneRepo
and pullRepo so a future flag added to one path lands on both.
Closes the SSRF surfaces codex flagged: DNS rebinding via redirects,
.gitmodules as a second-fetch surface, file:// scheme in remotes.
- parseRemoteUrl: https-only, rejects embedded credentials and path
traversal, delegates internal-target classification to isInternalUrl
from url-safety.ts (covers RFC1918, link-local, loopback, IPv6, CGNAT
100.64/10, metadata hostnames, hex/octal/single-int bypass forms).
GBRAIN_ALLOW_PRIVATE_REMOTES=1 escape hatch with stderr warning is
needed for self-hosted git over Tailscale (CGNAT trips the gate).
- cloneRepo: --depth=1 default (full clone via depth: 0); refuses
non-empty destDirs; spawns git via execFileSync (no shell injection)
with GIT_TERMINAL_PROMPT=0 + askpass=/bin/false to prevent credential
prompts. timeoutMs default 600s.
- pullRepo: -C path + GIT_SSRF_FLAGS + pull --ff-only, same env confine.
- validateRepoState: 6-state decision tree (missing | not-a-dir |
no-git | corrupted | url-drift | healthy). Used by performSync's
re-clone branch to recover from rmd clone dirs and refuse syncs on
url-drift or corruption.
test/git-remote.test.ts (304 lines, 32 tests): GIT_SSRF_FLAGS exact
shape, all parseRemoteUrl rejection cases including dedicated CGNAT
100.64/10 with/without GBRAIN_ALLOW_PRIVATE_REMOTES (codex T3 case),
fake-git harness for argv assertions on cloneRepo/pullRepo, all 6
validateRepoState branches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(core): add scope hierarchy + ALLOWED_SCOPES allowlist
New src/core/scope.ts (~120 lines) for v0.28's scoped MCP feature.
Hierarchy:
- admin implies all (escape hatch)
- write implies read
- sources_admin and users_admin are siblings (different axes —
sources-mgmt vs user-account-mgmt; neither implies the other)
Exported:
- hasScope(grantedScopes, requiredScope): the canonical scope check.
Replaces exact-string-match at three call sites in upcoming commits
(serve-http.ts:673, oauth-provider.ts:365 F3 refresh, oauth-provider.ts:498
token issuance). Without this rewrite, an admin-grant token would
fail to refresh down to sources_admin (codex finding).
- ALLOWED_SCOPES set + ALLOWED_SCOPES_LIST sorted array (deterministic
for OAuth metadata wire format and drift-check output).
- assertAllowedScopes / InvalidScopeError: registration-time gate so
tokens with bogus scope strings (read flying-unicorn) get rejected
with RFC 6749 §5.2 invalid_scope at auth.ts:296 + DCR /register +
registerClientManual. Today's behavior accepts any string silently.
- parseScopeString: space-separated wire format → array.
Forward-compat: hasScope ignores unknown granted scopes rather than
throwing, so pre-allowlist tokens with weird scope strings continue
working without crashes (registration is the gate, runtime is best-effort).
test/scope.test.ts (178 lines, 35 tests): hierarchy table including
all-implies for admin, sibling non-implication of *_admin scopes,
write→read but not the reverse, F3 refresh-token subset semantics
under hasScope, ALLOWED_SCOPES_LIST sorted-pinning, allowlist
rejection cases, parseScopeString edge cases (undefined/null/empty).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* build(admin): scope-constants mirror + drift CI for src/core/scope.ts
The admin React SPA's tsconfig.json scopes include: ['src'] to admin/src/,
so it cannot directly import ../../src/core/scope.ts. The plan considered
widening the include or generating a single source of truth; both options
either couple the SPA to the gbrain monorepo or add a build step. Eng
review picked the boring choice: hand-maintained mirror at
admin/src/lib/scope-constants.ts plus a CI drift check.
Files:
- admin/src/lib/scope-constants.ts: hand-maintained ALLOWED_SCOPES_LIST
duplicate, sorted alphabetically to match src/core/scope.ts.
- scripts/check-admin-scope-drift.sh: extracts the list from each file
via awk, normalizes via tr/sort, diffs. Exits 0 on match, 1 on drift
(with full breakdown of which scopes diverged), 2 on internal error.
Tested both passing and corrupted paths.
- package.json: wires check:admin-scope-drift into both `verify` and
`check:all` so any update to src/core/scope.ts that forgets the
admin-side mirror fails the build.
The Agents.tsx scope-checkbox sites (5 hardcoded locations) get updated
in a later commit to import from this constants file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(oauth): hasScope hierarchy + ALLOWED_SCOPES allowlist at registration
Switch three call sites in oauth-provider.ts from exact-string-match to
hasScope() so the v0.28 sources_admin and users_admin scopes — and the
admin-implies-all + write-implies-read hierarchy in src/core/scope.ts —
work end to end:
- F3 refresh-token subset enforcement at line 365: previously rejected
admin → sources_admin refresh because exact-match treated them as
unrelated scopes. gstack /setup-gbrain Path 4 needs admin tokens to
refresh down to least-privilege sources_admin scope; this fix lands
that path.
- Token issuance intersection at line 498 (client_credentials grant):
same hasScope swap so a client whose stored grant is `admin` can mint
tokens including any implied scope.
- registerClient (DCR /register) and registerClientManual: validate
every scope string against ALLOWED_SCOPES via assertAllowedScopes.
Pre-fix the system silently accepted `--scopes "read flying-unicorn"`
and persisted the bogus string in oauth_clients.scope. Post-fix the
caller gets RFC 6749 §5.2 invalid_scope. Existing rows with
pre-allowlist scopes keep working (allowlist gates registration only).
Tests amended in test/oauth.test.ts:
- T1 (eng-review): admin grant CAN refresh down to sources_admin
- T1 sibling: write grant CANNOT refresh up to sources_admin
- ALLOWED_SCOPES allowlist coverage (manual + DCR paths, all 5 valid)
- Scope-annotation contract tests widened to accept the v0.28 union
62 OAuth tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(serve-http): hasScope at /mcp + advertise full ALLOWED_SCOPES
Two changes against src/commands/serve-http.ts:
- Line 195: scopesSupported on the mcpAuthRouter options switches from the
hardcoded ['read','write','admin'] to Array.from(ALLOWED_SCOPES_LIST).
Without this, /.well-known/oauth-authorization-server keeps reporting
the old triple, so MCP clients (Claude Desktop, ChatGPT, Perplexity)
cannot discover the v0.28 sources_admin and users_admin scopes via
standard discovery — they would have to be pre-configured out of band.
- Line 673: request-time scope check on /mcp swaps
authInfo.scopes.includes(requiredScope) for hasScope(...). This was
the most-cited codex finding: without it, sources_admin tokens could
not even satisfy a `read`-scoped op (sources_admin doesn't include
the literal string "read"). hasScope routes through the hierarchy
table in src/core/scope.ts so admin implies all and write implies
read at the gate too.
T2 amendment in test/e2e/serve-http-oauth.test.ts: assert
/.well-known/oauth-authorization-server includes all 5 scopes in
scopes_supported. Pre-v0.28 the list was hardcoded to ['read','write',
'admin'] and this assertion would have failed. (The test is
Postgres-gated; runs under bun run test:e2e with DATABASE_URL set.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(core): sources-ops module — atomic clone + symlink-safe cleanup
src/core/sources-ops.ts (~470 lines): pure async functions extracted from
src/commands/sources.ts so the CLI handlers and the new MCP ops share
one implementation.
addSource: D3 atomicity contract from the eng review.
1. Validate id (matches existing SOURCE_ID_RE).
2. Q4 pre-flight SELECT — fail loudly with structured `source_id_taken`
before any clone work. Pre-fix the existing CLI used INSERT…ON
CONFLICT DO NOTHING which silently no-op'd; with clone-first that
would orphan the temp dir.
3. parseRemoteUrl gate (delegates to isInternalUrl from url-safety.ts).
4. Clone into $GBRAIN_HOME/clones/.tmp/<id>-<rand>/ via the new
git-remote helpers.
5. INSERT row with local_path=<final clone dir>, config.remote_url=<url>.
6. fs.renameSync(tmp/, final/). Rollback on either-side failure unlinks
the temp dir; rename-failed path also DELETEs the just-INSERTed row
best-effort.
removeSource: clone-cleanup with realpath+lstat confinement matching
validateUploadPath() shape at src/core/operations.ts:61. String startsWith
is symlink-unsafe and would let $GBRAIN_HOME/clones/<id> → /etc resolve
out of the confine. Two defenses layered:
- isPathContained (realpath-resolves both sides + parent-with-sep
string check) rejects symlinks whose target falls outside the
confine.
- lstat-then-isSymbolicLink check refuses symlinks whose realpath
happens to land back inside the confine (defense in depth).
getSourceStatus: returns clone_state via validateRepoState (the 6-state
decision tree from git-remote.ts). Lets a remote MCP caller diagnose
"healthy | missing | not-a-dir | no-git | url-drift | corrupted" without
SSH access to the brain host. listSources additionally exposes
remote_url so callers can see which sources are auto-managed.
recloneIfMissing: T4 follow-up for `gbrain sources restore` after the
clone dir was autopurged — re-clones via the same temp + rename
atomicity contract. Idempotent (returns false when clone is already
healthy).
test/sources-ops.test.ts (~470 lines, 24 tests): pre-flight collision
(Q4), happy paths for both --path and --url, all four D3 rollback paths
(clone-fail before INSERT, INSERT-fail after clone, rename-fail
post-INSERT, atomic temp-dir cleanup), symlink-target-OUTSIDE-clones
(realpath confinement), symlink-target-INSIDE-clones (lstat-check),
removeSource refuses to delete user-supplied paths, refuses "default"
source, getSourceStatus clone_state branches, T4 recloneIfMissing
recovery + idempotent + no-op for path-only sources, isPathContained
unit tests covering subtree / outside / symlink-escape / fail-closed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(operations): whoami + sources_{add,list,remove,status} MCP ops
Five new ops in src/core/operations.ts auto-flow through src/mcp/tool-defs.ts
so MCP clients (Claude Desktop, ChatGPT, Perplexity, OpenClaw) get them via
standard tools/list discovery — no SDK or transport code changes needed.
Operation.scope union widened to add 'sources_admin' and 'users_admin' (the
v0.28 hierarchy from src/core/scope.ts).
whoami (scope: read): introspect calling identity over MCP.
- Returns `{transport: 'oauth', client_id, client_name, scopes, expires_at}`
for OAuth clients (clientId starts with gbrain_cl_).
- Returns `{transport: 'legacy', token_name, scopes, expires_at: null}`
for grandfathered access_tokens.
- Returns `{transport: 'local', scopes: []}` when ctx.remote === false.
Empty scopes (NOT ['read','write','admin']) is the D2 decision —
returning OAuth-shaped scopes for local callers would resurrect the
v0.26.9 footgun where code conditionally trusted on
`auth.scopes.includes('admin')` instead of `ctx.remote === false`.
- Q3 fail-closed: throws unknown_transport when remote=true AND auth is
missing OR ctx.remote is the literal `undefined` (cast bypass guard).
A future transport that forgets to thread auth doesn't get a free
pass.
sources_add (sources_admin, mutating): register a source by --path
(existing v0.17 behavior) or --url (v0.28 federated remote-clone path).
Calls into addSource from sources-ops.ts which owns the temp-dir +
rename atomicity.
sources_list (read): list registered sources with page counts, federated
flag, and remote_url. The remote_url field is new — lets a remote MCP
caller see which sources are auto-managed.
sources_remove (sources_admin, mutating): cascade-delete a source +
symlink-safe clone cleanup. Requires confirm_destructive: true when the
source has data.
sources_status (read): per-source diagnostic returning clone_state
('healthy' | 'missing' | 'not-a-dir' | 'no-git' | 'url-drift' |
'corrupted' | 'not-applicable') — lets a remote MCP caller diagnose a
busted clone without SSH access to the brain host.
test/whoami.test.ts (9 tests): pinned transport-detection for all four
return shapes including Q3 fail-closed throw under both auth=undefined
and remote=undefined cast-bypass paths.
test/sources-mcp.test.ts (16 tests): op-metadata pins (scope, mutating,
localOnly), functional handler shape against PGLite, hasScope-driven
scope-enforcement smoke test simulating the serve-http.ts:673 gate
(read-only token rejected for sources_add; sources_admin token allowed;
admin token allowed for everything; gstack /setup-gbrain Path 4 token
covers all 4 ops), SSRF gate at the op layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): re-clone fallback when clone is missing/no-git/corrupted
src/commands/sync.ts gets a v0.28-aware front-half. When the source has
config.remote_url, performSync calls validateRepoState before the existing
fast-forward pull path:
- 'healthy' → fall through to existing pull (unchanged)
- 'missing' → loud stderr "auto-recovery: re-cloning <id>", then
'no-git' recloneIfMissing handles the temp-dir + rename. Sync
'not-a-dir' continues from the freshly-cloned head.
- 'corrupted' → throw with structured hint pointing at sources remove
+ add (no syncing wrong state).
- 'url-drift' → throw with hint pointing at the (deferred) sources
rebase-clone command.
Closes the operator-confidence gap: rm -rf $GBRAIN_HOME/clones/<id>/ no
longer breaks future syncs. The next sync sees the missing dir and
recovers via the recorded URL.
src/core/operations.ts: extend ErrorCode with 'unknown_transport' so
whoami's Q3 fail-closed path types check.
test/sources-resync-recovery.test.ts (12 tests): full validateRepoState
state matrix exercised under fake-git, recloneIfMissing recovery from
each degraded state, idempotent on healthy clones, the sync.ts:320
integration path that drives the recovery.
test/sources-ops.test.ts + test/sources-mcp.test.ts: drop the
GBRAIN_PGLITE_SNAPSHOT-disable line so these tests stop forcing cold
init across the parallel-shard runner. With snapshot allowed, init time
drops from 6+s to ~50ms and parallel runs stay under the 5s hook
timeout.
test/sources-mcp.test.ts: tighten scope literal-type so tsc keeps the
union narrow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cli): sources add --url + restore re-clone, thin-wrapper refactor
src/commands/sources.ts now delegates the data-mutation work to
src/core/sources-ops.ts (added in the previous commit). The CLI handler
parses argv, calls into addSource, and formats output.
Two new flags on `gbrain sources add`:
- `--url <https-url>` : federated remote-clone path (clone + INSERT +
rename, atomic rollback on failure).
- `--clone-dir <path>` : override the default
$GBRAIN_HOME/clones/<id>/ destination.
Validation rejects mutually-exclusive `--url` + `--path`. Errors from
the ops layer (SourceOpError) propagate through the CLI's standard
error wrapper in src/cli.ts so existing tests that assert throw shape
keep passing.
`gbrain sources restore <id>` (T4 from eng review): if the source has a
remote_url AND the on-disk clone was autopurged, call recloneIfMissing
before declaring success. Clone errors print a WARN with recovery
hints rather than failing the restore — the DB row is what restore
guarantees; the clone is best-effort.
54 sources-related tests pass (existing test/sources.test.ts +
sources-ops + sources-mcp).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(doctor,cycle): orphan-clones surface + autopilot purge phase (P1)
addSource's atomicity contract uses a temp dir that gets renamed to the
final clone path. If the process is SIGKILL'd between clone-finish and
rename, the temp dir orphans on disk. Without sweeping these, a brain
server accumulates gigabytes over months of failed `sources add --url`
attempts.
Two layers:
1. `gbrain doctor` now surfaces stale entries. A new orphan_clones check
walks $GBRAIN_HOME/clones/.tmp/, names anything older than 24h, and
prints a warn with disk-byte estimate. Operators see the leak before
`df` complains.
2. The autopilot cycle's existing `purge` phase grows a substep that
nukes .tmp/ entries past the same 72h TTL the page-soft-delete purge
uses. Operator behavior stays uniform across all soft-delete-style
surfaces.
Both layers are filesystem-only (no DB). On a brain that never used
--url cloning, both are no-ops.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* build(admin): scope checkboxes source from scope-constants mirror + dist
admin/src/pages/Agents.tsx Register Client modal:
- useState default sources from ALLOWED_SCOPES_LIST (defaulting `read`
to true, others false; unchanged UX for the common case).
- Scope checkbox map iterates ALLOWED_SCOPES_LIST instead of the old
hardcoded ['read','write','admin'].
Without this commit, even with the v0.28.1 server-side scope hierarchy,
operators registering an OAuth client from the admin UI cannot tick the
new sources_admin / users_admin scopes — defeats the whole gstack
/setup-gbrain Path 4 unblock.
The drift-check CI gate (scripts/check-admin-scope-drift.sh) ensures
this list stays in sync with src/core/scope.ts going forward.
admin/dist/* rebuilt via `cd admin && bun run build`. Old hash bundle
removed; new bundle (224.96 kB / 68.70 kB gzip).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: v0.28.1 — remote-source MCP + scope hierarchy + whoami
VERSION + package.json: bump to 0.28.1 (per CLAUDE.md branch-scoped
versioning rule — this branch adds substantial new features on top of
v0.28.0).
CHANGELOG.md: new top-level entry for v0.28.1 in the gstack/Garry voice
(no AI vocabulary, no em dashes, real numbers + commands). Lead
paragraph names what the user can now do that they couldn't before.
"Numbers that matter" table calls out the +5 MCP ops, +2 OAuth scopes,
and the 4-to-0 SSH-step number for gstack /setup-gbrain Path 4. "What
this means for you" closer ties the work to the operator workflow shift.
"To take advantage of v0.28.1" block has paste-ready upgrade commands
including the admin SPA rebuild step. Itemized changes section
describes the architecture cleanly without exposing scope-string
internals to public attack-surface enumeration (per CLAUDE.md
responsible-disclosure rule).
TODOS.md: file 6 follow-ups under a new "Remote-source MCP follow-ups
(v0.28.1)" section: token rotation, migration introspection in
get_health, Accept-header friendliness, sources rebase-clone for
URL-drift recovery, --filter=blob:none partial-clone option, and the
chunker_version PGLite-schema parity codex caught.
README.md: short subsection under the existing sources CLI listing
that names the new --url flag and what auto-recovery does. Capability
framing (no scope-string enumeration).
llms.txt + llms-full.txt: regenerated via `bun run build:llms` so the
documentation bundle reflects the v0.28.1 entry. The build-llms
generator's drift check passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(e2e): sources-remote-mcp — full gstack /setup-gbrain Path 4 round-trip
Spins up `gbrain serve --http` against real Postgres with a fake-git binary
in PATH (so `git clone` is exercised end-to-end without network), registers
two OAuth clients (sources_admin + read-only), mints tokens, calls the new
v0.28.1 MCP ops via /mcp, and asserts the gstack /setup-gbrain Path 4 flow
works end to end.
12 tests cover the full lifecycle:
- whoami over HTTP MCP returns transport=oauth + the right scopes
- /.well-known/oauth-authorization-server advertises all 5 scopes
- sources_add: clone fires, INSERT lands, row carries config.remote_url
- sources_status: clone_state=healthy after add
- sources_list: surfaces remote_url for the new source
- SSRF rejection: sources_add with RFC1918 URL fails at parseRemoteUrl gate
- Scope enforcement: read-only token gets insufficient_scope on sources_add
- Read-only token CAN call sources_list (read-scoped op)
- ALLOWED_SCOPES allowlist: CLI register-client rejects bogus scope
- Recovery: rm clone dir + sources_status reports clone_state=missing
- sources_remove: cascades + cleans up the auto-managed clone dir
Subprocess env threading replicates the v0.26.2 bun execSync inheritance
pattern — bun does NOT inherit process.env mutations, so every CLI
subprocess call passes env: { ...process.env } explicitly.
Cleanup contract mirrors test/e2e/serve-http-oauth.test.ts: revoke any
clients we registered, force-kill the server subprocess on SIGTERM
timeout, surface cleanup failures to stderr without throwing so real
test failures aren't masked.
The base table list in helpers.ts (ALL_TABLES) doesn't include sources
or oauth_clients, so this test explicitly truncates them in beforeAll
to avoid Q4 pre-flight collisions on re-run.
Skipped gracefully when DATABASE_URL is unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: codex adversarial review — confine remote sources_admin + close SSRF gaps
Pre-ship adversarial review (codex exec) caught five issues. Four ship in
this commit; the fifth (DNS rebinding) is filed as v0.28.x follow-up.
CRITICAL — `sources_admin` tokens over HTTP MCP could plant content at any
host path. The MCP op exposed `path` and `clone_dir` to remote callers; the
op layer trusted them verbatim, then auto-recovery's rm -rf on degraded
state turned that into arbitrary delete primitives. src/core/operations.ts
sources_add handler now drops both fields when ctx.remote !== false. Local
CLI keeps the override (operator trust). Loud logger.warn when a remote
caller tries — visible in the SSE feed without leaking values.
HIGH — Steady-state `git pull --ff-only` bypassed GIT_SSRF_FLAGS entirely.
The legacy helper at src/commands/sync.ts:192 spawned git without the
-c http.followRedirects=false -c protocol.{file,ext}.allow=never
--no-recurse-submodules set that cloneRepo applies. Every recurring sync
was reopening the redirect/submodule/protocol bypass. Routed the call site
at sync.ts:381 through pullRepo from git-remote.ts so initial clone and
ongoing pull share one defensive flag set.
MEDIUM — listSources ignored its `include_archived` flag. The op
advertised the param but the function destructured it as `_opts` and
queried every row. Archived sources' ids, local_paths, and remote_urls
were leaking to read-scoped MCP callers by default. Filter in SQL
(`WHERE archived IS NOT TRUE` unless the flag is set) so archived rows
never reach the wire.
PARTIAL HIGH — IPv6 ULA fc00::/7 and link-local fe80::/10 were not in
the isInternalUrl bypass list. Only ::1/:: and IPv4-mapped IPv6 were
blocked. Added regex-based ULA + link-local rejection to url-safety.ts.
Test coverage:
- test/git-remote.test.ts: 4 new IPv6 cases (ULA fc-prefix + fd-prefix,
link-local fe80::, public IPv6 still allowed).
- test/sources-mcp.test.ts: 3 new cases pinning the remote/local
asymmetry (clone_dir override silently ignored over MCP, path nulled,
local CLI keeps the override).
- test/sources-mcp.test.ts: 2 new cases for include_archived honored.
DNS rebinding (codex finding #3): the current gate is lexical only.
A deliberate attacker who controls a hostname's A/AAAA records can still
resolve to an internal IP. Closing this requires async DNS resolution +
revalidation; filed as v0.28.x follow-up in TODOS.md so the API change
surface (parseRemoteUrl becomes async, every caller updates) lands in
its own PR.
323 tests pass (9 files); 4071 unit tests pass (full suite).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: rebump v0.28.1 → v0.28.2 (master collision)
Caught after PR creation. master is at v0.28.1 already; this branch
forked from garrytan/v0.28-release at v0.28.0 and naively bumped to
v0.28.1 without checking the master queue. CI version-gate would have
rejected at merge time (requires VERSION strictly greater than
master's).
Root cause: I bumped VERSION mechanically during plan implementation
(echo "0.28.1" > VERSION) without consulting the queue-aware allocator
at bin/gstack-next-version. /ship Step 12's idempotency check then
classified state as ALREADY_BUMPED and the workflow's "queue drift"
comparison was the safety net I should have hit — but I skipped it.
Files updated:
- VERSION + package.json: 0.28.1 → 0.28.2
- CHANGELOG.md: header + "To take advantage of v0.28.2" subsection
- README.md: sources --url note version reference
- TODOS.md: 7 follow-up entries' version references
- llms.txt + llms-full.txt: regenerated
PR title rewrite via gstack-pr-title-rewrite.sh handled in a separate
gh pr edit call; CI version-gate now passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1309 lines
53 KiB
TypeScript
1309 lines
53 KiB
TypeScript
import { existsSync, readFileSync, writeFileSync, statSync, readdirSync } from 'fs';
|
|
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 { createInterface } from 'readline';
|
|
import {
|
|
buildSyncManifest,
|
|
isSyncable,
|
|
resolveSlugForPath,
|
|
recordSyncFailures,
|
|
unacknowledgedSyncFailures,
|
|
acknowledgeSyncFailures,
|
|
formatCodeBreakdown,
|
|
} from '../core/sync.ts';
|
|
import { estimateTokens, CHUNKER_VERSION } from '../core/chunkers/code.ts';
|
|
import { EMBEDDING_MODEL, estimateEmbeddingCostUsd } from '../core/embedding.ts';
|
|
import { errorFor, serializeError } from '../core/errors.ts';
|
|
import type { SyncManifest } from '../core/sync.ts';
|
|
import { createProgress } from '../core/progress.ts';
|
|
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
|
import { loadConfig } from '../core/config.ts';
|
|
import {
|
|
autoConcurrency,
|
|
shouldRunParallel,
|
|
parseWorkers,
|
|
} from '../core/sync-concurrency.ts';
|
|
import { tryAcquireDbLock, SYNC_LOCK_ID } from '../core/db-lock.ts';
|
|
import { loadStorageConfig } from '../core/storage-config.ts';
|
|
import { getDefaultSourcePath } from '../core/source-resolver.ts';
|
|
|
|
export interface SyncResult {
|
|
status: 'up_to_date' | 'synced' | 'first_sync' | 'dry_run' | 'blocked_by_failures';
|
|
fromCommit: string | null;
|
|
toCommit: string;
|
|
added: number;
|
|
modified: number;
|
|
deleted: number;
|
|
renamed: number;
|
|
chunksCreated: number;
|
|
/** Pages re-embedded during this sync's auto-embed step. 0 if --no-embed or skipped. */
|
|
embedded: number;
|
|
pagesAffected: string[];
|
|
failedFiles?: number; // count of parse failures (Bug 9)
|
|
}
|
|
|
|
/**
|
|
* v0.20.0 Cathedral II Layer 8 (D1) — walk each source's working tree and
|
|
* sum tokens for every syncable file. This is a conservative overestimate
|
|
* (full file content, not just the incremental diff) because `sync --all`
|
|
* on a source that hasn't been synced yet WILL embed every file in the
|
|
* working tree. For already-synced sources with only incremental changes,
|
|
* the overestimate is the ceiling, not the floor — users never get
|
|
* surprised by MORE cost than the preview claims. The false-high bias is
|
|
* intentional: a lower estimate that undersells the real bill would be
|
|
* worse than one that oversells.
|
|
*/
|
|
function estimateSyncAllCost(sources: Array<{ local_path: string | null; config: Record<string, unknown> }>): {
|
|
totalTokens: number;
|
|
totalFiles: number;
|
|
activeSources: number;
|
|
perSource: Array<{ path: string; tokens: number; files: number }>;
|
|
} {
|
|
let totalTokens = 0;
|
|
let totalFiles = 0;
|
|
let activeSources = 0;
|
|
const perSource: Array<{ path: string; tokens: number; files: number }> = [];
|
|
|
|
for (const src of sources) {
|
|
if (!src.local_path) continue;
|
|
const cfg = (src.config || {}) as { syncEnabled?: boolean; strategy?: 'markdown' | 'code' | 'auto' };
|
|
if (cfg.syncEnabled === false) continue;
|
|
activeSources++;
|
|
let sourceTokens = 0;
|
|
let sourceFiles = 0;
|
|
try {
|
|
walkSyncableFiles(src.local_path, (filePath: string, content: string) => {
|
|
sourceTokens += estimateTokens(content);
|
|
sourceFiles++;
|
|
}, cfg.strategy ?? 'markdown');
|
|
} catch {
|
|
// Best-effort: a source whose local_path is gone or unreadable just
|
|
// contributes 0. The sync itself would have failed anyway; no point
|
|
// blocking the preview on a pre-existing fault.
|
|
}
|
|
totalTokens += sourceTokens;
|
|
totalFiles += sourceFiles;
|
|
perSource.push({ path: src.local_path, tokens: sourceTokens, files: sourceFiles });
|
|
}
|
|
|
|
return { totalTokens, totalFiles, activeSources, perSource };
|
|
}
|
|
|
|
/**
|
|
* Walk a repo's working tree and invoke `cb(path, content)` for each
|
|
* syncable file. Honors the same strategy as `isSyncable` so the preview
|
|
* and the real sync agree on what's in scope.
|
|
*/
|
|
function walkSyncableFiles(
|
|
repoRoot: string,
|
|
cb: (path: string, content: string) => void,
|
|
strategy: 'markdown' | 'code' | 'auto',
|
|
): void {
|
|
const stack: string[] = [repoRoot];
|
|
while (stack.length > 0) {
|
|
const dir = stack.pop()!;
|
|
let entries: import('fs').Dirent[];
|
|
try {
|
|
entries = readdirSync(dir, { withFileTypes: true }) as unknown as import('fs').Dirent[];
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
const name = typeof entry.name === 'string' ? entry.name : String(entry.name);
|
|
// Skip hidden dirs, .git, node_modules (same rules isSyncable applies).
|
|
if (name.startsWith('.') || name === 'node_modules' || name === 'ops') continue;
|
|
const fullPath = `${dir}/${name}`;
|
|
if (entry.isDirectory()) {
|
|
stack.push(fullPath);
|
|
} else if (entry.isFile()) {
|
|
const relativePath = fullPath.slice(repoRoot.length + 1);
|
|
if (!isSyncable(relativePath, { strategy })) continue;
|
|
try {
|
|
const stat = statSync(fullPath);
|
|
if (stat.size > 5_000_000) continue; // skip large binaries
|
|
const content = readFileSync(fullPath, 'utf-8');
|
|
cb(fullPath, content);
|
|
} catch {
|
|
// Ignore files we can't read; consistent with sync's own tolerance.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Interactive [y/N] prompt. Resolves false on non-y answers or EOF. */
|
|
async function promptYesNo(question: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
rl.question(question, (answer) => {
|
|
rl.close();
|
|
resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes');
|
|
});
|
|
rl.on('close', () => resolve(false));
|
|
});
|
|
}
|
|
|
|
export interface SyncOpts {
|
|
repoPath?: string;
|
|
dryRun?: boolean;
|
|
full?: boolean;
|
|
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;
|
|
/**
|
|
* v0.18.0 Step 5 — sync a specific named source. When set, sync reads
|
|
* local_path + last_commit from the sources table (not the global
|
|
* config.sync.* keys) and writes last_commit + last_sync_at back to
|
|
* the same row. Backward compat: when undefined, sync uses the
|
|
* pre-v0.17 global-config path unchanged.
|
|
*/
|
|
sourceId?: string;
|
|
/** Multi-repo: sync strategy override (markdown, code, auto). */
|
|
strategy?: 'markdown' | 'code' | 'auto';
|
|
/**
|
|
* Number of parallel workers for the import phase. When > 1, each worker
|
|
* gets its own small Postgres connection pool and files are dispatched via
|
|
* an atomic queue index (same pattern as `import --workers N`).
|
|
*
|
|
* Deletes and renames remain serial (order-dependent).
|
|
* Default: undefined → auto-concurrency picks (`src/core/sync-concurrency.ts`).
|
|
*
|
|
* v0.22.13 (PR #490 Q1): when this is explicitly set, the >50-file floor
|
|
* is bypassed — explicit user intent beats the auto-path safety net.
|
|
*/
|
|
concurrency?: number;
|
|
/**
|
|
* Internal: skip acquiring the gbrain-sync DB lock. Set by the cycle
|
|
* handler (cycle.ts) which already holds gbrain-cycle and therefore
|
|
* already serializes against other cycle runs. CLI sync, jobs handler,
|
|
* and any external caller leave this undefined so they take the lock.
|
|
*
|
|
* v0.22.13 (PR #490 CODEX-2). Not part of the public CLI surface.
|
|
*/
|
|
skipLock?: boolean;
|
|
}
|
|
|
|
function git(repoPath: string, ...args: string[]): string {
|
|
return execFileSync('git', ['-C', repoPath, ...args], {
|
|
encoding: 'utf-8',
|
|
timeout: 30000,
|
|
}).trim();
|
|
}
|
|
|
|
// v0.18.0 Step 5: source-scoped sync state helpers. When opts.sourceId
|
|
// is set, read/write the per-source row instead of the global config
|
|
// keys. These wrappers centralize the branch so every read/write site
|
|
// picks the right storage — future Step 5 work (failure-tracking per
|
|
// source) hooks here too.
|
|
async function readSyncAnchor(
|
|
engine: BrainEngine,
|
|
sourceId: string | undefined,
|
|
which: 'repo_path' | 'last_commit',
|
|
): Promise<string | null> {
|
|
if (sourceId) {
|
|
const col = which === 'repo_path' ? 'local_path' : 'last_commit';
|
|
const rows = await engine.executeRaw<Record<string, string | null>>(
|
|
`SELECT ${col} AS value FROM sources WHERE id = $1`,
|
|
[sourceId],
|
|
);
|
|
return rows[0]?.value ?? null;
|
|
}
|
|
return await engine.getConfig(`sync.${which}`);
|
|
}
|
|
|
|
async function writeSyncAnchor(
|
|
engine: BrainEngine,
|
|
sourceId: string | undefined,
|
|
which: 'repo_path' | 'last_commit',
|
|
value: string,
|
|
): Promise<void> {
|
|
if (sourceId) {
|
|
const col = which === 'repo_path' ? 'local_path' : 'last_commit';
|
|
// last_sync_at bookmarked on every last_commit advance.
|
|
if (which === 'last_commit') {
|
|
await engine.executeRaw(
|
|
`UPDATE sources SET last_commit = $1, last_sync_at = now() WHERE id = $2`,
|
|
[value, sourceId],
|
|
);
|
|
} else {
|
|
await engine.executeRaw(
|
|
`UPDATE sources SET ${col} = $1 WHERE id = $2`,
|
|
[value, sourceId],
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
await engine.setConfig(`sync.${which}`, value);
|
|
}
|
|
|
|
/**
|
|
* v0.20.0 Cathedral II Layer 12 (SP-1 fix) — read/write the chunker version
|
|
* last used to sync a given source. When it mismatches CURRENT_CHUNKER_VERSION,
|
|
* `performSync` forces a full walk regardless of git HEAD equality. Without
|
|
* this gate, bumping CHUNKER_VERSION does NOTHING on an unchanged repo
|
|
* because sync short-circuits at `up_to_date` before reaching
|
|
* `importCodeFile`'s content_hash check.
|
|
*
|
|
* Per-source storage matches writeSyncAnchor's shape — sources.chunker_version
|
|
* TEXT column from the v27 migration. No global fallback: non-source syncs
|
|
* (pre-v0.17 brains with no sources table) never had CHUNKER_VERSION
|
|
* version-gating, so they keep the v0.19.0 behavior.
|
|
*/
|
|
async function readChunkerVersion(
|
|
engine: BrainEngine,
|
|
sourceId: string | undefined,
|
|
): Promise<string | null> {
|
|
if (!sourceId) return null;
|
|
const rows = await engine.executeRaw<{ chunker_version: string | null }>(
|
|
`SELECT chunker_version FROM sources WHERE id = $1`,
|
|
[sourceId],
|
|
);
|
|
return rows[0]?.chunker_version ?? null;
|
|
}
|
|
|
|
async function writeChunkerVersion(
|
|
engine: BrainEngine,
|
|
sourceId: string | undefined,
|
|
version: string,
|
|
): Promise<void> {
|
|
if (!sourceId) return;
|
|
await engine.executeRaw(
|
|
`UPDATE sources SET chunker_version = $1 WHERE id = $2`,
|
|
[version, sourceId],
|
|
);
|
|
}
|
|
|
|
export async function performSync(engine: BrainEngine, opts: SyncOpts): Promise<SyncResult> {
|
|
// CODEX-2 (v0.22.13): cross-process writer lock for performSync. Two
|
|
// concurrent syncs can otherwise read the same last_commit anchor, both
|
|
// write last_commit unconditionally, and the last writer wins — including
|
|
// regressing the bookmark backwards. cycle.ts already takes gbrain-cycle
|
|
// for its broader scope; performSync (called from cycle, jobs handler,
|
|
// and CLI) takes gbrain-sync just for the writer window. The two ids
|
|
// nest cleanly: cycle holds gbrain-cycle, calls performSync, performSync
|
|
// takes gbrain-sync. Other callers serialize on gbrain-sync against
|
|
// each other AND against the cycle's sync phase.
|
|
//
|
|
// skipLock is reserved for callers that already serialize via another
|
|
// mechanism (none in v0.22.13; reserved for future).
|
|
let lockHandle: { release: () => Promise<void> } | null = null;
|
|
if (!opts.skipLock) {
|
|
lockHandle = await tryAcquireDbLock(engine, SYNC_LOCK_ID);
|
|
if (!lockHandle) {
|
|
throw new Error(
|
|
`Another sync is in progress (lock ${SYNC_LOCK_ID} held). ` +
|
|
`Wait for it to finish, or run 'gbrain doctor' if it has been more than 30 minutes.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
return await performSyncInner(engine, opts);
|
|
} finally {
|
|
if (lockHandle) {
|
|
try { await lockHandle.release(); } catch { /* best-effort release */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<SyncResult> {
|
|
// Resolve repo path
|
|
const repoPath = opts.repoPath || await readSyncAnchor(engine, opts.sourceId, 'repo_path');
|
|
if (!repoPath) {
|
|
const hint = opts.sourceId
|
|
? `Source "${opts.sourceId}" has no local_path. Run: gbrain sources add ${opts.sourceId} --path <path>`
|
|
: `No repo path specified. Use --repo or run gbrain init with --repo first.`;
|
|
throw new Error(hint);
|
|
}
|
|
|
|
// v0.28: source-aware re-clone branch. When the source has a remote_url
|
|
// recorded (i.e. it was registered via `sources add --url`), the on-disk
|
|
// clone is auto-managed. validateRepoState classifies the on-disk state;
|
|
// we recover from missing/no-git/not-a-dir by re-cloning, refuse on
|
|
// url-drift or corruption with structured hints.
|
|
if (opts.sourceId) {
|
|
const { validateRepoState } = await import('../core/git-remote.ts');
|
|
const { recloneIfMissing } = await import('../core/sources-ops.ts');
|
|
const cfgRows = await engine.executeRaw<{ config: unknown }>(
|
|
`SELECT config FROM sources WHERE id = $1`,
|
|
[opts.sourceId],
|
|
);
|
|
const cfg =
|
|
typeof cfgRows[0]?.config === 'string'
|
|
? (JSON.parse(cfgRows[0].config as string) as Record<string, unknown>)
|
|
: ((cfgRows[0]?.config ?? {}) as Record<string, unknown>);
|
|
const remoteUrl = typeof cfg.remote_url === 'string' ? cfg.remote_url : null;
|
|
if (remoteUrl) {
|
|
const state = validateRepoState(repoPath, remoteUrl);
|
|
switch (state) {
|
|
case 'healthy':
|
|
break;
|
|
case 'missing':
|
|
case 'no-git':
|
|
case 'not-a-dir':
|
|
console.error(
|
|
`[gbrain] auto-recovery: re-cloning "${opts.sourceId}" (clone state: ${state}).`,
|
|
);
|
|
await recloneIfMissing(engine, opts.sourceId);
|
|
break;
|
|
case 'corrupted':
|
|
throw new Error(
|
|
`Source "${opts.sourceId}" clone at ${repoPath} is corrupted ` +
|
|
`(\`git remote get-url origin\` failed). Run: ` +
|
|
`gbrain sources remove ${opts.sourceId} --confirm-destructive && ` +
|
|
`gbrain sources add ${opts.sourceId} --url ${remoteUrl}`,
|
|
);
|
|
case 'url-drift':
|
|
throw new Error(
|
|
`Source "${opts.sourceId}" clone at ${repoPath} has a remote ` +
|
|
`that differs from config.remote_url=${remoteUrl}. ` +
|
|
`Re-clone with: gbrain sources rebase-clone ${opts.sourceId} ` +
|
|
`(if available, else: sources remove + sources add).`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate git repo
|
|
if (!existsSync(join(repoPath, '.git'))) {
|
|
throw new Error(`Not a git repository: ${repoPath}. GBrain sync requires a git-initialized repo.`);
|
|
}
|
|
|
|
// Git pull (unless --no-pull). v0.28.1 codex finding (HIGH): the legacy
|
|
// git() helper at sync.ts:192 spawns git without GIT_SSRF_FLAGS, so
|
|
// every steady-state pull was bypassing the redirect/submodule/protocol
|
|
// hardening that cloneRepo applies. Route through pullRepo from
|
|
// git-remote.ts so the flag set is consistent across initial clone and
|
|
// ongoing pulls — single source of truth for the defensive flags.
|
|
if (!opts.noPull) {
|
|
try {
|
|
const { pullRepo } = await import('../core/git-remote.ts');
|
|
pullRepo(repoPath);
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
if (msg.includes('non-fast-forward') || msg.includes('diverged')) {
|
|
console.error(`Warning: git pull failed (remote diverged). Syncing from local state.`);
|
|
} else {
|
|
console.error(`Warning: git pull failed: ${msg.slice(0, 100)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get current HEAD
|
|
let headCommit: string;
|
|
try {
|
|
headCommit = git(repoPath, 'rev-parse', 'HEAD');
|
|
} catch {
|
|
throw new Error(`No commits in repo ${repoPath}. Make at least one commit before syncing.`);
|
|
}
|
|
|
|
// Read sync state (source-scoped when sourceId is set, global otherwise)
|
|
const lastCommit = opts.full ? null : await readSyncAnchor(engine, opts.sourceId, 'last_commit');
|
|
|
|
// Ancestry validation: if lastCommit exists, verify it's still in history
|
|
if (lastCommit) {
|
|
try {
|
|
git(repoPath, 'cat-file', '-t', lastCommit);
|
|
} catch {
|
|
console.error(`Sync anchor commit ${lastCommit.slice(0, 8)} missing (force push?). Running full reimport.`);
|
|
return performFullSync(engine, repoPath, headCommit, opts);
|
|
}
|
|
|
|
// Verify ancestry
|
|
try {
|
|
git(repoPath, 'merge-base', '--is-ancestor', lastCommit, headCommit);
|
|
} catch {
|
|
console.error(`Sync anchor ${lastCommit.slice(0, 8)} is not an ancestor of HEAD. Running full reimport.`);
|
|
return performFullSync(engine, repoPath, headCommit, opts);
|
|
}
|
|
}
|
|
|
|
// First sync
|
|
if (!lastCommit) {
|
|
return performFullSync(engine, repoPath, headCommit, opts);
|
|
}
|
|
|
|
// v0.20.0 Cathedral II Layer 12 (codex SP-1 fix): before returning
|
|
// 'up_to_date' on git-HEAD equality, check the chunker version gate.
|
|
// If sources.chunker_version mismatches CURRENT_CHUNKER_VERSION, force
|
|
// a full re-walk so existing chunks get re-chunked under the new
|
|
// pipeline (qualified symbol names, parent scope, doc-comment column
|
|
// population, etc.). Without this, upgraded brains silently stay on
|
|
// the old chunks — the whole reason we bumped the version.
|
|
const storedVersion = await readChunkerVersion(engine, opts.sourceId);
|
|
const currentVersion = String(CHUNKER_VERSION);
|
|
const versionMismatch = storedVersion !== null && storedVersion !== currentVersion;
|
|
const versionNeverSet = storedVersion === null && opts.sourceId !== undefined;
|
|
|
|
if (lastCommit === headCommit && !versionMismatch && !versionNeverSet) {
|
|
return {
|
|
status: 'up_to_date',
|
|
fromCommit: lastCommit,
|
|
toCommit: headCommit,
|
|
added: 0, modified: 0, deleted: 0, renamed: 0,
|
|
chunksCreated: 0,
|
|
embedded: 0,
|
|
pagesAffected: [],
|
|
};
|
|
}
|
|
|
|
if ((versionMismatch || versionNeverSet) && lastCommit === headCommit) {
|
|
console.log(
|
|
`[sync] chunker_version gate: stored=${storedVersion ?? 'unset'}, current=${currentVersion}. ` +
|
|
`Forcing full re-chunk pass (git HEAD unchanged but pipeline version advanced).`,
|
|
);
|
|
const result = await performFullSync(engine, repoPath, headCommit, opts);
|
|
await writeChunkerVersion(engine, opts.sourceId, currentVersion);
|
|
return result;
|
|
}
|
|
|
|
// Diff using git diff (net result, not per-commit)
|
|
const diffOutput = git(repoPath, 'diff', '--name-status', '-M', `${lastCommit}..${headCommit}`);
|
|
const manifest = buildSyncManifest(diffOutput);
|
|
|
|
// Filter to syncable files (strategy-aware)
|
|
const syncOpts = opts.strategy ? { strategy: opts.strategy } : undefined;
|
|
const filtered: SyncManifest = {
|
|
added: manifest.added.filter(p => isSyncable(p, syncOpts)),
|
|
modified: manifest.modified.filter(p => isSyncable(p, syncOpts)),
|
|
deleted: manifest.deleted.filter(p => isSyncable(p, syncOpts)),
|
|
renamed: manifest.renamed.filter(r => isSyncable(r.to, syncOpts)),
|
|
};
|
|
|
|
// Delete pages that became un-syncable (modified but filtered out).
|
|
// v0.20.0 Cathedral II SP-5: resolveSlugForPath picks the right slug shape
|
|
// (markdown vs code) based on the chunker's classifier, so a Rust file that
|
|
// became un-syncable (e.g., moved under `.gitignore` or filtered by
|
|
// strategy=markdown) deletes the actual code-slug page, not a ghost
|
|
// markdown-slug that never existed.
|
|
const unsyncableModified = manifest.modified.filter(p => !isSyncable(p, syncOpts));
|
|
for (const path of unsyncableModified) {
|
|
const slug = resolveSlugForPath(path);
|
|
try {
|
|
const existing = await engine.getPage(slug);
|
|
if (existing) {
|
|
await engine.deletePage(slug);
|
|
console.log(` Deleted un-syncable page: ${slug}`);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
const totalChanges = filtered.added.length + filtered.modified.length +
|
|
filtered.deleted.length + filtered.renamed.length;
|
|
|
|
// Dry run
|
|
if (opts.dryRun) {
|
|
console.log(`Sync dry run: ${lastCommit.slice(0, 8)}..${headCommit.slice(0, 8)}`);
|
|
if (filtered.added.length) console.log(` Added: ${filtered.added.join(', ')}`);
|
|
if (filtered.modified.length) console.log(` Modified: ${filtered.modified.join(', ')}`);
|
|
if (filtered.deleted.length) console.log(` Deleted: ${filtered.deleted.join(', ')}`);
|
|
if (filtered.renamed.length) console.log(` Renamed: ${filtered.renamed.map(r => `${r.from} -> ${r.to}`).join(', ')}`);
|
|
if (totalChanges === 0) console.log(` No syncable changes.`);
|
|
return {
|
|
status: 'dry_run',
|
|
fromCommit: lastCommit,
|
|
toCommit: headCommit,
|
|
added: filtered.added.length,
|
|
modified: filtered.modified.length,
|
|
deleted: filtered.deleted.length,
|
|
renamed: filtered.renamed.length,
|
|
chunksCreated: 0,
|
|
embedded: 0,
|
|
pagesAffected: [],
|
|
};
|
|
}
|
|
|
|
if (totalChanges === 0) {
|
|
// Update sync state even with no syncable changes (git advanced)
|
|
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit);
|
|
await engine.setConfig('sync.last_run', new Date().toISOString());
|
|
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
|
return {
|
|
status: 'up_to_date',
|
|
fromCommit: lastCommit,
|
|
toCommit: headCommit,
|
|
added: 0, modified: 0, deleted: 0, renamed: 0,
|
|
chunksCreated: 0,
|
|
embedded: 0,
|
|
pagesAffected: [],
|
|
};
|
|
}
|
|
|
|
const noEmbed = opts.noEmbed || totalChanges > 100;
|
|
if (totalChanges > 100) {
|
|
console.log(`Large sync (${totalChanges} files). Importing text, deferring embeddings.`);
|
|
}
|
|
|
|
const pagesAffected: string[] = [];
|
|
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). SP-5: resolveSlugForPath
|
|
// dispatches to the right slug shape so code file deletes hit the real page.
|
|
if (filtered.deleted.length > 0) {
|
|
progress.start('sync.deletes', filtered.deleted.length);
|
|
for (const path of filtered.deleted) {
|
|
const slug = resolveSlugForPath(path);
|
|
await engine.deletePage(slug);
|
|
pagesAffected.push(slug);
|
|
progress.tick(1, slug);
|
|
}
|
|
progress.finish();
|
|
}
|
|
|
|
// Process renames (updateSlug preserves page_id, chunks, embeddings).
|
|
// SP-5: both old and new slugs use resolveSlugForPath so a .ts → .ts
|
|
// rename (code→code), .md → .md (markdown→markdown), or cross-kind rename
|
|
// all resolve to the right slug shape for each side.
|
|
if (filtered.renamed.length > 0) {
|
|
progress.start('sync.renames', filtered.renamed.length);
|
|
for (const { from, to } of filtered.renamed) {
|
|
const oldSlug = resolveSlugForPath(from);
|
|
const newSlug = resolveSlugForPath(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);
|
|
}
|
|
progress.finish();
|
|
}
|
|
|
|
// Process adds and modifies.
|
|
//
|
|
// NOTE: do NOT wrap this loop in engine.transaction(). importFromContent
|
|
// already opens its own inner transaction per file, and PGLite transactions
|
|
// are not reentrant — they acquire the same _runExclusiveTransaction mutex,
|
|
// so a nested call from inside a user callback queues forever on the mutex
|
|
// the outer transaction is still holding. Result: incremental sync hangs in
|
|
// 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];
|
|
|
|
// v0.22.13 (PR #490 Q5): one source of truth for the concurrency decision.
|
|
// engine.kind === 'pglite' → forced 1; explicit opts.concurrency wins;
|
|
// auto path returns DEFAULT_PARALLEL_WORKERS only when fileCount > 100.
|
|
const explicitConcurrency = opts.concurrency !== undefined;
|
|
const effectiveConcurrency = autoConcurrency(engine, addsAndMods.length, opts.concurrency);
|
|
const runParallel = shouldRunParallel(effectiveConcurrency, addsAndMods.length, explicitConcurrency);
|
|
|
|
if (addsAndMods.length > 0) {
|
|
progress.start('sync.imports', addsAndMods.length);
|
|
|
|
// Core import logic shared by serial and parallel paths.
|
|
// repoPath is validated non-null at the top of performSyncInner; narrow for TS.
|
|
const syncRepoPath = repoPath!;
|
|
async function importOnePath(eng: BrainEngine, path: string): Promise<void> {
|
|
const filePath = join(syncRepoPath, path);
|
|
if (!existsSync(filePath)) {
|
|
// CODEX-3 (v0.22.13): a file the diff said exists at headCommit but
|
|
// is gone from disk means the working tree has drifted (someone ran
|
|
// `git checkout` / `git reset` mid-sync, or the file was deleted
|
|
// post-diff). Record as a failure so last_commit does NOT advance —
|
|
// the silent-skip-then-advance pathology was the bug.
|
|
failedFiles.push({
|
|
path,
|
|
error: 'file vanished mid-sync (working tree drifted from headCommit)',
|
|
});
|
|
progress.tick(1, `skip:${path}`);
|
|
return;
|
|
}
|
|
try {
|
|
const result = await importFile(eng, filePath, path, { noEmbed });
|
|
if (result.status === 'imported') {
|
|
chunksCreated += result.chunks;
|
|
pagesAffected.push(result.slug);
|
|
} else if (result.status === 'skipped' && (result as any).error) {
|
|
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);
|
|
}
|
|
|
|
if (runParallel) {
|
|
// A1 (v0.22.13): use engine.kind discriminator instead of config?.engine
|
|
// string compare or constructor.name sniff. Q3: belt-and-suspenders fall
|
|
// back to serial when database_url is unset, so we never crash on a null
|
|
// assertion if config is missing.
|
|
const config = loadConfig();
|
|
if (engine.kind === 'pglite' || !config?.database_url) {
|
|
for (const path of addsAndMods) {
|
|
await importOnePath(engine, path);
|
|
}
|
|
} else {
|
|
const { PostgresEngine } = await import('../core/postgres-engine.ts');
|
|
const { resolvePoolSize } = await import('../core/db.ts');
|
|
const workerPoolSize = Math.min(2, resolvePoolSize(2));
|
|
const workerCount = Math.min(effectiveConcurrency, addsAndMods.length);
|
|
const databaseUrl = config.database_url;
|
|
|
|
// Q4 (v0.22.13): banner on stderr so stdout stays clean for --json.
|
|
console.error(` Parallel sync: ${workerCount} workers for ${addsAndMods.length} files`);
|
|
|
|
const workerEngines: InstanceType<typeof PostgresEngine>[] = [];
|
|
try {
|
|
// Connect workers one-by-one rather than Promise.all so a partial
|
|
// failure leaves us with the connected ones in workerEngines for
|
|
// the finally-block cleanup. The original code lost track of
|
|
// already-connected engines on any one failure.
|
|
for (let i = 0; i < workerCount; i++) {
|
|
const eng = new PostgresEngine();
|
|
await eng.connect({ database_url: databaseUrl, poolSize: workerPoolSize });
|
|
workerEngines.push(eng);
|
|
}
|
|
|
|
// Atomic queue index — JS is single-threaded; the read-then-increment
|
|
// happens between awaits, so no lock is needed.
|
|
let queueIndex = 0;
|
|
await Promise.all(
|
|
workerEngines.map(async (eng) => {
|
|
while (true) {
|
|
const idx = queueIndex++;
|
|
if (idx >= addsAndMods.length) break;
|
|
await importOnePath(eng, addsAndMods[idx]);
|
|
}
|
|
}),
|
|
);
|
|
} finally {
|
|
// A2 (v0.22.13): try/finally guarantees connection cleanup even when
|
|
// the worker loop throws (partial connect failure, OOM, mid-import
|
|
// signal). Each disconnect is best-effort — one worker failing to
|
|
// disconnect must not strand the others.
|
|
await Promise.all(
|
|
workerEngines.map((e) =>
|
|
e.disconnect().catch((err: unknown) =>
|
|
console.error(` worker disconnect failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// Serial path (small auto diffs or explicit --workers 1).
|
|
for (const path of addsAndMods) {
|
|
await importOnePath(engine, path);
|
|
}
|
|
}
|
|
|
|
progress.finish();
|
|
}
|
|
|
|
// CODEX-3 (v0.22.13): head-drift gate. If git HEAD moved during the import
|
|
// window (someone ran `git checkout` or `git pull` in another terminal /
|
|
// sibling Conductor workspace), the chunks we just imported reflect a
|
|
// different tree than `headCommit` claims. Refuse to advance last_commit
|
|
// so the next sync re-walks against the new HEAD. The lock from CODEX-2
|
|
// prevents *this* gbrain process from stepping on itself; this gate
|
|
// catches drift caused by external `git` commands the lock cannot see.
|
|
try {
|
|
const currentHead = git(repoPath, 'rev-parse', 'HEAD');
|
|
if (currentHead !== headCommit) {
|
|
failedFiles.push({
|
|
path: '<head>',
|
|
error: `git HEAD drifted during sync: captured ${headCommit.slice(0, 8)}, now ${currentHead.slice(0, 8)}`,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// rev-parse failure is itself a drift signal (worktree disappeared).
|
|
failedFiles.push({
|
|
path: '<head>',
|
|
error: `git HEAD verification failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
});
|
|
}
|
|
|
|
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);
|
|
// Emit structured summary grouped by error code so the operator
|
|
// can see *why* files failed, not just how many.
|
|
const codeBreakdown = formatCodeBreakdown(failedFiles);
|
|
if (!opts.skipFailed) {
|
|
console.error(
|
|
`\nSync blocked: ${failedFiles.length} file(s) failed to parse:\n` +
|
|
`${codeBreakdown}\n\n` +
|
|
`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 writeSyncAnchor(engine, opts.sourceId, '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,
|
|
embedded: 0,
|
|
pagesAffected,
|
|
failedFiles: failedFiles.length,
|
|
};
|
|
}
|
|
// --skip-failed: acknowledge the now-recorded set and proceed.
|
|
const acked = acknowledgeSyncFailures();
|
|
if (acked.count > 0) {
|
|
console.error(
|
|
` Acknowledged ${acked.count} failure(s) and advancing past them:\n` +
|
|
`${formatCodeBreakdown(acked.summary)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Update sync state AFTER all changes succeed (source-scoped when
|
|
// opts.sourceId is set, global config otherwise).
|
|
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit);
|
|
await engine.setConfig('sync.last_run', new Date().toISOString());
|
|
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
|
|
// v0.20.0 Cathedral II Layer 12: persist the chunker version we just
|
|
// finished with so the next sync's up_to_date gate respects it. Only
|
|
// source-scoped syncs track this (see readChunkerVersion for rationale).
|
|
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
|
|
|
// Log ingest
|
|
await engine.logIngest({
|
|
source_type: 'git_sync',
|
|
source_ref: `${repoPath} @ ${headCommit.slice(0, 8)}`,
|
|
pages_updated: pagesAffected,
|
|
summary: `Sync: +${filtered.added.length} ~${filtered.modified.length} -${filtered.deleted.length} R${filtered.renamed.length}, ${chunksCreated} chunks, ${elapsed}ms`,
|
|
});
|
|
|
|
// Auto-extract links + timeline (always, extraction is cheap CPU)
|
|
if (!opts.noExtract && pagesAffected.length > 0) {
|
|
try {
|
|
const { extractLinksForSlugs, extractTimelineForSlugs } = await import('./extract.ts');
|
|
const linksCreated = await extractLinksForSlugs(engine, repoPath, pagesAffected);
|
|
const timelineCreated = await extractTimelineForSlugs(engine, repoPath, pagesAffected);
|
|
if (linksCreated > 0 || timelineCreated > 0) {
|
|
console.log(` Extracted: ${linksCreated} links, ${timelineCreated} timeline entries`);
|
|
}
|
|
} catch { /* extraction is best-effort */ }
|
|
}
|
|
|
|
// Auto-embed (skip for large syncs — embedding calls OpenAI)
|
|
let embedded = 0;
|
|
if (!noEmbed && pagesAffected.length > 0 && pagesAffected.length <= 100) {
|
|
try {
|
|
const { runEmbed } = await import('./embed.ts');
|
|
await runEmbed(engine, ['--slugs', ...pagesAffected]);
|
|
// Before commit 2 lands: runEmbed is void. Best estimate is pagesAffected,
|
|
// since runEmbed re-embeds every requested slug. Commit 2 sharpens this
|
|
// with EmbedResult.embedded.
|
|
embedded = pagesAffected.length;
|
|
} catch { /* embedding is best-effort */ }
|
|
} else if (noEmbed || totalChanges > 100) {
|
|
console.log(`Text imported. Run 'gbrain embed --stale' to generate embeddings.`);
|
|
}
|
|
|
|
return {
|
|
status: 'synced',
|
|
fromCommit: lastCommit,
|
|
toCommit: headCommit,
|
|
added: filtered.added.length,
|
|
modified: filtered.modified.length,
|
|
deleted: filtered.deleted.length,
|
|
renamed: filtered.renamed.length,
|
|
chunksCreated,
|
|
embedded,
|
|
pagesAffected,
|
|
};
|
|
}
|
|
|
|
async function performFullSync(
|
|
engine: BrainEngine,
|
|
repoPath: string,
|
|
headCommit: string,
|
|
opts: SyncOpts,
|
|
): Promise<SyncResult> {
|
|
// Dry-run: walk the repo, count syncable files, return without writing.
|
|
// Fixes the silent-write-on-dry-run bug where performFullSync called
|
|
// runImport unconditionally regardless of opts.dryRun.
|
|
if (opts.dryRun) {
|
|
const { collectMarkdownFiles } = await import('./import.ts');
|
|
const allFiles = collectMarkdownFiles(repoPath);
|
|
const syncableRelPaths = allFiles
|
|
.map(abs => relative(repoPath, abs))
|
|
.filter(rel => isSyncable(rel));
|
|
console.log(
|
|
`Full-sync dry run: ${syncableRelPaths.length} file(s) would be imported ` +
|
|
`from ${repoPath} @ ${headCommit.slice(0, 8)}.`,
|
|
);
|
|
return {
|
|
status: 'dry_run',
|
|
fromCommit: null,
|
|
toCommit: headCommit,
|
|
added: syncableRelPaths.length,
|
|
modified: 0,
|
|
deleted: 0,
|
|
renamed: 0,
|
|
chunksCreated: 0,
|
|
embedded: 0,
|
|
pagesAffected: [],
|
|
};
|
|
}
|
|
|
|
// v0.22.13 (PR #490 A1 + Q5): full sync is always "large" by definition
|
|
// (entire working tree). Auto-concurrency fires unconditionally for Postgres;
|
|
// PGLite stays serial because its engine is single-connection. Routes the
|
|
// policy through autoConcurrency() so it stays consistent with incremental
|
|
// sync and the jobs handler.
|
|
const FULL_SYNC_LARGE_MARKER = Number.MAX_SAFE_INTEGER;
|
|
const fullConcurrency = autoConcurrency(engine, FULL_SYNC_LARGE_MARKER, opts.concurrency);
|
|
console.log(`Running full import of ${repoPath}${fullConcurrency > 1 ? ` (${fullConcurrency} workers)` : ''}...`);
|
|
const { runImport } = await import('./import.ts');
|
|
const importArgs = [repoPath];
|
|
if (opts.noEmbed) importArgs.push('--no-embed');
|
|
if (fullConcurrency > 1) importArgs.push('--workers', String(fullConcurrency));
|
|
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);
|
|
const codeBreakdown = formatCodeBreakdown(result.failures);
|
|
if (!opts.skipFailed) {
|
|
console.error(
|
|
`\nFull sync blocked: ${result.failures.length} file(s) failed:\n` +
|
|
`${codeBreakdown}\n\n` +
|
|
`Fix the YAML in those files and re-run, or use '--skip-failed'.`,
|
|
);
|
|
await engine.setConfig('sync.last_run', new Date().toISOString());
|
|
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
|
|
return {
|
|
status: 'blocked_by_failures',
|
|
fromCommit: null,
|
|
toCommit: headCommit,
|
|
added: 0, modified: 0, deleted: 0, renamed: 0,
|
|
chunksCreated: result.chunksCreated,
|
|
embedded: 0,
|
|
pagesAffected: [],
|
|
failedFiles: result.failures.length,
|
|
};
|
|
}
|
|
const acked = acknowledgeSyncFailures();
|
|
if (acked.count > 0) {
|
|
console.error(
|
|
` Acknowledged ${acked.count} failure(s) and advancing past them:\n` +
|
|
`${formatCodeBreakdown(acked.summary)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Persist sync state so next sync is incremental (C1 fix: was missing).
|
|
// v0.18.0 Step 5: routed through writeSyncAnchor so --source pins it
|
|
// to the right sources row rather than the global config.
|
|
await writeSyncAnchor(engine, opts.sourceId, 'last_commit', headCommit);
|
|
await engine.setConfig('sync.last_run', new Date().toISOString());
|
|
await writeSyncAnchor(engine, opts.sourceId, 'repo_path', repoPath);
|
|
// v0.20.0 Cathedral II Layer 12: persist chunker version for the gate.
|
|
await writeChunkerVersion(engine, opts.sourceId, String(CHUNKER_VERSION));
|
|
|
|
// Full sync doesn't track pagesAffected, so fall back to embed --stale.
|
|
// Before commit 2: runEmbed is void; use result.imported as best estimate of
|
|
// pages touched. Commit 2 sharpens this with real EmbedResult counts.
|
|
let embedded = 0;
|
|
if (!opts.noEmbed) {
|
|
try {
|
|
const { runEmbed } = await import('./embed.ts');
|
|
await runEmbed(engine, ['--stale']);
|
|
embedded = result.imported;
|
|
} catch { /* embedding is best-effort */ }
|
|
}
|
|
|
|
return {
|
|
status: 'first_sync',
|
|
fromCommit: null,
|
|
toCommit: headCommit,
|
|
added: result.imported,
|
|
modified: 0,
|
|
deleted: 0,
|
|
renamed: 0,
|
|
chunksCreated: result.chunksCreated,
|
|
embedded,
|
|
pagesAffected: [],
|
|
};
|
|
}
|
|
|
|
export async function runSync(engine: BrainEngine, args: string[]) {
|
|
const repoPath = args.find((a, i) => args[i - 1] === '--repo') || undefined;
|
|
const watch = args.includes('--watch');
|
|
const intervalStr = args.find((a, i) => args[i - 1] === '--interval');
|
|
const interval = intervalStr ? parseInt(intervalStr, 10) : 60;
|
|
const dryRun = args.includes('--dry-run');
|
|
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 syncAll = args.includes('--all');
|
|
const jsonOut = args.includes('--json');
|
|
const yesFlag = args.includes('--yes');
|
|
const strategyArg = args.find((a, i) => args[i - 1] === '--strategy') as SyncOpts['strategy'] | undefined;
|
|
const concurrencyStr = args.find((a, i) => args[i - 1] === '--concurrency' || args[i - 1] === '--workers');
|
|
// v0.22.13 (PR #490 Q2): parseWorkers throws on '0', '-3', 'foo', '1.5' instead
|
|
// of silently falling through to auto-concurrency or NaN. Loud failure beats
|
|
// a 4-worker spawn from a typo.
|
|
let concurrency: number | undefined;
|
|
try {
|
|
concurrency = parseWorkers(concurrencyStr);
|
|
} catch (e) {
|
|
console.error(e instanceof Error ? e.message : String(e));
|
|
process.exit(1);
|
|
}
|
|
|
|
// v0.18.0 Step 5: --source resolves to a sources(id) row. Falls back
|
|
// to pre-v0.17 global config (sync.repo_path + sync.last_commit) when
|
|
// no flag, no env, no dotfile is present.
|
|
const explicitSource = args.find((a, i) => args[i - 1] === '--source') || null;
|
|
let sourceId: string | undefined = undefined;
|
|
if (explicitSource || process.env.GBRAIN_SOURCE) {
|
|
const { resolveSourceId } = await import('../core/source-resolver.ts');
|
|
sourceId = await resolveSourceId(engine, explicitSource);
|
|
}
|
|
|
|
// v0.19.0 — `sync --all` iterates all registered sources with a
|
|
// local_path. Sources are the canonical v0.18.0 abstraction: per-source
|
|
// last_commit, last_sync_at, config.federated flags. Per-source
|
|
// bookmarks live in the sources table (not ~/.gbrain/config.json),
|
|
// which is why this path replaced Garry's OpenClaw `multi-repo.ts` shim.
|
|
//
|
|
// Only sources with a non-null local_path participate. A GitHub-only
|
|
// source (no checkout) has nothing for `sync` to pull. Sources with
|
|
// syncEnabled=false in config.jsonb are skipped too.
|
|
if (syncAll) {
|
|
const sources = await engine.executeRaw<{ id: string; name: string; local_path: string | null; config: Record<string, unknown> }>(
|
|
`SELECT id, name, local_path, config FROM sources WHERE local_path IS NOT NULL`,
|
|
);
|
|
if (!sources || sources.length === 0) {
|
|
console.log('No sources with local_path configured. Use `gbrain sources add <id> --path <path>` first.');
|
|
return;
|
|
}
|
|
|
|
// v0.20.0 Cathedral II Layer 8 D1 — cost preview + ConfirmationRequired
|
|
// gate. Before kicking off a multi-source sync that may embed tens of
|
|
// thousands of chunks (real money), walk the sync-diff set(s), sum
|
|
// tokens, compute USD estimate, and gate:
|
|
// - TTY + !json + !yes → interactive [y/N] prompt
|
|
// - non-TTY OR --json OR piped → emit ConfirmationRequired envelope,
|
|
// exit 2 (reserve 1 for runtime errors)
|
|
// - --yes → skip prompt entirely
|
|
// - --dry-run → preview + exit 0
|
|
// Skipped entirely when --no-embed is set (user already opted out of
|
|
// the cost and will run `embed --stale` later).
|
|
if (!noEmbed) {
|
|
const preview = estimateSyncAllCost(sources);
|
|
const costUsd = estimateEmbeddingCostUsd(preview.totalTokens);
|
|
const previewMsg =
|
|
`sync --all preview: ${preview.totalFiles} files across ${preview.activeSources} source(s), ` +
|
|
`~${preview.totalTokens.toLocaleString()} tokens, est. $${costUsd.toFixed(2)} on ${EMBEDDING_MODEL}.`;
|
|
|
|
if (dryRun) {
|
|
if (jsonOut) {
|
|
console.log(JSON.stringify({ status: 'dry_run', preview, costUsd, model: EMBEDDING_MODEL }));
|
|
} else {
|
|
console.log(previewMsg);
|
|
console.log('--dry-run: exit without syncing.');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!yesFlag) {
|
|
const isTTY = Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY);
|
|
if (!isTTY || jsonOut) {
|
|
// Agent-facing path: emit structured envelope, exit 2.
|
|
const envelope = serializeError(errorFor({
|
|
class: 'ConfirmationRequired',
|
|
code: 'cost_preview_requires_yes',
|
|
message: previewMsg,
|
|
hint: 'Pass --yes to proceed, or --dry-run to see the preview and exit 0.',
|
|
}));
|
|
console.log(JSON.stringify({ error: envelope, preview, costUsd, model: EMBEDDING_MODEL }));
|
|
process.exit(2);
|
|
}
|
|
// Interactive TTY path: prompt [y/N].
|
|
console.log(previewMsg);
|
|
const answer = await promptYesNo('Proceed? [y/N] ');
|
|
if (!answer) {
|
|
console.log('Cancelled.');
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const src of sources) {
|
|
const cfg = (src.config || {}) as { syncEnabled?: boolean; strategy?: 'markdown' | 'code' | 'auto' };
|
|
if (cfg.syncEnabled === false) {
|
|
console.log(`Skipping disabled source: ${src.name}`);
|
|
continue;
|
|
}
|
|
console.log(`\n--- Syncing source: ${src.name} ---`);
|
|
const repoOpts: SyncOpts = {
|
|
repoPath: src.local_path!,
|
|
dryRun, full, noPull, noEmbed, skipFailed, retryFailed,
|
|
sourceId: src.id,
|
|
strategy: cfg.strategy,
|
|
concurrency,
|
|
};
|
|
try {
|
|
const result = await performSync(engine, repoOpts);
|
|
printSyncResult(result);
|
|
// Codex P2: --all loop must also manage .gitignore per-source. Without
|
|
// this, multi-source users who rely on `gbrain sync --all` never get
|
|
// the advertised db_only ignore rules unless they sync each repo
|
|
// individually.
|
|
if (result.status !== 'dry_run' && result.status !== 'blocked_by_failures') {
|
|
manageGitignore(src.local_path!, engine.kind);
|
|
}
|
|
} catch (e: unknown) {
|
|
console.error(`Error syncing ${src.name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const opts: SyncOpts = { repoPath, dryRun, full, noPull, noEmbed, skipFailed, retryFailed, sourceId, strategy: strategyArg, concurrency };
|
|
|
|
// 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.
|
|
}
|
|
}
|
|
|
|
if (!watch) {
|
|
const result = await performSync(engine, opts);
|
|
printSyncResult(result);
|
|
// Issue #2 + eng-review pass-2 finding #1 + Codex P1: manage .gitignore ONLY
|
|
// on successful sync. Skip on dry-run (don't mutate disk in preview mode)
|
|
// and blocked_by_failures (sync state is inconsistent — defer .gitignore
|
|
// until next clean run). Resolve the effective repo path so the wire-up
|
|
// fires in the common case where the user runs `gbrain sync` without
|
|
// passing --repo every time.
|
|
if (result.status !== 'dry_run' && result.status !== 'blocked_by_failures') {
|
|
const effectiveRepoPath = opts.repoPath ?? (await getDefaultSourcePath(engine));
|
|
if (effectiveRepoPath) {
|
|
manageGitignore(effectiveRepoPath, engine.kind);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Watch mode
|
|
let consecutiveErrors = 0;
|
|
console.log(`Watching for changes every ${interval}s... (Ctrl+C to stop)`);
|
|
|
|
while (true) {
|
|
try {
|
|
const result = await performSync(engine, { ...opts, full: false });
|
|
consecutiveErrors = 0;
|
|
if (result.status === 'synced') {
|
|
const ts = new Date().toISOString().slice(11, 19);
|
|
console.log(`[${ts}] Synced: +${result.added} ~${result.modified} -${result.deleted} R${result.renamed}`);
|
|
}
|
|
// Same gate as non-watch: only manage .gitignore on successful sync.
|
|
// Same repo-resolution path so watch mode catches the implicit-resolved case.
|
|
if (result.status !== 'dry_run' && result.status !== 'blocked_by_failures') {
|
|
const effectiveRepoPath = opts.repoPath ?? (await getDefaultSourcePath(engine));
|
|
if (effectiveRepoPath) {
|
|
manageGitignore(effectiveRepoPath, engine.kind);
|
|
}
|
|
}
|
|
} catch (e: unknown) {
|
|
consecutiveErrors++;
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error(`[${new Date().toISOString().slice(11, 19)}] Sync error (${consecutiveErrors}/5): ${msg}`);
|
|
if (consecutiveErrors >= 5) {
|
|
console.error(`5 consecutive sync failures. Stopping watch.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
await new Promise(r => setTimeout(r, interval * 1000));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Auto-manage .gitignore entries for db_only directories.
|
|
*
|
|
* Caller invokes ONLY on successful sync — this function trusts that the
|
|
* sync's data state is consistent. See `runSync` for the gating logic.
|
|
*
|
|
* Idempotent: re-running adds no duplicate entries. The managed block has
|
|
* a stable comment header so it's grep-able and editable.
|
|
*
|
|
* Skipped (with actionable warning) when:
|
|
* - GBRAIN_NO_GITIGNORE=1 — D23 escape hatch for shared-repo setups
|
|
* - The repo is a git submodule (`.git` is a file not a directory) —
|
|
* D49 lock; submodule .gitignore changes don't survive parent updates
|
|
*
|
|
* On PGLite (D4): emits a once-per-process soft-warn explaining that
|
|
* tiering has limited effect — but still manages the .gitignore so the
|
|
* config-present user gets the gitignore housekeeping.
|
|
*
|
|
* Failures (write permission denied, EROFS, etc.) are caught, warned, and
|
|
* swallowed (D9 lock). Sync's primary job is moving data; .gitignore
|
|
* management is a side effect — don't kill the main job for the side effect.
|
|
*/
|
|
let _pgliteTierWarned = false;
|
|
export function __resetPGLiteTierWarn(): void {
|
|
_pgliteTierWarned = false;
|
|
}
|
|
|
|
export function manageGitignore(
|
|
repoPath: string,
|
|
engineKind?: 'pglite' | 'postgres',
|
|
): void {
|
|
if (process.env.GBRAIN_NO_GITIGNORE === '1') {
|
|
return;
|
|
}
|
|
|
|
// D49: submodule detection. In a submodule, `.git` is a regular file
|
|
// (containing `gitdir: ../path/to/parent.git/modules/x`), not a directory.
|
|
const dotGit = join(repoPath, '.git');
|
|
if (existsSync(dotGit)) {
|
|
try {
|
|
if (statSync(dotGit).isFile()) {
|
|
console.warn(
|
|
`Note: skipping .gitignore management — ${repoPath} is a git submodule. ` +
|
|
`Add db_only directories to your parent repo's .gitignore manually.`,
|
|
);
|
|
return;
|
|
}
|
|
} catch {
|
|
// proceed; can't tell, default to managing
|
|
}
|
|
}
|
|
|
|
let storageConfig;
|
|
try {
|
|
storageConfig = loadStorageConfig(repoPath);
|
|
} catch (error) {
|
|
// StorageConfigError (overlap) or read error — surface, don't manage.
|
|
console.warn(
|
|
`Skipped .gitignore update: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
return;
|
|
}
|
|
if (!storageConfig || storageConfig.db_only.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// D4 soft-warn: storage tiering has limited effect on PGLite, but the
|
|
// .gitignore housekeeping still helps. Warn once per process; proceed.
|
|
if (engineKind === 'pglite' && !_pgliteTierWarned) {
|
|
_pgliteTierWarned = true;
|
|
console.warn(
|
|
`Note: storage tiering has limited effect on PGLite — pages live in your ` +
|
|
`local database file regardless of tier. Managing .gitignore anyway.`,
|
|
);
|
|
}
|
|
|
|
const gitignorePath = join(repoPath, '.gitignore');
|
|
let gitignoreContent = '';
|
|
|
|
if (existsSync(gitignorePath)) {
|
|
try {
|
|
gitignoreContent = readFileSync(gitignorePath, 'utf-8');
|
|
} catch (error) {
|
|
console.warn(
|
|
`Could not read ${gitignorePath} (${error instanceof Error ? error.message : String(error)}) — ` +
|
|
`skipping .gitignore update. Add db_only directories manually.`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const existingLines = new Set(gitignoreContent.split('\n').map((line) => line.trim()));
|
|
const linesToAdd: string[] = [];
|
|
|
|
for (const dir of storageConfig.db_only) {
|
|
if (!existingLines.has(dir) && !existingLines.has(`/${dir}`)) {
|
|
linesToAdd.push(dir);
|
|
}
|
|
}
|
|
|
|
if (linesToAdd.length === 0) return;
|
|
|
|
if (gitignoreContent && !gitignoreContent.endsWith('\n')) {
|
|
gitignoreContent += '\n';
|
|
}
|
|
gitignoreContent += '\n# Auto-managed by gbrain (db_only directories)\n';
|
|
gitignoreContent += linesToAdd.join('\n') + '\n';
|
|
|
|
try {
|
|
writeFileSync(gitignorePath, gitignoreContent);
|
|
} catch (error) {
|
|
console.warn(
|
|
`Could not update ${gitignorePath} (${error instanceof Error ? error.message : String(error)}) — ` +
|
|
`please add db_only directories manually:\n ${linesToAdd.join('\n ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function printSyncResult(result: SyncResult) {
|
|
switch (result.status) {
|
|
case 'up_to_date':
|
|
console.log('Already up to date.');
|
|
break;
|
|
case 'synced':
|
|
console.log(`Synced ${result.fromCommit?.slice(0, 8)}..${result.toCommit.slice(0, 8)}:`);
|
|
console.log(` +${result.added} added, ~${result.modified} modified, -${result.deleted} deleted, R${result.renamed} renamed`);
|
|
console.log(` ${result.chunksCreated} chunks created${result.embedded > 0 ? `, ${result.embedded} pages embedded` : ''}`);
|
|
break;
|
|
case 'first_sync':
|
|
console.log(`First sync complete. Checkpoint: ${result.toCommit.slice(0, 8)}`);
|
|
console.log(` ${result.added} file(s) imported, ${result.chunksCreated} chunks${result.embedded > 0 ? `, ${result.embedded} pages embedded` : ''}`);
|
|
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;
|
|
}
|
|
}
|