diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f3b67ad30..43d3a127f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -555,6 +555,40 @@ export async function doctorReportRemote(engine: BrainEngine): Promise 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 diff --git a/src/core/doctor-categories.ts b/src/core/doctor-categories.ts index 5f7532861..38704197a 100644 --- a/src/core/doctor-categories.ts +++ b/src/core/doctor-categories.ts @@ -119,6 +119,7 @@ export const SKILL_CHECK_NAMES: ReadonlySet = new Set([ 'retrieval_reflex_health', 'skill_brain_first', 'skill_conformance', + 'skill_surface', 'whoknows_health', ]); diff --git a/src/core/pglite-engine.ts b/src/core/pglite-engine.ts index dc6d62063..ca8370f84 100644 --- a/src/core/pglite-engine.ts +++ b/src/core/pglite-engine.ts @@ -1290,12 +1290,37 @@ export class PGLiteEngine implements BrainEngine { } async updateSourceConfig(sourceId: string, patch: Record): Promise { - // 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 `); diff --git a/src/core/postgres-engine.ts b/src/core/postgres-engine.ts index d09af80bf..3d6c7b367 100644 --- a/src/core/postgres-engine.ts +++ b/src/core/postgres-engine.ts @@ -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 `;