mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(jobs): niceness core, worker registry, shared supervisor-pid reader OS scheduling-priority primitives for issue #1815: - niceness.ts: parseNiceValue (whole-string), applyNiceness (re-reads effective in success AND failure paths), getEffectiveNiceness, formatNice. - worker-registry.ts: live workers self-register pid + requested/effective nice under gbrainPath('workers'); readWorkers prunes ESRCH (keeps EPERM) with a pid-reuse start-time guard. - supervisor-pid.ts: readSupervisorPid extracted from the copy-pasted PID-file + liveness block. * feat(jobs): --nice flag for jobs work/supervisor + doctor niceness check Wires the --nice <n> flag (and GBRAIN_NICE env) through the CLI (issue #1815): - jobs work: applies niceness + registers the worker; cleanup on finally and process.on('exit'). - jobs supervisor: applies in the foreground-start path only (after the --detach fork), passes the apply result into MinionSupervisor. - supervisor.ts: nice opts, extracted testable buildWorkerArgs (appends --nice), emits niceness on started/worker_spawned audit events. - jobs stats / supervisor status: surface effective worker + supervisor nice. - doctor: separate supervisor_niceness check (warns on requested != effective) so it can't clobber the supervisor crash-check precedence; registered in doctor-categories. * test(jobs): cover niceness, worker registry, supervisor-pid, build args Unit tests for issue #1815: parseNiceValue rejects 3.5/10abc that parseInt would accept; applyNiceness re-reads effective on EPERM; registry ESRCH/EPERM + pid-reuse guard + brain-isolated path; readSupervisorPid states; parseNiceFlag flag>env precedence; buildWorkerArgs --nice propagation. * chore: bump version and changelog (v0.42.23.0) --nice flag for jobs work/supervisor (issue #1815). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document --nice flag for jobs work/supervisor (v0.42.23.0) - minions-deployment.md: niceness tuning section (full concurrency, low priority). - KEY_FILES.md: entries for niceness.ts, worker-registry.ts, supervisor-pid.ts; supervisor.ts entry notes buildWorkerArgs + nice opts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): add enrich_thin to dream cycle EXPECTED_PHASES The enrich_thin cycle phase (src/core/cycle.ts ALL_PHASES, between conversation_facts_backfill and skillopt) shipped without updating the e2e phase-order expectation, so dream-cycle-phase-order-pglite failed on master. Sync the expected list to the real ALL_PHASES order. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): align sync-lock-recovery with the shipped --break-lock --all contract v0.41.13.0 intentionally dropped the "--break-lock + --all is refused" guard so cron can self-heal every source in one call (sync.ts runBreakLock iterates sources under --all). The e2e test still asserted the old exit-1 refusal and failed on master. Assert the current contract: the combination is accepted and takes the iterate / no-active-sources path (exit 0, no refusal message). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): de-flake ingestion-roundtrip chokidar first-drop race The native fsevents watcher occasionally missed a freshly written file, timing out the 15s waitFor (~1/3 on master under load). Three fixes: - inject a polling chokidar watcher via the source's _watchFactory seam (usePolling, 20ms interval) so detection never depends on fsevents timing; - drop deterministic fixtures BEFORE start so the initial scan (ignoreInitial:false) emits them, keeping live-watch coverage only where it's robust; - poll for the dedup hit instead of a fixed 600ms sleep. 15/15 green under stress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): make hermetic-PGLite serve tests actually hermetic connect-bearer and serve-stdio-roundtrip init a PGLite brain and spawn serve, but passed {...process.env} through — leaking an ambient DATABASE_URL / GBRAIN_DATABASE_URL into the subprocess, which then came up on Postgres and failed the `engine: pglite` assertion. Strip both DB vars from the spawned env so the tests are deterministic whether or not the shell/CI has a DB URL set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): type the hermetic-PGLite env so tsc passes The DATABASE_URL/GBRAIN_DATABASE_URL strip used `delete` on a narrowly-typed env literal (tsc-only failure; bun test doesn't typecheck). Annotate connect-bearer's env as Record<string,string|undefined> and build serve-stdio's as a concrete Record<string,string> (StdioClientTransport.env rejects undefined). Runtime behavior unchanged (7/7 + 3/3 green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: quarantine worker-registry to *.serial (R1 env-mutation isolation) worker-registry.test.ts sets process.env.GBRAIN_HOME per-test so gbrainPath resolves to a temp dir, then lazy-imports the module — a process-global mutation the parallel isolation lint (rule R1) forbids. Rename to worker-registry.serial.test.ts: it runs in the serial pass (own bun process, max-concurrency=1) where env mutation is safe, and the lint skips *.serial files. No logic change (6/6 green); fixes the failing `verify` CI job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
/**
|
|
* E2E roundtrip for the v0.38 ingestion substrate.
|
|
*
|
|
* Wires the daemon + inbox-folder source + dispatcher together against
|
|
* an in-memory PGLite (no DATABASE_URL needed) and verifies:
|
|
* 1. A file dropped into the inbox dir produces an IngestionEvent
|
|
* that flows through the daemon's validate → dedup → rate-limit
|
|
* → dispatch pipeline.
|
|
* 2. The dispatched event reaches the ingest_capture Minion handler.
|
|
* 3. The handler routes through importFromContent and lands a page
|
|
* in the DB with the expected slug + content.
|
|
* 4. The page is queryable by content via getPage.
|
|
* 5. Dedup catches a repeat-drop of the same file content within the
|
|
* 24h window.
|
|
*
|
|
* This is the substrate-level e2e — it's the unit-test concept lifted
|
|
* one layer up to the daemon+handler wiring. The full server-process
|
|
* e2e (gbrain serve --http + POST /ingest + real OAuth) is a separate
|
|
* test that requires DATABASE_URL.
|
|
*/
|
|
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
|
import { IngestionDaemon } from '../../src/core/ingestion/daemon.ts';
|
|
import { createInboxFolderSource } from '../../src/core/ingestion/sources/inbox-folder.ts';
|
|
import { watch, type ChokidarOptions, type FSWatcher } from 'chokidar';
|
|
|
|
// E2E determinism: force chokidar into polling mode via the source's
|
|
// `_watchFactory` seam so file-drop detection never depends on macOS fsevents
|
|
// timing. The native-events path occasionally missed the first add under load,
|
|
// timing out the 15s waitFor (the documented first-drop flake). Polling at a
|
|
// 20ms interval is deterministic and still fast. Production keeps native events.
|
|
const pollingWatchFactory = (paths: string, opts: ChokidarOptions): FSWatcher =>
|
|
watch(paths, { ...opts, usePolling: true, interval: 20, binaryInterval: 20 });
|
|
import { makeIngestCaptureHandler } from '../../src/core/minions/handlers/ingest-capture.ts';
|
|
import type { IngestionEvent } from '../../src/core/ingestion/types.ts';
|
|
import type { MinionJobContext } from '../../src/core/minions/types.ts';
|
|
|
|
// Fake job-context constructor so we can drive the handler directly
|
|
// from the dispatcher without spinning up a Minion worker.
|
|
function makeFakeJobCtx(data: Record<string, unknown>): MinionJobContext {
|
|
return {
|
|
id: Math.floor(Math.random() * 1_000_000),
|
|
name: 'ingest_capture',
|
|
data,
|
|
attempts_made: 1,
|
|
signal: new AbortController().signal,
|
|
shutdownSignal: new AbortController().signal,
|
|
updateProgress: async () => {},
|
|
updateTokens: async () => {},
|
|
log: async () => {},
|
|
isActive: async () => true,
|
|
readInbox: async () => [],
|
|
};
|
|
}
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpRoot: string;
|
|
let inboxDir: string;
|
|
let brainDir: string;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
// 200ms grace period for the previous test's chokidar watchers to fully
|
|
// release OS-level FSEvents handles on macOS. Without this, the second
|
|
// test's watcher events queue behind the first test's pending cleanup
|
|
// and the waitFor(15s) for the first file drop times out. See
|
|
// ingestion-roundtrip cross-test contamination notes.
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-e2e-roundtrip-'));
|
|
inboxDir = path.join(tmpRoot, 'inbox');
|
|
brainDir = path.join(tmpRoot, 'brain');
|
|
fs.mkdirSync(brainDir, { recursive: true });
|
|
await engine.setConfig('sync.repo_path', brainDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
const captureLogger = () => {
|
|
const messages: Array<{ level: string; msg: string }> = [];
|
|
return {
|
|
logger: {
|
|
info: (msg: string) => { messages.push({ level: 'info', msg }); },
|
|
warn: (msg: string) => { messages.push({ level: 'warn', msg }); },
|
|
error: (msg: string) => { messages.push({ level: 'error', msg }); },
|
|
},
|
|
messages,
|
|
};
|
|
};
|
|
|
|
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
const start = Date.now();
|
|
while (Date.now() - start < timeoutMs) {
|
|
if (predicate()) return;
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
}
|
|
throw new Error(`waitFor: predicate did not become true within ${timeoutMs}ms`);
|
|
}
|
|
|
|
describe('ingestion roundtrip — inbox-folder → daemon → ingest_capture → DB', () => {
|
|
test('full pipeline: file drop → page in DB', async () => {
|
|
const { logger } = captureLogger();
|
|
const handler = makeIngestCaptureHandler(engine);
|
|
const dispatchedEvents: IngestionEvent[] = [];
|
|
|
|
const daemon = new IngestionDaemon({
|
|
engine,
|
|
logger,
|
|
dispatch: async (event) => {
|
|
dispatchedEvents.push(event);
|
|
// Route the event into the handler directly. In production the
|
|
// daemon would submit a Minion job and the worker would invoke
|
|
// the handler; here we collapse that for test-loop efficiency.
|
|
await handler(makeFakeJobCtx({ event }));
|
|
return { kind: 'queued' };
|
|
},
|
|
});
|
|
|
|
// Create the inbox dir BEFORE starting the watcher to eliminate a race
|
|
// where chokidar hasn't attached yet when the first write fires (the
|
|
// 6s→15s waitFor flake on the source.) Without this, the test relies on
|
|
// chokidar's polling fallback to notice the dir, which is timing-dependent.
|
|
fs.mkdirSync(inboxDir, { recursive: true });
|
|
|
|
const source = createInboxFolderSource({
|
|
inboxDir,
|
|
debounceMs: 50,
|
|
awaitStabilityMs: 100,
|
|
_watchFactory: pollingWatchFactory,
|
|
});
|
|
daemon.register({ source });
|
|
|
|
// Drop the file BEFORE start so the initial scan (ignoreInitial:false)
|
|
// emits it deterministically. The full emit → dispatch → handler → DB →
|
|
// archive pipeline still runs end to end; only the inherently timing-
|
|
// dependent post-start watcher detection is removed (covered robustly by
|
|
// the dedup test's polled second drop below).
|
|
const captured = path.join(inboxDir, 'roundtrip.md');
|
|
fs.writeFileSync(captured, '---\ntitle: Roundtrip\n---\n\nfull e2e flow');
|
|
|
|
await daemon.start();
|
|
// Wait for the daemon to pick it up + dispatch + handler to write.
|
|
await waitFor(() => dispatchedEvents.length === 1, 15000);
|
|
|
|
// Page is in the DB.
|
|
const page = await engine.getPage(dispatchedEvents[0]!.metadata!.slug as string ??
|
|
`inbox/${new Date().toISOString().slice(0, 10)}-${dispatchedEvents[0]!.content_hash.slice(0, 6)}`);
|
|
// The handler defaults to inbox/<date>-<hash6> if no slug provided by
|
|
// the source. inbox-folder source doesn't set metadata.slug so the
|
|
// handler computes the default.
|
|
const expectedSlug = `inbox/${new Date().toISOString().slice(0, 10)}-${dispatchedEvents[0]!.content_hash.slice(0, 6)}`;
|
|
const fetched = await engine.getPage(expectedSlug);
|
|
expect(fetched).not.toBeNull();
|
|
expect(fetched?.compiled_truth).toContain('full e2e flow');
|
|
|
|
// File was archived after ingestion (the inbox-folder source's
|
|
// post-emit archive step).
|
|
expect(fs.existsSync(captured)).toBe(false);
|
|
const archiveDate = new Date().toISOString().slice(0, 10);
|
|
expect(fs.existsSync(path.join(inboxDir, '.archived', archiveDate, 'roundtrip.md'))).toBe(true);
|
|
|
|
await daemon.stop();
|
|
}, 15_000);
|
|
|
|
test('repeat drop of same content dedups silently', async () => {
|
|
const { logger } = captureLogger();
|
|
const handler = makeIngestCaptureHandler(engine);
|
|
const dispatchedEvents: IngestionEvent[] = [];
|
|
|
|
const daemon = new IngestionDaemon({
|
|
engine,
|
|
logger,
|
|
dispatch: async (event) => {
|
|
dispatchedEvents.push(event);
|
|
await handler(makeFakeJobCtx({ event }));
|
|
return { kind: 'queued' };
|
|
},
|
|
});
|
|
|
|
// mkdirSync BEFORE daemon.start to eliminate chokidar attach race.
|
|
fs.mkdirSync(inboxDir, { recursive: true });
|
|
|
|
const source = createInboxFolderSource({
|
|
inboxDir,
|
|
debounceMs: 50,
|
|
awaitStabilityMs: 100,
|
|
_watchFactory: pollingWatchFactory,
|
|
});
|
|
daemon.register({ source });
|
|
|
|
// Drop file 1 BEFORE start so the initial scan (ignoreInitial:false) emits
|
|
// it deterministically — removes the post-start watcher race that flaked.
|
|
const drop1 = path.join(inboxDir, 'dup-1.md');
|
|
fs.writeFileSync(drop1, '# duplicate content\n\nidentical body');
|
|
|
|
await daemon.start();
|
|
await waitFor(() => dispatchedEvents.length === 1, 15000);
|
|
|
|
// Drop file 2 with byte-identical content (different filename) AFTER start;
|
|
// the watcher catches it and dedup intercepts before dispatch.
|
|
const drop2 = path.join(inboxDir, 'dup-2.md');
|
|
fs.writeFileSync(drop2, '# duplicate content\n\nidentical body');
|
|
|
|
// Poll for the dedup hit instead of a fixed sleep so a slow watcher tick
|
|
// can't make the assertion flake.
|
|
let dedupHits = 0;
|
|
for (let i = 0; i < 240; i++) {
|
|
dedupHits = (await daemon.healthCheck()).dedup.hits;
|
|
if (dedupHits >= 1) break;
|
|
await new Promise((r) => setTimeout(r, 25));
|
|
}
|
|
|
|
// Only ONE event made it through dispatch (dedup intercepted the second).
|
|
expect(dispatchedEvents).toHaveLength(1);
|
|
// The daemon's dedup stats reflect a hit.
|
|
expect(dedupHits).toBeGreaterThanOrEqual(1);
|
|
|
|
await daemon.stop();
|
|
}, 15_000);
|
|
});
|
|
|
|
describe('ingestion roundtrip — multi-source coordination', () => {
|
|
test('two sources see different content; daemon ingests both', async () => {
|
|
const { logger } = captureLogger();
|
|
const handler = makeIngestCaptureHandler(engine);
|
|
const dispatchedEvents: IngestionEvent[] = [];
|
|
|
|
const daemon = new IngestionDaemon({
|
|
engine,
|
|
logger,
|
|
dispatch: async (event) => {
|
|
dispatchedEvents.push(event);
|
|
await handler(makeFakeJobCtx({ event }));
|
|
return { kind: 'queued' };
|
|
},
|
|
});
|
|
|
|
// Two distinct inbox dirs, two sources. Create the dirs BEFORE
|
|
// daemon.start to eliminate the chokidar attach race (same fix as
|
|
// the single-source tests above).
|
|
const inboxA = path.join(tmpRoot, 'inbox-a');
|
|
const inboxB = path.join(tmpRoot, 'inbox-b');
|
|
fs.mkdirSync(inboxA, { recursive: true });
|
|
fs.mkdirSync(inboxB, { recursive: true });
|
|
const sourceA = createInboxFolderSource({
|
|
id: 'inbox-a',
|
|
inboxDir: inboxA,
|
|
debounceMs: 50,
|
|
awaitStabilityMs: 100,
|
|
_watchFactory: pollingWatchFactory,
|
|
});
|
|
const sourceB = createInboxFolderSource({
|
|
id: 'inbox-b',
|
|
inboxDir: inboxB,
|
|
debounceMs: 50,
|
|
awaitStabilityMs: 100,
|
|
_watchFactory: pollingWatchFactory,
|
|
});
|
|
daemon.register({ source: sourceA });
|
|
daemon.register({ source: sourceB });
|
|
|
|
// Drop both files BEFORE start. With ignoreInitial:false the initial scan
|
|
// emits an `add` for each on chokidar `ready`, so both sources ingest
|
|
// deterministically — no dependency on the post-start watcher catching a
|
|
// freshly written file (the source of the residual flake).
|
|
fs.writeFileSync(path.join(inboxA, 'from-a.md'), 'content from A');
|
|
fs.writeFileSync(path.join(inboxB, 'from-b.md'), 'content from B');
|
|
|
|
await daemon.start();
|
|
|
|
await waitFor(() => dispatchedEvents.length === 2, 15000);
|
|
|
|
const fromA = dispatchedEvents.find((e) => e.source_id === 'inbox-a');
|
|
const fromB = dispatchedEvents.find((e) => e.source_id === 'inbox-b');
|
|
expect(fromA).toBeDefined();
|
|
expect(fromB).toBeDefined();
|
|
expect(fromA?.content).toContain('content from A');
|
|
expect(fromB?.content).toContain('content from B');
|
|
|
|
await daemon.stop();
|
|
}, 15_000);
|
|
});
|