mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
#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:
co-authored by
Claude Opus 4.8
parent
814258dda6
commit
f399246db1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
`);
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user