mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 01:42:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f2397615 |
@@ -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) {
|
||||
|
||||
@@ -1651,7 +1651,7 @@ async function extractTimelineFromDB(
|
||||
* make re-extraction idempotent). EVERY processed page is stamped, including
|
||||
* zero-link pages — they WERE processed.
|
||||
*/
|
||||
async function extractStaleFromDB(
|
||||
export async function extractStaleFromDB(
|
||||
engine: BrainEngine,
|
||||
opts: {
|
||||
dryRun: boolean;
|
||||
|
||||
+39
-1
@@ -1479,7 +1479,31 @@ export async function registerBuiltinHandlers(
|
||||
embedSkipReason = 'auto_embed_disabled';
|
||||
}
|
||||
|
||||
return { ...result, embed_job_id: embedJobId, embed_skip_reason: embedSkipReason };
|
||||
// #2849: large-sync extract deferral follow-up. performSync skips inline
|
||||
// link/timeline extraction when totalChanges > 100, leaving
|
||||
// links_extracted_at unstamped. A standalone sync job (webhook push,
|
||||
// sync trigger) has no autopilot extract phase behind it, so the pages
|
||||
// would stay extraction-stale until a manual `gbrain extract --stale`.
|
||||
// Queue a source-scoped stale sweep instead. Best-effort + idempotent:
|
||||
// a duplicate sweep finds 0 stale pages and no-ops.
|
||||
let extractJobId: number | null = null;
|
||||
if (result.extractDeferred) {
|
||||
try {
|
||||
const { MinionQueue } = await import('../core/minions/queue.ts');
|
||||
const queue = new MinionQueue(engine);
|
||||
const followUp = await queue.add(
|
||||
'extract',
|
||||
{ stale: true, ...(sourceId ? { sourceId } : {}) },
|
||||
{
|
||||
idempotency_key: `sync-extract-stale:${sourceId ?? 'default'}:${Math.floor(Date.now() / 30_000)}`,
|
||||
maxWaiting: 1,
|
||||
},
|
||||
);
|
||||
extractJobId = followUp.id;
|
||||
} catch { /* best-effort: extract --stale sweeps it later */ }
|
||||
}
|
||||
|
||||
return { ...result, embed_job_id: embedJobId, embed_skip_reason: embedSkipReason, extract_stale_job_id: extractJobId };
|
||||
});
|
||||
|
||||
registerBuiltinJob(worker, engine, 'embed', async (job) => {
|
||||
@@ -1652,6 +1676,20 @@ export async function registerBuiltinHandlers(
|
||||
});
|
||||
|
||||
worker.register('extract', async (job) => {
|
||||
// #2849: stale-sweep mode — the sync handler's large-sync deferral
|
||||
// follow-up. DB-source (reads page content from the DB, so it runs on
|
||||
// checkout-less brains), source-scopable, idempotent. Same core as
|
||||
// `gbrain extract --stale`.
|
||||
if (job.data.stale === true) {
|
||||
const { extractStaleFromDB } = await import('./extract.ts');
|
||||
return await extractStaleFromDB(engine, {
|
||||
dryRun: !!job.data.dryRun,
|
||||
jsonMode: false,
|
||||
includeFrontmatter: false,
|
||||
sourceIdFilter: typeof job.data.sourceId === 'string' ? job.data.sourceId : undefined,
|
||||
catchUp: false,
|
||||
});
|
||||
}
|
||||
const { runExtractCore } = await import('./extract.ts');
|
||||
const mode = (typeof job.data.mode === 'string' && ['links', 'timeline', 'all'].includes(job.data.mode))
|
||||
? (job.data.mode as 'links' | 'timeline' | 'all')
|
||||
|
||||
@@ -2146,8 +2146,13 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
// Other event types (ping, pull_request, etc.) return 202 'ignored'
|
||||
// so GitHub doesn't retry.
|
||||
// D15.5: HMAC compare uses the shared safeHexEqual helper.
|
||||
// D18: submits 'sync' job with auto_embed_backfill=true and priority -10
|
||||
// (above autopilot's 0).
|
||||
// D18: submits 'sync' job with extraction + auto_embed_backfill enabled and
|
||||
// priority -10 (above autopilot's 0). noExtract:false opts normal
|
||||
// incremental pushes into sync's inline link/timeline extraction (#2849
|
||||
// — the standalone sync handler defaults noExtract to TRUE, which left
|
||||
// webhook-imported pages permanently stale). Large (>100 file) pushes
|
||||
// defer inline extract; the sync handler queues an extract --stale
|
||||
// follow-up job for that branch.
|
||||
// ---------------------------------------------------------------------------
|
||||
const githubWebhookLimiter = rateLimit({
|
||||
windowMs: 60_000,
|
||||
@@ -2267,6 +2272,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
'sync',
|
||||
{
|
||||
sourceId: source.id,
|
||||
noExtract: false,
|
||||
auto_embed_backfill: true,
|
||||
embed_reason: 'webhook',
|
||||
},
|
||||
|
||||
+19
-1
@@ -222,6 +222,14 @@ export interface SyncResult {
|
||||
* everything," the exact misdiagnosis in the #1794 recurrence report.
|
||||
*/
|
||||
bankedFiles?: number;
|
||||
/**
|
||||
* #2849: true when extraction was REQUESTED (noExtract false) but this sync
|
||||
* skipped inline link/timeline extraction because totalChanges > 100 (the
|
||||
* #1794 large-sync deferral). links_extracted_at stays unstamped for the
|
||||
* imported pages. The standalone `sync` job handler queues a source-scoped
|
||||
* `extract --stale` follow-up when set; CLI runs print the manual hint.
|
||||
*/
|
||||
extractDeferred?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1379,6 +1387,10 @@ See also:
|
||||
{
|
||||
sourceId: sourceIdArg,
|
||||
repoPath: source.local_path,
|
||||
// #2849: opt in to inline extraction — the standalone sync handler
|
||||
// defaults noExtract to TRUE (dedupe for doctor's [sync, extract]
|
||||
// remediation plan), which would leave triggered syncs extraction-stale.
|
||||
noExtract: false,
|
||||
auto_embed_backfill: true,
|
||||
embed_reason: 'sync_trigger',
|
||||
},
|
||||
@@ -3287,11 +3299,16 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
// the stale sweep scans the whole source, so banked-across-runs pages are
|
||||
// covered regardless.
|
||||
const extractOpts = opts.sourceId ? { sourceId: opts.sourceId } : undefined;
|
||||
let extractDeferred = false;
|
||||
if (!opts.noExtract && totalChanges > 100 && pagesAffected.length > 0) {
|
||||
// #2849: surface the deferral to callers. A standalone sync job (webhook
|
||||
// push, sync trigger) has no autopilot extract phase behind it, so the
|
||||
// job handler queues an `extract --stale` follow-up off this flag.
|
||||
extractDeferred = true;
|
||||
slog(
|
||||
` Large sync: deferring link/timeline extraction. ` +
|
||||
`Run 'gbrain extract --stale${opts.sourceId ? ` --source-id ${opts.sourceId}` : ''}' ` +
|
||||
`(or let the autopilot cycle's extract phase sweep it).`,
|
||||
`(sync jobs queue this follow-up automatically).`,
|
||||
);
|
||||
}
|
||||
if (!opts.noExtract && totalChanges <= 100 && pagesAffected.length > 0) {
|
||||
@@ -3400,6 +3417,7 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
chunksCreated,
|
||||
embedded,
|
||||
pagesAffected,
|
||||
extractDeferred,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { safeHexEqual } from '../src/core/timing-safe.ts';
|
||||
|
||||
const GITHUB_SECRET = 'super-secret-webhook-key';
|
||||
@@ -123,3 +124,25 @@ describe('Branch ref construction (D5)', () => {
|
||||
expect(pushedRef === `refs/heads/${trackedBranch}`).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook sync job extraction contract (#2849)', () => {
|
||||
test('opts into extraction before the pushed commit is consumed', () => {
|
||||
const serveSource = readFileSync(
|
||||
new URL('../src/commands/serve-http.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const routeStart = serveSource.indexOf("'/webhooks/github'");
|
||||
const queueStart = serveSource.indexOf('const job = await queue.add(', routeStart);
|
||||
const responseStart = serveSource.indexOf('res.status(202)', queueStart);
|
||||
expect(routeStart).toBeGreaterThanOrEqual(0);
|
||||
expect(queueStart).toBeGreaterThan(routeStart);
|
||||
expect(responseStart).toBeGreaterThan(queueStart);
|
||||
|
||||
const routeSource = serveSource.slice(queueStart, responseStart);
|
||||
const payload = routeSource.match(
|
||||
/queue\.add\(\s*'sync',\s*\{([\s\S]*?)\}\s*,\s*\{/,
|
||||
);
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.[1]).toMatch(/\bnoExtract:\s*false\b/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* #2849 — large-sync extract deferral queues an `extract --stale` follow-up.
|
||||
*
|
||||
* performSync's incremental path skips inline link/timeline extraction when
|
||||
* totalChanges > 100 (the #1794 large-sync deferral), leaving
|
||||
* links_extracted_at unstamped. Pre-fix, a standalone sync job (webhook push,
|
||||
* `gbrain sync trigger`) had NOTHING behind it to sweep those pages — the
|
||||
* autopilot cycle's extract phase only walks that cycle's changedSlugs — so a
|
||||
* large webhook push left extraction permanently stale until a manual
|
||||
* `gbrain extract --stale`.
|
||||
*
|
||||
* Pins:
|
||||
* (a) performSync surfaces `extractDeferred: true` on the >100 branch and
|
||||
* leaves the pages unstamped/unlinked.
|
||||
* (b) the `sync` job handler queues an `extract` job with
|
||||
* { stale: true, sourceId? } when extractDeferred is set.
|
||||
* (c) the `extract` handler's stale mode actually sweeps: links created +
|
||||
* watermark stamped (end-to-end recovery, no manual step).
|
||||
*
|
||||
* Marked .serial.test.ts — spawns git subprocesses + shares one PGLite engine.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { MinionWorker } from '../src/core/minions/worker.ts';
|
||||
import { MinionQueue } from '../src/core/minions/queue.ts';
|
||||
import { registerBuiltinHandlers } from '../src/commands/jobs.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let worker: MinionWorker;
|
||||
let repoPath: string;
|
||||
|
||||
function git(cmd: string): void { execSync(cmd, { cwd: repoPath, stdio: 'pipe' }); }
|
||||
|
||||
describe('#2849 — large sync defers extract and queues a stale sweep', () => {
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
worker = new MinionWorker(engine, { queue: 'test' });
|
||||
await registerBuiltinHandlers(worker, engine, { quiet: true });
|
||||
|
||||
repoPath = mkdtempSync(join(tmpdir(), 'gbrain-large-defer-'));
|
||||
git('git init');
|
||||
git('git config user.email "t@t.com"');
|
||||
git('git config user.name "T"');
|
||||
mkdirSync(join(repoPath, 'people'), { recursive: true });
|
||||
mkdirSync(join(repoPath, 'notes'), { recursive: true });
|
||||
writeFileSync(join(repoPath, 'people/alice.md'), [
|
||||
'---', 'type: person', 'title: Alice', '---', '', 'Alice is a founder.',
|
||||
].join('\n'));
|
||||
git('git add -A && git commit -m "initial"');
|
||||
|
||||
// Seed: full first sync imports the anchor page + sets last_commit.
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
await performSync(engine, { repoPath, full: true, noPull: true, noEmbed: true });
|
||||
|
||||
// Second commit: 101 new pages → incremental totalChanges > 100.
|
||||
for (let i = 0; i < 101; i++) {
|
||||
writeFileSync(join(repoPath, `notes/n${i}.md`), [
|
||||
'---', 'type: note', `title: Note ${i}`, '---', '',
|
||||
`[Alice](people/alice) appears in note ${i}.`,
|
||||
].join('\n'));
|
||||
}
|
||||
git('git add -A && git commit -m "add 101 pages"');
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (repoPath) rmSync(repoPath, { recursive: true, force: true });
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
test('sync handler defers inline extract and queues extract{stale} follow-up; stale sweep recovers', async () => {
|
||||
const syncHandler = (worker as unknown as { handlers: Map<string, (job: unknown) => Promise<unknown>> })
|
||||
.handlers.get('sync');
|
||||
expect(syncHandler).toBeDefined();
|
||||
|
||||
// Same payload shape the webhook submits (minus embed backfill noise).
|
||||
const result = await syncHandler!({
|
||||
data: { repoPath, noExtract: false, noPull: true, auto_embed_backfill: false },
|
||||
signal: { aborted: false },
|
||||
updateProgress: async () => {},
|
||||
}) as { status: string; extractDeferred?: boolean; extract_stale_job_id?: number | null };
|
||||
|
||||
expect(result.status).toBe('synced');
|
||||
// (a) inline extract was deferred, pages left stale.
|
||||
expect(result.extractDeferred).toBe(true);
|
||||
const staleBefore = await engine.countStalePagesForExtraction();
|
||||
expect(staleBefore).toBeGreaterThan(100);
|
||||
expect(await engine.getLinks('notes/n0')).toHaveLength(0);
|
||||
|
||||
// (b) a follow-up extract job with stale:true was queued.
|
||||
expect(result.extract_stale_job_id).toBeGreaterThan(0);
|
||||
const queue = new MinionQueue(engine);
|
||||
const extractJobs = await queue.getJobs({ name: 'extract', limit: 5 });
|
||||
expect(extractJobs.length).toBe(1);
|
||||
expect((extractJobs[0].data as { stale: boolean }).stale).toBe(true);
|
||||
|
||||
// (c) running the extract handler's stale mode recovers: links + stamps.
|
||||
const extractHandler = (worker as unknown as { handlers: Map<string, (job: unknown) => Promise<unknown>> })
|
||||
.handlers.get('extract');
|
||||
await extractHandler!({
|
||||
data: extractJobs[0].data,
|
||||
signal: { aborted: false },
|
||||
updateProgress: async () => {},
|
||||
});
|
||||
const links = await engine.getLinks('notes/n0');
|
||||
expect(links.some(l => l.to_slug === 'people/alice')).toBe(true);
|
||||
const rows = await engine.executeRaw<{ links_extracted_at: string | null }>(
|
||||
`SELECT links_extracted_at FROM pages WHERE slug = 'notes/n0'`,
|
||||
);
|
||||
expect(rows[0]?.links_extracted_at).not.toBeNull();
|
||||
}, 180_000);
|
||||
|
||||
test('sub-threshold sync does NOT set extractDeferred (no spurious follow-up)', async () => {
|
||||
// One more small commit → inline extract path, no deferral.
|
||||
writeFileSync(join(repoPath, 'notes/small.md'), [
|
||||
'---', 'type: note', 'title: Small', '---', '', 'No big deal.',
|
||||
].join('\n'));
|
||||
git('git add -A && git commit -m "one small page"');
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const result = await performSync(engine, { repoPath, noPull: true, noEmbed: true });
|
||||
expect(result.status).toBe('synced');
|
||||
expect(result.extractDeferred).toBeFalsy();
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -100,6 +100,10 @@ describe('runSyncTrigger', () => {
|
||||
const job = jobs[0];
|
||||
expect(job.priority).toBe(-10);
|
||||
expect((job.data as { sourceId: string }).sourceId).toBe('default');
|
||||
// #2849: opt in to inline extraction — the standalone sync handler
|
||||
// defaults noExtract to TRUE, which would leave triggered syncs
|
||||
// extraction-stale.
|
||||
expect((job.data as { noExtract: boolean }).noExtract).toBe(false);
|
||||
expect((job.data as { auto_embed_backfill: boolean }).auto_embed_backfill).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user