mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
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:
co-authored by
Claude Opus 4.8
parent
6f26d5e4df
commit
248fb7a90f
+105
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -141,5 +141,5 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.41.37.0"
|
||||
"version": "0.41.38.0"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user