v0.41.38.0 fix: code-callers/callees honor .gbrain-source pin + gbrain dream runs on postgres engines (#1666)

* fix(code-callers/callees): honor .gbrain-source pin via full source-resolution chain

code-callers and code-callees called resolveDefaultSource directly, which only
knew "1 source -> use it, else multiple_sources_ambiguous" and ignored the
.gbrain-source pin (and env / local_path / brain_default / sole_non_default
tiers). On a multi-source brain they errored even when a pin clearly selected
one source, while code-def/code-refs "worked" only because they never scope by
source at all.

New shared helper resolveScopedSourceOrThrow(engine, cwd) in sources-ops.ts runs
the full resolveSourceWithTier chain and applies the ambiguity guard ONLY when
nothing matched (tier seed_default). Both commands route through it; an explicit
--source/--all-sources still overrides. Adds source_id + scope to the JSON
envelope, a stderr nudge on the sole_non_default tier (matches sync/import), a
zero-result "try --all-sources" hint, and clean exit-2 handling for a bad pin.

Tests: test/code-scoped-source-resolve.test.ts (8 helper cases incl. dotfile
pin, env/brain_default/sole_non_default tiers, ambiguity, bad pin) and
test/code-callers-pin.serial.test.ts (9 CLI-wiring cases via process.chdir).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dream): run against postgres engines (skip filesystem-only phases when no checkout)

gbrain dream hard-failed with "No brain directory found" on a postgres/Supabase
brain with no local checkout, so the DB-only maintenance phases (notably
resolve_symbol_edges, the call-graph builder) could never run. doctor even
recommended `gbrain dream --source <id>`, a command that couldn't run.

