Files
gbrain/test/e2e/sync-status-pglite.test.ts
T
146a8f1eed v0.41.31.0 feat(embed): delta-aware sync --all cost gate + real stale-embedding semantics (#1632)
* fix(cost): embedding cost preview uses configured model rate, not hardcoded OpenAI

The sync --all cost gate computed spend from a hardcoded
EMBEDDING_COST_PER_1K_TOKENS = 0.00013 (OpenAI text-embedding-3-large)
and labeled the preview with the back-compat EMBEDDING_MODEL constant,
regardless of the actually-configured embedding model. A brain running a
cheaper model (e.g. zeroentropyai:zembed-1 @ $0.05/Mtok) saw a preview
that named the wrong provider and over-stated spend ~2.6x ($337 vs $130
on a 2.6B-token corpus).

estimateEmbeddingCostUsd now resolves the live model via the gateway and
prices it through embedding-pricing.ts (the existing per-provider:model
table), falling back to the OpenAI rate only when the gateway is
unconfigured (unit-test context) or the model is unknown. sync.ts surfaces
the real model name in the preview message and JSON.

Regression test pins model-aware pricing: openai 3-large vs zembed-1 must
produce materially different previews; collapsing both to the OpenAI number
fails the assertion.

* fix(cost): sync --all gate is informational when embed is deferred; delta-aware inline gate

Under federated_v2 (default), sync --all DEFERS embedding to per-source
embed-backfill jobs that already cap spend at $25/source/24h. The v0.20
cost gate predated that cap and fired ConfirmationRequired + exit 2 on
EVERY non-TTY sync --all without --yes, regardless of cost — blocking
nightly crons over already-synced corpora and forcing permanent --yes.

The gate is now mode-aware:
  - Deferred embed (v2 default): print an FYI deferred notice (cap-aware,
    "not charged by this sync") + the stale-chunk backlog estimate, and
    NEVER exit 2. The backfill cap is the real money gate.
  - Inline embed (v2 off, or --serial without --no-embed): keep the
    blocking gate, but estimate the actual delta — full-tree ceiling for
    changed sources (unchanged sources contribute 0 via the same git +
    chunker_version "do work?" gate doctor/sync use) + stale backlog — and
    block only when it exceeds the new configurable floor
    sync.cost_gate_min_usd (default $0.50).

New pure helpers in embedding.ts (willEmbedSynchronously, shouldBlockSync)
keep the decision logic hermetically testable. New engine method
sumStaleChunkChars (both engines) prices the embedding backlog via
estimateCostFromChars. estimateSyncAllCost's per-source walk extracted to
estimateSourceTreeTokens (reused by the inline estimator).

Regressions pinned: R-1 deferred non-TTY never exit 2 (headline), R-2
inline above-floor still exit 2 (protection), plus the willEmbedSynchronously
/ shouldBlockSync matrix and sumStaleChunkChars engine + scope + embed_skip
coverage.

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

* feat(embed): real stale semantics — re-embed on model/dims swap (migration v108)

Pre-v0.41.30 "stale" meant only `embedding IS NULL`, so swapping the
embedding model or dimensions left the whole corpus silently embedded under
the OLD model — `embed --stale` ignored it and search quality quietly
degraded.

New `pages.embedding_signature` (TEXT, migration v108) stamps the embedding
provenance (`<provider:model>:<dims>`) whenever a page's chunks are embedded.
A later model/dims swap makes the stored signature differ from the current
one, which the embed paths now detect and re-embed.

GRANDFATHER (critical): the stale predicate is
  `embedding IS NULL OR (embedding_signature IS NOT NULL AND <> $current)`
so a NULL signature is NEVER stale. After the migration every existing page
has NULL → none flagged → the next `embed --stale` does NOT re-embed the
whole corpus. Signatures are stamped going forward only.

Surface:
  - countStaleChunks / sumStaleChunkChars gain an optional `signature` opt
    that widens staleness (read-only; used by the dry-run preview + the
    sync cost preview, which is now signature-aware).
  - invalidateStaleSignatureEmbeddings(signature, sourceId?) NULLs the
    embeddings of signature-mismatched pages so the EXISTING NULL-embedding
    cursor (listStaleChunks, untouched) re-embeds them — keeps the keyset
    pagination logic intact.
  - setPageEmbeddingSignature stamps after a page's chunks land.
  - Both embed loops wired: `gbrain embed --stale`/`--all` (embed.ts) and the
    embed-backfill minion (embed-stale.ts) invalidate-then-stamp.

Migration v108 + bootstrap probe (both engines) + REQUIRED_BOOTSTRAP_COVERAGE
entry. Pinned by test/embedding-signature-stale.test.ts (R-4 grandfather,
mismatch detection, matching no-op, scoped invalidate, stamp) + the
bootstrap-coverage gate.

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

* feat(sync): surface embed-backfill job state in sources status + deferred notice

Under federated_v2, `sync --all` exits 0 and embedding lags behind in
embed-backfill jobs (subject to cooldown + the per-source 24h cap). Pre-fix
an operator had no signal those jobs were queued or lagging — the sync looked
"done" while embeddings trickled in later.

`gbrain sources status` now shows a BACKFILL column per source
(active(N)/queued(N)/idle) plus the last completion timestamp, read from
minion_jobs. The deferred-sync notice appends "N backfill job(s) queued" so a
cron operator sees work is enqueued, not lost. Both reads are best-effort —
a brain that never ran a worker (no minion_jobs table) reports idle/0 instead
of crashing the dashboard.

SyncStatusReportSource gains backfill_queued / backfill_active /
backfill_last_completed_at (additive; JSON envelope schema_version unchanged).
Pinned by a new case in test/e2e/sync-status-pglite.test.ts.

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

* test: add currentEmbeddingSignature to embedding.ts mocks + sync stale EXPECTED_PHASES

Commit 3 made embed.ts import currentEmbeddingSignature from embedding.ts.
Four tests mock.module the whole embedding.ts and omitted the new export, so
embed.ts (imported transitively) failed at load with "Export named
'currentEmbeddingSignature' not found". Add the export to each mock:
embed.serial.test.ts, e2e/cycle.test.ts, e2e/dream.test.ts,
e2e/dream-cycle-phase-order-pglite.test.ts.

Also sync the stale EXPECTED_PHASES in dream-cycle-phase-order-pglite.test.ts
to match cycle.ts ALL_PHASES — extract_atoms, synthesize_concepts, and
conversation_facts_backfill drifted in after the test was last touched
(v0.41.0.0) and were never added, so both phase-order assertions were failing
on the branch before this wave (confirmed against 0906ab0a). The dry-run cycle
emits all 20 phases, so mirroring the constant makes both assertions pass.

Pre-existing, unrelated: cycle.test.ts / dream.test.ts have 5 runCycle
failures via direct `bun test` (the conversation_facts_backfill phase uses the
module-singleton getConnection) — present identically at 0906ab0a, not touched
by this wave.

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

* test: pin R-3 chunker-drift regression + embed-signature stamp-call wiring

Ship-workflow coverage audit flagged two gaps:
- R-3 (mandatory regression) had no dedicated test: the inline unchanged-source
  short-circuit requires git-unchanged AND chunker_version match, but nothing
  pinned the chunker half. Add a case where git is unchanged (HEAD==last_commit,
  clean) but chunker_version is stale → estimate still fires (exit 2), plus a
  control where chunker matches → short-circuits to $0 (no block).
- The embed loops' setPageEmbeddingSignature call-site was only kept green by the
  mock, never asserted. Add a test that runs `embed --all` and asserts the stamp
  fires once per page with the current signature.

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

* fix(embed): stamp embedding_signature on the inline import + per-slug paths (F1); inline cost gate counts new-content only (F2)

Adversarial review caught that the stale-detection feature was inert for
non-federated/inline brains: the embed-write paths that DON'T go through
embed.ts/embed-stale.ts never stamped pages.embedding_signature.

F1 — stamp at the remaining write sites:
  - embedPage (gbrain embed <slug> + sync's post-import runEmbedCore({slugs}))
  - importFromContent markdown branch (inline import/sync embed + gbrain import)
  - importCodeFile (only when EVERY chunk was freshly embedded this call —
    reuse-by-hash carries old-model vectors, so a mixed page stays unstamped
    rather than falsely marked current)
Without this, inline-synced pages kept NULL signatures → grandfathered → never
re-embedded on a model/dims swap. Now all embed-write paths stamp.

F2 — coupled regression the F1 fix would otherwise introduce: the inline cost
gate added the stale backlog (NULL + signature drift) into the BLOCKING cost,
but `gbrain sync` inline only embeds new/changed content — the backlog is
`gbrain embed --stale`'s job. Once F1 gives inline brains real signatures, a
model swap would inflate the inline gate and block the next cron for cost the
sync never incurs. Inline blocking cost is now new-content only; the stale
backlog is shown informationally ("pending gbrain embed --stale"). Deferred
path keeps the signature-aware backlog FYI (the backfill does clear it).

Pinned by test/import-signature-stamp.serial.test.ts (inline stamp + --no-embed
NULL) and the existing R-2/R-3 inline-gate tests (still exit 2 on new-content).

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

* chore: bump version and changelog (v0.41.30.0)

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

* fix(sources): wire BACKFILL into the real `sources status` path (P0a); guard partial-page signature stamping (P0b)

Codex adversarial review caught two issues in the v0.41.30 wave:

P0a — `gbrain sources status` routes through computeAllSourceMetrics
(source-health.ts), not the buildSyncStatusReport helper where the BACKFILL
column was added, so the CLI never showed it. Add per-source embed-backfill
active/queued counts to computeAllSourceMetrics (one extra FILTER on the
existing minion_jobs query) and render a BACKFILL column in `sources status`.
The deferred-sync notice's queued-job count (live sync path) already worked.

P0b — embedPage / embedAllStale / embed-stale stamped embedding_signature
unconditionally after embedding only the STALE subset of a page's chunks. A
partially-embedded page (some chunks preserved from a prior embed under
unknown/old provenance) would be falsely marked current, hiding the old
vectors from future stale detection. Now stamp only when EVERY chunk of the
page was (re)embedded this pass (toEmbed === chunks / stale === existing).
importFromContent embeds the full chunk set so it stays unconditional;
importCodeFile already had the equivalent guard. `gbrain embed --all` fully
re-embeds and stamps mixed pages.

Accepted as documented limitations (not fixed): the inline cost gate can
over-estimate a >100-file `--serial` sync that performSync will defer
(non-default mode, conservative-high bias), and model-swap invalidation NULLs
drifted vectors before re-embed (a deliberate, rare operation).

Pinned by a new backfill-counts case in test/source-health.test.ts.

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

* docs: update project documentation for v0.41.30.0

Refresh CLAUDE.md Key Files + Commands for the embedding cost-model + stale-semantics wave: model-aware cost helpers in embedding.ts (currentEmbeddingPricePerMTok / currentEmbeddingSignature / willEmbedSynchronously / shouldBlockSync), the embedding-signature stale-detection engine quartet (sumStaleChunkChars / setPageEmbeddingSignature / invalidateStaleSignatureEmbeddings + widened countStaleChunks), migration v108, signature stamping across embed.ts / import-file.ts, the mode-aware sync --all cost gate + sync.cost_gate_min_usd config key, and the sources status BACKFILL column. Add a same-dimension-swap auto-reembed note to docs/embedding-migrations.md. Regenerate llms-full.txt.

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

* chore: re-version 0.41.30.0 → 0.41.31.0 (queue slot)

Mechanical version-string sweep across VERSION, package.json, CHANGELOG,
CLAUDE.md, docs, source/test comments, and regenerated llms bundles. No logic
change.

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

* fix(test): pin embedding dim in cosine-rescore-column test (CI shard-5 contamination)

CI shard 5 failed deterministically with `expected 1280 dimensions, not 1536`.
Root cause: cosine-rescore-column.test.ts hardcodes 1536-dim `embedding`
vectors and asserts length 1536, but its beforeAll ran `initSchema()` with no
gateway config. initSchema sizes the `embedding` column from
getEmbeddingDimensions(), whose default is 1280 (zeroentropyai:zembed-1). The
test only passed by inheriting a leaked 1536 gateway config from an earlier
test (or, locally, from ~/.gbrain). When the v0.41.31 merge shifted the
weight-aware shard bin-packing, the file order changed so the 1280 default won
in CI → vector(1280) column → 1536 insert rejected. (Passed locally because
the dev machine's ~/.gbrain resolves 1536.)

Fix: configureGateway({ openai:text-embedding-3-large, 1536 }) in beforeAll
BEFORE connect/initSchema so the column is deterministically vector(1536)
regardless of ambient/leaked state, and resetGateway() in afterAll for
hygiene. Proven: under a forced-1280 gateway preload the old test reproduces
the exact CI error and the fixed test passes (4/4).

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

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:30:56 -07:00

323 lines
14 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.
/**
* IRON RULE E2E regression for v0.40.3.0 `buildSyncStatusReport` SQL.
*
* Why this exists:
* PR #1314 shipped a broken SQL query for the per-source dashboard:
*
* FROM chunks ch JOIN pages pg ON pg.slug = ch.page_slug
*
* The actual schema is `content_chunks` joined on `page_id`. Every
* unit test in `test/sync-all-parallel.test.ts` stubbed `executeRaw`
* with regex-keyed canned responses, so the broken SQL never ran.
* The defensive `try/catch { countRows = [] }` would have silently
* returned "0 chunks for every source" in production — a misleading
* "your brain is empty" report on a real Postgres brain.
*
* This case exercises the REAL SQL against PGLite. If buildSyncStatusReport
* ever drifts back to a bad table name or join key, this test fails
* loudly at parse time (PGLite rejects unknown columns).
*
* Per CLAUDE.md's IRON RULE: "regression test is added to the plan as
* a critical requirement. No skipping." Codex's outside-voice review
* of the original plan caught this missing case.
*
* Coverage:
* - Canonical SQL parses and returns rows (Blocker 1 / Codex P0 #1)
* - Soft-deleted pages excluded from pages count + chunks count
* (v0.26.5 soft-delete shipped without updating this query path)
* - Archived sources excluded from the dashboard input
* (Expansion 3 from plan review)
* - Embedding column resolved via the registry (D16) — counts
* against the active column, not a hardcoded name
* - Errors propagate instead of returning lying zeroes (Q2 sub-fix)
*
* No DATABASE_URL needed; PGLite is in-memory.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { buildSyncStatusReport } from '../../src/commands/sync.ts';
import type { BrainEngine } from '../../src/core/engine.ts';
import type { ChunkInput } from '../../src/core/types.ts';
let engine: PGLiteEngine;
// Basis-vector embeddings keep the seed cheap (no real model needed) and
// deterministic. The dashboard only cares about NULL vs non-NULL on the
// embedding column, not the vector content.
function basisEmbedding(idx: number, dim = 1536): Float32Array {
const emb = new Float32Array(dim);
emb[idx % dim] = 1.0;
return emb;
}
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Seed two non-default sources: 'source-a' (active) + 'source-b' (active)
// + 'source-c' (archived — must be excluded by dashboard input filter).
// 'default' is auto-seeded by pglite-schema.ts:50.
await engine.executeRaw(
`INSERT INTO sources (id, name, local_path, last_commit, last_sync_at, config, archived)
VALUES
('source-a', 'source-a', '/tmp/source-a', 'aaa', NOW() - INTERVAL '1 hour', '{"syncEnabled": true}'::jsonb, FALSE),
('source-b', 'source-b', '/tmp/source-b', 'bbb', NOW() - INTERVAL '30 hours', '{"syncEnabled": true}'::jsonb, FALSE),
('source-c', 'source-c', '/tmp/source-c', 'ccc', NOW() - INTERVAL '100 hours', '{}'::jsonb, TRUE)`,
);
// Seed pages + chunks per source via the canonical engine API.
// source-a: 3 pages, 6 chunks, 4 embedded (2 unembedded)
// source-b: 2 pages, 4 chunks, 4 embedded (0 unembedded)
// Plus a soft-deleted page on source-a that should NOT count toward
// either pages or chunks (the v0.26.5 soft-delete-aware regression).
// source-a page 1: 2 chunks, both embedded
await engine.putPage('a/page-1', {
type: 'note',
title: 'A page 1',
compiled_truth: 'content for a/page-1',
timeline: '',
}, { sourceId: 'source-a' });
await engine.upsertChunks('a/page-1', [
{ chunk_index: 0, chunk_text: 'chunk a/1/0', chunk_source: 'compiled_truth', embedding: basisEmbedding(0), token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk a/1/1', chunk_source: 'compiled_truth', embedding: basisEmbedding(1), token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-a' });
// source-a page 2: 2 chunks, both unembedded (no embedding field)
await engine.putPage('a/page-2', {
type: 'note',
title: 'A page 2',
compiled_truth: 'content for a/page-2',
timeline: '',
}, { sourceId: 'source-a' });
await engine.upsertChunks('a/page-2', [
{ chunk_index: 0, chunk_text: 'chunk a/2/0', chunk_source: 'compiled_truth', token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk a/2/1', chunk_source: 'compiled_truth', token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-a' });
// source-a page 3: 2 chunks, both embedded
await engine.putPage('a/page-3', {
type: 'note',
title: 'A page 3',
compiled_truth: 'content for a/page-3',
timeline: '',
}, { sourceId: 'source-a' });
await engine.upsertChunks('a/page-3', [
{ chunk_index: 0, chunk_text: 'chunk a/3/0', chunk_source: 'compiled_truth', embedding: basisEmbedding(2), token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk a/3/1', chunk_source: 'compiled_truth', embedding: basisEmbedding(3), token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-a' });
// SOFT-DELETED page on source-a: must NOT count toward pages or chunks.
// This is the v0.26.5 regression — pre-fix, soft-deleted pages were
// double-counted in the dashboard.
await engine.putPage('a/page-deleted', {
type: 'note',
title: 'A page deleted',
compiled_truth: 'content for a/page-deleted (will be soft-deleted)',
timeline: '',
}, { sourceId: 'source-a' });
await engine.upsertChunks('a/page-deleted', [
{ chunk_index: 0, chunk_text: 'should-not-count', chunk_source: 'compiled_truth', embedding: basisEmbedding(4), token_count: 4 },
{ chunk_index: 1, chunk_text: 'should-not-count', chunk_source: 'compiled_truth', token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-a' });
await engine.softDeletePage('a/page-deleted', { sourceId: 'source-a' });
// source-b: 2 pages × 2 chunks, all embedded
await engine.putPage('b/page-1', {
type: 'note',
title: 'B page 1',
compiled_truth: 'content for b/page-1',
timeline: '',
}, { sourceId: 'source-b' });
await engine.upsertChunks('b/page-1', [
{ chunk_index: 0, chunk_text: 'chunk b/1/0', chunk_source: 'compiled_truth', embedding: basisEmbedding(5), token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk b/1/1', chunk_source: 'compiled_truth', embedding: basisEmbedding(6), token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-b' });
await engine.putPage('b/page-2', {
type: 'note',
title: 'B page 2',
compiled_truth: 'content for b/page-2',
timeline: '',
}, { sourceId: 'source-b' });
await engine.upsertChunks('b/page-2', [
{ chunk_index: 0, chunk_text: 'chunk b/2/0', chunk_source: 'compiled_truth', embedding: basisEmbedding(7), token_count: 4 },
{ chunk_index: 1, chunk_text: 'chunk b/2/1', chunk_source: 'compiled_truth', embedding: basisEmbedding(8), token_count: 4 },
] satisfies ChunkInput[], { sourceId: 'source-b' });
});
afterAll(async () => {
await engine.disconnect();
});
describe('buildSyncStatusReport against real PGLite (IRON RULE regression for Blocker 1)', () => {
test('correct SQL: content_chunks JOIN pages ON page_id (NOT chunks/page_slug)', async () => {
// Caller-side source list mirrors what `gbrain sources status` (the
// CLI route in sources.ts) would supply: `WHERE local_path IS NOT NULL
// AND archived IS NOT TRUE`.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources
WHERE local_path IS NOT NULL AND archived IS NOT TRUE
ORDER BY id`,
);
// source-a + source-b only (source-c is archived; 'default' has no local_path).
expect(sources.map((s) => s.id).sort()).toEqual(['source-a', 'source-b']);
// The SQL must not throw. Pre-fix, the broken SQL would have thrown
// `relation "chunks" does not exist` on PGLite (and Postgres). Post-
// fix, the canonical `content_chunks JOIN pages ON page_id` shape parses.
const report = await buildSyncStatusReport(engine, sources);
expect(report.schema_version).toBe(1);
expect(report.sources).toHaveLength(2);
const byId = new Map(report.sources.map((s) => [s.source_id, s]));
// source-a: 3 active pages (4th is soft-deleted and excluded).
// 6 active chunks (2 from the soft-deleted page are excluded).
// 4 chunks embedded, 2 unembedded.
const a = byId.get('source-a')!;
expect(a.pages).toBe(3);
expect(a.chunks_total).toBe(6);
expect(a.chunks_unembedded).toBe(2);
expect(a.embedding_coverage_pct).toBeCloseTo(66.7, 1);
// source-b: 2 pages × 2 chunks = 4 chunks, all embedded.
const b = byId.get('source-b')!;
expect(b.pages).toBe(2);
expect(b.chunks_total).toBe(4);
expect(b.chunks_unembedded).toBe(0);
expect(b.embedding_coverage_pct).toBe(100);
});
test('v0.41.31: embed-backfill job state surfaced per source (TODO-2)', async () => {
// Seed embed-backfill minion jobs for source-a: 2 queued + 1 active +
// 1 completed. source-b has none → idle.
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data) VALUES
('embed-backfill', 'waiting', '{"sourceId":"source-a"}'::jsonb),
('embed-backfill', 'waiting', '{"sourceId":"source-a"}'::jsonb),
('embed-backfill', 'active', '{"sourceId":"source-a"}'::jsonb)`,
);
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, finished_at)
VALUES ('embed-backfill', 'completed', '{"sourceId":"source-a"}'::jsonb, now())`,
);
const sources = await engine.executeRaw<{
id: string; name: string; local_path: string | null; config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources
WHERE local_path IS NOT NULL AND archived IS NOT TRUE ORDER BY id`,
);
const report = await buildSyncStatusReport(engine, sources);
const byId = new Map(report.sources.map((s) => [s.source_id, s]));
const a = byId.get('source-a')!;
expect(a.backfill_queued).toBe(2);
expect(a.backfill_active).toBe(1);
expect(a.backfill_last_completed_at).not.toBeNull();
const b = byId.get('source-b')!;
expect(b.backfill_queued).toBe(0);
expect(b.backfill_active).toBe(0);
expect(b.backfill_last_completed_at).toBeNull();
// Clean up so sibling tests in this describe see a clean minion_jobs.
await engine.executeRaw(`DELETE FROM minion_jobs WHERE name = 'embed-backfill'`);
});
test('soft-deleted pages excluded from pages count (v0.26.5 regression)', async () => {
// Verifies the `WHERE pg.deleted_at IS NULL` clause in BOTH subqueries
// of the dashboard SQL. Pre-fix the original PR query would have
// counted the soft-deleted page as part of source-a's totals.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources WHERE id = 'source-a'`,
);
const report = await buildSyncStatusReport(engine, sources);
const a = report.sources.find((s) => s.source_id === 'source-a')!;
expect(a.pages).toBe(3); // 4 raw rows, 1 soft-deleted → 3 active
expect(a.chunks_total).toBe(6); // 8 raw chunks, 2 on soft-deleted page → 6 active
});
test('archived sources are excluded by the caller-side filter (Expansion 3)', async () => {
// The dashboard input filter is `archived IS NOT TRUE`. source-c was
// seeded with archived=true and must not appear in the report.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources
WHERE local_path IS NOT NULL AND archived IS NOT TRUE`,
);
const ids = sources.map((s) => s.id);
expect(ids).not.toContain('source-c');
});
test('embedding column reported in envelope is what the SQL counted against (D16)', async () => {
// D16 → A: dashboard counts unembedded chunks against the ACTIVE
// embedding column (resolved via the registry). The envelope's
// `embedding_column` field exposes which column the SQL used so
// operators can verify Voyage / multimodal setups are reported
// correctly. Default brains (no embedding_model config) resolve to
// 'embedding'. Non-default brains would see their override here.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources
WHERE local_path IS NOT NULL AND archived IS NOT TRUE`,
);
const report = await buildSyncStatusReport(engine, sources);
expect(typeof report.embedding_column).toBe('string');
expect(report.embedding_column.length).toBeGreaterThan(0);
});
test('errors propagate (Q2 sub-fix — no silent swallowing of real DB errors)', async () => {
// Wrap a wrapped engine that throws on the count query. Pre-fix the
// PR's bare `catch { countRows = [] }` would have masked this and
// returned "0 chunks for every source" — exactly the misleading
// report the operator should NEVER see when their DB is actually
// broken.
const sources = await engine.executeRaw<{
id: string;
name: string;
local_path: string | null;
config: Record<string, unknown>;
}>(
`SELECT id, name, local_path, config FROM sources
WHERE local_path IS NOT NULL AND archived IS NOT TRUE`,
);
const proxyEngine: BrainEngine = {
kind: engine.kind,
executeRaw: async (sql: string, params?: unknown[]) => {
if (/WITH s AS \(\s*SELECT unnest/.test(sql)) {
throw new Error('synthetic test error: count query failed');
}
return (engine.executeRaw as (sql: string, params?: unknown[]) => Promise<unknown[]>)(sql, params);
},
} as unknown as BrainEngine;
await expect(buildSyncStatusReport(proxyEngine, sources)).rejects.toThrow(
/count query failed/,
);
});
});