* 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>
197 lines
7.5 KiB
TypeScript
197 lines
7.5 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|