Compare commits

..
Author SHA1 Message Date
08f2397615 fix(webhook): extract links for incremental push syncs (#2849)
GitHub webhook + sync-trigger 'sync' jobs never opted into extraction:
the standalone sync handler defaults noExtract to TRUE, so webhook-
imported pages advanced the source bookmark with links_extracted_at
unstamped — and nothing behind a standalone job ever swept them (the
autopilot cycle's extract phase only walks that cycle's changedSlugs).

Fix, both halves of the issue:
- webhook + sync-trigger payloads pass noExtract:false so normal
  incremental pushes get sync's inline link/timeline extraction.
- >100-file deferral branch: performSync surfaces extractDeferred, and
  the sync job handler queues a source-scoped extract{stale:true}
  follow-up job (idempotent sweep; extract handler gains a stale mode
  backed by the same extractStaleFromDB core as `gbrain extract --stale`).

Tests: new sync-large-deferral-extract.serial.test.ts drives a real
101-file incremental sync end-to-end (deferral flag → queued follow-up →
stale sweep recovers links + watermark); webhook payload contract test +
sync-trigger noExtract assertion salvaged from #2850.

Fixes #2849
Takeover of #2850

Co-authored-by: patentsong <patentsong@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:38:43 -07:00
13 changed files with 235 additions and 262 deletions
+3 -5
View File
@@ -223,16 +223,14 @@ export GBRAIN_REMOTE_CLIENT_ID=<Alice's client_id>
export GBRAIN_REMOTE_CLIENT_SECRET=<Alice's client_secret>
export GBRAIN_REMOTE_MCP_URL=https://brain.acme-co.com/mcp
gbrain search "performance review"
gbrain search "performance review" --remote
```
(On a thin-client install every shared op routes through the remote MCP server automatically — no flag needed. The env vars select whose credentials the call uses.)
Alice should see results only from `customers` and `shared`. The performance-review notes live in `internal`, which she's not scoped to read. She shouldn't see them.
```bash
# Terminal 2, as Bob (export his credentials similarly)
gbrain search "performance review"
gbrain search "performance review" --remote
```
Bob should see the performance-review notes from `internal`, plus anything related from `shared`. He shouldn't see anything that lives only in `customers`.
@@ -516,7 +514,7 @@ The first sync embeds every page, which takes time. Check `gbrain sources status
### "I see a page I shouldn't see"
This shouldn't happen, but if you suspect it, run `gbrain search <query> --json` as the constrained client (thin-client install, with the client's `GBRAIN_REMOTE_*` env exported) and inspect the `source_id` field on every returned result. Every row should be in the client's `--federated-read` set. If one isn't, file an issue with the exact slug and source IDs.
This shouldn't happen, but if you suspect it, run `gbrain search <query> --remote --json` as the constrained client and inspect the `source_id` field on every returned result. Every row should be in the client's `--federated-read` set. If one isn't, file an issue with the exact slug and source IDs.
### "The synthesized answer is wrong"
+5 -84
View File
@@ -464,11 +464,7 @@ async function main() {
// routed path. Date → ISO string; bigint → string (postgres.js shape);
// Buffer → object. Microsecond-cost; eliminates a whole drift bug class.
const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
// #380 pass-through: `--json` (undeclared on most ops, promised by docs)
// emits the raw op result instead of the human formatter.
const output = params.json === true
? JSON.stringify(result, null, 2) + '\n'
: formatResult(op.name, result);
const output = formatResult(op.name, result);
if (output) process.stdout.write(output);
} catch (e: unknown) {
// v0.42.20.0 (codex D4): on error, set exitCode + return so the `finally`
@@ -551,10 +547,7 @@ async function runThinClientRouted(
signal: sigintController.signal,
});
const result = unpackToolResult(raw);
// #380: same --json seam as the local-engine path (renderer parity).
const output = params.json === true
? JSON.stringify(result, null, 2) + '\n'
: formatResult(op.name, result);
const output = formatResult(op.name, result);
if (output) process.stdout.write(output);
} catch (e: unknown) {
if (e instanceof RemoteMcpError) {
@@ -764,28 +757,10 @@ export function resolveQueryImage(
return { path: imagePath, base64, mime };
}
/**
* #380: undeclared flags that are honored DOWNSTREAM of parseOpArgs and must
* keep passing through when unknown flags become hard errors:
* - source → makeContext's resolveSourceId (the --source axis)
* - brain → the mount/brain routing axis (docs promise the flag)
* - dry_run → makeContext's ctx.dryRun (ops without a declared dry_run)
* - json → raw-JSON output seam (local + thin-client paths)
*/
const PASSTHROUGH_VALUE_FLAGS = new Set(['source', 'brain']);
const PASSTHROUGH_BOOL_FLAGS = new Set(['dry_run', 'json']);
export function parseOpArgs(op: Operation, args: string[]): Record<string, unknown> {
const params: Record<string, unknown> = {};
const positional = op.cliHints?.positional || [];
let posIdx = 0;
const cliName = op.cliHints?.name || op.name;
const MAX_STDIN = 5_000_000; // 5MB cap, shared by stdin and --file
// #380: `--file <path>` fills the op's declared stdin param (put's `content`)
// from a file. Driven by cliHints.stdin — no per-op hard-coding — and
// disabled when the op declares a real `file` param of its own.
const fileParam = op.cliHints?.stdin && !op.params.file ? op.cliHints.stdin : undefined;
let filePath: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@@ -799,42 +774,12 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
}
}
const key = arg.slice(2).replace(/-/g, '_');
if (fileParam && key === 'file') {
if (i + 1 >= args.length) {
console.error(`Error: ${arg} requires a value.`);
process.exit(1);
}
filePath = args[++i];
continue;
}
const paramDef = op.params[key];
if (!paramDef) {
if (PASSTHROUGH_BOOL_FLAGS.has(key)) {
params[key] = true;
continue;
}
if (PASSTHROUGH_VALUE_FLAGS.has(key)) {
if (i + 1 >= args.length) {
console.error(`Error: ${arg} requires a value.`);
process.exit(1);
}
params[key] = args[++i];
continue;
}
// #380: unknown flags were silently swallowed into params, so typos
// like `put --file` created empty pages instead of erroring.
console.error(`Unknown option for gbrain ${cliName}: ${arg}`);
console.error(`Run 'gbrain ${cliName} --help' for valid flags.`);
process.exit(1);
}
if (paramDef.type === 'boolean') {
if (paramDef?.type === 'boolean') {
params[key] = true;
} else if (i + 1 < args.length) {
params[key] = args[++i];
if (paramDef.type === 'number') params[key] = Number(params[key]);
} else {
console.error(`Error: ${arg} requires a value.`);
process.exit(1);
if (paramDef?.type === 'number') params[key] = Number(params[key]);
}
} else if (posIdx < positional.length) {
const key = positional[posIdx++];
@@ -843,30 +788,10 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
}
}
// #380: resolve --file AFTER the loop so --file/--content conflicts are
// caught in either order.
if (filePath !== undefined && fileParam) {
if (params[fileParam] !== undefined) {
console.error(`Error: use only one of --file, --${fileParam}, or stdin for gbrain ${cliName}.`);
process.exit(1);
}
let fileContent: string;
try {
fileContent = readFileSync(filePath, 'utf-8');
} catch (e) {
console.error(`Error: cannot read --file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
}
if (Buffer.byteLength(fileContent, 'utf-8') > MAX_STDIN) {
console.error(`Error: file content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
}
params[fileParam] = fileContent;
}
// Read stdin for content params
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
const stdinContent = readFileSync(0, 'utf-8');
const MAX_STDIN = 5_000_000; // 5MB
if (Buffer.byteLength(stdinContent, 'utf-8') > MAX_STDIN) {
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
@@ -2328,10 +2253,6 @@ export function printOpHelp(op: Operation, invokedName?: string) {
const prefix = isPos ? ` <${key}>` : ` --${key.replace(/_/g, '-')}`;
console.log(`${prefix.padEnd(28)} ${def.description || ''}${req}`);
}
// #380: ops that read stdin also accept --file <path> (parseOpArgs).
if (op.cliHints?.stdin && !op.params.file) {
console.log(`${' --file <path>'.padEnd(28)} Read ${op.cliHints.stdin} from a file (alternative to --${op.cliHints.stdin} or stdin)`);
}
}
}
+1 -1
View File
@@ -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
View File
@@ -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')
+8 -2
View File
@@ -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
View File
@@ -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,
};
}
+2 -9
View File
@@ -769,7 +769,7 @@ const get_page: Operation = {
const put_page: Operation = {
name: 'put_page',
description: 'Write/update a page (markdown with frontmatter). Chunks, embeds, reconciles tags, and (when auto_link/auto_timeline are enabled) extracts + reconciles graph links and timeline entries. On the CLI, `gbrain put SLUG --file PATH` reads content from a file (also `--content` or stdin). For provenance write-through and a binary-NUL guard, prefer `gbrain capture --file PATH --slug SLUG` (v0.39.3.0).',
description: 'Write/update a page (markdown with frontmatter). Chunks, embeds, reconciles tags, and (when auto_link/auto_timeline are enabled) extracts + reconciles graph links and timeline entries. For large content on Windows (pipe-buffer limit ~45KB) or any file-as-input workflow, use `gbrain capture --file PATH --slug SLUG` — capture reads the file as a Buffer with a binary-NUL guard and adds provenance write-through (v0.39.3.0).',
params: {
slug: { type: 'string', required: true, description: 'Page slug' },
content: { type: 'string', required: true, description: 'Full markdown content with YAML frontmatter' },
@@ -1384,10 +1384,7 @@ const list_pages: Operation = {
params: {
type: { type: 'string', description: 'Filter by page type' },
tag: { type: 'string', description: 'Filter by tag' },
limit: { type: 'number', description: 'Max results (default 50, capped at 100 — use offset to paginate beyond)' },
// #2876: the 100-row cap was silent and there was no way past it even
// though both engines already support OFFSET on listPages.
offset: { type: 'number', description: 'Skip first N results (pagination; pair with limit)' },
limit: { type: 'number', description: 'Max results (default 50)' },
// v0.29 — surface filter that already exists on PageFilters.
updated_after: {
type: 'string',
@@ -1418,10 +1415,6 @@ const list_pages: Operation = {
type: p.type as any,
tag: p.tag as string,
limit: clampSearchLimit(p.limit as number | undefined, 50, 100),
// #2876: thread pagination through (engines already honor offset).
offset: Number.isFinite(p.offset as number) && (p.offset as number) > 0
? Math.floor(p.offset as number)
: undefined,
includeDeleted: (p.include_deleted as boolean) === true,
updated_after: typeof p.updated_after === 'string' ? p.updated_after : undefined,
sort,
-33
View File
@@ -1,41 +1,8 @@
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { parseOpArgs } from '../src/cli.ts';
import { operationsByName } from '../src/core/operations.ts';
describe('parseOpArgs', () => {
// #380: `gbrain put SLUG --file PATH` reads content from the file instead
// of silently swallowing the flag and creating an empty page.
test('put --file reads the stdin param (content) from a file', () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-put-file-'));
try {
const pagePath = join(dir, 'page.md');
writeFileSync(pagePath, '# From file\n\nBody loaded from --file.\n');
const params = parseOpArgs(operationsByName.put_page, ['concepts/from-file', '--file', pagePath]);
expect(params.slug).toBe('concepts/from-file');
expect(params.content).toBe('# From file\n\nBody loaded from --file.\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// #380 regression guard: undeclared-but-honored flags must keep passing
// through when unknown flags become hard errors (--source is read by
// makeContext; --json by the output seam; --dry-run by ctx.dryRun).
test('pass-through allowlist flags survive on ops that do not declare them', () => {
const params = parseOpArgs(operationsByName.get_page, [
'people/alice-example', '--source', 'wiki', '--json', '--dry-run',
]);
expect(params).toEqual({
slug: 'people/alice-example',
source: 'wiki',
json: true,
dry_run: true,
});
});
test('--no-<boolean> maps to false without consuming the next flag', () => {
const params = parseOpArgs(operationsByName.query, [
'freshEmbedSourceScope code source',
+1 -71
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
@@ -120,76 +120,6 @@ describe('CLI dispatch integration', () => {
expect(exitCode).toBe(0);
});
// #380 / PR #856: put --help documents the --file input path.
test('put --help documents --file input', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'put', '--help'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(stdout).toContain('Usage: gbrain put');
expect(stdout).toContain('--file <path>');
expect(exitCode).toBe(0);
});
// #380: unknown flags on shared ops are a hard error (previously silently
// swallowed into params — `put --file` created empty pages). parseOpArgs
// runs BEFORE engine connect, so the error must fire without a brain.
test('unknown shared-op flags fail before DB connection', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-unknown-flag-'));
try {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'get', 'people/alice', '--bogus'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
env: isolatedEnv(home),
});
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('Unknown option for gbrain get: --bogus');
expect(stderr).not.toContain('No brain configured');
expect(exitCode).toBe(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('put rejects combining --file and --content', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-put-conflict-'));
try {
const pagePath = join(home, 'page.md');
writeFileSync(pagePath, 'file body\n');
const proc = Bun.spawn(
['bun', 'run', 'src/cli.ts', 'put', 'a/b', '--content', 'inline', '--file', pagePath],
{ cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', env: isolatedEnv(home) },
);
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('use only one of --file, --content, or stdin');
expect(exitCode).toBe(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('put --file with a missing path errors instead of writing an empty page', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-put-missing-file-'));
try {
const proc = Bun.spawn(
['bun', 'run', 'src/cli.ts', 'put', 'a/b', '--file', join(home, 'nope.md')],
{ cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', env: isolatedEnv(home) },
);
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('cannot read --file');
expect(exitCode).toBe(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('upgrade --help prints usage without running upgrade', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'upgrade', '--help'], {
cwd: repoRoot,
-55
View File
@@ -1,55 +0,0 @@
import { describe, test, expect } from 'bun:test';
import { operationsByName } from '../src/core/operations.ts';
/**
* #2876: `gbrain list --limit` silently clamped at 100 with no pagination.
* list_pages now declares `offset` (both engines already supported it on
* PageFilters) and the limit description discloses the 100-row cap.
*/
describe('list_pages pagination (#2876)', () => {
const listPagesOp = operationsByName.list_pages;
function makeCtx(captured: unknown[]) {
return {
engine: {
listPages: async (filters: unknown) => {
captured.push(filters);
return [];
},
},
config: { engine: 'pglite' },
logger: { info() {}, warn() {}, error() {} },
dryRun: false,
remote: false,
sourceId: 'default',
} as any;
}
test('declares offset param and discloses the 100-row cap on limit', () => {
expect(listPagesOp.params.offset).toBeDefined();
expect(listPagesOp.params.offset.type).toBe('number');
expect(listPagesOp.params.limit.description).toContain('100');
});
test('threads offset through to engine.listPages', async () => {
const captured: any[] = [];
await listPagesOp.handler(makeCtx(captured), { limit: 10, offset: 30 });
expect(captured[0].offset).toBe(30);
expect(captured[0].limit).toBe(10);
});
test('drops negative, non-finite, and zero offsets', async () => {
const captured: any[] = [];
const ctx = makeCtx(captured);
await listPagesOp.handler(ctx, { offset: -5 });
await listPagesOp.handler(ctx, { offset: Infinity });
await listPagesOp.handler(ctx, { offset: 0 });
for (const f of captured) expect(f.offset).toBeUndefined();
});
test('floors fractional offsets', async () => {
const captured: any[] = [];
await listPagesOp.handler(makeCtx(captured), { offset: 7.9 });
expect(captured[0].offset).toBe(7);
});
});
+23
View File
@@ -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);
});
+4
View File
@@ -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);
});