mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(v0.22.0): eval_candidates + eval_capture_failures schema (Lane 1A)
R1 substrate for BrainBench-Real, replayed onto master after Cathedral II
landed. Migration v30 (slotted after master's v25-v29 Cathedral II wave)
creates two tables:
eval_candidates: per-call capture of MCP/CLI/subagent query+search
traffic. Column set lets gbrain-evals replay with full fidelity —
source_ids from v0.18 multi-source, vector_enabled/detail_resolved/
expansion_applied so replay knows what hybridSearch actually did,
remote + job_id + subagent_id so rows are traceable to their origin.
query is CHECK-capped at 50KB; PII scrubber (Lane 1B) runs before insert.
eval_capture_failures: cross-process audit trail. In-process counters
don't work because `gbrain doctor` runs in a separate process from
the MCP server. Persistent rows let doctor query capture health via
COUNT(*) GROUP BY reason over the last 24h.
Both tables get RLS on Postgres gated on BYPASSRLS (matches v24/v29
posture). PGLite ignores RLS; sqlFor split carries only DDL.
5 new BrainEngine methods (breaking-interface addition, drives v0.22.0
minor bump): logEvalCandidate, listEvalCandidates,
deleteEvalCandidatesBefore, logEvalCaptureFailure, listEvalCaptureFailures.
listEvalCandidates uses ORDER BY created_at DESC, id DESC so
`gbrain eval export` is deterministic across same-millisecond inserts.
Also adds HybridSearchMeta type for the side-channel callback used by
Lane 1C's op-layer capture (no change to hybridSearch return shape —
that respects Cathedral II's existing SearchResult[] contract).
Tests: 14 PGLite round-trip cases + 8 v30 structural assertions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.22.0): PII scrubber + op-layer capture module (Lane 1B)
Replayed onto master post-Cathedral II. Same semantics as the original
v0.21.0 work — only adjusted to import HybridSearchMeta from types.ts
(canonical home) instead of redeclaring it locally.
src/core/eval-capture-scrub.ts — pure-function regex scrubber with 6
pattern families: emails, phones (US + E.164), SSN (year-aware),
Luhn-verified credit cards, JWT-shaped tokens, bearer tokens. Zero
deps. Adversarial-input safe.
src/core/eval-capture.ts — op-layer hook helper:
- buildEvalCandidateInput(ctx, {scrub_pii}) — pure row builder
- classifyCaptureFailure(err) — Postgres SQLSTATE → reason tag
- captureEvalCandidate(engine, ctx, opts) — best-effort, never throws
- isEvalCaptureEnabled / isEvalScrubEnabled — file-plane config checks
GBrainConfig gains `eval?: {capture?, scrub_pii?}`. Both default ON.
File-plane only — `gbrain config set` writes the DB plane, doesn't
control capture.
Tests: 17 scrubber + 21 capture-module cases. Zero regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.22.0): hybridSearch onMeta callback + op-layer capture (Lane 1C)
Replayed onto master. Adapted from the original v0.21.0 work to keep
Cathedral II's contract intact: hybridSearch's return stays
`Promise<SearchResult[]>` (unchanged), and meta surfaces via an optional
`onMeta?: (meta: HybridSearchMeta) => void` callback in HybridSearchOpts.
Cathedral II callers leave onMeta undefined and pay no cost. The
op-layer capture wrapper passes a closure that threads meta into the
captured row so gbrain-evals can distinguish:
- "with OPENAI_API_KEY" vs "keyword-only fallback" (vector_enabled)
- "expansion fired" vs "expansion requested + silently fell back" (expansion_applied)
- what hybridSearch actually used after auto-detect (detail_resolved)
Op-layer capture wired into both `query` and `search` op handlers in
src/core/operations.ts. Single hook site catches MCP dispatch + CLI +
subagent tool-bridge from the same place. Fire-and-forget, never throws,
respects ctx.config.eval.capture off-switch.
Tests:
- test/hybrid-meta.test.ts (8 cases) — onMeta accuracy across the 4
return paths in hybridSearch + verification that omitting onMeta
leaves Cathedral II callers unchanged.
- test/mcp-eval-capture.test.ts (10 cases) — query/search ops capture
correctly with MCP/CLI/subagent contexts, scrub on/off, capture=false
off-switch, non-captured ops (list_pages, get_page), F1 failure
isolation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.22.0): gbrain eval export/prune + doctor eval_capture check (Lane 1D)
Replayed onto master. Same semantics as the original v0.21.0 work.
CLI:
gbrain eval export [--since DUR] [--limit N] [--tool query|search]
NDJSON to stdout, every row prefixed with "schema_version":1 per
docs/eval-capture.md contract. EPIPE-safe streaming, stderr
heartbeats, deterministic ordering (created_at DESC, id DESC).
gbrain eval prune --older-than DUR [--dry-run]
Explicit retention cleanup. Requires --older-than (never deletes
without a window). Duration strings: 30d, 7d, 1h, 90m, 3600s.
Legacy bare `gbrain eval --qrels …` still works via sub-subcommand
fall-through.
gbrain doctor gains an eval_capture check between markdown_body_completeness
and queue_health: reads eval_capture_failures for the last 24h, groups by
reason, warns when non-zero. Pre-v30 brains get "Skipped (table
unavailable)" — non-fatal.
docs/eval-capture.md ships the stable NDJSON schema reference for
gbrain-evals consumers.
Tests: 9 export cases + 5 prune cases. Doctor check covered by
existing doctor tests on master.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.22.0): public-exports contract test + CI count guard (Lane 2 / R2)
Master locks 17 public subpath exports as gbrain's stable third-party
contract. Zero enforcement existed. This PR locks the surface in two
layers:
1. test/public-exports.test.ts — runtime contract test.
Reads package.json "exports" at startup. For each subpath, imports
via the package name ("gbrain/engine"), NOT the relative filesystem
path — that's the difference between exercising the actual resolver
and bypassing it. Every subpath gets a canary symbol pinned (e.g.
gbrain/search/hybrid must export hybridSearch + rrfFusion) so a
refactor that renames or removes one fails CI before downstream
consumers (gbrain-evals) silently break.
2. scripts/check-exports-count.sh — CI structural guard.
Wired into `bun test` after check-jsonb-pattern.sh +
check-progress-to-stdout.sh + check-wasm-embedded.sh per master's
precedent. EXPECTED_COUNT=17 baseline — shrinks fail loudly,
growth also fails so the new canary must be pinned in the runtime
test deliberately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs+e2e(v0.22.0): VERSION/CHANGELOG/CLAUDE/README + Postgres E2E (Lane 3)
Bump VERSION + package.json to 0.22.0 (next free slot after master's
v0.21.0 Code Cathedral II minor).
CHANGELOG.md v0.22.0 entry follows the Garry voice template:
- Bold 2-line headline
- Lead paragraph contextualizing v0.20 + v0.21 + v0.22 progression
- Numbers-that-matter table comparing v0.21.0 → v0.22.0
- "What this means for you" sectioned by audience
- "## To take advantage of v0.22.0" operator runbook
- Itemized changes
CLAUDE.md updates:
- Key files: 8 new module entries (eval-capture*, eval-export,
eval-prune, docs/eval-capture.md, public-exports test).
hybrid.ts entry rewritten to reflect the additive `onMeta` callback
(return shape unchanged).
- Key commands: new v0.22.0 section for `gbrain eval export`,
`gbrain eval prune`, and the doctor `eval_capture` check, with the
file-plane vs DB-plane config gotcha called out.
README.md: one-paragraph pointer after the BrainBench blurb so anyone
reading the landing page sees the new session-capture feature.
llms.txt + llms-full.txt regenerated to pick up the doc additions.
test/e2e/eval-capture.test.ts (Postgres-only E1 spec):
- CHECK violation surfaces as Postgres SQLSTATE 23514 on oversize input
- RLS is actually enabled on both eval_candidates + eval_capture_failures
- 50 concurrent logEvalCandidate calls — no deadlock, all distinct IDs
Skips gracefully when DATABASE_URL is unset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(todos): P0 — PGLite test-runner concurrency flake
Pre-existing on master, surfaces ~27 false failures when bun test runs all
174 files together. Each failing file passes in isolation. Tracked for a
dedicated investigation branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(v0.22.0): adversarial review post-fixes (doctor RLS, onMeta safety)
Two surgical fixes from /ship adversarial review, plus 6 follow-ups TODO'd
into v0.22.1:
- doctor.ts: distinguish pre-v30 missing-table (42P01, ok skip) from
RLS-denied SELECT (42501, warn) and other DB errors (warn). The check
exists specifically to surface capture-failure misconfigs cross-process,
so silently reporting "ok / skipped" on the most diagnostic class
defeated the purpose.
- hybrid.ts: wrap onMeta invocation in try/catch via small emitMeta
helper. The callback is part of the public gbrain/search/hybrid
contract; a throwing user-supplied closure must never break the search
hot path.
- TODOS.md: 6 P1 follow-ups (eval prune real COUNT, scrubber CC false
positives, dead 'scrubber_exception' enum value, id-cursor for
cross-window dedup, public-export canary pinning, EXPECTED_COUNT dedup).
- TODOS.md: P0 entry for the pre-existing PGLite test-runner concurrency
flake (~27 false failures in full bun test on master).
- CHANGELOG.md: 2 bullets noting the doctor + onMeta hardening.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(version): bump v0.22.0 → v0.25.0 (queue-aware version pick)
Master is at v0.21.0. Open PRs claim v0.21.1 (#432) and v0.24.0 (#387).
v0.25 is the first uncontested slot, so this branch claims it. Pure
rename across VERSION, package.json, CHANGELOG header, and every "v0.22.0"
reference in CLAUDE.md / README.md / TODOS.md / docs/eval-capture.md /
src/ / test/ files. CHANGELOG date bumped to 2026-04-26.
llms.txt + llms-full.txt regenerated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.25.0): gbrain eval replay + contributor doc + CONTRIBUTING link
Closes the gap between "session capture works" (this PR's core) and
"contributors actually use it before merging." Three artifacts:
- src/commands/eval-replay.ts (~340 LOC) — reads NDJSON from `gbrain eval
export`, re-runs each captured query/search against the current brain,
computes set-Jaccard@k, top-1 stability, and latency delta. Stable JSON
shape (schema_version:1) for CI gating; human mode prints a regression
table sorted worst-first. Pure Bun, zero new deps. Stub-engine tests
cover Jaccard math, NDJSON parser (including v2 forward-compat
rejection + line-numbered errors), --limit, --verbose, --json, and
graceful per-row error handling. 16/16 passing.
- docs/eval-bench.md (~80 lines) — contributor guide. The 4-command loop
(export → change → replay → diff), metric definitions with healthy
ranges (Jaccard ≥0.85, top-1 ≥85%, latency Δ within ±50ms), trigger
paths, CI integration snippet, hand-crafted NDJSON corpus path for
fresh installs, and the off-switch. Pairs with the existing
docs/eval-capture.md which is the consumer-facing wire format.
- CONTRIBUTING.md gains a "Running real-world eval benchmarks (touching
retrieval code)" section with the trigger paths and a link to
docs/eval-bench.md. Reviewers now have a one-line ask: "did you run
replay?"
CLAUDE.md key files updated. CHANGELOG bullets added. llms.txt
regenerated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v0.25.0): CONTRIBUTOR_MODE flag — capture off by default for users
Eval capture was on for everyone in the v0.25.0 draft. Privacy footgun:
end users had retrieval traffic accumulate in their brain DB without
asking, even with PII scrubbing. Flips to off by default + explicit
opt-in for contributors who actually use the replay loop.
Resolution order in isEvalCaptureEnabled():
1. config.eval.capture === true → on
2. config.eval.capture === false → off
3. process.env.GBRAIN_CONTRIBUTOR_MODE === '1' → on
4. otherwise → off
The env var is the contributor-facing toggle (one line in .zshrc, no
JSON edit). Explicit config wins both directions for users who want to
override per-brain.
PII scrubbing gate stays independent — default true regardless of
CONTRIBUTOR_MODE — so any brain that does capture still scrubs.
Tests rewritten: env var hygiene per-test (origMode preserved + restored
in finally). 9/9 pass; total v0.25.0 suite is 198/198.
Docs:
- README.md gains a Contributing-section pointer to the env var.
- CONTRIBUTING.md gains a "CONTRIBUTOR_MODE — turn on the dev loop"
section with verification commands and resolution-order table.
- docs/eval-bench.md leads with the prerequisite (must set the env var
for the rest of the doc to be useful).
- docs/eval-capture.md "Config" section split into Path A (env var) +
Path B (config) with explicit resolution-order rules.
- CHANGELOG v0.25.0 entry corrected ("on by default" was wrong) plus a
new top itemized bullet calling out the gate change.
- CLAUDE.md eval-capture entry annotated with the new gate logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: post-ship documentation pass for v0.25.0
Cross-references every doc against the final state of the branch
(CONTRIBUTOR_MODE flag, eval replay tool, off-by-default capture):
- README.md: top callout rewritten — was implying capture-on-by-default
contradicting the gate landed in 7a80ce25. Now leads with
"contributor opt-in" and links docs/eval-bench.md alongside
docs/eval-capture.md.
- AGENTS.md: new "Eval retrieval changes" task entry with the
CONTRIBUTOR_MODE+replay one-liner so non-Claude agents (Codex, Cursor,
Aider) have the same path.
- CLAUDE.md: "Key commands added in v0.25.0" gains the replay command and
a CONTRIBUTOR_MODE bullet covering the resolution order.
- CHANGELOG.md: headline rewritten to match the actual feature ("benchmark
retrieval changes against real captured queries before merging" — was
"every real query is captured"). Stale "v0.22 ships the substrate"
→ v0.25. Test count corrected 82 → 144 (added 16 replay + 9
CONTRIBUTOR_MODE + 8 v31-shape tests since the original count). Two
metric rows added to the numbers table: default-off posture, in-tree
replay tooling. "To take advantage" block split into user vs
contributor branches with shell-rc instructions.
- TODOS.md: v0.22.1 follow-up reference corrected to v0.25.1.
llms.txt + llms-full.txt regenerated. Typecheck clean. 198/198 v0.25.0
tests still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
722 lines
35 KiB
PL/PgSQL
722 lines
35 KiB
PL/PgSQL
-- GBrain Postgres + pgvector schema
|
|
|
|
CREATE EXTENSION IF NOT EXISTS vector;
|
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
-- gen_random_uuid() is core in Postgres 13+; enable pgcrypto as fallback for older versions
|
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
|
|
-- ============================================================
|
|
-- sources: multi-repo / multi-brain tenancy (v0.18.0)
|
|
-- ============================================================
|
|
-- A source is a logical brain-within-the-DB: wiki, gstack, yc-media, etc.
|
|
-- Every page/file/ingest_log row carries source_id.
|
|
--
|
|
-- id: immutable citation key. [a-z0-9-]{1,32} enforced at app layer.
|
|
-- Used in [source:slug] citations, --source flag, wikilink syntax.
|
|
-- name: mutable display label. Rename via `gbrain sources rename`.
|
|
-- local_path: optional git checkout root for filesystem-backed sources.
|
|
-- config: forward-compat JSONB. Currently used for federation + ACL slot.
|
|
-- { "federated": bool, "access_policy": {...} }
|
|
-- - federated=true (or missing-but-explicit on 'default'):
|
|
-- participates in cross-source default search.
|
|
-- - federated=false (default for new sources):
|
|
-- only searched when explicitly named via --source.
|
|
-- - access_policy: forward-compat slot, no enforcement in v0.17.
|
|
-- Write-side lockdown: mutated only when ctx.remote=false.
|
|
CREATE TABLE IF NOT EXISTS sources (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
local_path TEXT,
|
|
last_commit TEXT,
|
|
last_sync_at TIMESTAMPTZ,
|
|
config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
-- v0.20.0 Cathedral II (SP-1): chunker version last used to sync this source.
|
|
-- performSync forces a full walk when this mismatches CURRENT_CHUNKER_VERSION,
|
|
-- bypassing the git-HEAD up_to_date early-return so CHUNKER_VERSION bumps
|
|
-- actually trigger re-chunking on upgrade.
|
|
chunker_version TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Seed the default source. 'default' is federated=true for backward compat
|
|
-- (pre-v0.17 brains behave exactly as before — every page appears in search).
|
|
-- Pre-existing sync.repo_path / sync.last_commit are copied in by the v16
|
|
-- migration, not here; fresh installs have no local_path until `sources add`
|
|
-- or the first `sync`.
|
|
INSERT INTO sources (id, name, config)
|
|
VALUES ('default', 'default', '{"federated": true}'::jsonb)
|
|
ON CONFLICT (id) DO NOTHING;
|
|
|
|
-- ============================================================
|
|
-- pages: the core content table
|
|
-- ============================================================
|
|
-- v0.18.0 (Step 2): pages.source_id scopes each row to a sources(id) row.
|
|
-- Slugs are unique per source, NOT globally. The default source is
|
|
-- seeded in the sources block above so the DEFAULT 'default' FK is
|
|
-- always valid at INSERT time.
|
|
CREATE TABLE IF NOT EXISTS pages (
|
|
id SERIAL PRIMARY KEY,
|
|
source_id TEXT NOT NULL DEFAULT 'default'
|
|
REFERENCES sources(id) ON DELETE CASCADE,
|
|
slug TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
-- v0.19.0: distinguishes markdown vs code pages at the DB level.
|
|
-- Drives orphans filter, auto-link bypass, and `query --lang`.
|
|
page_kind TEXT NOT NULL DEFAULT 'markdown'
|
|
CHECK (page_kind IN ('markdown','code')),
|
|
title TEXT NOT NULL,
|
|
compiled_truth TEXT NOT NULL DEFAULT '',
|
|
timeline TEXT NOT NULL DEFAULT '',
|
|
frontmatter JSONB NOT NULL DEFAULT '{}',
|
|
content_hash TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT pages_source_slug_key UNIQUE (source_id, slug)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_pages_type ON pages(type);
|
|
CREATE INDEX IF NOT EXISTS idx_pages_frontmatter ON pages USING GIN(frontmatter);
|
|
CREATE INDEX IF NOT EXISTS idx_pages_trgm ON pages USING GIN(title gin_trgm_ops);
|
|
-- v0.13.1 #170: avoids 14.6s seqscan on large brains when listing pages newest-first.
|
|
CREATE INDEX IF NOT EXISTS idx_pages_updated_at_desc ON pages (updated_at DESC);
|
|
-- v0.18.0: source-scoped scans (per /plan-eng-review Section 4).
|
|
CREATE INDEX IF NOT EXISTS idx_pages_source_id ON pages(source_id);
|
|
|
|
-- ============================================================
|
|
-- content_chunks: chunked content with embeddings
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS content_chunks (
|
|
id SERIAL PRIMARY KEY,
|
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
chunk_index INTEGER NOT NULL,
|
|
chunk_text TEXT NOT NULL,
|
|
chunk_source TEXT NOT NULL DEFAULT 'compiled_truth',
|
|
embedding vector(1536),
|
|
model TEXT NOT NULL DEFAULT 'text-embedding-3-large',
|
|
token_count INTEGER,
|
|
embedded_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
-- v0.19.0: code chunk metadata. Nullable — markdown chunks leave these NULL.
|
|
-- Powers `query --lang`, `code-def <symbol>`, and `code-refs <symbol>`.
|
|
language TEXT,
|
|
symbol_name TEXT,
|
|
symbol_type TEXT,
|
|
start_line INTEGER,
|
|
end_line INTEGER,
|
|
-- v0.20.0 Cathedral II: qualified symbol identity + parent scope + doc-comment
|
|
-- + chunk-grain FTS. All nullable — markdown chunks leave these NULL.
|
|
parent_symbol_path TEXT[],
|
|
doc_comment TEXT,
|
|
symbol_name_qualified TEXT,
|
|
search_vector TSVECTOR
|
|
);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunks_page_index ON content_chunks(page_id, chunk_index);
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_page ON content_chunks(page_id);
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_embedding ON content_chunks USING hnsw (embedding vector_cosine_ops);
|
|
-- v0.19.0: partial indexes — only code chunks populate these columns.
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_symbol_name ON content_chunks(symbol_name) WHERE symbol_name IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_language ON content_chunks(language) WHERE language IS NOT NULL;
|
|
-- v0.20.0 Cathedral II: GIN index on the new chunk-grain FTS vector.
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_search_vector ON content_chunks USING GIN(search_vector);
|
|
CREATE INDEX IF NOT EXISTS idx_chunks_symbol_qualified
|
|
ON content_chunks(symbol_name_qualified) WHERE symbol_name_qualified IS NOT NULL;
|
|
|
|
-- v0.20.0 Cathedral II: chunk-grain FTS trigger.
|
|
-- Weight 'A' on doc_comment + symbol_name_qualified; weight 'B' on chunk_text.
|
|
-- NL queries ("how do we handle errors") rank doc-comment hits above body text.
|
|
-- BEFORE INSERT OR UPDATE OF specific columns — only refires when those change,
|
|
-- not on every chunk update (e.g., embedding refresh doesn't trigger rebuild).
|
|
CREATE OR REPLACE FUNCTION update_chunk_search_vector() RETURNS TRIGGER AS $fn$
|
|
BEGIN
|
|
NEW.search_vector :=
|
|
setweight(to_tsvector('english', COALESCE(NEW.doc_comment, '')), 'A') ||
|
|
setweight(to_tsvector('english', COALESCE(NEW.symbol_name_qualified, '')), 'A') ||
|
|
setweight(to_tsvector('english', COALESCE(NEW.chunk_text, '')), 'B');
|
|
RETURN NEW;
|
|
END;
|
|
$fn$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS chunk_search_vector_trigger ON content_chunks;
|
|
CREATE TRIGGER chunk_search_vector_trigger
|
|
BEFORE INSERT OR UPDATE OF chunk_text, doc_comment, symbol_name_qualified
|
|
ON content_chunks
|
|
FOR EACH ROW EXECUTE FUNCTION update_chunk_search_vector();
|
|
|
|
-- ============================================================
|
|
-- code_edges_chunk + code_edges_symbol: v0.20.0 Cathedral II structural edges
|
|
-- ============================================================
|
|
-- Two-table design (codex F4 + SP-7):
|
|
-- - code_edges_chunk: resolved edges (both endpoints = known chunk IDs)
|
|
-- - code_edges_symbol: unresolved refs (target known by qualified name,
|
|
-- defining chunk not yet imported)
|
|
-- Readers UNION both tables; no promotion step.
|
|
-- Source scoping: from_chunk_id -> content_chunks -> pages.source_id
|
|
-- determines the source. Resolution logic MUST scope on source (codex SP-3);
|
|
-- only --all-sources callers bypass this. UNIQUE keys don't include source_id
|
|
-- because from_chunk_id already pins it.
|
|
CREATE TABLE IF NOT EXISTS code_edges_chunk (
|
|
id SERIAL PRIMARY KEY,
|
|
from_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
|
|
to_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
|
|
from_symbol_qualified TEXT NOT NULL,
|
|
to_symbol_qualified TEXT NOT NULL,
|
|
edge_type TEXT NOT NULL,
|
|
edge_metadata JSONB NOT NULL DEFAULT '{}',
|
|
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT code_edges_chunk_unique UNIQUE (from_chunk_id, to_chunk_id, edge_type)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_from
|
|
ON code_edges_chunk(from_chunk_id, edge_type);
|
|
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to
|
|
ON code_edges_chunk(to_chunk_id, edge_type);
|
|
CREATE INDEX IF NOT EXISTS idx_code_edges_chunk_to_symbol
|
|
ON code_edges_chunk(to_symbol_qualified, edge_type);
|
|
|
|
CREATE TABLE IF NOT EXISTS code_edges_symbol (
|
|
id SERIAL PRIMARY KEY,
|
|
from_chunk_id INTEGER NOT NULL REFERENCES content_chunks(id) ON DELETE CASCADE,
|
|
from_symbol_qualified TEXT NOT NULL,
|
|
to_symbol_qualified TEXT NOT NULL,
|
|
edge_type TEXT NOT NULL,
|
|
edge_metadata JSONB NOT NULL DEFAULT '{}',
|
|
source_id TEXT REFERENCES sources(id) ON DELETE CASCADE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT code_edges_symbol_unique UNIQUE (from_chunk_id, to_symbol_qualified, edge_type)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_from
|
|
ON code_edges_symbol(from_chunk_id, edge_type);
|
|
CREATE INDEX IF NOT EXISTS idx_code_edges_symbol_to
|
|
ON code_edges_symbol(to_symbol_qualified, edge_type);
|
|
|
|
-- ============================================================
|
|
-- links: cross-references between pages
|
|
-- ============================================================
|
|
-- Provenance model (v0.13):
|
|
-- link_source — 'markdown' | 'frontmatter' | 'manual' | NULL
|
|
-- (NULL = legacy row written before v0.13; unknown source)
|
|
-- origin_page_id — for link_source='frontmatter', the page whose YAML
|
|
-- frontmatter created this edge; scopes reconciliation
|
|
-- origin_field — the frontmatter field name (e.g. 'key_people')
|
|
--
|
|
-- The unique constraint includes link_source + origin_page_id so a manual edge
|
|
-- and a frontmatter-derived edge with the same (from, to, type) tuple coexist.
|
|
-- Reconciliation on put_page filters by (link_source='frontmatter' AND
|
|
-- origin_page_id = written_page) — never touches other pages' edges.
|
|
CREATE TABLE IF NOT EXISTS links (
|
|
id SERIAL PRIMARY KEY,
|
|
from_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
to_page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
link_type TEXT NOT NULL DEFAULT '',
|
|
context TEXT NOT NULL DEFAULT '',
|
|
link_source TEXT CHECK (link_source IS NULL OR link_source IN ('markdown', 'frontmatter', 'manual')),
|
|
origin_page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
|
|
origin_field TEXT,
|
|
-- v0.18.0 Step 4: 'qualified' when the link was written as
|
|
-- [[source:slug]] (target source pinned). 'unqualified' when written
|
|
-- as bare [[slug]] and resolved via local-first fallback at
|
|
-- extraction time. NULL for legacy/manual/frontmatter edges.
|
|
resolution_type TEXT CHECK (resolution_type IS NULL OR resolution_type IN ('qualified', 'unqualified')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
-- NULLS NOT DISTINCT (PG15+) so two rows with link_source IS NULL or
|
|
-- origin_page_id IS NULL collide as expected. Without this, every row with
|
|
-- NULL origin_page_id (markdown/manual edges) would be treated as unique.
|
|
CONSTRAINT links_from_to_type_source_origin_unique
|
|
UNIQUE NULLS NOT DISTINCT (from_page_id, to_page_id, link_type, link_source, origin_page_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_links_from ON links(from_page_id);
|
|
CREATE INDEX IF NOT EXISTS idx_links_to ON links(to_page_id);
|
|
CREATE INDEX IF NOT EXISTS idx_links_source ON links(link_source);
|
|
CREATE INDEX IF NOT EXISTS idx_links_origin ON links(origin_page_id);
|
|
|
|
-- ============================================================
|
|
-- tags
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS tags (
|
|
id SERIAL PRIMARY KEY,
|
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
tag TEXT NOT NULL,
|
|
UNIQUE(page_id, tag)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag);
|
|
CREATE INDEX IF NOT EXISTS idx_tags_page_id ON tags(page_id);
|
|
|
|
-- ============================================================
|
|
-- raw_data: sidecar data (replaces .raw/ JSON files)
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS raw_data (
|
|
id SERIAL PRIMARY KEY,
|
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
source TEXT NOT NULL,
|
|
data JSONB NOT NULL,
|
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE(page_id, source)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_raw_data_page ON raw_data(page_id);
|
|
|
|
-- ============================================================
|
|
-- timeline_entries: structured timeline
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS timeline_entries (
|
|
id SERIAL PRIMARY KEY,
|
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
date DATE NOT NULL,
|
|
source TEXT NOT NULL DEFAULT '',
|
|
summary TEXT NOT NULL,
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_timeline_page ON timeline_entries(page_id);
|
|
CREATE INDEX IF NOT EXISTS idx_timeline_date ON timeline_entries(date);
|
|
-- Dedup constraint: same (page, date, summary) treated as same event
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_timeline_dedup ON timeline_entries(page_id, date, summary);
|
|
|
|
-- ============================================================
|
|
-- page_versions: snapshot history for compiled_truth
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS page_versions (
|
|
id SERIAL PRIMARY KEY,
|
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
|
compiled_truth TEXT NOT NULL,
|
|
frontmatter JSONB NOT NULL DEFAULT '{}',
|
|
snapshot_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_versions_page ON page_versions(page_id);
|
|
|
|
-- ============================================================
|
|
-- ingest_log
|
|
-- ============================================================
|
|
-- NOTE (v0.18.0 Step 1): ingest_log.source_id is NOT added yet — lands
|
|
-- in v17 alongside the sync rewrite (Step 5), which starts writing
|
|
-- source-scoped entries.
|
|
CREATE TABLE IF NOT EXISTS ingest_log (
|
|
id SERIAL PRIMARY KEY,
|
|
source_type TEXT NOT NULL,
|
|
source_ref TEXT NOT NULL,
|
|
pages_updated JSONB NOT NULL DEFAULT '[]',
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- ============================================================
|
|
-- config: brain-level settings
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
INSERT INTO config (key, value) VALUES
|
|
('version', '1'),
|
|
('embedding_model', 'text-embedding-3-large'),
|
|
('embedding_dimensions', '1536'),
|
|
('chunk_strategy', 'semantic')
|
|
ON CONFLICT (key) DO NOTHING;
|
|
|
|
-- ============================================================
|
|
-- access_tokens: bearer tokens for remote MCP access
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS access_tokens (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
scopes TEXT[],
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
last_used_at TIMESTAMPTZ,
|
|
revoked_at TIMESTAMPTZ
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_access_tokens_hash ON access_tokens (token_hash) WHERE revoked_at IS NULL;
|
|
|
|
-- ============================================================
|
|
-- mcp_request_log: usage logging for remote MCP requests
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS mcp_request_log (
|
|
id SERIAL PRIMARY KEY,
|
|
token_name TEXT,
|
|
operation TEXT NOT NULL,
|
|
latency_ms INTEGER,
|
|
status TEXT NOT NULL DEFAULT 'success',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- ============================================================
|
|
-- files: binary attachments stored in Supabase Storage
|
|
-- ============================================================
|
|
-- v0.18.0 Step 7: files gains source_id + page_id alongside the
|
|
-- legacy page_slug (kept for backward compat until a later release).
|
|
-- The file_migration_ledger below drives the storage object rewrite.
|
|
-- page_slug FK had ON UPDATE CASCADE — removed because slugs are no
|
|
-- longer global (composite UNIQUE) so CASCADE on-update is ambiguous.
|
|
-- ON DELETE SET NULL is preserved via both page_slug and page_id.
|
|
CREATE TABLE IF NOT EXISTS files (
|
|
id SERIAL PRIMARY KEY,
|
|
source_id TEXT NOT NULL DEFAULT 'default'
|
|
REFERENCES sources(id) ON DELETE CASCADE,
|
|
page_slug TEXT,
|
|
page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
|
|
filename TEXT NOT NULL,
|
|
storage_path TEXT NOT NULL,
|
|
mime_type TEXT,
|
|
size_bytes BIGINT,
|
|
content_hash TEXT NOT NULL,
|
|
metadata JSONB NOT NULL DEFAULT '{}',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
UNIQUE(storage_path)
|
|
);
|
|
|
|
-- Migration: drop storage_url if it exists (renamed to storage_path only)
|
|
ALTER TABLE files DROP COLUMN IF EXISTS storage_url;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_files_page ON files(page_slug);
|
|
CREATE INDEX IF NOT EXISTS idx_files_page_id ON files(page_id);
|
|
CREATE INDEX IF NOT EXISTS idx_files_source_id ON files(source_id);
|
|
CREATE INDEX IF NOT EXISTS idx_files_hash ON files(content_hash);
|
|
|
|
-- ============================================================
|
|
-- file_migration_ledger (v0.18.0 Step 7)
|
|
-- Drives the storage-object rewrite performed by the v0_18_0
|
|
-- orchestrator's phase B. Keyed on file_id so two sources can share
|
|
-- an old path during migration without PK collision (Codex second-
|
|
-- pass caught this).
|
|
-- Status state machine: pending → copy_done → db_updated → complete
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS file_migration_ledger (
|
|
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
|
|
storage_path_old TEXT NOT NULL,
|
|
storage_path_new TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
error TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT chk_ledger_status CHECK (status IN ('pending','copy_done','db_updated','complete','failed'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_file_migration_ledger_status
|
|
ON file_migration_ledger(status) WHERE status != 'complete';
|
|
|
|
-- ============================================================
|
|
-- Trigger-based search_vector (spans pages + timeline_entries)
|
|
-- ============================================================
|
|
ALTER TABLE pages ADD COLUMN IF NOT EXISTS search_vector tsvector;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_pages_search ON pages USING GIN(search_vector);
|
|
|
|
-- Function to rebuild search_vector for a page
|
|
CREATE OR REPLACE FUNCTION update_page_search_vector() RETURNS trigger AS $$
|
|
DECLARE
|
|
timeline_text TEXT;
|
|
BEGIN
|
|
-- Gather timeline_entries text for this page
|
|
SELECT coalesce(string_agg(summary || ' ' || detail, ' '), '')
|
|
INTO timeline_text
|
|
FROM timeline_entries
|
|
WHERE page_id = NEW.id;
|
|
|
|
-- Build weighted tsvector
|
|
NEW.search_vector :=
|
|
setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
|
|
setweight(to_tsvector('english', coalesce(NEW.compiled_truth, '')), 'B') ||
|
|
setweight(to_tsvector('english', coalesce(NEW.timeline, '')), 'C') ||
|
|
setweight(to_tsvector('english', coalesce(timeline_text, '')), 'C');
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS trg_pages_search_vector ON pages;
|
|
CREATE TRIGGER trg_pages_search_vector
|
|
BEFORE INSERT OR UPDATE ON pages
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION update_page_search_vector();
|
|
|
|
-- Note: timeline_entries trigger removed (v0.10.1).
|
|
-- Structured timeline_entries power temporal queries (graph layer).
|
|
-- The markdown timeline section in pages.timeline still feeds search_vector via
|
|
-- the trg_pages_search_vector trigger above. Removing the timeline_entries
|
|
-- trigger avoids double-weighting the same content in search and prevents
|
|
-- mutation-induced reordering during timeline-extract pagination.
|
|
DROP TRIGGER IF EXISTS trg_timeline_search_vector ON timeline_entries;
|
|
DROP FUNCTION IF EXISTS update_page_search_vector_from_timeline();
|
|
|
|
-- ============================================================
|
|
-- Minion Jobs: BullMQ-inspired Postgres-native job queue
|
|
-- ============================================================
|
|
CREATE TABLE IF NOT EXISTS minion_jobs (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
queue TEXT NOT NULL DEFAULT 'default',
|
|
status TEXT NOT NULL DEFAULT 'waiting',
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
data JSONB NOT NULL DEFAULT '{}',
|
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
attempts_made INTEGER NOT NULL DEFAULT 0,
|
|
attempts_started INTEGER NOT NULL DEFAULT 0,
|
|
backoff_type TEXT NOT NULL DEFAULT 'exponential',
|
|
backoff_delay INTEGER NOT NULL DEFAULT 1000,
|
|
backoff_jitter REAL NOT NULL DEFAULT 0.2,
|
|
stalled_counter INTEGER NOT NULL DEFAULT 0,
|
|
max_stalled INTEGER NOT NULL DEFAULT 5,
|
|
lock_token TEXT,
|
|
lock_until TIMESTAMPTZ,
|
|
delay_until TIMESTAMPTZ,
|
|
parent_job_id INTEGER REFERENCES minion_jobs(id) ON DELETE SET NULL,
|
|
on_child_fail TEXT NOT NULL DEFAULT 'fail_parent',
|
|
tokens_input INTEGER NOT NULL DEFAULT 0,
|
|
tokens_output INTEGER NOT NULL DEFAULT 0,
|
|
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
|
|
result JSONB,
|
|
progress JSONB,
|
|
error_text TEXT,
|
|
stacktrace JSONB DEFAULT '[]',
|
|
depth INTEGER NOT NULL DEFAULT 0,
|
|
max_children INTEGER,
|
|
timeout_ms INTEGER,
|
|
timeout_at TIMESTAMPTZ,
|
|
remove_on_complete BOOLEAN NOT NULL DEFAULT FALSE,
|
|
remove_on_fail BOOLEAN NOT NULL DEFAULT FALSE,
|
|
idempotency_key TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
started_at TIMESTAMPTZ,
|
|
finished_at TIMESTAMPTZ,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT chk_status CHECK (status IN ('waiting','active','completed','failed','delayed','dead','cancelled','waiting-children','paused')),
|
|
CONSTRAINT chk_backoff_type CHECK (backoff_type IN ('fixed','exponential')),
|
|
CONSTRAINT chk_on_child_fail CHECK (on_child_fail IN ('fail_parent','remove_dep','ignore','continue')),
|
|
CONSTRAINT chk_jitter_range CHECK (backoff_jitter >= 0.0 AND backoff_jitter <= 1.0),
|
|
CONSTRAINT chk_attempts_order CHECK (attempts_made <= attempts_started),
|
|
CONSTRAINT chk_nonnegative CHECK (attempts_made >= 0 AND attempts_started >= 0 AND stalled_counter >= 0 AND max_attempts >= 1 AND max_stalled >= 0),
|
|
CONSTRAINT chk_depth_nonnegative CHECK (depth >= 0),
|
|
CONSTRAINT chk_max_children_positive CHECK (max_children IS NULL OR max_children > 0),
|
|
CONSTRAINT chk_timeout_positive CHECK (timeout_ms IS NULL OR timeout_ms > 0)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_minion_jobs_claim ON minion_jobs (queue, priority ASC, created_at ASC) WHERE status = 'waiting';
|
|
CREATE INDEX IF NOT EXISTS idx_minion_jobs_status ON minion_jobs(status);
|
|
CREATE INDEX IF NOT EXISTS idx_minion_jobs_stalled ON minion_jobs (lock_until) WHERE status = 'active';
|
|
CREATE INDEX IF NOT EXISTS idx_minion_jobs_delayed ON minion_jobs (delay_until) WHERE status = 'delayed';
|
|
CREATE INDEX IF NOT EXISTS idx_minion_jobs_parent ON minion_jobs(parent_job_id);
|
|
CREATE INDEX IF NOT EXISTS idx_minion_jobs_timeout ON minion_jobs (timeout_at) WHERE status = 'active' AND timeout_at IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_minion_jobs_parent_status ON minion_jobs (parent_job_id, status) WHERE parent_job_id IS NOT NULL;
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_minion_jobs_idempotency ON minion_jobs (idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
|
|
-- Inbox table for sidechannel messaging
|
|
CREATE TABLE IF NOT EXISTS minion_inbox (
|
|
id SERIAL PRIMARY KEY,
|
|
job_id INTEGER NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
|
sender TEXT NOT NULL,
|
|
payload JSONB NOT NULL,
|
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
read_at TIMESTAMPTZ
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_minion_inbox_unread ON minion_inbox (job_id) WHERE read_at IS NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_minion_inbox_child_done ON minion_inbox (job_id, sent_at) WHERE payload->>'type' = 'child_done';
|
|
|
|
-- Attachments table: per-job binary blobs (manifests, agent outputs, files)
|
|
CREATE TABLE IF NOT EXISTS minion_attachments (
|
|
id SERIAL PRIMARY KEY,
|
|
job_id INTEGER NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
|
filename TEXT NOT NULL,
|
|
content_type TEXT NOT NULL,
|
|
content BYTEA,
|
|
storage_uri TEXT,
|
|
size_bytes INTEGER NOT NULL,
|
|
sha256 TEXT NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT uniq_minion_attachments_job_filename UNIQUE (job_id, filename),
|
|
CONSTRAINT chk_attachment_storage CHECK (content IS NOT NULL OR storage_uri IS NOT NULL),
|
|
CONSTRAINT chk_attachment_size CHECK (size_bytes >= 0)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_minion_attachments_job ON minion_attachments (job_id);
|
|
ALTER TABLE minion_attachments ALTER COLUMN content SET STORAGE EXTERNAL;
|
|
|
|
-- ============================================================
|
|
-- Subagent runtime (v0.16.0) — durable LLM loops
|
|
-- ============================================================
|
|
-- Anthropic-native message blocks, one row per Messages API message. Parallel
|
|
-- tool_use blocks in one assistant message live in content_blocks JSONB,
|
|
-- not across rows.
|
|
CREATE TABLE IF NOT EXISTS subagent_messages (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
|
message_idx INTEGER NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content_blocks JSONB NOT NULL,
|
|
tokens_in INTEGER,
|
|
tokens_out INTEGER,
|
|
tokens_cache_read INTEGER,
|
|
tokens_cache_create INTEGER,
|
|
model TEXT,
|
|
ended_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT uniq_subagent_messages_idx UNIQUE (job_id, message_idx),
|
|
CONSTRAINT chk_subagent_messages_role CHECK (role IN ('user','assistant'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_subagent_messages_job ON subagent_messages (job_id, message_idx);
|
|
|
|
-- Two-phase tool execution ledger. Before tool call: INSERT status='pending'.
|
|
-- After success: UPDATE to 'complete' + output. On failure: 'failed' + error.
|
|
-- Replay re-runs 'pending' rows only if the tool is idempotent.
|
|
CREATE TABLE IF NOT EXISTS subagent_tool_executions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
|
message_idx INTEGER NOT NULL,
|
|
tool_use_id TEXT NOT NULL,
|
|
tool_name TEXT NOT NULL,
|
|
input JSONB NOT NULL,
|
|
status TEXT NOT NULL,
|
|
output JSONB,
|
|
error TEXT,
|
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
ended_at TIMESTAMPTZ,
|
|
CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id),
|
|
CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status);
|
|
|
|
-- Rate-lease table — concurrency cap on outbound providers (e.g.
|
|
-- anthropic:messages). Acquire: INSERT if active < max_concurrent under
|
|
-- advisory lock. Release: DELETE. Stale leases (expires_at past) auto-prune
|
|
-- on next acquire so crashed workers can't strand capacity.
|
|
CREATE TABLE IF NOT EXISTS subagent_rate_leases (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
key TEXT NOT NULL,
|
|
owner_job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE,
|
|
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
expires_at TIMESTAMPTZ NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_rate_leases_key_expires ON subagent_rate_leases (key, expires_at);
|
|
|
|
-- ============================================================
|
|
-- Dream-cycle significance verdict cache — v0.21 synthesize phase
|
|
-- ============================================================
|
|
-- Caches the cheap Haiku "is this transcript worth processing?" verdict
|
|
-- per (file_path, content_hash) so backfill re-runs skip already-judged
|
|
-- files. Distinct from raw_data (which is page-scoped); transcripts
|
|
-- aren't pages.
|
|
CREATE TABLE IF NOT EXISTS dream_verdicts (
|
|
file_path TEXT NOT NULL,
|
|
content_hash TEXT NOT NULL,
|
|
worth_processing BOOLEAN NOT NULL,
|
|
reasons JSONB,
|
|
judged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
PRIMARY KEY (file_path, content_hash)
|
|
);
|
|
|
|
-- ============================================================
|
|
-- Cycle coordination lock — v0.17 runCycle primitive
|
|
-- ============================================================
|
|
-- One row per active cycle. Any caller (autopilot daemon, Minions
|
|
-- autopilot-cycle handler, gbrain dream CLI) tries to acquire this
|
|
-- row before running a DB-write phase. Holders refresh ttl_expires_at
|
|
-- between phases; crashed holders auto-release once TTL expires.
|
|
-- Works through PgBouncer transaction pooling, unlike session-scoped
|
|
-- pg_try_advisory_lock.
|
|
CREATE TABLE IF NOT EXISTS gbrain_cycle_locks (
|
|
id TEXT PRIMARY KEY,
|
|
holder_pid INT NOT NULL,
|
|
holder_host TEXT,
|
|
acquired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
ttl_expires_at TIMESTAMPTZ NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cycle_locks_ttl ON gbrain_cycle_locks(ttl_expires_at);
|
|
|
|
-- ============================================================
|
|
-- Eval capture (v0.25.0 — BrainBench-Real substrate)
|
|
-- ============================================================
|
|
-- eval_candidates: captured query/search calls from the op-layer wrapper
|
|
-- in src/core/operations.ts. PII is scrubbed before insert by
|
|
-- src/core/eval-capture-scrub.ts. query is CHECK-capped at 50KB.
|
|
-- eval_capture_failures: cross-process audit of insert failures, surfaced
|
|
-- by `gbrain doctor` (in-process counters can't bridge MCP server + doctor
|
|
-- CLI process boundaries).
|
|
CREATE TABLE IF NOT EXISTS eval_candidates (
|
|
id SERIAL PRIMARY KEY,
|
|
tool_name TEXT NOT NULL CHECK (tool_name IN ('query', 'search')),
|
|
query TEXT NOT NULL CHECK (length(query) <= 51200),
|
|
retrieved_slugs TEXT[] NOT NULL DEFAULT '{}',
|
|
retrieved_chunk_ids INTEGER[] NOT NULL DEFAULT '{}',
|
|
source_ids TEXT[] NOT NULL DEFAULT '{}',
|
|
expand_enabled BOOLEAN,
|
|
detail TEXT CHECK (detail IS NULL OR detail IN ('low', 'medium', 'high')),
|
|
detail_resolved TEXT CHECK (detail_resolved IS NULL OR detail_resolved IN ('low', 'medium', 'high')),
|
|
vector_enabled BOOLEAN NOT NULL,
|
|
expansion_applied BOOLEAN NOT NULL,
|
|
latency_ms INTEGER NOT NULL,
|
|
remote BOOLEAN NOT NULL,
|
|
job_id INTEGER,
|
|
subagent_id INTEGER,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_eval_candidates_created_at ON eval_candidates(created_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS eval_capture_failures (
|
|
id SERIAL PRIMARY KEY,
|
|
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
reason TEXT NOT NULL CHECK (reason IN ('db_down', 'rls_reject', 'check_violation', 'scrubber_exception', 'other'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_eval_capture_failures_ts ON eval_capture_failures(ts DESC);
|
|
|
|
-- NOTIFY trigger for real-time job events (Postgres only, not PGLite)
|
|
CREATE OR REPLACE FUNCTION notify_minion_job_change() RETURNS trigger AS $$
|
|
BEGIN
|
|
PERFORM pg_notify('minion_jobs', json_build_object(
|
|
'id', NEW.id, 'status', NEW.status, 'name', NEW.name,
|
|
'queue', NEW.queue, 'prev_status', COALESCE(OLD.status, 'new')
|
|
)::text);
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS minion_job_notify ON minion_jobs;
|
|
CREATE TRIGGER minion_job_notify AFTER INSERT OR UPDATE OF status ON minion_jobs
|
|
FOR EACH ROW EXECUTE FUNCTION notify_minion_job_change();
|
|
|
|
-- ============================================================
|
|
-- Row Level Security: block anon access, postgres role bypasses
|
|
-- ============================================================
|
|
-- The postgres role (used by gbrain via pooler) has BYPASSRLS.
|
|
-- Enabling RLS with no policies means the anon key can't read anything.
|
|
-- Only enable if the current role actually has BYPASSRLS privilege,
|
|
-- otherwise we'd lock ourselves out.
|
|
DO $$
|
|
DECLARE
|
|
has_bypass BOOLEAN;
|
|
BEGIN
|
|
SELECT rolbypassrls INTO has_bypass FROM pg_roles WHERE rolname = current_user;
|
|
IF has_bypass THEN
|
|
ALTER TABLE pages ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE content_chunks ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE links ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE raw_data ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE timeline_entries ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE page_versions ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE ingest_log ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE config ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE minion_jobs ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE sources ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE file_migration_ledger ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE access_tokens ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE mcp_request_log ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE minion_inbox ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE minion_attachments ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE subagent_messages ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE subagent_tool_executions ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE subagent_rate_leases ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE gbrain_cycle_locks ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE dream_verdicts ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE eval_candidates ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE eval_capture_failures ENABLE ROW LEVEL SECURITY;
|
|
RAISE NOTICE 'RLS enabled on all tables (role % has BYPASSRLS)', current_user;
|
|
ELSE
|
|
RAISE WARNING 'Skipping RLS: role % does not have BYPASSRLS privilege. Run as postgres role to enable.', current_user;
|
|
END IF;
|
|
END $$;
|