Files
gbrain/test/ingestion/ingest-capture.test.ts
Garry Tan 8ab733471b v0.41.17.0 feat: --workers N on every bulk command + facts dim doctor parity (#1519)
* feat(worker-pool): shared sliding pool + bounded semaphore + PGLite-clamp wrapper

T1 + T2 of the v0.41.16.0 workers cathedral. New src/core/worker-pool.ts is
the canonical primitive every --workers N bulk command in this wave (and
future bulk commands) builds on. Atomic-claim invariant enforced by
scripts/check-worker-pool-atomicity.sh (wired into bun run verify).
BudgetExhausted bypass + AbortSignal composition baked into the helper so
budget caps are a structural ceiling under concurrency, not a per-caller
convention.

The new resolveWorkersWithClamp wrapper composes existing autoConcurrency
with PGLite-clamp + per-(command, requested) stderr dedup. Deliberately
NOT a modification to shared autoConcurrency (silent today, used by sync
+ import); embed.ts keeps GBRAIN_EMBED_CONCURRENCY || 20 default per
codex #13.

23 + 12 + 9 = 44 hermetic tests pin every contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: structural + dim-check regression suites for v0.41.16.0 wave

- test/embed-helper-migration.test.ts (T3): asserts embed.ts's two
  sliding-pool sites are migrated to runSlidingPool, pre-migration
  shapes (let nextIdx = 0, Promise.all(Array.from(...))) are gone,
  GBRAIN_EMBED_CONCURRENCY || 20 default preserved, failureLabel
  threads page.slug. Per codex #16/#17 these are invariant assertions,
  not byte-equality on progress event ORDERING.
- test/embedding-dim-check-facts.test.ts (T6): readFactsEmbeddingDim
  covers vector(N) + halfvec(N), halfvec-before-vector regex ordering
  pinned (codex #19), buildFactsAlterRecipe emits DROP INDEX + ALTER
  USING + CREATE INDEX (codex #18, not bare REINDEX),
  FactsEmbeddingDimMismatchError tagged class shape,
  assertFactsEmbeddingDimMatchesConfig PGLite skip + Postgres absent-
  column skip, doctor check + insert-cast wiring assertions.
- test/extract-conversation-facts-workers.test.ts (T5): helper
  exports (extractConversationFactsLockId, PER_PAGE_LOCK_TTL_MINUTES),
  structural wiring (runSlidingPool, resolveWorkersWithClamp,
  withRefreshingLock, LockUnavailableError, delete-orphans-first
  before segment loop, preflight before pool, exit 3 when lock_skipped
  > 0), Minion handler round-trip.
- test/extract-workers.test.ts (T7): --workers wiring on all 3 inner
  fs-walk loops (extractForSlugs, extractLinksFromDir,
  extractTimelineFromDir) + CLI parse + opts threading through
  runExtractCore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: rebump v0.41.16.0 → v0.41.17.0 (queue collision with PR #1510)

PR #1510 (garrytan/dynamic-regex-conversation-formats) claimed v0.41.16.0
on master in parallel. Advancing this wave to v0.41.17.0 so both can land
cleanly. Pure mechanical version bump:

- VERSION + package.json → 0.41.17.0
- CHANGELOG.md header + "To take advantage of v0.41.17.0" block
- TODOS.md section header + v0.41.18+ forward references
- CLAUDE.md inline version tags
- Regenerated llms-full.txt / llms.txt

No code changes. The actual workers cathedral feature set is unchanged
from the two prior commits in this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): search-image-column probes column dim at runtime

CI shard 5 failed on `searchVector column routing (v0.27.1)` with:
  error: expected 1280 dimensions, not 1536

The test had a hardcoded `fakeText1536` helper that seeded chunks at
1536-d vectors. Master's default embedding model switched from OpenAI
text-embedding-3-large (1536) to ZeroEntropy zembed-1 (1280) so a fresh
PGLite brain on CI now sizes content_chunks.embedding at 1280; the
test's 1536-d INSERT trips pgvector's CheckExpectedDim.

Fix: probe `content_chunks.embedding` width via
`readContentChunksEmbeddingDim(engine)` in `beforeAll`, store in
`TEXT_DIM`, and build `fakeTextDefault(seed)` at that width. The test
now passes regardless of which default ships (the model has flipped
twice and may flip again). Local dev (1536 from older config) and CI
fresh-install (1280 from new default) both pass.

Image-side vectors stay at 1024 (matches Voyage multimodal-3 + the
column's fixed width on the image side).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): bump PGLite hook timeout for shard-4 deep-process files

facts-anti-loop.test.ts and ingest-capture.test.ts were timing out in CI
shard 4 with "beforeEach/afterEach hook timed out" after the v0.41.16.0
master merge brought migration count to 99. When these files run deep in
a shard process that has already created ~20 PGLite engines, the WASM
cold-start + 95-migration replay legitimately exceeds bun's 5s default
hook timeout (observed 5.6s and 7.3s locally when reproducing).

Bun's --timeout=60000 from scripts/test-shard.sh covers TEST timeouts
but NOT hook timeouts; those default to 5s and must be set per-hook via
the optional 2nd arg to beforeAll/afterAll.

Reproduced locally by running the first 21 shard-4 files via
  head -21 /tmp/shard4-list.txt | xargs bun test
  → 179 pass, 2 fail (both with hook-timeout error)

After fix:
  → 198 pass, 0 fail (the 4 anti-loop + 15 ingest-capture tests recover)

Full shard 4 with fix:  955 pass, 0 fail.
Full shard 5 with fix:  1261 pass, 0 fail.

Also added a defensive diagnostic to the two put_page tests: if
facts_backstop is missing in the response payload, throw with the full
payload + isError so future failures surface the actual handler error
instead of a bare "expected {...} got undefined" assertion. No-op when
the test passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:29:03 -07:00

197 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ingest_capture Minion handler tests. Exercises the slug-resolution
* fallback chain, content-type gating (binary rejection), validation,
* and the importFromContent integration against an in-memory PGLite.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { resetPgliteState } from '../helpers/reset-pglite.ts';
import {
defaultSlugForEvent,
makeIngestCaptureHandler,
} from '../../src/core/minions/handlers/ingest-capture.ts';
import {
computeContentHash,
type IngestionEvent,
} from '../../src/core/ingestion/types.ts';
import type { MinionJobContext } from '../../src/core/minions/types.ts';
let engine: PGLiteEngine;
// 30s hook timeout — when this file runs deep in a shard process that's
// already created ~20 PGLite engines, the WASM cold-start + 95 migrations
// on a fresh DB legitimately exceeds bun's 5s hook default. CI shard 4
// hit this on v0.41.17.0 (95 migrations × 21 files × 1 bun process).
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
}, 30_000);
afterAll(async () => {
await engine.disconnect();
}, 30_000);
beforeEach(async () => {
await resetPgliteState(engine);
});
function makeEvent(overrides: Partial<IngestionEvent> = {}): IngestionEvent {
const content = overrides.content ?? '# captured thought';
return {
source_id: 'webhook-test',
source_kind: 'webhook',
source_uri: 'mcp-webhook:client-x:1234',
received_at: new Date('2026-05-20T12:00:00Z').toISOString(),
content_type: 'text/markdown',
content,
content_hash: overrides.content_hash ?? computeContentHash(content),
...overrides,
};
}
function makeJob(data: Record<string, unknown>): MinionJobContext {
return {
id: 1,
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 () => [],
};
}
describe('defaultSlugForEvent', () => {
test('builds inbox/YYYY-MM-DD-<hash6> slug', () => {
const ev = makeEvent({ content_hash: 'abcdef1234567890'.padEnd(64, '0') });
const slug = defaultSlugForEvent(ev, new Date('2026-05-20T00:00:00Z'));
expect(slug).toBe('inbox/2026-05-20-abcdef');
});
test('stable for same content (deterministic hash)', () => {
const ev = makeEvent({ content: 'same thought' });
const date = new Date('2026-05-20T00:00:00Z');
expect(defaultSlugForEvent(ev, date)).toBe(defaultSlugForEvent(ev, date));
});
test('UTC date math (no tz drift)', () => {
const ev = makeEvent();
const slug = defaultSlugForEvent(ev, new Date('2026-01-05T23:59:59Z'));
expect(slug).toMatch(/^inbox\/2026-01-05-/);
});
});
describe('ingest_capture handler — slug resolution', () => {
test('uses caller-provided job.data.slug when present', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = makeEvent({ content: 'with explicit slug' });
const result = await handler(makeJob({ event: ev, slug: 'wiki/specific/page' }));
expect(result.slug).toBe('wiki/specific/page');
expect(result.status).toBe('imported');
});
test('uses event.metadata.slug when set', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = makeEvent({ content: 'metadata slug', metadata: { slug: 'inbox/custom-from-meta' } });
const result = await handler(makeJob({ event: ev }));
expect(result.slug).toBe('inbox/custom-from-meta');
});
test('falls back to inbox/YYYY-MM-DD-<hash6> when no slug provided', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = makeEvent({ content: 'fallback slug' });
const result = await handler(makeJob({ event: ev }));
expect(result.slug).toMatch(/^inbox\/\d{4}-\d{2}-\d{2}-[a-f0-9]{6}$/);
});
});
describe('ingest_capture handler — validation + routing', () => {
test('throws when event missing', async () => {
const handler = makeIngestCaptureHandler(engine);
await expect(handler(makeJob({}))).rejects.toThrow(/job.data.event is required/);
});
test('throws on invalid event payload (caught at the handler boundary)', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = { ...makeEvent(), content_hash: 'short' };
await expect(handler(makeJob({ event: ev }))).rejects.toThrow(/invalid event payload/);
});
test('rejects binary content_type with helpful message', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = makeEvent({
content_type: 'image/*',
content: '/path/to/screenshot.png',
content_hash: computeContentHash('/path/to/screenshot.png'),
});
await expect(handler(makeJob({ event: ev }))).rejects.toThrow(
/content_type 'image\/\*' requires a content-type processor/,
);
});
test('untrusted_payload flag round-trips to the result', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = makeEvent({ content: 'untrusted', untrusted_payload: true });
const result = await handler(makeJob({ event: ev }));
expect(result.untrusted_payload).toBe(true);
});
test('trusted (default) payload round-trips as false', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = makeEvent({ content: 'trusted' });
const result = await handler(makeJob({ event: ev }));
expect(result.untrusted_payload).toBe(false);
});
test('source provenance round-trips into the result', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = makeEvent({
content: 'with provenance',
source_kind: 'inbox-folder',
source_uri: '/Users/test/.gbrain/inbox/note.md',
});
const result = await handler(makeJob({ event: ev }));
expect(result.source_kind).toBe('inbox-folder');
expect(result.source_uri).toBe('/Users/test/.gbrain/inbox/note.md');
});
});
describe('ingest_capture handler — integration with importFromContent', () => {
test('imported event lands as a page in the DB', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = makeEvent({
content: '---\ntitle: Test Page\n---\n\n# E2E import\n\nbody content',
});
const result = await handler(makeJob({ event: ev, slug: 'wiki/e2e-test' }));
expect(result.status).toBe('imported');
const page = await engine.getPage('wiki/e2e-test');
expect(page).not.toBeNull();
expect(page?.compiled_truth).toContain('E2E import');
});
test('repeat ingest of same content returns skipped status (content_hash dedup at importFromContent level)', async () => {
const handler = makeIngestCaptureHandler(engine);
const ev = makeEvent({ content: '# stable content' });
const result1 = await handler(makeJob({ event: ev, slug: 'wiki/stable' }));
expect(result1.status).toBe('imported');
const result2 = await handler(makeJob({ event: ev, slug: 'wiki/stable' }));
expect(result2.status).toBe('skipped');
});
test('chunks count is reported on imported events', async () => {
const handler = makeIngestCaptureHandler(engine);
const longContent = '---\ntitle: long\n---\n\n' + 'Paragraph.\n\n'.repeat(50);
const ev = makeEvent({ content: longContent });
const result = await handler(makeJob({ event: ev, slug: 'wiki/long' }));
expect(result.chunks).toBeGreaterThan(0);
});
});