mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* fix(core): drain fire-and-forget sinks before disconnect via a background-work registry (#1762) New src/core/background-work.ts registry (Map<name,drainer>, ordered drain, awaited abort). facts-queue (order 0, abort=shutdown), last-retrieved (1), and eval-capture (3, now self-tracked) register as sinks. Both CLI exit paths (op-dispatch finally + handleCliOnly finally) drain the registry before engine.disconnect() so a PGLite db.close() can't race in-flight work into the re-pump busy-loop that pinned the single-writer lock. Op-dispatch error path converts process.exit(1) to exitCode+return so the finally still drains. * fix(ai): bound every outbound AI call so a stalled provider can't hang (#1762/#1775) withDefaultTimeout composes a per-touchpoint default deadline (chat 300s, embed/multimodal 60s) with any caller signal via AbortSignal.any. Applied at the SDK call layer (chat generateText, expand generateObject, OCR, per-sub-batch embed) — covers native-anthropic + retries — plus per-request multimodal fetch. embedQuery forwards abortSignal. Env: GBRAIN_AI_{CHAT,EMBED,MULTIMODAL}_TIMEOUT_MS. * fix(postgres): module-mode reconnect preserves the shared singleton (#1745) reconnect() branches on connection style. Module-singleton engines re-establish idempotently via db.connect() (no-op when alive) + refresh the ConnectionManager read pool, never db.disconnect() — so a transient blip no longer nulls the shared sql out from under concurrent ops (which threw 'connect() has not been called'). Fail-loud on real connect failure. Instance pools keep teardown+recreate. * fix(search): bound the query-time embed so a stall falls back to keyword (#1775) search/query default to cheap-hybrid (embeds the query); a stalled provider made the embed never settle, so the keyword fallback never engaged and the command force-exited with no output. One shared QueryEmbedDeadline (6s, floored 2s per embed) covers both the cache-lookup and inner embeds via embedQueryBounded (abortSignal + Promise.race) → existing keyword fallback engages. Also registers the search-cache background-work drainer (now bounded). Env: GBRAIN_QUERY_EMBED_TIMEOUT_MS. * test+chore: reliability wave tests + v0.42.11.0 (#1762 #1745 #1775) New: background-work registry unit, query-embed deadline unit, eval-capture drain unit, postgres reconnect E2E (#1745), gbrain capture exit-clean case in the PGLite serial test. Updated fix-wave-structural assertions to the registry shape. VERSION/package.json/CHANGELOG -> 0.42.11.0; TODOS retrofit marked done. Incorporates + hardens PR #1763 (drain-before-disconnect + embed fetch timeout); the residual hung-Haiku hole is closed by the facts shutdown() abort belt. Co-Authored-By: ElliotDrel <noreply@github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document background-work registry + v0.42.11.0 reliability wave in CLAUDE.md (regen llms) * chore: bump release version 0.42.11.0 -> 0.42.20.0 Rename the reliability-wave release version per request. Trio (VERSION / package.json / CHANGELOG) reconciled; in-code version-tag comments and test fixtures updated; llms regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ElliotDrel <noreply@github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
11 KiB
TypeScript
203 lines
11 KiB
TypeScript
/**
|
|
* Structural assertions for fix-wave fixes whose behavior is best verified
|
|
* at source-shape level rather than via a runtime harness:
|
|
*
|
|
* - #1125 query drain cache writes — assert cli.ts awaits the drain after
|
|
* the query op completes.
|
|
* - #1090 admin embed — assert the two-tier resolution (cwd path + embedded
|
|
* manifest fallback) is in serve-http.ts and consumes ADMIN_ASSETS.
|
|
* - #1077 admin register-client PKCE — assert the admin endpoint honors
|
|
* grantTypes / redirectUris / tokenEndpointAuthMethod from the body.
|
|
* - #1100 PGLite phaseASchema — assert the v0.11.0 orchestrator routes
|
|
* in-process when the engine is pglite (not via execSync subprocess).
|
|
* - #1124 query no-expand — assert the parseOpArgs negation logic exists.
|
|
*
|
|
* Source-grep regression tests are the right tool when the rule is "this
|
|
* specific line shape must stay present"; a behavioral test would either
|
|
* duplicate what an E2E covers or require heavy mocking that hides the
|
|
* regression behind a test seam.
|
|
*/
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { readFileSync } from 'fs';
|
|
|
|
describe('v0.42.20.0 — search-cache drained via the background-work registry', () => {
|
|
// Supersedes the v0.36.1.x #1125 query-only drain: search-cache now registers
|
|
// a registry drainer (drained for BOTH search and query, bounded), and cli.ts
|
|
// drains the whole registry rather than calling awaitPendingSearchCacheWrites
|
|
// directly for the 'query' op only.
|
|
test('hybrid.ts registers a bounded search-cache drainer', () => {
|
|
const src = readFileSync('src/core/search/hybrid.ts', 'utf8');
|
|
expect(src).toMatch(/export async function awaitPendingSearchCacheWrites/);
|
|
expect(src).toMatch(/pendingCacheWrites\.add\(promise\)/);
|
|
expect(src).toMatch(/trackCacheWrite\(/);
|
|
// Now bounded (was an unbounded Promise.allSettled) + registered.
|
|
expect(src).toMatch(/registerBackgroundWorkDrainer\(\{[\s\S]*?name:\s*'search-cache'/);
|
|
expect(src).toMatch(/Promise\.race/);
|
|
});
|
|
});
|
|
|
|
describe('v0.36.1.x #1090 — admin embed two-tier resolution', () => {
|
|
test('serve-http.ts uses ADMIN_ASSETS manifest when admin/dist is not next to cwd', () => {
|
|
const src = readFileSync('src/commands/serve-http.ts', 'utf8');
|
|
expect(src).toMatch(/import\(['"]\.\.\/admin-embedded/);
|
|
expect(src).toMatch(/ADMIN_ASSETS/);
|
|
expect(src).toMatch(/ADMIN_INDEX_HTML/);
|
|
// Two-tier: dev path (cwd-relative admin/dist) AND embedded manifest fallback
|
|
expect(src).toMatch(/useDevPath/);
|
|
});
|
|
|
|
test('src/admin-embedded.ts is auto-generated with file: imports', () => {
|
|
const src = readFileSync('src/admin-embedded.ts', 'utf8');
|
|
expect(src).toMatch(/AUTO-GENERATED/);
|
|
expect(src).toMatch(/with \{ type: 'file' \}/);
|
|
expect(src).toMatch(/export const ADMIN_ASSETS/);
|
|
expect(src).toMatch(/export const ADMIN_INDEX_HTML/);
|
|
});
|
|
|
|
test('build script + CI guard exist', () => {
|
|
const buildSrc = readFileSync('scripts/build-admin-embedded.ts', 'utf8');
|
|
expect(buildSrc).toMatch(/walk\(DIST/);
|
|
expect(buildSrc).toMatch(/with \{ type: 'file' \}/);
|
|
const guard = readFileSync('scripts/check-admin-embedded.sh', 'utf8');
|
|
expect(guard).toMatch(/git diff --exit-code -- src\/admin-embedded\.ts/);
|
|
});
|
|
});
|
|
|
|
describe('v0.36.1.x #1077 — admin register-client supports PKCE public clients', () => {
|
|
test('admin endpoint reads grantTypes / redirectUris / tokenEndpointAuthMethod from request body', () => {
|
|
const src = readFileSync('src/commands/serve-http.ts', 'utf8');
|
|
// The destructure must surface name / tokenTtl / grantTypes /
|
|
// redirectUris / tokenEndpointAuthMethod from req.body. v0.39.3.0
|
|
// WARN-9 (PR #1308) moved `scopes` to a separate read line that
|
|
// accepts BOTH `scopes` (admin SPA) AND `scope` (OAuth wire singular)
|
|
// via `?? `, so this regex no longer requires `scopes` in the inline
|
|
// destructure — it's separately covered by the scope-source check
|
|
// below.
|
|
expect(src).toMatch(/const\s+\{\s*name,\s*(?:[^}]*?,\s*)?tokenTtl,\s*grantTypes,\s*redirectUris,\s*tokenEndpointAuthMethod\s*\}\s*=\s*req\.body/);
|
|
// v0.39.3.0 WARN-9: the route must still read a `scope`/`scopes` field
|
|
// (under either name) from req.body. Pin the fallback pattern so the
|
|
// PKCE-fix regression contract stays load-bearing.
|
|
expect(src).toMatch(/req\.body[^;]*scopes\s*\?\?\s*[^;]*scope\b/);
|
|
// v0.41.3 (T4 atomicity fix, codex F4): admin endpoint now validates
|
|
// tokenEndpointAuthMethod via the shared validator and passes it to
|
|
// registerClientManual as a positional arg. Pre-v0.41.3 the route did
|
|
// INSERT (confidential) → UPDATE (NULL out secret_hash) for the 'none'
|
|
// case, which left a confidential row stranded if the UPDATE failed.
|
|
// Atomic now: one INSERT writes the correct shape; no post-insert
|
|
// UPDATE block (the regex deliberately asserts the post-insert UPDATE
|
|
// is GONE).
|
|
expect(src).toMatch(/validateTokenEndpointAuthMethod\(tokenEndpointAuthMethod\)/);
|
|
expect(src).toMatch(/registerClientManual\([^)]*validatedAuthMethod[^)]*\)/);
|
|
// Regression guard: post-insert UPDATE flipping client_secret_hash to
|
|
// NULL based on a runtime check is exactly the non-atomic pattern T4
|
|
// killed. Re-introducing it brings back codex F4.
|
|
expect(src).not.toMatch(/UPDATE oauth_clients SET client_secret_hash = NULL, token_endpoint_auth_method = 'none'/);
|
|
});
|
|
});
|
|
|
|
describe('v0.41.37.0 #1605 — v0.11.0 phaseASchema routes in-process for ALL engines', () => {
|
|
test('phaseASchema calls runMigrateOnlyCore (in-process) + is awaited', () => {
|
|
// Supersedes #1100's PGLite-only in-process branch. v0.41.37.0 #1605 routes
|
|
// EVERY engine through runMigrateOnlyCore (no execSync subprocess at all),
|
|
// which is strictly stronger: PGLite still never subprocesses, AND the
|
|
// Windows+Postgres getaddrinfo-ENOTFOUND spawn bug is closed too.
|
|
// The eng.initSchema() call moved into src/commands/migrations/in-process.ts.
|
|
const src = readFileSync('src/commands/migrations/v0_11_0.ts', 'utf8');
|
|
expect(src).toContain('runMigrateOnlyCore()');
|
|
expect(src).not.toContain("execSync('gbrain init --migrate-only'");
|
|
expect(src).toMatch(/await\s+phaseASchema/);
|
|
});
|
|
|
|
test('apply-migrations skips pre-flight schema-version probe on PGLite', () => {
|
|
const src = readFileSync('src/commands/apply-migrations.ts', 'utf8');
|
|
expect(src).toMatch(/skipPreflight\s*=\s*cfg\.engine\s*===\s*'pglite'/);
|
|
});
|
|
});
|
|
|
|
describe('v0.36.1.x #1124 — query --no-expand actually negates expand', () => {
|
|
test("cli.ts parseOpArgs handles --no-<key> as boolean negation", () => {
|
|
const src = readFileSync('src/cli.ts', 'utf8');
|
|
expect(src).toMatch(/arg\.startsWith\(['"]--no-['"]\)/);
|
|
expect(src).toMatch(/positiveDef\?\.type\s*===\s*'boolean'/);
|
|
expect(src).toMatch(/params\[positiveKey\]\s*=\s*false/);
|
|
});
|
|
});
|
|
|
|
describe('v0.42.20.0 — background-work registry drains every sink before disconnect', () => {
|
|
// Supersedes the v0.41.8.0 #1247/#1269/#1290 per-call last-retrieved drain:
|
|
// last-retrieved is now one of four registry sinks; cli.ts drains the whole
|
|
// registry (drainAllBackgroundWorkForCliExit) before disconnect on BOTH the
|
|
// op-dispatch path AND the CLI_ONLY path (the latter closes #1762 for capture).
|
|
test('cli.ts imports + uses drainAllBackgroundWorkForCliExit', () => {
|
|
const src = readFileSync('src/cli.ts', 'utf8');
|
|
expect(src).toMatch(/import\s+\{\s*drainAllBackgroundWorkForCliExit\s*\}\s*from\s+['"]\.\/core\/background-work\.ts['"]/);
|
|
// Two call sites: op-dispatch finally + handleCliOnly finally.
|
|
const calls = src.match(/await\s+drainAllBackgroundWorkForCliExit\s*\(/g) ?? [];
|
|
expect(calls.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
test('last-retrieved.ts still exports the bounded drain + registers a drainer', () => {
|
|
const src = readFileSync('src/core/last-retrieved.ts', 'utf8');
|
|
expect(src).toMatch(/export async function awaitPendingLastRetrievedWrites/);
|
|
expect(src).toMatch(/pendingLastRetrievedWrites\s*=\s*new\s+Set/);
|
|
expect(src).toMatch(/Promise\.race/);
|
|
expect(src).toMatch(/registerBackgroundWorkDrainer\(\{[\s\S]*?name:\s*'last-retrieved'/);
|
|
});
|
|
|
|
test('all four sinks register a drainer', () => {
|
|
expect(readFileSync('src/core/facts/queue.ts', 'utf8'))
|
|
.toMatch(/registerBackgroundWorkDrainer\(\{[\s\S]*?name:\s*'facts'[\s\S]*?abort:/);
|
|
expect(readFileSync('src/core/search/hybrid.ts', 'utf8'))
|
|
.toMatch(/name:\s*'search-cache'/);
|
|
expect(readFileSync('src/core/last-retrieved.ts', 'utf8'))
|
|
.toMatch(/name:\s*'last-retrieved'/);
|
|
expect(readFileSync('src/core/eval-capture.ts', 'utf8'))
|
|
.toMatch(/name:\s*'eval-capture'/);
|
|
});
|
|
|
|
test('cli.ts behavioral positioning: registry drain appears BEFORE engine.disconnect (op-dispatch)', () => {
|
|
const src = readFileSync('src/cli.ts', 'utf8');
|
|
const localPath = src.match(/\/\/ Local engine path \(unchanged behavior[\s\S]+?^\}/m);
|
|
expect(localPath).not.toBeNull();
|
|
const block = localPath![0];
|
|
const drainCallRe = /await\s+drainAllBackgroundWorkForCliExit\s*\(/;
|
|
const disconnectCallRe = /await\s+engine\.disconnect\s*\(/;
|
|
expect(block).toMatch(drainCallRe);
|
|
expect(block).toMatch(disconnectCallRe);
|
|
const drainIdx = block.indexOf(block.match(drainCallRe)![0]);
|
|
const disconnectIdx = block.indexOf(block.match(disconnectCallRe)![0]);
|
|
expect(drainIdx).toBeLessThan(disconnectIdx);
|
|
});
|
|
|
|
test('background-work.ts: Map registry, ordered drain, awaited abort, test seam', () => {
|
|
const src = readFileSync('src/core/background-work.ts', 'utf8');
|
|
expect(src).toMatch(/new\s+Map<string,\s*BackgroundWorkDrainer>/);
|
|
expect(src).toMatch(/sort\(\s*\(a,\s*b\)\s*=>\s*a\.order\s*-\s*b\.order/);
|
|
expect(src).toMatch(/if\s*\(unfinished\s*>\s*0\s*&&\s*d\.abort\)\s*\{[\s\S]*?await\s+d\.abort\(\)/);
|
|
expect(src).toMatch(/export function __registerDrainerForTest/);
|
|
});
|
|
|
|
test('cli-force-exit.ts daemon guard excludes "serve"', () => {
|
|
const src = readFileSync('src/core/cli-force-exit.ts', 'utf8');
|
|
expect(src).toMatch(/export function shouldForceExitAfterMain/);
|
|
expect(src).toMatch(/DAEMON_COMMANDS[\s\S]*serve/);
|
|
});
|
|
});
|
|
|
|
describe('v0.41.8.0 #1340 — PGLite WASM init classifier', () => {
|
|
test('pglite-engine.ts exports classifyPgliteInitError + buildPgliteInitErrorMessage', () => {
|
|
const src = readFileSync('src/core/pglite-engine.ts', 'utf8');
|
|
expect(src).toMatch(/export function classifyPgliteInitError/);
|
|
expect(src).toMatch(/export function buildPgliteInitErrorMessage/);
|
|
// Per Codex finding #9: regex tightened to $$bunfs OR ENOENT+pglite.data
|
|
expect(src).toMatch(/\$\$bunfs/);
|
|
expect(src).toMatch(/ENOENT/);
|
|
});
|
|
|
|
test('pglite-engine.ts connect catch block routes through the classifier', () => {
|
|
const src = readFileSync('src/core/pglite-engine.ts', 'utf8');
|
|
expect(src).toMatch(/classifyPgliteInitError\(original\)/);
|
|
expect(src).toMatch(/buildPgliteInitErrorMessage\(verdict, original\)/);
|
|
});
|
|
});
|