mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 10:22:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2247540b4f |
@@ -38,7 +38,6 @@ import { logSelfUpgrade } from '../core/audit/self-upgrade-audit.ts';
|
||||
import { detectInstallMethod } from './upgrade.ts';
|
||||
import { evaluateQuietHours } from '../core/minions/quiet-hours.ts';
|
||||
import { inspectLock } from '../core/db-lock.ts';
|
||||
import { registerCleanup } from '../core/process-cleanup.ts';
|
||||
|
||||
/**
|
||||
* v0.37.7.0 #1162 — classify autopilot reconnect-loop errors.
|
||||
@@ -434,37 +433,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
let stopping = false;
|
||||
let childSupervisor: ChildWorkerSupervisor | null = null;
|
||||
|
||||
// #1872: graceful engine shutdown. On PGLite the cycle steps run INLINE in
|
||||
// this process, so a hard `process.exit` mid-write (systemctl stop →
|
||||
// SIGTERM) kills WASM Postgres with the WAL dirty and can corrupt the
|
||||
// brain. Two exit paths must both close the engine:
|
||||
// - autopilot's own shutdown() below (owns SIGINT + internal stops like
|
||||
// max_crashes / cycle-failure-cap), and
|
||||
// - process-cleanup's SIGTERM handler (installed at cli.ts module load;
|
||||
// it runs the cleanup registry with a 3s deadline and then exits) —
|
||||
// which is why closeEngine is ALSO registered there.
|
||||
// closeEngine aborts the in-flight inline cycle (runCycle checks the
|
||||
// signal between phases and threads it into phase sub-work), gives it a
|
||||
// short bounded window to wind down, then disconnects. PGLite's
|
||||
// disconnect() drains the pending query and checkpoints before closing;
|
||||
// a second call is a no-op (disconnect snapshots + nulls the handle), so
|
||||
// both paths firing is safe.
|
||||
const shutdownAbort = new AbortController();
|
||||
let inflightInlineCycle: Promise<unknown> | null = null;
|
||||
const closeEngine = async () => {
|
||||
shutdownAbort.abort(new Error('autopilot shutdown'));
|
||||
if (inflightInlineCycle) {
|
||||
// ponytail: 2s cap keeps us inside process-cleanup's 3s deadline; a
|
||||
// between-phase abort resolves instantly, a mid-phase one may not.
|
||||
await Promise.race([
|
||||
inflightInlineCycle.catch(() => { /* cycle errors already logged by the loop */ }),
|
||||
new Promise((r) => setTimeout(r, 2_000)),
|
||||
]);
|
||||
}
|
||||
try { await engine.disconnect(); } catch { /* best-effort */ }
|
||||
};
|
||||
const deregisterEngineClose = registerCleanup('autopilot-engine-close', closeEngine);
|
||||
|
||||
if (spawnManagedWorker) {
|
||||
const cliPath = resolveGbrainCliPath();
|
||||
// Cgroup-aware auto-sized RSS watchdog cap (issue #1678). The old flat
|
||||
@@ -552,10 +520,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
childSupervisor.killChild('SIGKILL');
|
||||
}
|
||||
}
|
||||
// #1872: abort the in-flight inline cycle and close the engine BEFORE
|
||||
// process.exit — a hard exit mid-write corrupts PGLite's WASM Postgres.
|
||||
await closeEngine();
|
||||
deregisterEngineClose();
|
||||
try { unlinkSync(lockPath); } catch { /* already gone */ }
|
||||
process.exit(0);
|
||||
};
|
||||
@@ -1044,21 +1008,16 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
|
||||
// path's phase set). Now both converge on the same primitive.
|
||||
try {
|
||||
const { runCycle } = await import('../core/cycle.ts');
|
||||
// #1872: track the promise so closeEngine can drain it on shutdown,
|
||||
// and pass the abort signal so the cycle winds down between phases.
|
||||
const cyclePromise = runCycle(engine, {
|
||||
const report = await runCycle(engine, {
|
||||
brainDir: repoPath,
|
||||
// Autopilot daemon path: pulls by default (matches
|
||||
// pre-v0.17 autopilot behavior). CLI dream defaults false
|
||||
// for cron safety; that choice is scoped to dream only.
|
||||
pull: true,
|
||||
signal: shutdownAbort.signal,
|
||||
yieldBetweenPhases: async () => {
|
||||
await new Promise(r => setImmediate(r));
|
||||
},
|
||||
});
|
||||
inflightInlineCycle = cyclePromise;
|
||||
const report = await cyclePromise.finally(() => { inflightInlineCycle = null; });
|
||||
// Only 'failed' (every attempted phase failed) trips the autopilot
|
||||
// circuit breaker. 'partial' means at least one phase warned or
|
||||
// failed while others ran — that's a soft signal, not a fatal
|
||||
|
||||
+3
-23
@@ -26,7 +26,6 @@
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import {
|
||||
runCycle,
|
||||
resolveSourceForDir,
|
||||
ALL_PHASES,
|
||||
type CyclePhase,
|
||||
type CycleReport,
|
||||
@@ -381,9 +380,9 @@ Options:
|
||||
|
||||
--source <id> Scope the cycle to one source so doctor's
|
||||
cycle_freshness check sees a fresh stamp on
|
||||
completion. When omitted, gbrain derives the
|
||||
source from --dir / the configured checkout
|
||||
when it matches a source's local_path (#1869).
|
||||
completion. Without this, gbrain dream's
|
||||
timestamp never lands and federated brains
|
||||
see "stale cycle" forever.
|
||||
--source-id <id> Alias for --source. Matches the v0.37.7.0+
|
||||
naming used by import/extract/graph-query.
|
||||
|
||||
@@ -635,25 +634,6 @@ export async function runDream(engine: BrainEngine | null, args: string[]): Prom
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// #1869: a path-scoped run (--dir, or the configured sync.repo_path) whose
|
||||
// directory matches a registered source's local_path IS that source's cycle
|
||||
// — derive the source id so runCycle writes last_source_cycle_at /
|
||||
// last_full_cycle_at on success and doctor's cycle_freshness check stops
|
||||
// reading perpetually stale. Explicit --source still wins (resolved above).
|
||||
// Fixed here at the command level, NOT in runCycle's stamp gate, so legacy
|
||||
// global callers (autopilot-global-maintenance runs GLOBAL_PHASES with a
|
||||
// brainDir and no sourceId) can't falsely stamp per-source freshness.
|
||||
// A derived match on an archived source is skipped silently (falls back to
|
||||
// legacy unscoped behavior) — stamping it would mask staleness on restore,
|
||||
// mirroring the explicit --source archived guard above.
|
||||
if (resolvedSourceId === undefined && engine !== null && brainDir !== null) {
|
||||
const derived = await resolveSourceForDir(engine, brainDir);
|
||||
if (derived !== undefined) {
|
||||
const src = await fetchSource(engine, derived);
|
||||
if (src?.archived !== true) resolvedSourceId = derived;
|
||||
}
|
||||
}
|
||||
// ─── issue #1678: bounded single-hold extract_atoms drain ──────────
|
||||
if (opts.drain) {
|
||||
if (engine === null) {
|
||||
|
||||
+42
-12
@@ -161,7 +161,7 @@ interface ResolveAIOptionsArgs {
|
||||
nonInteractive: boolean; // --non-interactive (forces D3 fail-loud, no picker)
|
||||
}
|
||||
|
||||
interface ResolvedAIOptions {
|
||||
export interface ResolvedAIOptions {
|
||||
embedding_model?: string;
|
||||
embedding_dimensions?: number;
|
||||
expansion_model?: string;
|
||||
@@ -170,6 +170,41 @@ interface ResolvedAIOptions {
|
||||
noEmbedding?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed init's AI options from persisted config, falling back to the raw env
|
||||
* vars when loadConfig() returned null (#1058). On a cold install (no
|
||||
* config.json AND no DATABASE_URL) loadConfig short-circuits BEFORE its env
|
||||
* merge, so GBRAIN_EMBEDDING_MODEL / GBRAIN_EMBEDDING_DIMENSIONS /
|
||||
* GBRAIN_EXPANSION_MODEL / GBRAIN_CHAT_MODEL were silently ignored by init
|
||||
* and Tier-3 detection auto-picked by API key instead. Exported for unit
|
||||
* tests (env injectable).
|
||||
*/
|
||||
export function seedAIOptionsFromConfig(
|
||||
cfg: GBrainConfig | null,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ResolvedAIOptions {
|
||||
const envDims = env.GBRAIN_EMBEDDING_DIMENSIONS
|
||||
? parseInt(env.GBRAIN_EMBEDDING_DIMENSIONS, 10)
|
||||
: NaN;
|
||||
const seed = cfg ?? {
|
||||
embedding_disabled: undefined,
|
||||
embedding_model: env.GBRAIN_EMBEDDING_MODEL,
|
||||
embedding_dimensions: Number.isFinite(envDims) ? envDims : undefined,
|
||||
expansion_model: env.GBRAIN_EXPANSION_MODEL,
|
||||
chat_model: env.GBRAIN_CHAT_MODEL,
|
||||
};
|
||||
const out: ResolvedAIOptions = {};
|
||||
if (seed.embedding_disabled) {
|
||||
out.noEmbedding = true;
|
||||
} else if (seed.embedding_model) {
|
||||
out.embedding_model = seed.embedding_model;
|
||||
if (seed.embedding_dimensions) out.embedding_dimensions = seed.embedding_dimensions;
|
||||
}
|
||||
if (seed.expansion_model) out.expansion_model = seed.expansion_model;
|
||||
if (seed.chat_model) out.chat_model = seed.chat_model;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve AI provider options for `gbrain init`.
|
||||
*
|
||||
@@ -203,18 +238,13 @@ async function resolveAIOptions(opts: ResolveAIOptionsArgs): Promise<ResolvedAIO
|
||||
// user already opted into deferred mode.
|
||||
try {
|
||||
const { loadConfig } = await import('../core/config.ts');
|
||||
const cfg = loadConfig();
|
||||
if (cfg?.embedding_disabled) {
|
||||
out.noEmbedding = true;
|
||||
} else if (cfg?.embedding_model) {
|
||||
out.embedding_model = cfg.embedding_model;
|
||||
if (cfg.embedding_dimensions) out.embedding_dimensions = cfg.embedding_dimensions;
|
||||
}
|
||||
if (cfg?.expansion_model) out.expansion_model = cfg.expansion_model;
|
||||
if (cfg?.chat_model) out.chat_model = cfg.chat_model;
|
||||
// #1058: loadConfig() returns null on a cold install (no config.json AND
|
||||
// no DATABASE_URL) — before it ever reaches its env merge. The seed helper
|
||||
// falls back to the same GBRAIN_* env vars directly in that case.
|
||||
Object.assign(out, seedAIOptionsFromConfig(loadConfig()));
|
||||
} catch {
|
||||
// loadConfig throws when no brain configured — first-time install, fall
|
||||
// through to env detection.
|
||||
// loadConfig threw — treat as first-time install, fall through to env
|
||||
// detection.
|
||||
}
|
||||
|
||||
// --- Tier 1+2: explicit flags ---------------------------------------------
|
||||
|
||||
+1
-9
@@ -855,16 +855,8 @@ interface SyncPhaseResult extends PhaseResult {
|
||||
* Resolve the source id for a brain directory by looking up the sources
|
||||
* table. Returns undefined when no registered source matches (falls back
|
||||
* to pre-v0.18 global config.sync.* keys).
|
||||
*
|
||||
* Exported for dream.ts (#1869): a `gbrain dream --dir <path>` run whose
|
||||
* path matches a registered source's local_path is a per-source cycle in
|
||||
* everything but name, so dream derives the source id up front and passes
|
||||
* it as opts.sourceId — landing the freshness stamp without changing
|
||||
* runCycle's stamp/lock semantics for legacy global callers (the
|
||||
* autopilot-global-maintenance handler runs GLOBAL_PHASES with a brainDir
|
||||
* and MUST NOT stamp per-source freshness; see rejected PR #2549).
|
||||
*/
|
||||
export async function resolveSourceForDir(
|
||||
async function resolveSourceForDir(
|
||||
engine: BrainEngine,
|
||||
brainDir: string | null,
|
||||
): Promise<string | undefined> {
|
||||
|
||||
+19
-3
@@ -332,6 +332,15 @@ export interface OperationContext {
|
||||
* remote/untrusted (defense in depth in case the type is bypassed via cast).
|
||||
*/
|
||||
remote: boolean;
|
||||
/**
|
||||
* Transport marker for auth-less remote surfaces (#1061). The stdio MCP
|
||||
* dispatch sets 'stdio' — it is deliberately `remote: true` (agent-facing,
|
||||
* untrusted) but has no per-token auth (local pipe), so identity ops like
|
||||
* whoami need a way to distinguish "known auth-less transport" from "a
|
||||
* transport bug forgot to thread ctx.auth". Trust decisions MUST NOT key
|
||||
* off this field — only `ctx.remote === false` grants trust.
|
||||
*/
|
||||
transport?: 'stdio';
|
||||
/**
|
||||
* Subagent runtime context (v0.16+). Set by the subagent tool dispatcher when
|
||||
* dispatching an op as a tool call from an LLM loop. Used to enforce per-op
|
||||
@@ -3713,9 +3722,10 @@ const whoami: Operation = {
|
||||
'Introspect the calling identity. Returns one of three transport shapes: ' +
|
||||
'{transport: "oauth", client_id, client_name, scopes, expires_at}, ' +
|
||||
'{transport: "legacy", token_name, scopes, expires_at: null}, or ' +
|
||||
'{transport: "local", scopes: []}. Throws unknown_transport when the ' +
|
||||
'context is ambiguous (remote=true without auth) — fail-closed posture ' +
|
||||
'mirroring the v0.26.9 trust-boundary contract.',
|
||||
'{transport: "local", scopes: []}, or {transport: "stdio", scopes: []} ' +
|
||||
'for the auth-less stdio MCP pipe. Throws unknown_transport when the ' +
|
||||
'context is ambiguous (remote=true without auth and no transport marker) ' +
|
||||
'— fail-closed posture mirroring the v0.26.9 trust-boundary contract.',
|
||||
params: {},
|
||||
scope: 'read',
|
||||
handler: async (ctx) => {
|
||||
@@ -3727,6 +3737,12 @@ const whoami: Operation = {
|
||||
if (ctx.remote === false) {
|
||||
return { transport: 'local', scopes: [] };
|
||||
}
|
||||
// #1061: stdio MCP is remote/untrusted by design but has no per-token
|
||||
// auth (local pipe) — a known transport, not a bug. Report it instead of
|
||||
// throwing. Empty scopes: nothing here may be used to gate anything.
|
||||
if (!ctx.auth && ctx.transport === 'stdio') {
|
||||
return { transport: 'stdio', scopes: [] };
|
||||
}
|
||||
if (!ctx.auth) {
|
||||
throw new OperationError(
|
||||
'unknown_transport',
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface DispatchOpts {
|
||||
remote?: boolean;
|
||||
/** Override the default stderr logger (e.g. CLI uses console.* directly). */
|
||||
logger?: OperationContext['logger'];
|
||||
/**
|
||||
* #1061: transport marker for auth-less remote surfaces. The stdio MCP
|
||||
* server passes 'stdio' so identity ops (whoami) can report the transport
|
||||
* instead of throwing unknown_transport. Never used for trust decisions.
|
||||
*/
|
||||
transport?: OperationContext['transport'];
|
||||
/**
|
||||
* v0.28: per-token allow-list for the takes.holder field. Threaded by
|
||||
* the HTTP/stdio transport from `access_tokens.permissions.takes_holders`.
|
||||
@@ -203,6 +209,7 @@ export function buildOperationContext(
|
||||
logger: opts.logger || stderrLogger,
|
||||
dryRun: !!params.dry_run,
|
||||
remote: opts.remote ?? true,
|
||||
transport: opts.transport,
|
||||
takesHoldersAllowList: opts.takesHoldersAllowList,
|
||||
// v0.34 D4: sourceId is REQUIRED at the type level. Auto-fill 'default'
|
||||
// for single-source brains and any caller who didn't resolve a sourceId.
|
||||
|
||||
@@ -42,6 +42,10 @@ export async function startMcpServer(engine: BrainEngine) {
|
||||
// `gbrain call <op>` (sets remote=false in src/cli.ts).
|
||||
return dispatchToolCall(engine, name, params, {
|
||||
remote: true,
|
||||
// #1061: mark the transport so whoami can report {transport: 'stdio'}
|
||||
// instead of throwing unknown_transport. Trust posture unchanged —
|
||||
// stdio stays remote/untrusted.
|
||||
transport: 'stdio',
|
||||
takesHoldersAllowList: ['world'],
|
||||
// v0.31: source defaults to 'default' for stdio (no per-token scope).
|
||||
// Operators who want a different source on stdio MCP should set
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* #1872 — autopilot SIGTERM/SIGINT must close the engine before exit.
|
||||
*
|
||||
* On PGLite the cycle steps run INLINE in the autopilot process, so a hard
|
||||
* `process.exit` mid-write (systemctl stop → SIGTERM) kills WASM Postgres
|
||||
* with the WAL dirty and can corrupt the brain. Two exit paths must both
|
||||
* close the engine:
|
||||
*
|
||||
* - autopilot's own shutdown() (owns SIGINT + internal stops like
|
||||
* max_crashes / cycle-failure-cap), and
|
||||
* - process-cleanup's SIGTERM handler (installed at cli.ts module load,
|
||||
* which exits within its 3s cleanup deadline) — reached via the
|
||||
* registered 'autopilot-engine-close' cleanup callback.
|
||||
*
|
||||
* Because the shutdown path is deep inside `runAutopilot()` (a long-running
|
||||
* daemon loop that ends in process.exit), a behavioral test would have to
|
||||
* spawn + signal a real daemon. Following the established precedent
|
||||
* (test/autopilot-supervisor-wiring.test.ts, test/autopilot-fanout-wiring.test.ts),
|
||||
* these static-shape regressions pin the load-bearing wiring instead.
|
||||
*/
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const AUTOPILOT_SRC = readFileSync(
|
||||
join(import.meta.dir, '..', 'src', 'commands', 'autopilot.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('autopilot.ts graceful engine shutdown (#1872)', () => {
|
||||
it('registers an engine-close callback in the process-cleanup registry (SIGTERM path)', () => {
|
||||
// process-cleanup owns SIGTERM (installed at cli.ts:10) and hard-exits
|
||||
// after its cleanup pass; without this registration the engine is never
|
||||
// closed on `systemctl stop`.
|
||||
expect(AUTOPILOT_SRC).toContain(
|
||||
"import { registerCleanup } from '../core/process-cleanup.ts';",
|
||||
);
|
||||
expect(AUTOPILOT_SRC).toContain(
|
||||
"registerCleanup('autopilot-engine-close', closeEngine)",
|
||||
);
|
||||
});
|
||||
|
||||
it('closeEngine aborts the in-flight inline cycle then disconnects the engine', () => {
|
||||
// Abort first (runCycle checks the signal between phases and threads it
|
||||
// into phase sub-work), bounded drain, then disconnect.
|
||||
expect(AUTOPILOT_SRC).toMatch(
|
||||
/const closeEngine = async \(\) => \{[\s\S]{0,900}shutdownAbort\.abort\([\s\S]{0,900}engine\.disconnect\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('the inline runCycle call carries the shutdown abort signal and is tracked as in-flight', () => {
|
||||
// PGLite / --inline path: the cycle runs in-process, so shutdown must be
|
||||
// able to (a) signal it to wind down and (b) await it before closing.
|
||||
expect(AUTOPILOT_SRC).toMatch(/signal:\s*shutdownAbort\.signal/);
|
||||
expect(AUTOPILOT_SRC).toMatch(/inflightInlineCycle\s*=\s*cyclePromise/);
|
||||
});
|
||||
|
||||
it('shutdown() awaits closeEngine() before process.exit(0) (SIGINT + internal-stop path)', () => {
|
||||
expect(AUTOPILOT_SRC).toMatch(
|
||||
/await closeEngine\(\);[\s\S]{0,400}process\.exit\(0\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* #1869 — `gbrain dream --dir <path>` stamps cycle freshness when the path
|
||||
* matches a registered source's local_path.
|
||||
*
|
||||
* Pre-fix, only `--source <id>` runs wrote last_source_cycle_at /
|
||||
* last_full_cycle_at (runCycle's stamp gate reads opts.sourceId, and dream
|
||||
* never derived one from --dir), so a path-scoped brain showed doctor's
|
||||
* cycle_freshness as perpetually stale.
|
||||
*
|
||||
* The fix lives in dream.ts (derive the source id from the resolved brain
|
||||
* dir via resolveSourceForDir), NOT in runCycle's stamp gate — a runCycle-
|
||||
* wide change would make the autopilot-global-maintenance handler (global
|
||||
* phases, brainDir set, no sourceId) falsely stamp per-source freshness
|
||||
* (the #2194 poisoning class; see rejected PR #2549).
|
||||
*
|
||||
* Same real-PGLite/no-mocks discipline as test/dream.test.ts; same
|
||||
* GBRAIN_HOME isolation as test/cycle-last-full-cycle-at.test.ts (the
|
||||
* cycle's PGLite file lock lives under ~/.gbrain).
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } 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 { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { runDream } from '../src/commands/dream.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let brainDir: string;
|
||||
let gbrainHome: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
brainDir = mkdtempSync(join(tmpdir(), 'gbrain-dream-stamp-'));
|
||||
gbrainHome = mkdtempSync(join(tmpdir(), 'gbrain-dream-stamp-home-'));
|
||||
}, 60_000);
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(brainDir, { recursive: true, force: true });
|
||||
rmSync(gbrainHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedSource(id: string, archived = false): Promise<void> {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
|
||||
VALUES ($1, $2, $3, '{}'::jsonb, $4, NOW())`,
|
||||
[id, id, brainDir, archived],
|
||||
);
|
||||
}
|
||||
|
||||
async function readLastFullCycleAt(sourceId: string): Promise<string | null> {
|
||||
const rows = await engine.executeRaw<{ config: Record<string, unknown> | null }>(
|
||||
`SELECT config FROM sources WHERE id = $1`,
|
||||
[sourceId],
|
||||
);
|
||||
const raw = rows[0]?.config?.last_full_cycle_at;
|
||||
return typeof raw === 'string' ? raw : null;
|
||||
}
|
||||
|
||||
describe('gbrain dream --dir <path> freshness stamp (#1869)', () => {
|
||||
test('--dir matching a source local_path stamps last_full_cycle_at', async () => {
|
||||
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
|
||||
await seedSource('path-scoped');
|
||||
expect(await readLastFullCycleAt('path-scoped')).toBeNull();
|
||||
|
||||
const report = await runDream(engine, ['--dir', brainDir, '--phase', 'lint', '--json']);
|
||||
expect(report).toBeTruthy();
|
||||
if (report) expect(['ok', 'clean']).toContain(report.status);
|
||||
|
||||
// Pre-fix this stays null forever: dream never passed a sourceId, so
|
||||
// runCycle's stamp gate skipped the write.
|
||||
expect(await readLastFullCycleAt('path-scoped')).not.toBeNull();
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('--dir matching an ARCHIVED source does not stamp it', async () => {
|
||||
await withEnv({ GBRAIN_HOME: gbrainHome }, async () => {
|
||||
await seedSource('mothballed', true);
|
||||
|
||||
const report = await runDream(engine, ['--dir', brainDir, '--phase', 'lint', '--json']);
|
||||
expect(report).toBeTruthy();
|
||||
|
||||
// Stamping an archived source would mask data staleness when it is
|
||||
// later restored (mirrors the explicit --source archived guard).
|
||||
expect(await readLastFullCycleAt('mothballed')).toBeNull();
|
||||
});
|
||||
}, 60_000);
|
||||
});
|
||||
+4
-14
@@ -562,22 +562,12 @@ describe('runDream — --source / --source-id (v0.41.13)', () => {
|
||||
|
||||
// ─── Back-compat: bare `gbrain dream` does NOT write per-source stamp ─
|
||||
|
||||
test('gbrain dream (no --source) stamps only the source whose local_path matches --dir (#1869)', async () => {
|
||||
// Pre-#1869 this asserted NO source was ever stamped without an explicit
|
||||
// --source — which is exactly the bug: a path-scoped `gbrain dream --dir`
|
||||
// run never landed a freshness stamp and doctor's cycle_freshness stayed
|
||||
// stale forever. New truth: the source whose local_path matches the
|
||||
// resolved brain dir is derived and stamped; unrelated sources stay
|
||||
// untouched (cross-source isolation).
|
||||
await seedSource('alpha'); // local_path = repo → derived + stamped
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, archived, created_at)
|
||||
VALUES ($1, $2, $3, '{}'::jsonb, false, NOW())`,
|
||||
['beta', 'beta', '/somewhere/else'],
|
||||
);
|
||||
test('gbrain dream (no --source) leaves all sources untouched (back-compat regression)', async () => {
|
||||
await seedSource('alpha');
|
||||
await seedSource('beta');
|
||||
const report = await runDream(engine, ['--dir', repo, '--phase', 'lint', '--json']);
|
||||
expect(report).toBeTruthy();
|
||||
expect(await readLastFullCycleAt('alpha')).not.toBeNull();
|
||||
expect(await readLastFullCycleAt('alpha')).toBeNull();
|
||||
expect(await readLastFullCycleAt('beta')).toBeNull();
|
||||
}, 60_000);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { groupReadyByProvider, findEnvKeyTypos } from '../src/commands/init.ts';
|
||||
import { groupReadyByProvider, findEnvKeyTypos, seedAIOptionsFromConfig } from '../src/commands/init.ts';
|
||||
|
||||
describe('groupReadyByProvider — embedding touchpoint', () => {
|
||||
test('OPENAI_API_KEY alone → openai is ready', async () => {
|
||||
@@ -149,3 +149,47 @@ describe('findEnvKeyTypos', () => {
|
||||
expect(got.find(t => t.userSet === 'COMPLETELY_UNRELATED_KEY')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedAIOptionsFromConfig — #1058 cold-install env fallback', () => {
|
||||
test('null config (no config.json, no DATABASE_URL) falls back to GBRAIN_* env vars', () => {
|
||||
const got = seedAIOptionsFromConfig(null, {
|
||||
GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large',
|
||||
GBRAIN_EMBEDDING_DIMENSIONS: '1024',
|
||||
GBRAIN_EXPANSION_MODEL: 'openai:gpt-5-mini',
|
||||
GBRAIN_CHAT_MODEL: 'anthropic:claude-sonnet-4-6',
|
||||
});
|
||||
expect(got.embedding_model).toBe('voyage:voyage-3-large');
|
||||
expect(got.embedding_dimensions).toBe(1024);
|
||||
expect(got.expansion_model).toBe('openai:gpt-5-mini');
|
||||
expect(got.chat_model).toBe('anthropic:claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
test('null config + no env vars → empty seed (Tier-3 detection takes over)', () => {
|
||||
const got = seedAIOptionsFromConfig(null, {});
|
||||
expect(got).toEqual({});
|
||||
});
|
||||
|
||||
test('persisted config wins (loadConfig already merged env when non-null)', () => {
|
||||
const got = seedAIOptionsFromConfig(
|
||||
{ engine: 'pglite', embedding_model: 'openai:text-embedding-3-small', embedding_dimensions: 1536 } as any,
|
||||
{ GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large' },
|
||||
);
|
||||
expect(got.embedding_model).toBe('openai:text-embedding-3-small');
|
||||
expect(got.embedding_dimensions).toBe(1536);
|
||||
});
|
||||
|
||||
test('embedding_disabled sentinel honored on re-init', () => {
|
||||
const got = seedAIOptionsFromConfig({ engine: 'pglite', embedding_disabled: true } as any, {});
|
||||
expect(got.noEmbedding).toBe(true);
|
||||
expect(got.embedding_model).toBeUndefined();
|
||||
});
|
||||
|
||||
test('non-numeric GBRAIN_EMBEDDING_DIMENSIONS ignored, model still seeds', () => {
|
||||
const got = seedAIOptionsFromConfig(null, {
|
||||
GBRAIN_EMBEDDING_MODEL: 'voyage:voyage-3-large',
|
||||
GBRAIN_EMBEDDING_DIMENSIONS: 'not-a-number',
|
||||
});
|
||||
expect(got.embedding_model).toBe('voyage:voyage-3-large');
|
||||
expect(got.embedding_dimensions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,35 @@ describe('whoami op contract', () => {
|
||||
expect(result.expires_at).toBeNull();
|
||||
});
|
||||
|
||||
// #1061: stdio MCP is remote/untrusted by design but has no per-token auth
|
||||
// (local pipe). The stdio dispatch marks ctx.transport='stdio'; whoami
|
||||
// reports it instead of throwing unknown_transport.
|
||||
test('stdio transport (remote=true, no auth, transport marker) reports stdio', async () => {
|
||||
const result = (await whoami.handler(
|
||||
ctxWith({ remote: true, auth: undefined, transport: 'stdio' }),
|
||||
{},
|
||||
)) as any;
|
||||
expect(result.transport).toBe('stdio');
|
||||
expect(result.scopes).toEqual([]);
|
||||
});
|
||||
|
||||
test('stdio marker does not mask real auth (auth still wins)', async () => {
|
||||
const result = (await whoami.handler(
|
||||
ctxWith({
|
||||
remote: true,
|
||||
transport: 'stdio',
|
||||
auth: {
|
||||
token: 'gbrain_at_xxx',
|
||||
clientId: 'gbrain_cl_abc',
|
||||
scopes: ['read'],
|
||||
expiresAt: 1,
|
||||
} as AuthInfo,
|
||||
}),
|
||||
{},
|
||||
)) as any;
|
||||
expect(result.transport).toBe('oauth');
|
||||
});
|
||||
|
||||
// Q3: ambiguous transport — fail-closed. The footgun this guards against
|
||||
// is a future transport that lands without threading auth, where a buggy
|
||||
// caller might trust whoami's output to gate sensitive ops.
|
||||
|
||||
Reference in New Issue
Block a user