- dream.ts: resolveBrainDir returns string|null (order: --dir -> resolved
  source's local_path -> sync.repo_path -> null); runDream owns the both-null
  (no checkout AND no engine) exit 1.
- cycle.ts: CycleOpts.brainDir is string|null; resolveSourceForDir null-tolerant;
  the 6 filesystem phases (lint/backlinks/sync/synthesize/extract/patterns) skip
  with reason 'no_brain_dir' when there's no checkout; DB phases run.
  cycleSourceId = opts.sourceId ?? resolveSourceForDir(...) scopes the per-source
  DB phases (extract_facts/extract_atoms/calibration) correctly even on a
  checkout-less brain (previously they scoped to 'default' while the cycle
  stamped the requested source fresh — a freshness stamp that lied). deriveStatus
  counts resolved/ambiguous edges as work so an edges-only cycle reports 'ok'.
- jobs.ts: the autopilot-cycle handler passes null (not cwd '.') when no repo is
  configured, so the queued cycle follows the same no_brain_dir contract.

Tests: test/dream-postgres.test.ts (8: null-brainDir path, --source scope
regression, edges->ok, both-null exit) + test/jobs-autopilot-cycle-braindir.test.ts
(1: handler passes null -> FS phases skip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.41.38.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): scope dream brainDir to --source; null fallback for phase handlers; quarantine heavy PGLite tests

Addresses pre-landing review findings (codex P1/P2):
- dream.ts resolveBrainDir: when --source resolves but that source has no
  on-disk checkout, return null (DB-only) instead of falling through to the
  global sync.repo_path. That global path belongs to the default/unscoped
  brain; running FS phases against it while DB phases + the last_full_cycle_at
  stamp target the requested source mixed scopes (codex P1). Adds a regression
  test (--source repo-a + a configured global sync.repo_path → brain_dir null).
- jobs.ts makePhaseHandler: fall back to null (not cwd '.') when no repo is
  configured, matching the autopilot-cycle handler + gbrain dream. A direct
  phase job (synthesize/patterns) on a checkout-less brain now skips FS phases
  as no_brain_dir instead of running against the worker cwd (codex P2).
- Move dream-postgres + jobs-autopilot-cycle-braindir tests to *.serial.test.ts
  (they run full runCycle passes; the serial pass avoids parallel-shard PGLite
  cold-start contention).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(todos): record v0.41.38.0 dream-postgres / source-pin follow-ups

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: update CLAUDE.md Key Files for v0.41.38.0 (dream-postgres + source pin)

- dream.ts entry: resolveBrainDir returns string|null; checkout-less postgres
  runs DB phases + skips FS phases (no_brain_dir); --source-with-no-checkout
  doesn't borrow a different source's global repo.
- cycle.ts entry: CycleOpts.brainDir nullable; cycleSourceId per-source scope;
  deriveStatus counts edges; jobs.ts handlers pass null not '.'.
- Regenerated llms-full.txt (bun run build:llms).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-30 14:58:40 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6f26d5e4df
commit 248fb7a90f
16 changed files with 1190 additions and 72 deletions
+105
View File
@@ -2,6 +2,111 @@
All notable changes to GBrain will be documented in this file.
## [0.41.38.0] - 2026-05-30
**Two fixes for Supabase brains with a code source. `gbrain code-callers` and
`gbrain code-callees` now respect your `.gbrain-source` pin instead of demanding
`--source` every time, and `gbrain dream` finally runs on a postgres brain that
has no local checkout instead of dying with "No brain directory found."**
If you build a code call graph with gbrain (the way gstack's `/sync-gbrain`
does), both of these bit you. You'd run `gbrain dream` to build the graph and it
would error out on Supabase. And every `gbrain code-callers Foo` query forced a
`--source` flag even though you'd pinned the source with a `.gbrain-source`
dotfile in your repo. Now "who calls this / what does this call" works out of
the box on a Supabase brain.
### What changed for you
- **`code-callers` / `code-callees` honor the source pin.** When you don't pass
`--source` or `--all-sources`, they now resolve through the same chain as
`gbrain sources current`: flag, then `GBRAIN_SOURCE`, then the
`.gbrain-source` dotfile, then the source whose `local_path` contains your cwd,
then the brain default, then the single non-default source. They only fall
back to the old "multi-source, pick one" error when nothing in that chain
matches. `code-def` / `code-refs` are unchanged.
- **`gbrain dream` runs on postgres brains with no checkout.** The maintenance
cycle's database-only phases (including `resolve_symbol_edges`, the call-graph
builder) run against the DB. The six filesystem phases (lint, backlinks, sync,
synthesize, extract, patterns) are skipped with a clear reason instead of a
blanket failure. `gbrain doctor`'s "Run `gbrain dream --source <id>`"
recommendation now actually works on this engine.
### How to use it
```bash
# In a repo with a .gbrain-source pin, on a multi-source Supabase brain:
gbrain code-callers myFunction # resolves to the pinned source, no --source
gbrain code-callees myFunction --json # JSON now includes source_id + scope
# Build the call graph on a checkout-less postgres brain:
gbrain dream --phase resolve_symbol_edges
gbrain dream --source my-code-source # scopes per-source phases correctly
```
### Things to know
- `code-callers` / `code-callees` JSON output gains `source_id` and `scope`
(`"single"` or `"all"`) so a tool can confirm which source it actually queried.
When the pin auto-routed to the only non-default source, a one-line stderr
nudge names it (same as `gbrain sync`). A zero-result implicit-scope query
appends a "try `--all-sources`" hint.
- `gbrain dream`'s JSON report exposes skipped filesystem phases as
`phases[].status: "skipped"` with `phases[].details.reason: "no_brain_dir"`
a stable shape downstream tools can key off. Pass `--dir <path>` to run the
filesystem phases.
- `gbrain dream --source <id>` now scopes the per-source database phases
(`extract_facts`, `extract_atoms`, calibration) to that source even with no
checkout. Previously they silently ran against the `default` source while the
cycle marked your source "fresh" — a freshness stamp that lied.
- An edges-only cycle now reports `ok` (not `clean`) when it resolves call-graph
edges, so you can tell real work from a no-op.
- The queued `autopilot-cycle` job (what `gbrain remote ping` triggers) follows
the same checkout-less behavior instead of running against the worker's
current directory.
## To take advantage of v0.41.38.0
Nothing to run. `gbrain upgrade` ships the fix. After upgrading, on a postgres
brain with a `.gbrain-source` pin:
```bash
gbrain code-callers <symbol> # should resolve without --source
gbrain dream --phase resolve_symbol_edges # should run, not error
```
If `gbrain dream` still prints "No brain directory found and no database
connection," your `~/.gbrain/config.json` has neither a postgres `database_url`
nor a `sync.repo_path`; run `gbrain doctor` and file an issue with its output.
### Itemized changes
**code-callers / code-callees source resolution**
- New `resolveScopedSourceOrThrow(engine, cwd)` in `src/core/sources-ops.ts`:
runs `resolveSourceWithTier` and only applies the multi-source ambiguity guard
(`resolveDefaultSource`) on the `seed_default` tier. Returns `{source_id, tier}`.
- `src/commands/code-callers.ts` + `src/commands/code-callees.ts` route through
it, add `source_id` + `scope` to the JSON envelope, emit the `sole_non_default`
stderr nudge, append the zero-result `--all-sources` hint, and surface a bad
`.gbrain-source` / `GBRAIN_SOURCE` value as a clean `invalid_source_pin` exit 2.
**gbrain dream on postgres**
- `src/commands/dream.ts`: `resolveBrainDir` returns `string | null` (resolution
order: `--dir` → resolved source's `local_path``sync.repo_path` → null);
`runDream` owns the both-null (no checkout AND no engine) exit 1.
- `src/core/cycle.ts`: `CycleOpts.brainDir` is now `string | null`;
`resolveSourceForDir` is null-tolerant; the six filesystem phases skip with
`reason: "no_brain_dir"` when there's no checkout; `cycleSourceId =
opts.sourceId ?? resolveSourceForDir(...)` scopes the per-source DB phases;
`deriveStatus` counts resolved/ambiguous edges as work.
- `src/commands/jobs.ts`: the `autopilot-cycle` handler passes `null` (not cwd
`'.'`) when no repo is configured.
**Tests**
- `test/code-scoped-source-resolve.test.ts` (8), `test/code-callers-pin.serial.test.ts`
(9), `test/dream-postgres.test.ts` (8), `test/jobs-autopilot-cycle-braindir.test.ts`
(1) — covering the pin chain, env/brain_default/sole_non_default tiers, the
source-scope regression, the null-brainDir path, edges→ok, and the both-null exit.
## [0.41.37.0] - 2026-05-30
**A four-bug critical fix wave: your tags stop disappearing on reindex, a
+2 -2
View File
File diff suppressed because one or more lines are too long
+31
View File
@@ -1,5 +1,36 @@
# TODOS
## v0.41.38.0 dream-postgres / source-pin follow-ups (v0.42+)
Deferred from the v0.41.38.0 wave (code-callers/callees pin + dream-on-postgres).
Documented tradeoffs, not blockers — the shipped bug fixes are complete and tested.
- [ ] **P1 — Per-source autopilot fan-out passes the global repoPath.**
`src/commands/autopilot-fanout.ts:~206` submits every per-source `autopilot-cycle`
job with `repoPath: opts.repoPath` (the global checkout), not `src.local_path`.
With v0.41.38.0's `cycleSourceId = opts.sourceId ?? resolveSourceForDir(...)`,
a per-source job now reconciles DB phases for `src.id` while the filesystem
phases (sync/lint/extract) run against the default brain's checkout, then stamps
`src.id` fresh — mixed scope. Pre-existing fan-out limitation (cycle.ts PHASE_SCOPE
comment already notes genuine per-source fan-out needs deferred work); the common
single-source autopilot path (legacy no-source dispatch) is unaffected. Fix:
resolve brainDir from the source's `local_path` inside the `autopilot-cycle`
handler when `source_id` is set (mirror dream.ts's T1), so FS and DB phases agree.
Needs its own review (touches the deferred autopilot path).
- [ ] **P2 — `.gbrain-source` with invalid SYNTAX still falls through silently.**
`readDotfileWalk` (source-resolver.ts:39) intentionally skips a dotfile whose
content fails `isValidSourceId` (e.g. `repo_a` with an underscore) per the v0.31.8
P1-F silent-fallback design, so `resolveScopedSourceOrThrow` resolves it to a
later tier rather than surfacing `invalid_source_pin`. A valid-syntax-but-missing
pin DOES surface (assertSourceExists throws). Decide whether a typo'd dotfile
should warn loudly; changing it alters resolver semantics shared by other callers.
- [ ] **P3 — Sibling source-scoped commands don't honor the pin.** `blast`/`flow`/
`clusters`/`wiki` still call `resolveDefaultSource` directly. Route them through
`resolveScopedSourceOrThrow` for consistency with code-callers/code-callees.
- [ ] **P3 — `gbrain autopilot` CLI daemon pre-guard.** `autopilot.ts:~152`
`if (!repoPath) exit 1` still blocks the daemon on a checkout-less postgres brain.
Relax to the same null-brainDir contract so the daemon can run DB phases.
## v0.41.37.0 critical-fix-wave follow-ups (v0.42+)
Filed from the v0.41.37.0 wave (#1621 tag-wipe, #1581 grandfather hang,
+1 -1
View File
@@ -1 +1 @@
0.41.37.0
0.41.38.0
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -141,5 +141,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.41.37.0"
"version": "0.41.38.0"
}
+54 -8
View File
@@ -5,9 +5,10 @@
* Forward view of the A1 call graph. Matches `from_symbol_qualified`
* in both code_edges_chunk + code_edges_symbol.
*
* v0.34 W0b (Codex finding #7): pre-v0.34 default was inverted to
* cross-source whenever --source was omitted. See code-callers.ts for
* the full rationale. Same fix here.
* Source resolution: honors the full chain (incl. the `.gbrain-source` pin)
* via `resolveScopedSourceOrThrow` when --source/--all-sources are omitted.
* See code-callers.ts for the full rationale. Same behavior here. JSON
* envelope carries `source_id` + `scope`.
*
* Output: same JSON-on-non-TTY convention as code-callers / code-def /
* code-refs.
@@ -15,7 +16,19 @@
import type { BrainEngine } from '../core/engine.ts';
import { errorFor, serializeError } from '../core/errors.ts';
import { resolveDefaultSource, SourceResolutionError } from '../core/sources-ops.ts';
import { resolveScopedSourceOrThrow, SourceResolutionError } from '../core/sources-ops.ts';
import { formatSoleNonDefaultNudge } from '../core/source-resolver.ts';
/** A bad/invalid `.gbrain-source` pin or GBRAIN_SOURCE value surfaces from
* `resolveSourceWithTier`'s `assertSourceExists` as a plain Error with one of
* these message prefixes. Mirrors dream.ts:isResolverUserError. */
function isResolverUserError(e: unknown): boolean {
if (!(e instanceof Error)) return false;
const m = e.message;
return (m.startsWith('Source "') && m.includes(' not found.'))
|| m.startsWith('Invalid --source value')
|| m.startsWith('Invalid GBRAIN_SOURCE value');
}
function parseFlag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
@@ -49,10 +62,16 @@ export async function runCodeCallees(engine: BrainEngine, args: string[]): Promi
const allSources = args.includes('--all-sources');
let sourceId = parseFlag(args, '--source');
// v0.34 W0b: source-scoped default. Matches code-callers behavior.
// Full source-resolution chain (honors .gbrain-source pin, env, local_path,
// brain_default, sole_non_default). Matches code-callers behavior.
if (!allSources && !sourceId) {
try {
sourceId = await resolveDefaultSource(engine);
const resolved = await resolveScopedSourceOrThrow(engine);
sourceId = resolved.source_id;
if (resolved.tier === 'sole_non_default') {
const nudge = formatSoleNonDefaultNudge(resolved.source_id);
if (nudge) console.error(nudge);
}
} catch (e: unknown) {
if (e instanceof SourceResolutionError) {
const env = errorFor({
@@ -68,6 +87,20 @@ export async function runCodeCallees(engine: BrainEngine, args: string[]): Promi
}
process.exit(2);
}
if (isResolverUserError(e)) {
const env = errorFor({
class: 'UsageError',
code: 'invalid_source_pin',
message: (e as Error).message,
hint: 'fix the .gbrain-source pin / GBRAIN_SOURCE value, or pass --source <id> / --all-sources',
}).envelope;
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: env }));
} else {
console.error((e as Error).message);
}
process.exit(2);
}
throw e;
}
}
@@ -79,10 +112,23 @@ export async function runCodeCallees(engine: BrainEngine, args: string[]): Promi
sourceId: sourceId ?? undefined,
});
const scope = allSources ? 'all' : 'single';
const envelopeSourceId = allSources ? null : (sourceId ?? null);
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ symbol: sym, count: edges.length, callees: edges }, null, 2));
const out: Record<string, unknown> = {
symbol: sym, source_id: envelopeSourceId, scope, count: edges.length, callees: edges,
};
if (edges.length === 0 && !allSources && sourceId) {
out.hint = `No callees in source '${sourceId}'. Try --all-sources to search every source.`;
}
console.log(JSON.stringify(out, null, 2));
} else if (edges.length === 0) {
console.log(`No callees found for "${sym}".`);
if (!allSources && sourceId) {
console.log(`No callees found for "${sym}" in source '${sourceId}'. Try --all-sources to search every source.`);
} else {
console.log(`No callees found for "${sym}".`);
}
} else {
console.log(`${edges.length} callee(s) for "${sym}":`);
for (const e of edges) {
+69 -16
View File
@@ -11,21 +11,37 @@
* in repo A ≠ same string in repo B). Pass `--all-sources` to search
* globally.
*
* v0.34 W0b (Codex finding #7): the pre-v0.34 implementation set
* `allSources: allSources || !sourceId`, which INVERTED the documented
* default to global whenever --source was omitted. Multi-source brains
* cross-contaminated structural retrieval despite the docstring claim.
* Fix: when --source is omitted AND --all-sources is NOT set, resolve to
* the brain's only source (single-source brains) or fail with a clear
* error listing valid source ids (multi-source brains).
* Source resolution: when --source is omitted AND --all-sources is NOT set,
* resolve through the full source-resolution chain via
* `resolveScopedSourceOrThrow` (flag → env → .gbrain-source dotfile →
* local_path → brain_default → sole_non_default), matching `gbrain sources
* current`. A `.gbrain-source` pin selects the source; only a no-signal
* multi-source brain still fails with `multiple_sources_ambiguous`. (Pre-
* v0.41.30 this called `resolveDefaultSource` directly, which ignored the pin
* and errored on every multi-source brain — Codex finding #7's source-scoped
* default is preserved; the pin is now honored on top of it.) `--all-sources`
* searches globally and overrides any pin.
*
* Output: non-TTY → JSON envelope. TTY → human table. Follows the
* code-def / code-refs pattern.
* Output: non-TTY → JSON envelope (carries `source_id` + `scope`). TTY → human
* table. Follows the code-def / code-refs pattern.
*/
import type { BrainEngine } from '../core/engine.ts';
import { errorFor, serializeError } from '../core/errors.ts';
import { resolveDefaultSource, SourceResolutionError } from '../core/sources-ops.ts';
import { resolveScopedSourceOrThrow, SourceResolutionError } from '../core/sources-ops.ts';
import { formatSoleNonDefaultNudge } from '../core/source-resolver.ts';
/** A bad/invalid `.gbrain-source` pin or GBRAIN_SOURCE value surfaces from
* `resolveSourceWithTier`'s `assertSourceExists` as a plain Error with one of
* these message prefixes. Mirrors dream.ts:isResolverUserError so we surface a
* clean usage error instead of an uncaught stack. */
function isResolverUserError(e: unknown): boolean {
if (!(e instanceof Error)) return false;
const m = e.message;
return (m.startsWith('Source "') && m.includes(' not found.'))
|| m.startsWith('Invalid --source value')
|| m.startsWith('Invalid GBRAIN_SOURCE value');
}
function parseFlag(args: string[], name: string): string | undefined {
const i = args.indexOf(name);
@@ -59,12 +75,20 @@ export async function runCodeCallers(engine: BrainEngine, args: string[]): Promi
const allSources = args.includes('--all-sources');
let sourceId = parseFlag(args, '--source');
// v0.34 W0b: when neither --source nor --all-sources is set, resolve
// to the brain's only source. Multi-source brains require an explicit
// choice — no more silent cross-source default.
// When neither --source nor --all-sources is set, resolve through the full
// source-resolution chain (honors the .gbrain-source pin, env, local_path,
// brain_default, sole_non_default). Only a no-signal multi-source brain
// still errors as multiple_sources_ambiguous.
if (!allSources && !sourceId) {
try {
sourceId = await resolveDefaultSource(engine);
const resolved = await resolveScopedSourceOrThrow(engine);
sourceId = resolved.source_id;
// Nudge only when we auto-routed to the sole non-default source (the one
// tier with no explicit user signal). Matches sync/import behavior.
if (resolved.tier === 'sole_non_default') {
const nudge = formatSoleNonDefaultNudge(resolved.source_id);
if (nudge) console.error(nudge);
}
} catch (e: unknown) {
if (e instanceof SourceResolutionError) {
const env = errorFor({
@@ -80,6 +104,22 @@ export async function runCodeCallers(engine: BrainEngine, args: string[]): Promi
}
process.exit(2);
}
// Bad/invalid pin (.gbrain-source or GBRAIN_SOURCE points at a missing
// source) → clean usage error, not an uncaught stack.
if (isResolverUserError(e)) {
const env = errorFor({
class: 'UsageError',
code: 'invalid_source_pin',
message: (e as Error).message,
hint: 'fix the .gbrain-source pin / GBRAIN_SOURCE value, or pass --source <id> / --all-sources',
}).envelope;
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ error: env }));
} else {
console.error((e as Error).message);
}
process.exit(2);
}
throw e;
}
}
@@ -91,10 +131,23 @@ export async function runCodeCallers(engine: BrainEngine, args: string[]): Promi
sourceId: sourceId ?? undefined,
});
const scope = allSources ? 'all' : 'single';
const envelopeSourceId = allSources ? null : (sourceId ?? null);
if (shouldEmitJson(args)) {
console.log(JSON.stringify({ symbol: sym, count: edges.length, callers: edges }, null, 2));
const out: Record<string, unknown> = {
symbol: sym, source_id: envelopeSourceId, scope, count: edges.length, callers: edges,
};
if (edges.length === 0 && !allSources && sourceId) {
out.hint = `No callers in source '${sourceId}'. Try --all-sources to search every source.`;
}
console.log(JSON.stringify(out, null, 2));
} else if (edges.length === 0) {
console.log(`No callers found for "${sym}".`);
if (!allSources && sourceId) {
console.log(`No callers found for "${sym}" in source '${sourceId}'. Try --all-sources to search every source.`);
} else {
console.log(`No callers found for "${sym}".`);
}
} else {
console.log(`${edges.length} caller(s) for "${sym}":`);
for (const e of edges) {
+51 -13
View File
@@ -198,18 +198,26 @@ function parseArgs(args: string[]): DreamArgs {
/**
* Resolve the brain directory without the `findRepoRoot` footgun.
*
* Prior dream.ts walked up 10 levels of cwd looking for `.git` and would
* happily run lint + sync against an unrelated git repo the user happened
* to be cd'd into. This resolver only trusts two sources:
* 1. An explicit --dir argument.
* 2. The `sync.repo_path` config key set by `gbrain init` (engine-backed).
* Resolution order (v0.41.30 — postgres support):
* 1. An explicit --dir argument (exits 1 if it doesn't exist — a real mistake).
* 2. T1: when --source resolved to a source that has an on-disk `local_path`,
* use it (matches `gbrain sync`, lets that source's filesystem phases run).
* 3. The legacy `sync.repo_path` config key (pre-v0.18 default-source brains).
* 4. `null` — no local checkout. The cycle then SKIPS filesystem phases
* (lint/backlinks/sync/synthesize/extract/patterns) with reason
* `no_brain_dir` and runs the DB-only phases (resolve_symbol_edges, embed,
* orphans, ...). This is what makes `gbrain dream` work on a postgres /
* Supabase brain with no checkout. `runDream` owns the only hard error:
* no checkout AND no engine = truly nothing to run.
*
* If neither is available, we error out instead of guessing.
* Still never walks cwd for a `.git` — only the explicit / source / config
* signals are trusted.
*/
async function resolveBrainDir(
engine: BrainEngine | null,
explicit: string | null,
): Promise<string> {
resolvedSourceId?: string,
): Promise<string | null> {
if (explicit) {
if (!existsSync(explicit)) {
console.error(`--dir path does not exist: ${explicit}`);
@@ -220,6 +228,22 @@ async function resolveBrainDir(
return resolve(explicit);
}
// T1: the user scoped to a specific source via --source/--source-id; if that
// source has a checkout on disk, use it so its filesystem phases can run.
if (engine && resolvedSourceId) {
const src = await fetchSource(engine, resolvedSourceId);
if (src?.local_path && existsSync(src.local_path)) {
return resolve(src.local_path);
}
// Explicit --source whose checkout isn't on disk → DB-only (skip FS phases).
// Do NOT fall through to the global sync.repo_path below: that path belongs
// to the default/unscoped brain, and running FS phases (sync/lint/extract)
// against it while the DB phases AND the last_full_cycle_at stamp target
// <resolvedSourceId> would mix scopes — syncing one source's checkout while
// marking a different source fresh. (codex P1 review finding.)
return null;
}
if (engine) {
const configured = await engine.getConfig('sync.repo_path');
if (configured && existsSync(configured)) {
@@ -227,10 +251,9 @@ async function resolveBrainDir(
}
}
console.error(
'No brain directory found. Pass --dir <path> or configure one via `gbrain init`.',
);
process.exit(1);
// No checkout found. Return null (NOT exit) — DB-only phases can still run
// against the engine. The both-null hard error lives in runDream.
return null;
}
function printHelp() {
@@ -251,7 +274,11 @@ Options:
--json Emit the CycleReport as JSON (agent-readable)
--phase <name> Run a single phase: ${ALL_PHASES.join(' | ')}
--pull git pull the brain repo before syncing (default: no pull)
--dir <path> Brain directory (default: configured brain)
--dir <path> Brain directory (default: configured brain). On a
postgres/remote brain with no local checkout, the
filesystem phases (lint, backlinks, sync, synthesize,
extract, patterns) are skipped (reason: no_brain_dir)
and the DB-only phases still run.
--source <id> Scope the cycle to one source so doctor's
cycle_freshness check sees a fresh stamp on
@@ -420,7 +447,18 @@ export async function runDream(engine: BrainEngine | null, args: string[]): Prom
}
}
const brainDir = await resolveBrainDir(engine, opts.dir);
const brainDir = await resolveBrainDir(engine, opts.dir, resolvedSourceId);
// Both-null is the only hard error: no local checkout AND no DB connection
// means neither filesystem phases nor DB phases can run. With an engine but
// no checkout, the cycle skips filesystem phases and runs DB-only phases
// (resolve_symbol_edges, embed, orphans, ...) — the postgres support path.
if (brainDir === null && engine === null) {
console.error(
'No brain directory found and no database connection. ' +
'Pass --dir <path> or configure a brain via `gbrain init`.',
);
process.exit(1);
}
const phases: CyclePhase[] | undefined = opts.phase ? [opts.phase] : undefined;
const report = await runCycle(engine, {
+14 -4
View File
@@ -1334,9 +1334,14 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
// throw on partial: a flaky phase must not block every future cycle.
worker.register('autopilot-cycle', async (job) => {
const { runCycle } = await import('../core/cycle.ts');
const repoPath = typeof job.data.repoPath === 'string'
// v0.41.30 (T2): fall back to null (NOT cwd '.') when no repo is configured.
// The queued cycle is the same primitive `gbrain dream` uses; a checkout-less
// postgres brain should skip filesystem phases (no_brain_dir) and run the
// DB-only phases (resolve_symbol_edges, embed, ...) — not silently lint/sync
// against whatever directory the worker happens to be running in.
const repoPath: string | null = typeof job.data.repoPath === 'string'
? job.data.repoPath
: (await engine.getConfig('sync.repo_path')) ?? '.';
: (await engine.getConfig('sync.repo_path')) ?? null;
// v0.38 (codex r1 P1-2 + P1-5): per-source dispatch threading.
// - source_id: when set, runCycle uses the per-source lock ID and
@@ -1525,9 +1530,14 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
// the single source of truth for phase semantics.
const makePhaseHandler = (phase: string) => async (job: any) => {
const { runCycle } = await import('../core/cycle.ts');
const repoPath = typeof job.data.repoPath === 'string'
// v0.41.38 (codex P2 review): fall back to null (NOT cwd '.') when no repo
// is configured, matching the autopilot-cycle handler + `gbrain dream`. On a
// checkout-less postgres brain a filesystem phase (synthesize/patterns/...)
// skips with reason 'no_brain_dir' instead of running against the worker cwd;
// DB-only phases (resolve_symbol_edges/embed/...) ignore brainDir either way.
const repoPath: string | null = typeof job.data.repoPath === 'string'
? job.data.repoPath
: ((await engine.getConfig('sync.repo_path')) ?? '.');
: ((await engine.getConfig('sync.repo_path')) ?? null);
const report = await runCycle(engine, {
brainDir: repoPath,
phases: [phase as any],
+81 -25
View File
@@ -348,8 +348,13 @@ export interface CycleOpts {
dryRun?: boolean;
/** Defaults to ALL_PHASES. Pass a subset for --phase lint etc. */
phases?: CyclePhase[];
/** Brain directory (git repo). Required for filesystem phases. */
brainDir: string;
/**
* Brain directory (git repo). Required for filesystem phases (lint,
* backlinks, sync, synthesize, extract, patterns). `null` when the brain has
* no on-disk checkout (postgres/remote engine) — those phases are skipped
* with reason `no_brain_dir` and the DB-only phases still run.
*/
brainDir: string | null;
/** Whether sync should run `git pull`. Default false (cron-safe). */
pull?: boolean;
/**
@@ -754,8 +759,11 @@ interface SyncPhaseResult extends PhaseResult {
*/
async function resolveSourceForDir(
engine: BrainEngine,
brainDir: string,
brainDir: string | null,
): Promise<string | undefined> {
// No checkout → no path-derived source. Callers fall back to opts.sourceId
// (the cycleSourceId precedence) or 'default'.
if (brainDir === null) return undefined;
try {
const rows = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE local_path = $1 LIMIT 1`,
@@ -1288,6 +1296,33 @@ export async function runCycle(
const timestamp = new Date().toISOString();
const phaseResults: PhaseResult[] = [];
// Capture as a const so it narrows to `string` inside the `else` branches of
// the per-phase `if (brainDir === null)` guards, even within async closures
// (const bindings narrow across closures; property accesses don't).
const brainDir = opts.brainDir;
// Skip result for a filesystem phase when the brain has no on-disk checkout.
const skipNoBrainDir = (phase: CyclePhase): PhaseResult => ({
phase,
status: 'skipped',
duration_ms: 0,
summary: 'requires a local brain directory; this brain has no on-disk checkout '
+ '(postgres/remote engine); pass --dir <path> to run filesystem phases',
details: { reason: 'no_brain_dir' },
});
// A1: canonical per-source scope for the DB-capable per-source phases
// (extract_facts, extract_atoms, the calibration trio). Explicit --source
// (opts.sourceId) wins; else derive from the resolved checkout dir. Without
// this, `gbrain dream --source repo-a` on a checkout-less brain would scope
// those phases to 'default' (resolveSourceForDir(null) → undefined) while the
// cycle still locks + stamps last_full_cycle_at for repo-a — a freshness
// stamp that lies. resolveSourceForDir returns undefined when brainDir is
// null, so opts.sourceId is the only signal in the no-checkout case.
const cycleSourceId: string | undefined = engine
? (opts.sourceId ?? (await resolveSourceForDir(engine, brainDir)))
: opts.sourceId;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// Decide if we need the cycle lock: any state-mutating phase in the selection.
@@ -1417,22 +1452,30 @@ export async function runCycle(
// ── Phase 1: lint ────────────────────────────────────────────
if (phases.includes('lint')) {
checkAborted(opts.signal);
progress.start('cycle.lint');
const { result, duration_ms } = await timePhase(() => runPhaseLint(opts.brainDir, dryRun));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
if (brainDir === null) {
phaseResults.push(skipNoBrainDir('lint'));
} else {
progress.start('cycle.lint');
const { result, duration_ms } = await timePhase(() => runPhaseLint(brainDir, dryRun));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
// ── Phase 2: backlinks ──────────────────────────────────────
if (phases.includes('backlinks')) {
checkAborted(opts.signal);
progress.start('cycle.backlinks');
const { result, duration_ms } = await timePhase(() => runPhaseBacklinks(opts.brainDir, dryRun));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
if (brainDir === null) {
phaseResults.push(skipNoBrainDir('backlinks'));
} else {
progress.start('cycle.backlinks');
const { result, duration_ms } = await timePhase(() => runPhaseBacklinks(brainDir, dryRun));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
}
await safeYield(opts.yieldBetweenPhases);
}
@@ -1452,9 +1495,11 @@ export async function runCycle(
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else if (brainDir === null) {
phaseResults.push(skipNoBrainDir('sync'));
} else {
progress.start('cycle.sync');
const { result, duration_ms } = await timePhase(() => runPhaseSync(engine, opts.brainDir, dryRun, pull, phases.includes('extract')));
const { result, duration_ms } = await timePhase(() => runPhaseSync(engine, brainDir, dryRun, pull, phases.includes('extract')));
result.duration_ms = duration_ms;
// Capture changed slugs for incremental extract.
syncPagesAffected = (result as SyncPhaseResult).pagesAffected;
@@ -1474,11 +1519,13 @@ export async function runCycle(
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else if (brainDir === null) {
phaseResults.push(skipNoBrainDir('synthesize'));
} else {
progress.start('cycle.synthesize');
const { runPhaseSynthesize } = await import('./cycle/synthesize.ts');
const { result, duration_ms } = await timePhase(() => runPhaseSynthesize(engine, {
brainDir: opts.brainDir,
brainDir,
dryRun,
yieldDuringPhase: opts.yieldDuringPhase,
inputFile: opts.synthInputFile,
@@ -1510,12 +1557,14 @@ export async function runCycle(
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else if (brainDir === null) {
phaseResults.push(skipNoBrainDir('extract'));
} else {
// Pass changed slugs from sync for incremental extract.
// If sync didn't run (phases exclude it) or failed, syncPagesAffected
// is undefined → extract falls back to full walk (safe default).
progress.start('cycle.extract');
const { result, duration_ms } = await timePhase(() => runPhaseExtract(engine, opts.brainDir, dryRun, syncPagesAffected));
const { result, duration_ms } = await timePhase(() => runPhaseExtract(engine, brainDir, dryRun, syncPagesAffected));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
@@ -1548,9 +1597,9 @@ export async function runCycle(
// already-resolved cycle scope; sourceId defaults to 'default' when
// the sources table doesn't recognize this brainDir (pre-multi-
// source installs).
const xfSourceId = (await resolveSourceForDir(engine, opts.brainDir)) ?? 'default';
const xfSourceId = cycleSourceId ?? 'default';
const { result, duration_ms } = await timePhase(() =>
runPhaseExtractFacts(engine, opts.brainDir, xfSourceId, dryRun, syncPagesAffected));
runPhaseExtractFacts(engine, brainDir, xfSourceId, dryRun, syncPagesAffected));
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
@@ -1591,7 +1640,7 @@ export async function runCycle(
} else {
progress.start('cycle.extract_atoms');
const { runPhaseExtractAtoms } = await import('./cycle/extract-atoms.ts');
const xaSourceId = (await resolveSourceForDir(engine, opts.brainDir)) ?? 'default';
const xaSourceId = cycleSourceId ?? 'default';
// v0.41.2.1 (D9 #5): union sync + synthesize affected slugs so the
// incremental discovery path doesn't miss pages just-written by the
// synthesize phase that ran earlier in the same cycle.
@@ -1603,7 +1652,7 @@ export async function runCycle(
]
: undefined;
const { result, duration_ms } = await timePhase(() => runPhaseExtractAtoms(engine, {
brainDir: opts.brainDir,
brainDir: brainDir ?? undefined,
sourceId: xaSourceId,
dryRun,
affectedSlugs: xaAffectedSlugs,
@@ -1659,11 +1708,13 @@ export async function runCycle(
summary: 'no database connected',
details: { reason: 'no_database' },
});
} else if (brainDir === null) {
phaseResults.push(skipNoBrainDir('patterns'));
} else {
progress.start('cycle.patterns');
const { runPhasePatterns } = await import('./cycle/patterns.ts');
const { result, duration_ms } = await timePhase(() => runPhasePatterns(engine, {
brainDir: opts.brainDir,
brainDir,
dryRun,
yieldDuringPhase: opts.yieldDuringPhase,
}));
@@ -1700,7 +1751,7 @@ export async function runCycle(
progress.start('cycle.synthesize_concepts');
const { runPhaseSynthesizeConcepts } = await import('./cycle/synthesize-concepts.ts');
const { result, duration_ms } = await timePhase(() => runPhaseSynthesizeConcepts(engine, {
brainDir: opts.brainDir,
brainDir: brainDir ?? undefined,
dryRun,
// v0.41.19.0 (T3): closure refreshes cycle lock + fires outer hook.
yieldDuringPhase: buildYieldDuringPhase(lock, opts.yieldDuringPhase),
@@ -1798,7 +1849,7 @@ export async function runCycle(
if (engine) {
const cfgMod = await import('./config.ts');
const calibrationConfig = cfgMod.loadConfig() ?? ({} as ReturnType<typeof cfgMod.loadConfig> & object);
const calibrationSourceId = await resolveSourceForDir(engine, opts.brainDir);
const calibrationSourceId = cycleSourceId;
const calibrationCtx = {
engine,
config: calibrationConfig,
@@ -1812,7 +1863,7 @@ export async function runCycle(
checkAborted(opts.signal);
progress.start('cycle.propose_takes');
const { runPhaseProposeTakes } = await import('./cycle/propose-takes.ts');
const { result, duration_ms } = await timePhase(() => runPhaseProposeTakes(calibrationCtx, { repoPath: opts.brainDir }) as Promise<PhaseResult>);
const { result, duration_ms } = await timePhase(() => runPhaseProposeTakes(calibrationCtx, { repoPath: brainDir ?? undefined }) as Promise<PhaseResult>);
result.duration_ms = duration_ms;
phaseResults.push(result);
progress.finish();
@@ -2126,6 +2177,11 @@ function deriveStatus(phases: PhaseResult[], totals: CycleReport['totals']): Cyc
totals.pages_synced > 0 ||
totals.pages_extracted > 0 ||
totals.pages_embedded > 0 ||
totals.pages_emotional_weight_recomputed > 0;
totals.pages_emotional_weight_recomputed > 0 ||
// A7: a code brain runs `gbrain dream` specifically to build the call graph
// (resolve_symbol_edges). Without these, an edges-only cycle reports 'clean'
// — indistinguishable from "nothing happened" even when N edges resolved.
totals.edges_resolved > 0 ||
totals.edges_ambiguous > 0;
return anyWork ? 'ok' : 'clean';
}
+42
View File
@@ -51,6 +51,7 @@ import {
} from './git-remote.ts';
import { gbrainPath } from './config.ts';
import { isValidSourceId } from './source-id.ts';
import { resolveSourceWithTier, type SourceTier } from './source-resolver.ts';
// ── Errors ──────────────────────────────────────────────────────────────────
@@ -446,6 +447,47 @@ export async function resolveDefaultSource(engine: BrainEngine): Promise<string>
);
}
/** Result of `resolveScopedSourceOrThrow`: the resolved source id plus the
* tier that won, so callers can nudge (sole_non_default) or surface the
* source in their output envelope. */
export interface ScopedSourceResolution {
source_id: string;
tier: SourceTier;
}
/**
* Source scope for the structural-retrieval commands (`code-callers` /
* `code-callees`) when neither `--source` nor `--all-sources` is given.
*
* Runs the FULL 7-tier resolution chain via `resolveSourceWithTier`
* (flag → env → dotfile → local_path → brain_default → sole_non_default →
* seed_default), so a `.gbrain-source` pin (or any real signal) selects the
* source. The multi-source ambiguity guard (`resolveDefaultSource`) is
* applied ONLY when the chain matched nothing real (tier `seed_default`):
* 1 source → returns it, 0 → `no_sources` throw, 2+ → `multiple_sources_ambiguous`.
*
* Contrast with `resolveSourceId` (silently returns `'default'` and never
* throws on ambiguity) — this helper deliberately preserves the loud
* multi-source error when there's genuinely no signal.
*
* @throws SourceResolutionError on a no-signal 0/2+-source brain (seed_default tier).
* @throws Error ("Source \"…\" not found." / "Invalid …") on a bad pin / env value
* via `assertSourceExists` inside `resolveSourceWithTier` — callers should
* surface these as clean usage errors, not uncaught stacks.
*/
export async function resolveScopedSourceOrThrow(
engine: BrainEngine,
cwd: string = process.cwd(),
): Promise<ScopedSourceResolution> {
const resolved = await resolveSourceWithTier(engine, null, cwd);
if (resolved.tier !== 'seed_default') {
return { source_id: resolved.source_id, tier: resolved.tier };
}
// Nothing in the chain matched → apply the ambiguity guard (may throw).
const id = await resolveDefaultSource(engine);
return { source_id: id, tier: 'seed_default' };
}
// ── listSources ─────────────────────────────────────────────────────────────
export async function listSources(
+245
View File
@@ -0,0 +1,245 @@
/**
* v0.41.30.0 — code-callers / code-callees end-to-end source resolution (BUG 1).
*
* Serial (`*.serial.test.ts`) because it process.chdir()s into a temp dir
* holding a .gbrain-source pin — process.cwd() is process-global and races
* with parallel files. Drives the real runCodeCallers / runCodeCallees through
* their process.cwd()-based resolution, asserting:
* - a .gbrain-source pin resolves on a multi-source brain (no exit 2)
* - no pin + no flag + multi-source still errors (exit 2)
* - explicit --source overrides
* - A4: JSON envelope carries source_id + scope; --all-sources → null/'all'
* - A4: sole_non_default tier emits the stderr nudge
* - A5: zero-result implicit scope appends the "try --all-sources" hint
*
* PGLite in-memory, no DATABASE_URL.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach, spyOn } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import { runCodeCallers } from '../src/commands/code-callers.ts';
import { runCodeCallees } from '../src/commands/code-callees.ts';
let engine: PGLiteEngine;
let origCwd: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
origCwd = process.cwd();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
afterEach(() => {
process.chdir(origCwd);
});
async function addSource(id: string, localPath: string | null): Promise<void> {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, created_at)
VALUES ($1, $1, $2, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
[id, localPath],
);
}
function pinnedDir(sourceId: string): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-pin-cli-'));
writeFileSync(join(dir, '.gbrain-source'), `${sourceId}\n`);
return dir;
}
/** Run fn with process.exit + console.log/error spied. Returns captured output
* + the exit code if process.exit was called (spied to throw an EXIT sentinel). */
async function capture(fn: () => Promise<void>): Promise<{ logs: string[]; errs: string[]; exitCode: number | null }> {
const logs: string[] = [];
const errs: string[] = [];
let exitCode: number | null = null;
const logSpy = spyOn(console, 'log').mockImplementation((m?: unknown) => { logs.push(String(m)); });
const errSpy = spyOn(console, 'error').mockImplementation((m?: unknown) => { errs.push(String(m)); });
const exitSpy = spyOn(process, 'exit').mockImplementation(((code?: number) => {
exitCode = code ?? 0;
throw new Error('EXIT');
}) as never);
try {
await fn();
} catch (e) {
if (!(e instanceof Error) || e.message !== 'EXIT') throw e;
} finally {
logSpy.mockRestore();
errSpy.mockRestore();
exitSpy.mockRestore();
}
return { logs, errs, exitCode };
}
describe('code-callers / code-callees — .gbrain-source pin (CLI wiring)', () => {
test('pin resolves on a multi-source brain: no exit 2, output names the pinned source', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const dir = pinnedDir('repo-a');
process.chdir(dir);
try {
const callers = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallers(engine, ['someSym', '--no-json'])));
expect(callers.exitCode).toBeNull(); // resolved, did NOT error
expect(callers.logs.join('\n')).toContain("repo-a");
const callees = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallees(engine, ['someSym', '--no-json'])));
expect(callees.exitCode).toBeNull();
expect(callees.logs.join('\n')).toContain("repo-a");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('no pin + no flag + multi-source → exit 2 (ambiguous preserved)', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const dir = mkdtempSync(join(tmpdir(), 'gbrain-nopin-cli-'));
process.chdir(dir);
try {
const callers = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallers(engine, ['someSym', '--no-json'])));
expect(callers.exitCode).toBe(2);
expect(callers.errs.join('\n')).toContain('--source');
const callees = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallees(engine, ['someSym', '--no-json'])));
expect(callees.exitCode).toBe(2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('explicit --source overrides (even with a conflicting pin)', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const dir = pinnedDir('repo-a');
process.chdir(dir);
try {
const { logs, exitCode } = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallers(engine, ['someSym', '--source', 'repo-b', '--json'])));
expect(exitCode).toBeNull();
const env = JSON.parse(logs.join('\n'));
expect(env.source_id).toBe('repo-b');
expect(env.scope).toBe('single');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('A4: JSON envelope carries source_id + scope (resolved pin)', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const dir = pinnedDir('repo-a');
process.chdir(dir);
try {
const { logs } = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallers(engine, ['someSym', '--json'])));
const env = JSON.parse(logs.join('\n'));
expect(env.source_id).toBe('repo-a');
expect(env.scope).toBe('single');
expect(env.symbol).toBe('someSym');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('A4: --all-sources → source_id null, scope "all"', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const dir = mkdtempSync(join(tmpdir(), 'gbrain-all-cli-'));
process.chdir(dir);
try {
const { logs, exitCode } = await capture(() =>
runCodeCallers(engine, ['someSym', '--all-sources', '--json']));
expect(exitCode).toBeNull();
const env = JSON.parse(logs.join('\n'));
expect(env.source_id).toBeNull();
expect(env.scope).toBe('all');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('A4: sole_non_default tier emits the stderr nudge', async () => {
await addSource('repo-a', '/fake/a'); // default + one non-default w/ local_path
const dir = mkdtempSync(join(tmpdir(), 'gbrain-sole-cli-'));
process.chdir(dir);
try {
const { errs, exitCode } = await withEnv(
{ GBRAIN_SOURCE: undefined, GBRAIN_NO_SOLE_NON_DEFAULT_NUDGE: undefined },
() => capture(() => runCodeCallers(engine, ['someSym', '--json'])));
expect(exitCode).toBeNull();
expect(errs.join('\n')).toContain("routing to source 'repo-a'");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('A4: dotfile-pin tier does NOT emit the sole_non_default nudge', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const dir = pinnedDir('repo-a');
process.chdir(dir);
try {
const { errs } = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallers(engine, ['someSym', '--json'])));
expect(errs.join('\n')).not.toContain('routing to source');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('A5: zero-result implicit scope appends the "try --all-sources" hint', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const dir = pinnedDir('repo-a');
process.chdir(dir);
try {
// human output
const human = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallers(engine, ['someSym', '--no-json'])));
expect(human.logs.join('\n')).toContain('Try --all-sources');
// JSON hint field
const jsonRun = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallees(engine, ['someSym', '--json'])));
const env = JSON.parse(jsonRun.logs.join('\n'));
expect(env.count).toBe(0);
expect(env.hint).toContain('--all-sources');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('bad .gbrain-source pin → exit 2 with JSON error envelope', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const dir = pinnedDir('nonexistent-src');
process.chdir(dir);
try {
const { logs, exitCode } = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
capture(() => runCodeCallers(engine, ['someSym', '--json'])));
expect(exitCode).toBe(2);
const env = JSON.parse(logs.join('\n'));
expect(env.error.code).toBe('invalid_source_pin');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* v0.41.30.0 — resolveScopedSourceOrThrow resolution rule (BUG 1).
*
* code-callers / code-callees used to call resolveDefaultSource directly,
* which only knew "1 source → use it, else multiple_sources_ambiguous" and
* ignored the .gbrain-source pin. The new helper runs the full 7-tier chain
* (flag → env → dotfile → local_path → brain_default → sole_non_default →
* seed_default) and only applies the ambiguity guard on the no-signal
* seed_default tier.
*
* This drives the helper directly with an explicit cwd (hermetic). The CLI
* end-to-end wiring (process.cwd() + chdir) lives in
* test/code-callers-pin.serial.test.ts.
*
* PGLite in-memory, no DATABASE_URL.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resolveScopedSourceOrThrow, SourceResolutionError } from '../src/core/sources-ops.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
async function addSource(id: string, localPath: string | null): Promise<void> {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, created_at)
VALUES ($1, $1, $2, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
[id, localPath],
);
}
/** A throwaway directory NOT under any registered source's local_path and
* with no .gbrain-source, so the resolver falls through to the DB tiers. */
function cleanCwd(): string {
return mkdtempSync(join(tmpdir(), 'gbrain-scoped-clean-'));
}
describe('resolveScopedSourceOrThrow', () => {
test('single-source brain: returns default via seed_default tier (no throw)', async () => {
const cwd = cleanCwd();
try {
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('default');
expect(r.tier).toBe('seed_default');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('.gbrain-source pin resolves on a multi-source brain (THE bug)', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const cwd = mkdtempSync(join(tmpdir(), 'gbrain-scoped-pin-'));
writeFileSync(join(cwd, '.gbrain-source'), 'repo-a\n');
try {
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('repo-a');
expect(r.tier).toBe('dotfile');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('no pin + no signal + multi-source → multiple_sources_ambiguous', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const cwd = cleanCwd();
let caught: unknown = null;
try {
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
} catch (e) {
caught = e;
} finally {
rmSync(cwd, { recursive: true, force: true });
}
expect(caught).toBeInstanceOf(SourceResolutionError);
if (caught instanceof SourceResolutionError) {
expect(caught.code).toBe('multiple_sources_ambiguous');
expect(caught.availableSources).toContain('repo-a');
expect(caught.availableSources).toContain('repo-b');
}
});
test('default + one non-default source with local_path → sole_non_default tier', async () => {
await addSource('repo-a', '/fake/a');
const cwd = cleanCwd();
try {
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('repo-a');
expect(r.tier).toBe('sole_non_default');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('GBRAIN_SOURCE env tier wins on a multi-source brain', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const cwd = cleanCwd();
try {
const r = await withEnv({ GBRAIN_SOURCE: 'repo-b' }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('repo-b');
expect(r.tier).toBe('env');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('brain_default (sources.default config) tier wins when no pin/env/cwd-match', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
await engine.setConfig('sources.default', 'repo-a');
const cwd = cleanCwd();
try {
const r = await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
expect(r.source_id).toBe('repo-a');
expect(r.tier).toBe('brain_default');
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test('zero sources → no_sources throw', async () => {
await engine.executeRaw(`DELETE FROM sources WHERE id = 'default'`, []);
const cwd = cleanCwd();
let caught: unknown = null;
try {
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
} catch (e) {
caught = e;
} finally {
rmSync(cwd, { recursive: true, force: true });
}
expect(caught).toBeInstanceOf(SourceResolutionError);
if (caught instanceof SourceResolutionError) {
expect(caught.code).toBe('no_sources');
}
});
test('bad .gbrain-source pin (nonexistent source) throws a resolver user error', async () => {
await addSource('repo-a', '/fake/a');
await addSource('repo-b', '/fake/b');
const cwd = mkdtempSync(join(tmpdir(), 'gbrain-scoped-badpin-'));
writeFileSync(join(cwd, '.gbrain-source'), 'does-not-exist\n');
let caught: unknown = null;
try {
await withEnv({ GBRAIN_SOURCE: undefined }, () =>
resolveScopedSourceOrThrow(engine, cwd));
} catch (e) {
caught = e;
} finally {
rmSync(cwd, { recursive: true, force: true });
}
// Bad pin surfaces as a plain Error from assertSourceExists, NOT a
// SourceResolutionError — the command layer maps this to a clean exit 2.
expect(caught).toBeInstanceOf(Error);
expect(caught).not.toBeInstanceOf(SourceResolutionError);
expect((caught as Error).message).toContain('does-not-exist');
});
});
+238
View File
@@ -0,0 +1,238 @@
/**
* v0.41.30.0 — `gbrain dream` runs against a checkout-less (postgres-shaped)
* brain (BUG 2).
*
* Pre-fix dream.ts:resolveBrainDir process.exit(1)'d with "No brain directory
* found" when neither --dir nor an on-disk sync.repo_path existed — so the
* DB-only maintenance phases (notably resolve_symbol_edges, the call-graph
* builder) could never run on a Supabase brain. Now brainDir can be null: the
* 6 filesystem phases skip with reason `no_brain_dir` and the DB phases run.
*
* Covers: the null-brainDir path, A1 (the --source per-source scope fix),
* A7 (deriveStatus reports `ok` not `clean` when edges resolve), and the
* both-null hard error. PGLite in-memory (the bug branches on
* `brainDir === null`, not engine.kind, so PGLite is a faithful proxy).
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach, spyOn } from 'bun:test';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runDream } from '../src/commands/dream.ts';
import { runCycle } from '../src/core/cycle.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
function phase(report: any, name: string) {
return report.phases.find((p: any) => p.phase === name);
}
// ─── BUG 2: no checkout → DB phases run, FS phases skip ──────────────
describe('runDream — checkout-less brain (no --dir, no sync.repo_path)', () => {
test('full --dry-run returns a report with brain_dir null (no "No brain directory found" exit)', async () => {
const report = await runDream(engine, ['--dry-run', '--json']);
expect(report).toBeTruthy();
if (!report) return;
expect(report.brain_dir).toBeNull();
// A filesystem phase is present and skipped with the no_brain_dir reason.
const lint = phase(report, 'lint');
expect(lint?.status).toBe('skipped');
expect(lint?.details?.reason).toBe('no_brain_dir');
// A DB-only phase ran (not a no_brain_dir skip).
const orphans = phase(report, 'orphans');
expect(orphans).toBeTruthy();
expect(orphans?.details?.reason).not.toBe('no_brain_dir');
});
test('--phase resolve_symbol_edges runs on a checkout-less brain (the call-graph phase)', async () => {
const report = await runDream(engine, ['--phase', 'resolve_symbol_edges', '--json']);
expect(report).toBeTruthy();
if (!report) return;
expect(report.brain_dir).toBeNull();
const rse = phase(report, 'resolve_symbol_edges');
expect(rse).toBeTruthy();
// It RAN — not skipped for no_brain_dir / no_database, not failed.
expect(rse?.status).not.toBe('fail');
expect(rse?.details?.reason).not.toBe('no_brain_dir');
expect(rse?.details?.reason).not.toBe('no_database');
});
test('--phase lint skips with no_brain_dir (does not exit 1)', async () => {
const report = await runDream(engine, ['--phase', 'lint', '--json']);
expect(report).toBeTruthy();
if (!report) return;
const lint = phase(report, 'lint');
expect(lint?.status).toBe('skipped');
expect(lint?.details?.reason).toBe('no_brain_dir');
});
test('--source <id> --dry-run on a checkout-less brain succeeds (the command doctor recommends)', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, created_at)
VALUES ('repo-a', 'repo-a', NULL, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
[],
);
const report = await runDream(engine, ['--source', 'repo-a', '--dry-run', '--json']);
expect(report).toBeTruthy();
if (!report) return;
expect(report.brain_dir).toBeNull();
expect(report.status).not.toBe('failed');
});
});
// ─── A1: --source scopes per-source DB phases correctly on null brainDir ──
describe('runDream — A1 per-source scope (no checkout)', () => {
test('dream --source repo-a --phase extract_facts reconciles facts for repo-a, not default', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, created_at)
VALUES ('repo-a', 'repo-a', '/fake/repo-a', '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
[],
);
// A page that exists ONLY under repo-a, carrying a Facts fence. With the
// pre-fix bug, brainDir===null → resolveSourceForDir(null)→undefined →
// xfSourceId='default' → getPage('people/bob',{sourceId:'default'}) → null →
// 0 facts. The fix makes cycleSourceId = opts.sourceId = 'repo-a'.
const fence =
`# Bob\n\nBody.\n\n## Facts\n\n` +
`<!--- gbrain:facts:begin -->\n` +
`| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context |\n` +
`|---|-------|------|------------|------------|------------|------------|-------------|--------|---------|\n` +
`| 1 | Founded Acme | fact | 1.0 | world | high | 2017-01-01 | | linkedin | |\n` +
`<!--- gbrain:facts:end -->\n`;
await engine.executeRaw(
`INSERT INTO pages (slug, source_id, title, type, page_kind, compiled_truth, timeline, frontmatter, updated_at, created_at)
VALUES ('people/bob', 'repo-a', 'Bob', 'person', 'markdown', $1, '', '{}'::jsonb, NOW(), NOW())`,
[fence],
);
const report = await runDream(engine, ['--source', 'repo-a', '--phase', 'extract_facts', '--json']);
expect(report).toBeTruthy();
if (!report) return;
expect(report.brain_dir).toBeNull();
const rows = await engine.executeRaw<{ source_id: string; fact: string }>(
`SELECT source_id, fact FROM facts WHERE source_markdown_slug = 'people/bob'`,
[],
);
expect(rows.length).toBe(1);
expect(rows[0].source_id).toBe('repo-a'); // NOT 'default' (the A1 bug)
expect(rows[0].fact).toBe('Founded Acme');
});
test('--source repo-a (no checkout) does NOT borrow the global sync.repo_path of a different source', async () => {
// codex P1 regression: with --source set but that source having no on-disk
// checkout, resolveBrainDir must return null (DB-only) and NOT fall through
// to the global sync.repo_path — otherwise FS phases run against the default
// brain's checkout while DB phases + the freshness stamp target repo-a.
const globalRepo = mkdtempSync(join(tmpdir(), 'gbrain-global-repo-'));
try {
await engine.setConfig('sync.repo_path', globalRepo); // exists on disk
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, created_at)
VALUES ('repo-a', 'repo-a', NULL, '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
[],
);
const report = await runDream(engine, ['--source', 'repo-a', '--phase', 'lint', '--json']);
expect(report).toBeTruthy();
if (!report) return;
// brain_dir must be null — NOT the globalRepo path.
expect(report.brain_dir).toBeNull();
const lint = report.phases.find((p: any) => p.phase === 'lint');
expect(lint?.status).toBe('skipped');
expect(lint?.details?.reason).toBe('no_brain_dir');
} finally {
rmSync(globalRepo, { recursive: true, force: true });
}
});
});
// ─── A7: edges-only cycle reports ok, not clean ─────────────────────
describe('runCycle — A7 deriveStatus counts resolved edges as work', () => {
test('an edges-only cycle that resolves an edge reports status ok (not clean)', async () => {
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, config, created_at)
VALUES ('src-a', 'src-a', '/fake/src-a', '{}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING`,
[],
);
const pageRows = await engine.executeRaw<{ id: number }>(
`INSERT INTO pages (slug, source_id, title, type, page_kind, compiled_truth, frontmatter, updated_at, created_at)
VALUES ('src/foo.ts', 'src-a', 'src/foo.ts', 'code', 'code', '', '{}'::jsonb, NOW(), NOW())
RETURNING id`,
[],
);
const pageId = pageRows[0]!.id;
const caller = await engine.executeRaw<{ id: number }>(
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text, chunk_source, language, symbol_name_qualified, symbol_type)
VALUES ($1, 0, '// caller', 'compiled_truth', 'typescript', 'callerA', 'function') RETURNING id`,
[pageId],
);
await engine.executeRaw(
`INSERT INTO content_chunks (page_id, chunk_index, chunk_text, chunk_source, language, symbol_name_qualified, symbol_type)
VALUES ($1, 1, '// def', 'compiled_truth', 'typescript', 'targetFn', 'function')`,
[pageId],
);
await engine.executeRaw(
`INSERT INTO code_edges_symbol (from_chunk_id, from_symbol_qualified, to_symbol_qualified, edge_type, source_id, edge_metadata)
VALUES ($1, 'callerA', 'targetFn', 'calls', 'src-a', '{}'::jsonb)`,
[caller[0]!.id],
);
const report = await runCycle(engine, { brainDir: null, phases: ['resolve_symbol_edges'] });
expect(report.totals.edges_resolved).toBe(1);
expect(report.status).toBe('ok'); // pre-A7 this was 'clean'
});
});
// ─── both-null hard error preserved ─────────────────────────────────
describe('runDream — both-null (no engine, no dir) still exits 1', () => {
test('runDream(null, []) exits 1', async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('EXIT'); }) as never);
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
let threw = '';
try {
await runDream(null, []);
} catch (e) {
threw = (e as Error).message;
}
// Assert BEFORE mockRestore — bun's mockRestore clears recorded calls.
expect(threw).toBe('EXIT');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
errSpy.mockRestore();
});
test("runDream(null, ['--phase','orphans']) exits 1", async () => {
const exitSpy = spyOn(process, 'exit').mockImplementation((() => { throw new Error('EXIT'); }) as never);
const errSpy = spyOn(console, 'error').mockImplementation(() => {});
let threw = '';
try {
await runDream(null, ['--phase', 'orphans']);
} catch (e) {
threw = (e as Error).message;
}
expect(threw).toBe('EXIT');
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
errSpy.mockRestore();
});
});
@@ -0,0 +1,68 @@
/**
* v0.41.30.0 (T2) — the Minions `autopilot-cycle` handler runs on a
* checkout-less brain.
*
* Pre-fix jobs.ts defaulted repoPath to cwd `'.'` when no repo was configured,
* then fed that into runCycle — so a queued cycle (what `gbrain remote ping`
* triggers) on a checkout-less postgres brain ran filesystem phases against the
* worker's cwd instead of skipping them. Now it passes `null`, so the handler
* follows the same no_brain_dir contract as `gbrain dream`.
*
* Drives the REAL handler (captured from registerBuiltinHandlers) — not a
* source-grep — so a future refactor that reintroduces the '.' fallback fails
* here. PGLite in-memory.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { registerBuiltinHandlers } from '../src/commands/jobs.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
/** Capture registered handlers via a minimal fake worker. */
async function captureHandlers(): Promise<Map<string, (job: any) => Promise<any>>> {
const handlers = new Map<string, (job: any) => Promise<any>>();
const fakeWorker = { register(name: string, fn: (job: any) => Promise<any>) { handlers.set(name, fn); } };
await registerBuiltinHandlers(fakeWorker as never, engine);
return handlers;
}
describe('jobs autopilot-cycle handler — no repo configured', () => {
test('feeds null brainDir → filesystem phases skip (no_brain_dir), DB phases run', async () => {
const handlers = await captureHandlers();
const handler = handlers.get('autopilot-cycle');
expect(handler).toBeTruthy();
// No sync.repo_path config on a fresh brain, no repoPath in job data →
// the handler must pass null (not cwd '.') to runCycle.
const result = await handler!({
data: { phases: ['lint', 'resolve_symbol_edges'] },
signal: undefined,
});
const report = result.report;
expect(report.brain_dir).toBeNull();
const lint = report.phases.find((p: any) => p.phase === 'lint');
expect(lint?.status).toBe('skipped');
expect(lint?.details?.reason).toBe('no_brain_dir');
const rse = report.phases.find((p: any) => p.phase === 'resolve_symbol_edges');
expect(rse).toBeTruthy();
expect(rse?.details?.reason).not.toBe('no_brain_dir');
expect(rse?.status).not.toBe('fail');
});
});