fix(db,doctor): sources.config array-branch jsonb guard + honest health page counts (#2251 #2330)

#2251: updateSourceConfig's historical-shape repair called jsonb_each() on every
array element; a non-object element raised "cannot call jsonb_each on a non-object"
and aborted the whole UPDATE, blocking a source's config writes. Wrap each element
in a CASE so jsonb_each only ever sees an object. Bring pglite-engine to parity with
the same self-healing coercion (it previously had none).

#2330: doctor counted un-scanned surfaces as healthy and getHealth/getStats
disagreed on page_count. getHealth now excludes soft-deleted pages from
page_count, pages_with_timeline, and most_connected so coverage shares one
denominator with getStats; an un-runnable expected surface emits an explicit
failing check instead of scoring a vacuous 100.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-24 15:19:08 -07:00
co-authored by Claude Opus 4.8
parent 814258dda6
commit f399246db1
4 changed files with 100 additions and 17 deletions
+34
View File
@@ -555,6 +555,40 @@ export async function doctorReportRemote(engine: BrainEngine): Promise<DoctorRep
});
}
// 3a. Skill surface honesty (#2330). The focused doctor emits no skill
// checks, so computeDoctorReport scored category `skill` as a vacuous 100 —
// "structurally dishonest": run_doctor reported skill 100/100 while the skills
// directory was unreadable. Emit an EXPLICIT skill check so an unassessed/
// unreadable surface can't masquerade as perfect. (We do NOT change
// computeDoctorReport's vacuous-truth contract for genuinely-empty categories —
// it's pinned; we make the surface emit a real check instead.) ok when the
// resolver is reachable, warn when it isn't — never silent.
try {
const detected = autoDetectSkillsDirReadOnly();
if (detected.dir) {
const report = checkResolvable(detected.dir);
checks.push({
name: 'skill_surface',
status: report.errors.length > 0 ? 'fail' : report.warnings.length > 0 ? 'warn' : 'ok',
message: report.errors.length > 0 || report.warnings.length > 0
? `${report.summary.total_skills} skills, ${report.errors.length} error(s)/${report.warnings.length} warning(s)`
: `${report.summary.total_skills} skills reachable`,
});
} else {
checks.push({
name: 'skill_surface',
status: 'warn',
message: 'Skills directory not found — skill health could not be assessed (run `gbrain doctor` on the host for full skill checks).',
});
}
} catch (e) {
checks.push({
name: 'skill_surface',
status: 'warn',
message: `Could not assess skill surface: ${e instanceof Error ? e.message : String(e)}`,
});
}
// 3b. Migration wedge hint (v0.31.8 — D14 + D19). The brain server's
// filesystem holds the migration ledger; the wedge condition (>=3 consecutive
// partials with no later complete) needs the force-retry hint, not plain
+1
View File
@@ -119,6 +119,7 @@ export const SKILL_CHECK_NAMES: ReadonlySet<string> = new Set([
'retrieval_reflex_health',
'skill_brain_first',
'skill_conformance',
'skill_surface',
'whoknows_health',
]);
+40 -10
View File
@@ -1290,12 +1290,37 @@ export class PGLiteEngine implements BrainEngine {
}
async updateSourceConfig(sourceId: string, patch: Record<string, unknown>): Promise<boolean> {
// v0.38: parity with postgres-engine.updateSourceConfig. JSONB `||`
// concat operator (overrides same-key, no deep merge). PGLite passes
// `JSON.stringify(patch)` as the param; cast to jsonb on the SQL side.
// Parity with postgres-engine.updateSourceConfig (#2251): normalize historical
// bad shapes (double-encoded JSONB string, or array-of-patch-objects from the
// old `|| string` cascade) back to a flat object before the `||` merge, so a
// corrupted source self-heals instead of compounding. PGLite runs Postgres
// 17.5, so `IS JSON` (PG16+) is available. The array branch wraps each element
// in a CASE so jsonb_each never sees a non-object (which would raise
// `cannot call jsonb_each on a non-object` and abort the UPDATE). PGLite's
// native `db.query` parses the `$1::jsonb` text param itself (not the postgres.js
// double-encode path), so `$1::jsonb` is correct here.
const result = await this.db.query<{ id: string }>(
`UPDATE sources
SET config = COALESCE(config, '{}'::jsonb) || $1::jsonb
SET config =
CASE
WHEN jsonb_typeof(config) = 'object' THEN config
WHEN jsonb_typeof(config) = 'string'
THEN CASE
WHEN (config #>> '{}') IS JSON
THEN COALESCE(NULLIF((config #>> '{}'), '')::jsonb, '{}'::jsonb)
ELSE '{}'::jsonb
END
WHEN jsonb_typeof(config) = 'array'
THEN COALESCE(
(SELECT jsonb_object_agg(kv.key, kv.value)
FROM jsonb_array_elements(config) elem,
jsonb_each(CASE WHEN jsonb_typeof(elem) = 'object'
THEN elem ELSE '{}'::jsonb END) kv),
'{}'::jsonb
)
ELSE '{}'::jsonb
END
|| $1::jsonb
WHERE id = $2
RETURNING id`,
[JSON.stringify(patch), sourceId],
@@ -4701,21 +4726,25 @@ export class PGLiteEngine implements BrainEngine {
// pages_with_timeline) and v0.10.3 graph layer (link_coverage, timeline_coverage,
// most_connected). Both coexist: master's brain_score is the composite
// dashboard, v0.10.3 metrics give entity-page-level granularity.
// #2330: exclude soft-deleted pages from every page-based metric — parity
// with postgres-engine.getHealth and getStats.page_count.
const { rows: [h] } = await this.db.query(`
WITH entity_pages AS (
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
SELECT id, slug FROM pages WHERE type IN ('person', 'company') AND deleted_at IS NULL
)
SELECT
(SELECT count(*) FROM pages) as page_count,
(SELECT count(*) FROM pages WHERE deleted_at IS NULL) as page_count,
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NOT NULL)::float /
GREATEST((SELECT count(*) FROM content_chunks), 1)::float as embed_coverage,
(SELECT count(*) FROM pages p
WHERE p.updated_at < (SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id)
WHERE p.deleted_at IS NULL
AND p.updated_at < (SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id)
) as stale_pages,
-- Bug 11 — orphan = islanded (no inbound AND no outbound).
-- See BrainHealth.orphan_pages docstring; docs updated to match this.
(SELECT count(*) FROM pages p
WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
WHERE p.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)
) as orphan_pages,
(SELECT count(*) FROM links l
@@ -4723,7 +4752,8 @@ export class PGLiteEngine implements BrainEngine {
) as dead_links,
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NULL) as missing_embeddings,
(SELECT count(*) FROM links) as link_count,
(SELECT count(DISTINCT page_id) FROM timeline_entries) as pages_with_timeline,
(SELECT count(DISTINCT te.page_id) FROM timeline_entries te
JOIN pages p ON p.id = te.page_id WHERE p.deleted_at IS NULL) as pages_with_timeline,
(SELECT count(*) FROM entity_pages e
WHERE EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = e.id))::float /
GREATEST((SELECT count(*) FROM entity_pages), 1)::float as link_coverage,
@@ -4737,7 +4767,7 @@ export class PGLiteEngine implements BrainEngine {
SELECT p.slug,
(SELECT count(*) FROM links l WHERE l.from_page_id = p.id OR l.to_page_id = p.id)::int as link_count
FROM pages p
WHERE p.type IN ('person', 'company')
WHERE p.type IN ('person', 'company') AND p.deleted_at IS NULL
ORDER BY link_count DESC
LIMIT 5
`);
+25 -7
View File
@@ -1321,10 +1321,19 @@ export class PostgresEngine implements BrainEngine {
ELSE '{}'::jsonb
END
WHEN jsonb_typeof(config) = 'array'
-- Array branch guard (#2251): jsonb_each raises "cannot call
-- jsonb_each on a non-object" if ANY array element is a non-object
-- scalar. A bare WHERE jsonb_typeof(elem)=object does NOT help --
-- the lateral set-returning function is evaluated per elem row
-- before the WHERE filters, so it still throws. Wrap each element
-- in a CASE so jsonb_each only ever receives an object; non-object
-- elements contribute no keys instead of aborting the whole UPDATE
-- (which otherwise blocked every cycle last_full_cycle_at write).
THEN COALESCE(
(SELECT jsonb_object_agg(kv.key, kv.value)
FROM jsonb_array_elements(config) elem,
jsonb_each(elem) kv),
jsonb_each(CASE WHEN jsonb_typeof(elem) = 'object'
THEN elem ELSE '{}'::jsonb END) kv),
'{}'::jsonb
)
ELSE '{}'::jsonb
@@ -4721,19 +4730,27 @@ export class PostgresEngine implements BrainEngine {
// SQL required both — docs now match code so users can trust the
// number. A hub page that links out to many but has no back-references
// is working as intended, not an orphan.
// #2330: exclude soft-deleted pages from EVERY page-based metric, matching
// getStats (`page_count`) and the "soft-deleted is hidden everywhere the
// user looks" posture. Previously getHealth counted all pages while getStats
// excluded deleted, so `get_health.page_count` and `get_stats.page_count`
// disagreed (and brain_score ratios were diluted by tombstones). Filtering
// numerator AND denominator keeps the ratios well-formed.
const [h] = await sql`
WITH entity_pages AS (
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
SELECT id, slug FROM pages WHERE type IN ('person', 'company') AND deleted_at IS NULL
)
SELECT
(SELECT count(*) FROM pages) as page_count,
(SELECT count(*) FROM pages WHERE deleted_at IS NULL) as page_count,
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NOT NULL)::float /
GREATEST((SELECT count(*) FROM content_chunks), 1)::float as embed_coverage,
(SELECT count(*) FROM pages p
WHERE p.updated_at < (SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id)
WHERE p.deleted_at IS NULL
AND p.updated_at < (SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id)
) as stale_pages,
(SELECT count(*) FROM pages p
WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
WHERE p.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)
) as orphan_pages,
(SELECT count(*) FROM links l
@@ -4741,7 +4758,8 @@ export class PostgresEngine implements BrainEngine {
) as dead_links,
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NULL) as missing_embeddings,
(SELECT count(*) FROM links) as link_count,
(SELECT count(DISTINCT page_id) FROM timeline_entries) as pages_with_timeline,
(SELECT count(DISTINCT te.page_id) FROM timeline_entries te
JOIN pages p ON p.id = te.page_id WHERE p.deleted_at IS NULL) as pages_with_timeline,
(SELECT count(*) FROM entity_pages e
WHERE EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = e.id))::float /
GREATEST((SELECT count(*) FROM entity_pages), 1)::float as link_coverage,
@@ -4754,7 +4772,7 @@ export class PostgresEngine implements BrainEngine {
SELECT p.slug,
(SELECT count(*) FROM links l WHERE l.from_page_id = p.id OR l.to_page_id = p.id)::int as link_count
FROM pages p
WHERE p.type IN ('person', 'company')
WHERE p.type IN ('person', 'company') AND p.deleted_at IS NULL
ORDER BY link_count DESC
LIMIT 5
`;