v0.51.0.0 feat: make concurrent writes durable and revision-safe (#5149)
Some checks failed
Actionlint / actionlint (push) Failing after 16s
E2E Tests / Tier 2 (LLM Skills) (push) Has been skipped
Release / version (push) Successful in 5s
Test / gitleaks (push) Failing after 59s
Test / security-regressions (1.3.11, ubuntu-latest) (push) Failing after 10m54s
Test / security-regressions (1.3.13, ubuntu-latest) (push) Failing after 10m49s
Test / dependency-audit (push) Successful in 9s
Test / verify (push) Failing after 11s
Test / serial-tests (1) (push) Failing after 1m4s
Test / serial-tests (2) (push) Failing after 14s
Test / serial-tests (3) (push) Failing after 14s
Test / serial-tests (4) (push) Failing after 14s
Test / slow-eval-longmemeval (push) Failing after 11s
Test / slow-brainbench-e2e (push) Failing after 13s
Test / brainbench (push) Failing after 10s
Test / slow-entity-resolve-perf (push) Failing after 12s
Test / test (1) (push) Failing after 28s
Test / test (10) (push) Failing after 27s
Test / test (2) (push) Failing after 26s
Test / test (3) (push) Failing after 34s
Test / test (4) (push) Failing after 39s
Test / test (5) (push) Failing after 36s
Test / test (6) (push) Failing after 36s
Test / test (7) (push) Failing after 22s
Test / test (8) (push) Failing after 42s
Test / test (9) (push) Failing after 23s
Test / native-locks (push) Failing after 23m32s
Test / persistence-validation (push) Failing after 38m54s
Release / build (gbrain-linux-x64, ubuntu-latest, bun-linux-x64) (push) Failing after 11m35s
Test / coverage-report (push) Failing after 27s
Test / Native-only result (full CI not run) (push) Has been skipped
Release / build (gbrain-darwin-arm64, macos-latest, bun-darwin-arm64) (push) Has been cancelled
Test / security-regressions (1.3.11, macos-latest) (push) Has been cancelled
Test / security-regressions (1.3.11, windows-latest) (push) Has been cancelled
Test / security-regressions (1.3.13, macos-latest) (push) Has been cancelled
Test / security-regressions (1.3.13, windows-latest) (push) Has been cancelled
Release / release (push) Has been cancelled
Release / publish-template (push) Has been cancelled
Release / publish-codex-plugin (push) Has been cancelled
Test / ${{ github.event_name == 'workflow_dispatch' && inputs.native_only == true && 'full-suite-not-run' || 'test-status' }} (push) Has been cancelled
E2E Tests / JSONB parity (#2339 regression guard) (push) Failing after 12s
E2E Tests / prepare-e2e (push) Failing after 11s
E2E Tests / Selected E2E (diff-relevant) ${{ matrix.shard }} (push) Has been skipped
E2E Tests / Tier 1 (Mechanical) (push) Failing after 11s
E2E Tests / coverage-full-unit (1) (push) Failing after 24s
E2E Tests / coverage-full-unit (10) (push) Failing after 24s
E2E Tests / coverage-full-unit (2) (push) Failing after 21s
E2E Tests / coverage-full-unit (3) (push) Failing after 31s
E2E Tests / coverage-full-unit (4) (push) Failing after 33s
E2E Tests / coverage-full-unit (5) (push) Failing after 30s
E2E Tests / coverage-full-unit (6) (push) Failing after 30s
E2E Tests / coverage-full-unit (7) (push) Failing after 19s
E2E Tests / coverage-full-unit (8) (push) Failing after 37s
E2E Tests / coverage-full-unit (9) (push) Failing after 18s
E2E Tests / coverage-full-serial (push) Failing after 8s
E2E Tests / coverage-full-slow (push) Failing after 8s
E2E Tests / coverage-full-e2e (push) Failing after 11s
E2E Tests / coverage-full-report (push) Failing after 13s
E2E Tests / e2e-status (push) Failing after 0s
OSV-Scanner / osv-scan (push) Failing after 0s
Semgrep / semgrep (push) Failing after 0s
Heavy Tests / Heavy tests (push) Failing after 10s
Heavy Tests / Real-agent door e2e (skips without authed binaries) (push) Failing after 9s
Heavy Tests / Hermes door e2e (real binary, loud-fail) (push) Failing after 10s
Heavy Tests / Grok door e2e (real binary, keyless-first) (push) Has been skipped
Heavy Tests / Plugin doors (codex + claude, install tier) (push) Failing after 9s
Heavy Tests / opencode door e2e (real binary, keyless SMOKE) (push) Failing after 10s
Heavy Tests / opencode door canary (latest, keyless, non-gating) (push) Failing after 9s

* Add typed mutation receipts and preserve pending write errors

* Add canonical page revisions, snapshot reads, and composable transaction guards

* fix: add portable native writer locks and runtime gates

* Route PGLite operations through bounded local persistence IPC

* Add durable mutation admission and recoverable canonical page publication

* Prepare CLI mutations before connecting and preserve replay intent

* Wire resident writer ingress and fence unmanaged canonical mutation

* Fence derived projections by canonical revision and persist withdrawal rebuild work

* Expose principal-scoped durable write receipts without widening grants

* fix: retain datastore ownership through shutdown and fence lease cleanup

* Journal memory verbs and publish semantic fact updates under canonical guards

* Coordinate semantic page mutations and seal only current sanitized projections

* Administer resident writers through authenticated local CLI IPC

* Journal takes mutations and share long-hold budgets across physical pools

* Route take mutations through replayable operations before opening PGLite

* Compose canonical projections with publication and retry transient database contention

* Document concurrent write guarantees and explicit receipt grant migration

* test: exercise durable persistence schedules and process crash recovery

* Apply shared persistence limits and compact permanent receipts with exact accounting

* fix: honor explicit direct routes for generic Postgres poolers

* Retain original page visibility ceilings and intentional sandbox persistence

* test: verify pooler capacity and owner incarnation fencing

* Preserve canonical projection round trips and timeline detail through recovery

* Wire persistence distribution guards and refresh public operation documentation

* Retry postcommit effects and recover withdrawal mirrors independently

* Include outbox schema in snapshot identities and isolate fresh test brains

* Fence legacy canonical writers and require revision-safe imports

* Activate managed persistence only after explicit quiescence and durable fencing

* Require honest median-of-three read latency under public writer load

* Require writer progress during read latency samples

* Compose graph reconciliation with canonical publication and retain writer ceilings

* Exercise source-scoped takes and duplicate fences with durable principals

* Preserve transaction receivers when restoring extraction test stubs

* Install revision-bound retrieval fixtures and preserve strict projection gates

* Keep engine test overrides composable across transaction clones

* Assert atomic readback rollback and public failed write receipts

* Settle definite publication failures and preserve typed receipts across CLI boundaries

* Restore bounded fact extraction receipts and durable postpublication scheduling

* Share activation fixtures within explicit engine lifecycles

* Model scoped read transactions and publish complete retrieval fixtures

* Coordinate resumable owner-scoped Markdown sync through durable page receipts

* Exercise page source and privacy boundaries with durable writer fixtures

* Publish complete search fixtures and assert composable scoped SQL routing

* fix: retain archived alias reads and expose writer ingress diagnostics

* Exercise managed writer activation in persistence validation workloads

* Publish revision-bound ranker and max-pool retrieval fixtures

* test: bind engine fixtures to coherent page projections and durable writes

* fix: compare enrichment against its original page snapshot

* fix: preserve withdrawal markers across legacy history and projection rebuilds

* fix: authenticate resident sync ingress and prevent writer starvation

* fix: durably acknowledge recovery and advance missing-file withdrawal effects

* Retain mutation identity and revision through internal caller retries

* Preserve contextual wrapping for deferred coordinated embeddings

* Install coherent retrieval fixtures and inspect raw chunk state explicitly

* fix: budget and fairly schedule standalone publication recovery

* Keep privacy and image fixtures aligned with projection visibility

* Bind embedding provenance to the prepared provider context

* Publish complete relational and search regression fixtures

* Preserve raw registry write checks and seal deferred embedding fixtures

* Retry coordinated imports when source policy cannot be read

* Preserve legacy migration data and exercise revision-aware public writers

* Coordinate managed source lifecycle and durable clone recovery

* fix: bind owner identity to the physical canonical checkout

* fix: reserve control capacity during source lifecycle publication

* fix: preserve delegated sync recovery and deferred embed ownership

* Verify lifecycle staging identity and preserve bounded recovery

* fix: account for pending clone requests in shared admission quotas

* test: align retrieval and replay fixtures with durable publication

* Release clone reservations exactly once and recheck source routing

* Require PostgreSQL lifecycle and recovery contracts in CI

* test: require physical-root refusal in the deployment matrix

* Route source administration through resident ownership and retained identities

* fix: retain captured canonical file routing under registered ownership

* test: exercise authenticated atomic take publication and retained replay IDs

* Retry confirmed admission aborts and preserve direct CLI conflict receipts

* Fix revision-safe import revival and preserve identity deduplication

* test: publish embedding migration fixtures before re-embedding

* Exercise durable publication with the actual release executable

* Exercise conditional embedding updates with coherent serial fixtures

* Verify clean resident EOF shutdown in the release smoke

* Seal search fixtures and assert durable side-effect outcomes

* Bound recovery preparation and exercise admission timeout receipts

* fix: retain delegated ownership and enforce take receipt holder grants

* Restore tombstoned pages consistently and preserve unexpected local files

* Preserve deletion state in canonical page versions

* Retry withdrawals after releasing the complete transaction

* Keep canonical version deletion schema bootstraps consistent

* Keep canonical version types beside page state

* Filter protected timeline content from remote version history

* Require PostgreSQL version history privacy coverage

* Require explicit native test timeouts in every CI entry point

* Bind Windows native locks to the active runtime API

* Test fact authorization with durable source grants

* Wait for authenticated persistence readiness in compiled CLI smoke

* Seal search fixtures against their canonical page revisions

* Refresh managed lifecycle fixtures and reviewed canonical writer inventory

* Measure staging bytes in clone metadata quota regression

* Preserve canonical write provenance and typed storage failures

* Measure durable admission through stable warmed workload instrumentation

* Require both engines in durable admission instrumentation regression

* Preserve bounded soak failure diagnostics and test fixtures

* Exercise timeout preflight in local CI execution fixtures

* Verify tombstone restoration values and stable no-op bytes

* Keep native workflow coverage checks compatible with explicit timeouts

* fix: bind local IPC safely across long paths and concurrent owners

* Require IPC ownership and path tests on every native target

* fix: preserve inaccessible IPC owners when Bun loses connect errno

* Isolate PgBouncer fixtures from test database guards and shard resets

* ci: isolate native-only dispatches from full persistence validation

* Keep agent seed worktrees outside their coordination state

* Seal expert search fixtures at their canonical page revision

* test: exercise durable PostgreSQL take publication and replay

* fix: retain native ownership across Windows IPC listeners

* fix: serve admin SPA from hidden checkout directories

* fix: derive Windows pipe claims from NT Unicode upcasing

* test: match Unicode IPC aliases to actual Windows pipe equality

The four Windows runners in run35037789501 proved dotless-i and I are
separate pipe names: kernel_alias=false, CompareStringOrdinal=3, with
identical NT and invariant-locale case tables. Keep both independent
providers in coverage and require genuine sigma/accent aliases to share
one live provider and native claim. Preserve the native API oracle.

Restore the exact native source, build recipe, manifest and artifacts from
2a703bb9875f214708d0c23b070fdd188ed3b459; the provisional NT upcase change
was unnecessary. All six non-Windows artifacts remain byte-identical.

Input SHA256:
before b86ba93886091ab6b8dbba48c891966680ed3484b5d815ac31684da401f1ec73
after  4759bdd927c0c0fa29c2d5af30e42972a183f9be006d595144d73b6ca1111636
Windows x64 SHA256:
before f5c9d45e261757b510979534b447bb118e95d5699c3dd6e8f1ec31200c6372aa
after  0b494ec94896219a3b142b6f216bf12c9ee94d27fa7e7399bcdab707ca60212e
Windows ARM64 SHA256:
before 74fe0d4e8205738675ba5dcccf55edbf5bb250ec96724b84b3490c30e3e27bf2
after  e8315b4453dce3df775eeb76c73e8895c273390e64b16a2c255e10790fa75dd7

* Repair merged fixtures for revision-bound chunk reads

* Require local legacy-chunk reads to honor projection seals

* Account for reviewed upstream canonical write references

* Isolate ambient keyset fixture page clocks from fact writes

* fix: settle publication failures beneath blocked file ancestors

* Match TTL vector fixture to the initialized fact column

* Record coordinated purge in canonical writer census

* docs: align concurrent persistence contracts and sync guidance

* fix: fence projection replacement to its originating snapshot

* fix: bound unit shard processes without dropping coverage

* fix: publish embedding vectors and contextual completion atomically

* test: isolate thin-client health and write fixtures

* test: isolate healthy doctor checks from retained E2E grants

* test: isolate each local unit file in its own Bun process

* fix: keep persistence parameter schemas free of registry cycles

* test: restore thin-client routing exit state

* style: consolidate page operation engine imports

* fix: isolate purge validator from generic flag scans

* test: isolate Postgres bootstrap ownership fixture

* test: isolate open-loop engine parity database

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

Document the coordinated concurrent-writer upgrade and synchronize the
release manifests, migration guide, generated stamps and skills lock.

Co-Authored-By: Codex <noreply@openai.com>

* docs: update concurrent-write guide for v0.51.0.0

* fix: resolve concurrency persistence security scan findings

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Garry Tan
2026-09-17 06:44:17 -07:00
committed by GitHub
parent 668b9bac30
commit d13aa742fd
566 changed files with 32009 additions and 8304 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.50.5.0",
"version": "0.51.0.0",
"description": "Personal knowledge brain for your coding agent — hybrid search, synthesis, graph traversal, and durable cross-session memory over Postgres/PGLite with pgvector, plus a curated brain-first skill set.",
"author": {
"name": "Garry Tan",

View File

@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.50.5.0",
"version": "0.51.0.0",
"description": "Personal knowledge brain for your coding agent — hybrid search, synthesis, graph traversal, and durable cross-session memory over Postgres/PGLite with pgvector, plus a curated brain-first skill set.",
"author": {
"name": "Garry Tan",

7
.gitattributes vendored
View File

@@ -28,3 +28,10 @@
# removes the whole class for anyone working here.
*.md text eol=lf
/.gbrain-evals/eval-results.jsonl merge=union
# Native source hashes and prebuilds must survive Windows checkout unchanged.
native/locks/** text eol=lf
native/locks/prebuilds/*.node binary
# Preserve the vendored upstream license byte-for-byte, including indentation.
native/locks/vendor/node-v22.15.0/LICENSE -whitespace
scripts/native/** text eol=lf

View File

@@ -63,7 +63,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
# This job runs bare `bun test` (not run-e2e.sh), so the snapshot must
# be activated here or the restored tar is never read (the engine gates
@@ -176,7 +176,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
- name: Run frozen E2E partition
shell: bash
@@ -230,7 +230,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
# Bare `bun test` job — activate the restored snapshot (see jsonb-parity).
- name: Ensure PGLite snapshot (build-or-validate, non-fatal)
@@ -359,7 +359,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
# Bare `bun test` job — activate the restored snapshot (see jsonb-parity).
- name: Ensure PGLite snapshot (build-or-validate, non-fatal)
@@ -539,7 +539,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
- name: Full e2e glob with coverage (all files, incl. engine-parity)
# run-e2e.sh redirects HOME, so COVERAGE_DIR must be absolute; its

85
.github/workflows/native-locks.yml vendored Normal file
View File

@@ -0,0 +1,85 @@
name: Native writer locks
on:
workflow_call:
permissions:
contents: read
jobs:
native:
name: ${{ matrix.target }} / Bun ${{ matrix.bun }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
bun: ['1.3.11', '1.3.13']
target: [linux-x64-glibc, linux-arm64-glibc, darwin-arm64, darwin-x64, win32-x64, win32-arm64]
include:
- runner: ubuntu-24.04
target: linux-x64-glibc
- runner: ubuntu-24.04-arm
target: linux-arm64-glibc
- runner: macos-15
target: darwin-arm64
- runner: macos-15-intel
target: darwin-x64
- runner: windows-2022
target: win32-x64
- runner: windows-11-arm
target: win32-arm64
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ matrix.bun }}
- run: bun install --frozen-lockfile --ignore-scripts
- run: bun scripts/native/setup-toolchain.ts --dir "${{ runner.temp }}/gbrain-zig" --github-path
- run: bun scripts/native/build.ts --target ${{ matrix.target }} --output "${{ runner.temp }}/native-rebuilt"
- run: bun scripts/native/verify.ts --rebuilt "${{ runner.temp }}/native-rebuilt"
- name: Verify Darwin ABI against the native SDK
if: runner.os == 'macOS'
run: cc -std=c11 native/locks/abi-check.c -o "${{ runner.temp }}/native-abi-check"
- run: bun test --timeout=60000 test/native-lock.test.ts test/pglite-lock.test.ts test/local-ipc-path.test.ts
- run: bun scripts/native/compiled-smoke.ts
- name: Build the published CLI platforms and verify persistent writes
if: matrix.target == 'linux-x64-glibc' || matrix.target == 'darwin-arm64'
timeout-minutes: 8
run: |
bun build --compile --no-compile-autoload-bunfig --outfile bin/gbrain-native-validation src/cli.ts
bun scripts/native/compiled-smoke.ts --binary bin/gbrain-native-validation
bun scripts/native/cli-persistence-smoke.ts --binary bin/gbrain-native-validation
musl:
name: ${{ matrix.target }} / Bun ${{ matrix.bun }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
bun: ['1.3.11', '1.3.13']
target: [linux-x64-musl, linux-arm64-musl]
include:
- runner: ubuntu-24.04
target: linux-x64-musl
- runner: ubuntu-24.04-arm
target: linux-arm64-musl
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ matrix.bun }}
- run: bun scripts/native/setup-toolchain.ts --dir "${{ runner.temp }}/gbrain-zig" --github-path
- run: bun scripts/native/build.ts --target ${{ matrix.target }} --output "${{ runner.temp }}/native-rebuilt"
- run: bun scripts/native/verify.ts --rebuilt "${{ runner.temp }}/native-rebuilt"
- name: Execute prebuild in native musl userspace
env:
BUN_VERSION: ${{ matrix.bun }}
run: |
docker run --rm -v "$PWD:/work" -w /work "oven/bun:${BUN_VERSION}-alpine" sh -eu -c '
bun install --frozen-lockfile --ignore-scripts
bun scripts/native/verify.ts
bun test --timeout=60000 test/native-lock.test.ts test/pglite-lock.test.ts test/local-ipc-path.test.ts
bun scripts/native/compiled-smoke.ts
'

View File

@@ -0,0 +1,167 @@
name: Durable persistence validation
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
jobs:
read-performance:
name: Read latency / ${{ matrix.engine }} / Bun ${{ matrix.bun }}
runs-on: ubuntu-24.04
timeout-minutes: 55
strategy:
fail-fast: false
matrix:
engine: [pglite, postgres]
bun: ['1.3.11', '1.3.13']
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: gbrain_test
POSTGRES_PASSWORD: gbrain_test
POSTGRES_DB: gbrain_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U gbrain_test -d gbrain_test"
--health-interval 5s --health-timeout 5s --health-retries 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ matrix.bun }}
- run: bun install --frozen-lockfile --ignore-scripts
- name: Require three valid samples, 90 percent overlap and at most 50 percent p99 regression
env:
DATABASE_URL: postgres://gbrain_test:gbrain_test@127.0.0.1:5432/gbrain_test
run: >-
bun --no-env-file scripts/persistence/performance.ts --engine=${{ matrix.engine }}
--manifest=.context/persistence-read-latency.json
- if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: persistence-read-${{ matrix.engine }}-bun-${{ matrix.bun }}
path: .context/persistence-read-latency.json
if-no-files-found: error
deployment-matrix:
name: PgBouncer, RLS and pool capacity / Bun ${{ matrix.bun }}
runs-on: ubuntu-24.04
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
bun: ['1.3.11', '1.3.13']
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: gbrain_test
POSTGRES_PASSWORD: gbrain_test
POSTGRES_DB: gbrain_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U gbrain_test -d gbrain_test"
--health-interval 5s --health-timeout 5s --health-retries 20
pgbouncer:
image: edoburu/pgbouncer:latest
env:
DB_HOST: postgres
DB_PORT: '5432'
DB_USER: gbrain_test
DB_PASSWORD: gbrain_test
POOL_MODE: transaction
AUTH_TYPE: plain
MAX_CLIENT_CONN: '200'
DEFAULT_POOL_SIZE: '10'
IGNORE_STARTUP_PARAMETERS: extra_float_digits,statement_timeout,idle_in_transaction_session_timeout,search_path
ports: ['55433:5432']
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ matrix.bun }}
- run: bun install --frozen-lockfile --ignore-scripts
- name: Execute all 24 deployment cases and owner transfer fencing
env:
DATABASE_URL: postgres://gbrain_test:gbrain_test@127.0.0.1:5432/gbrain_test
GBRAIN_PGBOUNCER_URL: postgres://gbrain_test:gbrain_test@127.0.0.1:55433/gbrain_test
run: bun --no-env-file scripts/persistence/matrix.ts
- name: Require PostgreSQL lifecycle, projection and recovery contracts
timeout-minutes: 10
env:
DATABASE_URL: postgres://gbrain_test:gbrain_test@127.0.0.1:5432/gbrain_test
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
run: |
: "${DATABASE_URL:?PostgreSQL contract tests require the explicit test database}"
bun --no-env-file test --timeout=120000 \
test/persistence-admission-observer.test.ts \
test/persistence-journal.test.ts \
test/persistence-staging.test.ts \
test/persistence-effect-recovery-fairness.test.ts \
test/import-publication-guards.test.ts \
test/persistence-withdrawal-retry.test.ts \
test/persistence-tombstone-noop.test.ts \
test/persistence-version-deletion.test.ts \
test/version-history-privacy.test.ts \
test/persistence-take-receipt-authority.test.ts \
test/import-revision-revival.test.ts \
test/persistence-source-lifecycle.test.ts \
test/persistence-physical-root.test.ts \
test/persistence-topology-capacity.test.ts \
test/persistence-topology-quotas.test.ts \
test/persistence-contextual-embedding.test.ts \
test/page-projection-concurrency.test.ts \
test/persistence-migration-preservation.test.ts \
test/persistence-recovery-capacity.test.ts \
test/audit/pool-recovery-audit.test.ts \
test/persistence-recovery-fairness.test.ts
- if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: persistence-deployment-bun-${{ matrix.bun }}
path: .context/persistence-runtime-matrix.json
if-no-files-found: error
invariants:
name: ${{ matrix.engine }} / Bun ${{ matrix.bun }}
runs-on: ubuntu-24.04
timeout-minutes: 110
strategy:
fail-fast: false
matrix:
engine: [pglite, postgres]
bun: ['1.3.11', '1.3.13']
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: gbrain_test
POSTGRES_PASSWORD: gbrain_test
POSTGRES_DB: gbrain_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U gbrain_test -d gbrain_test"
--health-interval 5s --health-timeout 5s --health-retries 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: ${{ matrix.bun }}
- run: bun install --frozen-lockfile --ignore-scripts
- name: Execute 1000 schedules, eight SIGKILL boundaries and 10000 process-separated writes
env:
DATABASE_URL: postgres://gbrain_test:gbrain_test@127.0.0.1:5432/gbrain_test
run: >-
bun --no-env-file scripts/persistence/validate.ts --engine=${{ matrix.engine }}
--manifest=.context/persistence-manifest.json
- name: Upload executed-case and latency manifest
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: persistence-${{ matrix.engine }}-bun-${{ matrix.bun }}
path: .context/persistence-manifest.json
if-no-files-found: error

View File

@@ -114,6 +114,11 @@ jobs:
# release on ambient-env tests (run 30698650484). The build job's gate
# is the artifact itself: compile, then smoke-test the binary.
- run: bun build --compile --no-compile-autoload-bunfig --target=${{ matrix.target }} --outfile bin/${{ matrix.artifact }} src/cli.ts
- name: Verify embedded native writer locks and compiled process exclusion
run: bun scripts/native/verify.ts && bun scripts/native/compiled-smoke.ts --binary bin/${{ matrix.artifact }}
- name: Verify persistent writes with the actual release executable
timeout-minutes: 8
run: bun scripts/native/cli-persistence-smoke.ts --binary bin/${{ matrix.artifact }}
- name: Smoke-test the compiled binary
run: |
chmod +x bin/${{ matrix.artifact }}

View File

@@ -10,6 +10,12 @@ on:
# Frees a load-saturated local machine (e.g. many Conductor agents running
# their own bun-test suites at once — load avg 120 on 16 cores).
workflow_dispatch:
inputs:
native_only:
description: Run native platform checks only; this does not satisfy full CI
type: boolean
default: false
required: false
permissions:
contents: read
@@ -19,13 +25,15 @@ permissions:
# from forks sharing a branch name don't cancel each other) and falls back to
# github.ref for push/scheduled runs. Mirrors heavy-tests.yml; frees runners
# and stops a stale-SHA run from reporting a flaky failure on an obsolete commit.
# Explicit native-only retries use a separate group so full persistence soaks finish.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'workflow_dispatch' && inputs.native_only == true && '-native-only' || '' }}
cancel-in-progress: true
jobs:
# Re-run checks for every event. Only dependencies and PGLite snapshots are cached.
gitleaks:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -74,6 +82,7 @@ jobs:
gitleaks detect --redact --no-banner --log-opts "$RANGE"
security-regressions:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
@@ -93,6 +102,7 @@ jobs:
run: bun test --timeout 60000 test/guarded-http-tls.serial.test.ts
dependency-audit:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -108,6 +118,7 @@ jobs:
working-directory: admin
verify:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
# Pre-test gates: privacy/jsonb/source-id/etc + typecheck + admin-build.
# Lives in its own runner so the matrix shards aren't carrying ~2-3min
# of verify work in addition to their test files (the old shape stuffed
@@ -138,7 +149,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
- run: bun run verify
# Guard: no bare `bun test` in workflows/scripts — bun ignores
@@ -148,6 +159,7 @@ jobs:
- run: bash scripts/check-bun-test-timeout.sh
serial-tests:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
# *.serial.test.ts — one bun process per file (module-registry isolation),
# POOLED across files by scripts/run-serial-tests.sh (was strictly
# sequential: an 8.5-minute job whose serialization the quarantine
@@ -177,17 +189,17 @@ jobs:
# not once per job per run. This job SAVES; verify + matrix + slow jobs
# restore-only. The key is an approximation on purpose: it only has to
# be a superset-trigger of real schema changes.
# KEY HAS 11 HOMES (this save + 5 restores here: verify, matrix,
# slow-eval, slow-perf, slow-brainbench; + 5 restores in e2e.yml:
# jsonb-parity, selected-e2e, tier1, tier2, coverage-full-e2e) — edit
# all together, or drift shows up only as silent rebuild cost.
# HASH INPUTS HAVE 13 HOMES: 11 legacy keys (six here and five in
# e2e.yml) plus two default-profile keys here (slow-brainbench and
# brainbench). Edit all together when schema dependencies change;
# profile names stay distinct, and the serial save reuses its restore key.
- uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
id: snapshot-cache
with:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
- run: bun run test:serial
env:
@@ -223,6 +235,7 @@ jobs:
overwrite: true
slow-eval-longmemeval:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
# Dedicated runner for the LongMemEval end-to-end test file. The file
# was originally 359s. TODO #1 (engine-sharing in runEvalLongMemEval
# via RunOpts.engine) cut it to ~200s by amortizing PGLite cold-create
@@ -248,7 +261,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
- name: Ensure PGLite snapshot (build-or-validate, non-fatal)
run: bash -c '. scripts/lib/test-env.sh && ensure_pglite_snapshot slow-eval && echo "GBRAIN_PGLITE_SNAPSHOT=${GBRAIN_PGLITE_SNAPSHOT:-}" >> "$GITHUB_ENV"'
@@ -269,6 +282,7 @@ jobs:
overwrite: true
slow-brainbench-e2e:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
# Dedicated runner for the BrainBench CLI e2e file. At 98s mined it was
# the matrix's single heaviest atom (10% of the whole corpus weight) and
# the hard floor on shard-count scaling; a Phase-2 rewrite (spawn batching
@@ -292,13 +306,13 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
test/fixtures/pglite-snapshot-default.tar
test/fixtures/pglite-snapshot-default.version
key: pglite-snapshot-default-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-default-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
- name: Ensure PGLite snapshot (build-or-validate, non-fatal)
# Absolutized: this file's batch jobs spawn CLI children with varying
@@ -333,6 +347,7 @@ jobs:
overwrite: true
brainbench:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
# BrainBench memory-conformance gate (Cathedral 2). Hermetic: in-memory
# PGLite, zero API keys, ~15s for the full 141-fixture × 3-harness run.
# Governance (decision 4): compares HEAD's run against MAIN's committed
@@ -361,7 +376,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot-default.tar
test/fixtures/pglite-snapshot-default.version
key: pglite-snapshot-default-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-default-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
- run: bash scripts/ci-brainbench-gate.sh
env:
@@ -374,6 +389,7 @@ jobs:
fi
slow-entity-resolve-perf:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
# Dedicated runner for the entity-resolve perf test (~159s, single perf
# describe with one test that builds 5000+ pages and asserts the NEW
# tryPrefixExpansion shape is 5x faster than the OLD shape — not
@@ -397,7 +413,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
- name: Ensure PGLite snapshot (build-or-validate, non-fatal)
run: bash -c '. scripts/lib/test-env.sh && ensure_pglite_snapshot slow-perf && echo "GBRAIN_PGLITE_SNAPSHOT=${GBRAIN_PGLITE_SNAPSHOT:-}" >> "$GITHUB_ENV"'
@@ -433,6 +449,7 @@ jobs:
bun run src/cli.ts protocol conformance --synthesize
test:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
# Pure matrix shard — no verify, no serial. Each shard runs its slice
# of the unit test set under one `bun test` invocation.
#
@@ -479,7 +496,7 @@ jobs:
path: |
test/fixtures/pglite-snapshot.tar
test/fixtures/pglite-snapshot.version
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
key: pglite-snapshot-${{ runner.os }}-${{ hashFiles('src/core/migrate.ts', 'src/core/pglite-schema.ts', 'src/core/pglite-engine.ts', 'src/core/fts-language.ts', 'src/core/vector-index.ts', 'src/core/ai/defaults.ts', 'src/core/timeline-dedup-repair.ts', 'src/core/pages-upsert-arbiter.ts', 'src/core/link-extraction.ts', 'src/core/grants/*.ts', 'src/core/scope.ts', 'src/core/sql-query.ts', 'src/core/minions/tools/brain-allowlist.ts', 'src/core/facts/withdrawal-schema.ts', 'src/core/page-state/schema.ts', 'src/core/lease-schema.ts', 'src/core/page-state/projection-schema.ts', 'src/core/persistence/schema.ts', 'src/core/persistence/effect-schema.ts', 'src/core/persistence/writer-guard-schema.ts', 'src/core/persistence/topology-schema.ts', 'test/helpers/legacy-embedding-config.ts', 'scripts/build-pglite-snapshot.ts') }}
- run: bun install --frozen-lockfile
- name: Run test shard ${{ matrix.shard }}/10
shell: bash
@@ -517,7 +534,7 @@ jobs:
# ──────────────────────────────────────────────────────────────────────
coverage-report:
needs: [serial-tests, slow-eval-longmemeval, slow-entity-resolve-perf, slow-brainbench-e2e, test]
if: always()
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.native_only != true) }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@@ -571,11 +588,19 @@ jobs:
if-no-files-found: ignore
overwrite: true
native-locks:
uses: ./.github/workflows/native-locks.yml
persistence-validation:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.native_only != true }}
uses: ./.github/workflows/persistence-validation.yml
# The stable aggregate succeeds only when every required lane ran successfully.
# Coverage percentages remain advisory until their separate graduation.
test-status:
needs: [gitleaks, security-regressions, dependency-audit, verify, serial-tests, slow-eval-longmemeval, slow-entity-resolve-perf, slow-brainbench-e2e, brainbench, test]
if: always()
name: ${{ github.event_name == 'workflow_dispatch' && inputs.native_only == true && 'full-suite-not-run' || 'test-status' }}
needs: [gitleaks, security-regressions, dependency-audit, verify, serial-tests, slow-eval-longmemeval, slow-entity-resolve-perf, slow-brainbench-e2e, brainbench, test, native-locks, persistence-validation]
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.native_only != true) }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -591,11 +616,45 @@ jobs:
SLOW_BRAINBENCH="${{ needs.slow-brainbench-e2e.result }}"
BRAINBENCH="${{ needs.brainbench.result }}"
TEST="${{ needs.test.result }}"
echo "gitleaks=$GITLEAKS security-regressions=$SECURITY dependency-audit=$AUDIT verify=$VERIFY serial-tests=$SERIAL slow-eval-longmemeval=$SLOW_EVAL slow-entity-resolve-perf=$SLOW_PERF slow-brainbench-e2e=$SLOW_BRAINBENCH brainbench=$BRAINBENCH test=$TEST"
for r in "$GITLEAKS" "$SECURITY" "$AUDIT" "$VERIFY" "$SERIAL" "$SLOW_EVAL" "$SLOW_PERF" "$SLOW_BRAINBENCH" "$BRAINBENCH" "$TEST"; do
NATIVE_LOCKS="${{ needs.native-locks.result }}"
PERSISTENCE="${{ needs.persistence-validation.result }}"
echo "gitleaks=$GITLEAKS security-regressions=$SECURITY dependency-audit=$AUDIT verify=$VERIFY serial-tests=$SERIAL slow-eval-longmemeval=$SLOW_EVAL slow-entity-resolve-perf=$SLOW_PERF slow-brainbench-e2e=$SLOW_BRAINBENCH brainbench=$BRAINBENCH test=$TEST native-locks=$NATIVE_LOCKS persistence=$PERSISTENCE"
for r in "$GITLEAKS" "$SECURITY" "$AUDIT" "$VERIFY" "$SERIAL" "$SLOW_EVAL" "$SLOW_PERF" "$SLOW_BRAINBENCH" "$BRAINBENCH" "$TEST" "$NATIVE_LOCKS" "$PERSISTENCE"; do
if [ "$r" != "success" ]; then
echo "✗ gated job did not succeed (got $r) — CI fail"
exit 1
fi
done
echo "✓ all gated jobs succeeded — CI green"
# A native-only run emits its own scoped result, never the required full-suite check.
native-only-status:
name: Native-only result (full CI not run)
if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.native_only == true }}
needs: [native-locks]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Record validation scope
env:
NATIVE_RESULT: ${{ needs.native-locks.result }}
run: |
set -euo pipefail
[[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]]
[[ "$GITHUB_RUN_ID" =~ ^[0-9]+$ ]]
case "$NATIVE_RESULT" in success|failure|cancelled|skipped) ;; *) exit 1 ;; esac
printf '{"scope":"native-only","full_ci":false,"head":"%s","run_id":"%s","native_result":"%s"}\n' "$GITHUB_SHA" "$GITHUB_RUN_ID" "$NATIVE_RESULT" > native-only-scope.json
echo 'Native-only validation. The full Test suite was not run.' >> "$GITHUB_STEP_SUMMARY"
- name: Upload validation scope
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: native-only-validation-scope
path: native-only-scope.json
if-no-files-found: error
retention-days: 14
- name: Require native success
env:
NATIVE_RESULT: ${{ needs.native-locks.result }}
run: test "$NATIVE_RESULT" = success

View File

@@ -1,4 +1,4 @@
<!-- gbrain-runbook-stamp: 0.50.5.0 -->
<!-- gbrain-runbook-stamp: 0.51.0.0 -->
<!-- This stamp must equal the VERSION file at every release; CI enforces it
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
the installed binary and warns on skew. -->

View File

@@ -2,6 +2,57 @@
All notable changes to GBrain will be documented in this file.
## [0.51.0.0] - 2026-09-16
**Concurrent edits now have durable outcomes, safe retries, and one coherent page revision.**
Several agents can save to the same brain without silently replacing a newer edit. Read a page, keep its revision, and submit that revision with your replacement. An intentional overwrite requires an explicit force option. Creating a missing page still works without reading it first.
Every accepted write has a request ID and a receipt. If a response is lost or the publishing host is offline, keep that ID and repeat the original request. Queued work survives restarts, and a completed retry returns its original result. A busy folder no longer turns an accepted request into an ambiguous failure.
A successful canonical receipt means the page and its required database state are durable. If publication is interrupted, recovery checks the recorded file bytes before proceeding. An unexpected local edit pauses the affected worktree for repair while unrelated worktrees can continue. Page reads return content, tags and withdrawal state from the same revision.
### What changes in practice
| Situation | Result |
|---|---|
| Two callers replace the same revision | One commits; the stale replacement receives a conflict. |
| A publishing host is unavailable | Accepted work stays queued without expiring. |
| The same request is sent again | Its retained receipt returns; terminal work does not run twice. |
| A fact is withdrawn while its file owner is offline | Active memory honors withdrawal immediately; the file mirror follows later. |
| Delayed embedding, healing or rebuilding finishes after its projection or context changed | It rejects the obsolete result; newer chunks, vectors and contextual state remain intact. |
| A local purge cannot remove its recorded file | The prior page state remains; replaying a completed purge cannot remove a recreated page. |
| An identical timeline entry is replayed | Markdown, history and structured rows stay unchanged. |
### Things to watch
This is a coordinated writer upgrade. Back up both canonical files and the database, stop older writers on every host, and follow the activation guide. Each worktree has one designated owner; this release does not automatically fail over to another host. Direct filesystem readers can observe the file/database publication interval. Search embeddings and Git completion may lag, with their own status. Some maintenance writers refuse under managed ownership until a supported coordinator path is available. Existing access grants are preserved.
## To take advantage of v0.51.0.0
Follow [the coordinated upgrade guide](skills/migrations/v0.51.0.0.md). After quiescing and upgrading all writers, apply migrations, verify native locking, register owners and inspect the activation preview:
```bash
gbrain apply-migrations --yes
gbrain sources writer status --probe --json
gbrain sources writer activate --confirm-quiesced --dry-run --json
```
Activate only after verifying the preview and all upgraded writers. Once managed requests have been accepted, use forward repair or a fully drained, verified downgrade. Agents must keep original request IDs and revision tokens across retries. Receipt helpers require an explicit, version-checked regrant for older operation grants; no upgrade silently widens access.
**Say to your agent:** "Upgrade GBrain using the concurrent-write migration guide. Check every writer and backup first, preserve my access and capture settings, and verify a saved page through its receipt and revision."
### Itemized changes
- Add canonical revisions, coherent exact/alias/fuzzy page snapshots, complete version history and revision-safe delete, restore, capture and revert. Preserve legacy snapshot fields, tombstone state and hidden facts.
- Add principal-scoped durable mutation admission, seven receipt states, cancellation, permanent replay identities, configurable atomic quotas, bounded compaction and reserved publication recovery space.
- Coordinate page, fact, take, tag and timeline mutations, including local purge with retained receipts and file-removal rollback; retain exact timeline replay semantics (#5067). Protect page and source identity across deletion and recreation, and fence unsupported canonical writers after activation.
- Add designated worktree ownership, epoch/physical-root checks, verified transfer, resumable source lifecycle and managed sync with bounded batches and foreground-write fairness.
- Ship eight bundled native lock prebuilds, retained kernel ownership through PGLite shutdown, authenticated resident HTTP/stdio ingress and private CLI/stdio registrations. Preserve IDs and typed pending/conflict outcomes through local IPC and thin clients.
- Commit withdrawal to its authoritative database ledger, gate stale retrieval projections, rebuild sanitized snapshots and condition delayed embeddings, healing and rebuilds on their captured page identity, chunk set, text seal and indexing context. Keep vector/provenance/context completion atomic, compare stored contextual generations, and reject stale contextual reindex results without replacing canonical chunks. Keep vectors in the exact embedding column validated before installation, and remove private/withdrawn fact rows before splitting either body column into chunks. Keep Git and mirror effects independent of canonical commitment.
- Add explicit activation, writer/queue/recovery diagnostics and a narrow receipt-grant migration. Keep intentional DB-only knowledge and withdrawal/receipt records in the backup contract.
- Exercise deterministic competing writers, real process-crash recovery, separate-process soak, deployment topology and read/write overlap, plus native and published-executable smoke matrices.
## [0.50.5.0] - 2026-09-16
**Security hardening pass across the remote OAuth surface, transcript ingest, and environment handling.** This wave closes the critical- and high-severity items from privately reported advisories. Fresh installs and existing brains are on the same footing after upgrade; where an operator kept a security-relevant setting in a project directory's `.env`, gbrain now says so and names the fix. Thanks to the reporters credited below.

View File

@@ -52,6 +52,8 @@ src/
operations.ts Operation contract assembly (façade over ops/)
ops/ Contract types + security fences + the op domain modules
engine.ts BrainEngine interface
page-state/ Canonical snapshots, revisions, versions and guarded projections
persistence/ Durable requests, owner coordination, recovery and writer enforcement
engine-factory.ts Engine factory (dynamic import of the configured engine)
postgres-engine.ts Postgres + pgvector implementation (façade)
postgres-engine/ Narrow-deps engine modules (facts, takes, code-edges, salience)
@@ -106,7 +108,7 @@ bun test test/markdown.test.ts # specific unit test
# Pre-push gate (50+ parallel checks + typecheck)
bun run verify
# Pre-merge sanity (everything CI runs)
# Pre-merge local suites (platform/persistence matrices run separately)
bun run test:full # verify + parallel unit + slow + smart e2e
# Slow / serial / e2e in isolation
@@ -134,6 +136,12 @@ the database name must carry "test" as a word segment (like `gbrain_test`
above) or destructive tests refuse to run — opt a differently-named database
in one-shot with `GBRAIN_E2E_ALLOW_DB=<name>`.
Changes to durable persistence also require the native/runtime, process-crash,
soak, deployment-matrix and read-latency gates in
[`docs/TESTING.md`](docs/TESTING.md#durable-persistence-schedules-and-process-crashes).
`test:full` alone does not execute those complete platform and runtime matrices.
Keep each result tied to its tested revision and disclose skipped cells.
Use `bun run verify` before pushing. It runs 50+ guard checks in parallel
(`scripts/run-verify-parallel.sh`), including: banned fork-name leaks
(`scripts/check-privacy.sh`), `JSON.stringify(x)::jsonb` interpolation

View File

@@ -264,7 +264,7 @@ echo "from a pipe" | gbrain capture --stdin
SLUG=$(gbrain capture "..." --quiet)
```
For a file-backed source, the page is saved to the database and canonical Markdown before optional embedding. Ordinary file-write failures roll back the database revision; this is not a crash-atomic transaction across files and the database. A source without a configured repository can hold DB-only pages, which need a database backup. See the [persistence boundary](docs/architecture/system-of-record.md#page-write-persistence-boundary). Default slug `inbox/YYYY-MM-DD-<hash8>` so captures cluster in a predictable triage location. On thin-client installs the verb routes through MCP to the server.
Page writes return durable receipts. Replacements require the revision you read or explicit `force`; keep the request UUID when retrying. Accepted work can remain queued while its owner is unavailable, and uncertain publication has an explicit recovery state. Embedding completion is separate from canonical commitment. A source without a configured repository can hold DB-only pages, which need a database backup alongside withdrawal and receipt records. See [concurrent writes](docs/guides/concurrent-writes.md) and the [persistence boundary](docs/architecture/system-of-record.md#page-write-persistence-boundary). Default slug `inbox/YYYY-MM-DD-<hash8>` so captures cluster in a predictable triage location. On thin-client installs the verb routes through MCP to the server.
**Say to your agent:** *"Remember this: ..."* — *"Save this thought to my brain"* — *"Capture this."* And to fill an empty brain from your existing life: *"Fill my brain"* (the cold-start skill walks your email, calendar, contacts, and archives one consented step at a time).
@@ -648,12 +648,12 @@ gbrain sync --no-schema-pack --no-pull --no-embed --yes
shapes (`(a+)+`, `(a*)*`, …) in pack regexes, and the runtime caps
inference-regex input length (override via `GBRAIN_MAX_REGEX_INPUT_CHARS`).
Third, on a PGLite brain with a live `gbrain serve` (your agent's MCP
server), `gbrain sync` delegates the run to the serve process over its
local IPC socket — the lock owner does the work, your agent stays up,
and Ctrl-C aborts to a checkpoint the next sync resumes from. Embeds
defer to the serve's background sweep. See
server), `gbrain sync` delegates through authenticated local IPC to the
owner, whether it serves HTTP or stdio. If the client exits, accepted page
requests can finish; repeat the same options to resume the managed sync
cursor. Embeds defer to the owner's background work. See
[`docs/architecture/serve-sync-concurrency.md`](docs/architecture/serve-sync-concurrency.md)
for the limits (unsupported flags, `serve --http`) and the full triage.
for supported flags, managed-mode limits and the full triage.
**`gbrain init --migrate-only` / a schema migration fails on Windows
with `getaddrinfo ENOTFOUND`?** Run `gbrain upgrade`. Schema bring-up

View File

@@ -9362,17 +9362,19 @@ covers DEAD logs; go-forward capture beyond Claude Code is deliberately absent.
separate backups for DB-only knowledge. The guide is current; the historical
banner remains documentation debt.
- [ ] **P2 — durable contention queue and caller revision preconditions (#5105).**
The collector rejects a busy worktree before changing the page and reports
that the write was not queued. Add a separately reviewed acceptance/replay
contract and revision check before claiming queued or conflict-safe writes.
Preserve source authorization, cancellation, and idempotency across replay.
- [x] **P2 — durable contention queue and caller revision preconditions (#5105).**
Dedicated principal-scoped requests now retain acceptance, replay and terminal
outcomes. Existing-page replacements require the observed revision or explicit
force; native contention leaves accepted work queued. Publication and receipt
operations recheck source authorization and cancellation under shared guards.
See `docs/guides/concurrent-writes.md` and `test/persistence-journal.test.ts`.
- [ ] **P2 — file/database commit-failure recovery.** A crash or database commit
failure after atomic rename can leave canonical Markdown ahead of the index.
Add fault injection at that boundary and a reconciler with explicit recovery
semantics before claiming crash-atomic persistence. Do not treat a Markdown
rebuild as recovery of DB-only knowledge or operational state.
- [x] **P2 — file/database commit-failure recovery.** Durable beforeimages and
fingerprints now precede publication; uncertain completion blocks its worktree
until receipt inspection and conditional recovery settle the outcome. Tests
inject actual process death at eight publication boundaries and preserve unknown
file bytes. Direct filesystem readers may still observe the publication
interval. DB-only knowledge, withdrawals and receipts need database backups.
- [ ] **P2 — reviewed historical fact and stub repair (#5110, #5111).** New
unresolved facts retain provenance without inventing an entity page, and the

View File

@@ -1 +1 @@
0.50.5.0
0.51.0.0

View File

@@ -20,6 +20,7 @@
"chokidar": "^4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"detect-libc": "2.0.4",
"eventsource-parser": "^3.0.8",
"exifr": "^7.1.3",
"express": "^5.1.0",
@@ -364,6 +365,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],

View File

@@ -94,7 +94,7 @@ services:
# pooled :6543) behind three consecutive pooler-teardown waves
# (#1972 → #2015 → #2084) that CI could never reproduce.
# test/e2e/pgbouncer-teardown.test.ts uses a DEDICATED database
# (gbrain_pgbouncer) on postgres-1 so it never races shard 1's
# (gbrain_pgbouncer_test) on postgres-1 so it never races shard 1's
# TRUNCATE-based fixtures; pgbouncer's wildcard [databases] section
# forwards any dbname to DB_HOST.
pgbouncer:

View File

@@ -6,12 +6,14 @@ only.
`test/e2e/serve-http-oauth.test.ts` additionally pins confidential POST/Basic revocation, public-client SDK fallthrough, malformed/mixed authentication rejection, cross-client isolation, unknown-token opacity, metadata auth methods, no-store responses, strict post-revoke `401`, and retryable backend `503` semantics. SDK-driven discovery and real owner-approved PKCE also pin read-only bootstrap, explicit writer requests, scope clamping, and DCR delegation refusal. `test/oauth-scope-hint.test.ts` exercises the actual SDK middleware over HTTP without requiring a database.
`test/put-page-persistence.test.ts` and `test/e2e/put-page-persistence-postgres.test.ts`
pin the ordinary-error persistence boundary: contention does not publish a
revision, filesystem failure rolls back the database transaction, embedding
failure preserves the saved page, and a slow embed releases the page-owned
worktree lock. The PGLite suite also covers source-path bookkeeping failure,
legacy hashes, deletion/recreation, and secret-safe embedding diagnostics.
Neither suite proves crash-atomic filesystem/database commit or a durable queue.
pin durable page acceptance and ordinary-error publication: native contention
returns an accepted pending receipt without changing the page, and replay of its
original UUID commits exactly once after release. Filesystem or required
source-path failure rolls back the database transaction. Embedding failure
preserves the canonical receipt; a delayed result superseded by another revision
cannot install vectors. The PGLite suite also covers scoped physical file paths,
unchanged-content no-ops, legacy hashes, deletion/recreation, and sanitized
diagnostics. Actual process-death boundaries belong to the crash suites below.
`test/subagent-required-writes.test.ts` and
`test/subagent-put-page-rejection.serial.test.ts` distinguish a persisted write
@@ -44,6 +46,77 @@ array in `scripts/run-verify-parallel.sh` is the single execution list
`check:no-legacy-getconnection`). The guard REGISTRY is `scripts/guards-manifest.tsv` (see "Guard registry and
self-test" below).
### Native writer locks
`bun test test/native-lock.test.ts test/scripts/native-lock-prebuilds.test.ts`
checks real process exclusion, crash handoff, retained files, cancellation,
missing-addon failure and source/binary manifest integrity. Tests use isolated
temporary paths and never open an operator datastore. The required
`native-locks.yml` lane rebuilds and executes all eight OS/architecture/libc
targets on Bun 1.3.11 and 1.3.13, including native musl Docker userspace.
Every pair also runs `bun scripts/native/compiled-smoke.ts` to prove compiled
process locking. Release CI verifies the shipped CLI embeds the matching
addon and runs the compiled smoke on its two release platforms. Rebuild
instructions and the precise packaging/runtime distinction are in
`native/locks/README.md`.
For platform-only feedback, dispatch
`gh workflow run test.yml --ref <branch> -f native_only=true`. This explicit manual option uses a separate concurrency
group so it does not cancel an ongoing full persistence soak. Its
`native-only-validation-scope` artifact records the exact commit and
`full_ci: false`; it never emits the required `test-status` check for unrun full
CI. Omitting the option preserves every normal PR, push and full manual gate.
### Datastore shutdown and lease ownership
`test/pglite-lock.test.ts` proves process pause/crash handoff, metadata damage,
legacy migration refusal and stable ownership across datastore replacement.
`test/pglite-engine-disconnect.serial.test.ts` uses actual disk-backed PGLite
for concurrent opens, consumer/statement drains, persisted reopen, delayed
close and failed close. A close deadline retains the kernel lock; it is never
successful shutdown evidence. Watchdog and telemetry regression suites cover
loop starvation and background statement teardown.
`test/db-lock-concurrency.test.ts` proves unique identities even when two
acquisitions have identical database timestamps, exact successor-safe cleanup,
renewal cancellation/late-completion drain and mandatory loss propagation.
`test/e2e/db-lock-acquisition-token.test.ts` repeats acquisition/cleanup
invariants against real Postgres; the E2E map selects it for lease and engine
changes. `test/engine-control-routing.test.ts` pins direct/shared pool routing,
nested transaction confinement and the Postgres resident-stop barrier.
### Durable persistence schedules and process crashes
`test/persistence-chaos.slow.test.ts` and `test/e2e/persistence-chaos.test.ts`
execute real journal/coordinator schedules and eight SIGKILL publication
boundaries, followed by a small multi-process soak. The Postgres test creates
and drops fresh test databases, requiring CREATEDB on the explicit test URL.
It never truncates the shared E2E database. The reusable
`persistence-validation.yml` gate runs 1,000 schedules and 10,000 writes per
engine under Bun 1.3.11 and 1.3.13 and uploads actual executed-case manifests.
See [`scripts/persistence/README.md`](../scripts/persistence/README.md) for
workloads, reruns, performance measurements and the process-crash scope.
`test/e2e/persistence-runtime-matrix.test.ts` additionally requires the real
transaction-mode PgBouncer fixture. Its 24 cells exercise direct/pooler
connections, enforced RLS under a non-bypass role, ordinary pool sizes 1/2/3,
and shared pools or a separate one-connection direct route. It verifies
reserved short control capacity while bulk connections remain held, then
drains and commits the original request. Ownership cases cover mismatched
successor manifests, stale owners, root replacement under a held kernel
lock, and actual source deletion/recreation. The reusable persistence lane
runs this matrix on both supported Bun versions and uploads its manifest.
The required persistence lane also runs `scripts/persistence/performance.ts`
on both engines and Bun versions. Three independent instances use the
existing 500-page/200-query read-latency corpus, with public `put_page`
mutations and actual in-flight interval coverage of at least 90%. Any read
or write failure invalidates the sample. Median loaded p99 must be at most
1.5 times median idle p99 on the same runner. Manifests retain each sample,
admission/commit latency, queue age, RSS, recovery bytes and pool activity.
The original heavy shell entry invokes this harness; its optional strict
flag affects only the latency threshold, never validity requirements.
### PGLite schema snapshot (default-on)
`scripts/build-pglite-snapshot.ts` (`bun run build:pglite-snapshot`) bakes a
@@ -220,7 +293,7 @@ there even though they pass on Linux and macOS.
### CI vs local: intentionally divergent file sets
- **CI matrix** (`.github/workflows/test.yml`) runs `scripts/test-shard.sh` across 10 matrix shards partitioned by weight-aware LPT bin-packing (`scripts/sharding.ts`; files with no mined weight fall back to the p75 file weight so a new unweighted file can't silently unbalance a shard) and INCLUDES `*.slow.test.ts` (the four dedicated slow files — longmemeval, entity-resolve-perf, entity-card-perf, brainbench-e2e — run as dedicated jobs alongside the matrix) plus `evals/**/*.test.ts` (keyless-allowlist-gated — `test/scripts/evals-collection.test.ts`). Each shard's bun process is bounded by `--max-concurrency` (`GBRAIN_TEST_MAX_CONCURRENCY`, default 4). Every bun-test job — matrix shards, serial-tests, verify, the slow/eval jobs — activates the PGLite schema snapshot (built in-runner via `scripts/lib/test-env.sh`; the BrainBench gate uses the separate default-profile snapshot for its in-memory PGLite; the ~42MB tar is also cached across jobs via actions/cache, with the runner's own hash check staying authoritative). CI EXCLUDES `*.serial.test.ts` from the shards and runs them across four `serial-tests` workers via `bun run test:serial` — one bun process per file preserves the `mock.module` quarantine; the pool runs those processes concurrently. `bun run verify` gets its own job too, as does the BrainBench memory-conformance gate (`brainbench` job → `scripts/ci-brainbench-gate.sh`, hermetic in-memory PGLite, ~15s), which compares HEAD's fresh run against master's committed baseline (`evals/brainbench/baselines/main.json`) — the `test-status` aggregate checks its result explicitly. E2E (`.github/workflows/e2e.yml`) always runs its applicable execution lanes, with the jsonb-parity job in front of tier2 as the token-spend gate, and aggregates through `e2e-status`. Scheduled runs also require the full-corpus lanes, including each slow suite excluded from the coverage shards (longmemeval, entity-resolve-perf, and brainbench-e2e). Both aggregates reject failures, cancellations, and unexpected skips. Dependency caches and validated PGLite snapshots remain; successful test results are never reused. CI is the ground truth for "did everything pass."
- **Local fast loop** (`scripts/run-unit-shard.sh` via the parallel wrapper) uses the same weighted partitioner as CI and EXCLUDES `*.slow.test.ts` AND `*.serial.test.ts`. Local trades coverage for inner-loop speed; CI catches what local skips.
- **Local fast loop** (`scripts/run-unit-shard.sh` via the parallel wrapper) uses the same weighted partitioner as CI and EXCLUDES `*.slow.test.ts` AND `*.serial.test.ts`. Each shard runs its complete ordered selection with a fresh Bun process per file, without adding workers. Later groups still run after failures; missing summaries or file-completion evidence fail the shard. Local trades coverage for inner-loop speed; CI catches what local skips.
This divergence is intentional. Don't try to make them equal — the two scripts deliberately solve different problems. The regression test at `test/scripts/run-unit-shard.test.ts` pins what the local fast loop should and shouldn't include, and that no unit-lane file spawning the CLI through `test/helpers/cli-spawn.ts` hand-pins a per-test timeout below the bunfig default (an explicit `test(name, fn, N)` ceiling overrides bun's `--timeout`, so `GBRAIN_TEST_TIMEOUT_MULTIPLIER` never reaches it — inherit the default instead; cli-spawn's own kill timer still reaps a hung child); `test/scripts/run-unit-parallel.test.ts` pins the wrapper's memory-adaptive concurrency, and the OOM/external-kill serial rescue pass, and operator-interrupt teardown (a Ctrl-C / SIGTERM to the wrapper while shards are live TERMs then KILLs every shard descendant, so a cancelled run cannot leave gtimeout/bun alive until the shard cap).
@@ -787,7 +860,7 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
- `test/e2e/job-isolation.test.ts` — process isolation on real Postgres (DATABASE_URL-gated, wired EXPLICITLY into `.github/workflows/e2e.yml` tier1 — the workflow runs only named files): a concurrency-3 isolated drain through real child processes (the `fake-run-child.mjs` fixture — real spawns, no child DB pools), and the REAL `jobs run-child` CLI entrypoint end-to-end (engine bootstrap incl. the child's own pools, quiet handler registry, token validation, outcome protocol).
- `test/e2e/sync-reconcile-postgres.test.ts` — the sync reconcile's real-Postgres array-parameter binding path (`DATABASE_URL`-gated). Wired EXPLICITLY into `.github/workflows/e2e.yml` tier1 beside job-isolation, and listed in the selected-e2e EXCLUDE set so a PR touching sync.ts doesn't run it a second time there.
- `test/e2e/pglite-cli-exit.serial.test.ts` — real spawned-CLI exit behavior on PGLite (in-memory, no `DATABASE_URL`): read commands (`search`/`get`/`query`) exit 0 promptly; CLI_ONLY `capture` exits clean and frees the single-writer lock; the teardown describes pin every disconnect site — a failed op exits 1 with the error on stderr, and the dashboard, read-only-timeout, doctor, and `dream --dry-run` paths all exit with no force-exit banner.
- `test/e2e/pgbouncer-teardown.test.ts` — PgBouncer TRANSACTION-mode teardown. Pins the bug CLASS, not timings: a CLI op against a txn-mode pooled URL exits 0 with intact stdout and does NOT ride the 10s hard-deadline backstop (the `engine.disconnect() did not return` banner is the smoking gun). Gated by `GBRAIN_PGBOUNCER_URL` + `GBRAIN_PGBOUNCER_DIRECT_URL` (NOT `DATABASE_URL`) — set automatically by `bun run ci:local`'s `pgbouncer` compose service. Both URLs survive the E2E runner and preload scrub, while CLI children clear ordinary database overrides so the pooled URL in their isolated config wins. Selected CI runs require a nonzero executed-test count (`GBRAIN_CI_REQUIRE_PGBOUNCER=1`); missing targets or an all-skipped file fail the gate. It skips gracefully elsewhere. Uses a DEDICATED `gbrain_pgbouncer` database so it never races the `gbrain_test` TRUNCATE fixtures.
- `test/e2e/pgbouncer-teardown.test.ts` — PgBouncer TRANSACTION-mode teardown. Pins the bug CLASS, not timings: a CLI op against a txn-mode pooled URL exits 0 with intact stdout and does NOT ride the 10s hard-deadline backstop (the `engine.disconnect() did not return` banner is the smoking gun). Gated by `GBRAIN_PGBOUNCER_URL` + `GBRAIN_PGBOUNCER_DIRECT_URL` (NOT `DATABASE_URL`) — set automatically by `bun run ci:local`'s `pgbouncer` compose service. Both URLs survive the E2E runner and preload scrub, while CLI children clear ordinary database overrides so the pooled URL in their isolated config wins. Selected CI runs require a nonzero executed-test count (`GBRAIN_CI_REQUIRE_PGBOUNCER=1`); missing targets or an all-skipped file fail the gate. It skips gracefully elsewhere. Uses a DEDICATED `gbrain_pgbouncer_test` database so it never races the `gbrain_test` TRUNCATE fixtures.
- `test/e2e/volunteer-context-postgres.test.ts` — `volunteer_context` on REAL Postgres (engine parity beyond the hermetic PGLite unit suite): resolution arms through the actual op handler, the fire-and-forget volunteer-event sink landing rows, the stats join, and the RLS pin that `context_volunteer_events` has ROW LEVEL SECURITY enabled (keeps the v35 auto-RLS event trigger honest for migration-created tables). `DATABASE_URL`-gated.
- `test/e2e/openclaw-reference-compat.test.ts` — `check-resolvable` + skillpack install-model against a minimal AGENTS.md workspace fixture (`test/fixtures/openclaw-reference-minimal/`), regression guard for the OpenClaw deployment shape.
- `test/e2e/workspace-generic-compat.test.ts` — always-on (PGLite, no binary): pins the INSTALL_FOR_AGENTS.md "any repo with a workspace" contract against `test/fixtures/generic-agents-workspace/` (Hermes is the motivating consumer): `cwd_walk_up` detection, the `GBRAIN_SKILLS_DIR` override, `check-resolvable` on a root AGENTS.md, and scaffold additivity + refuse-overwrite. The real Hermes-behavior proof is the door suite below.

View File

@@ -4,7 +4,7 @@
<!-- Regenerate: bun run scripts/generate-tool-catalog.ts -->
<!-- Freshness-guarded by scripts/check-tool-catalog-fresh.sh (bun run verify). -->
Every non-localOnly operation on the MCP surface: 122 tools across 23 areas. **Starter** marks membership in the ~26-op `starter` surface (`src/mcp/surface.ts`); **Gate** names the config key that must be true before remote callers see/call the op (`gbrain config set <key> true`). What a given token actually sees is further filtered per request by scope, bound-client fence, publish gates, and the per-client surface — see `docs/operations/mcp-surface-runbook.md`. Area names are non-contractual groupings.
Every non-localOnly operation on the MCP surface: 125 tools across 23 areas. **Starter** marks membership in the ~29-op `starter` surface (`src/mcp/surface.ts`); **Gate** names the config key that must be true before remote callers see/call the op (`gbrain config set <key> true`). What a given token actually sees is further filtered per request by scope, bound-client fence, publish gates, and the per-client surface — see `docs/operations/mcp-surface-runbook.md`. Area names are non-contractual groupings.
## admin
@@ -157,6 +157,7 @@ Every non-localOnly operation on the MCP surface: 122 tools across 23 areas. **S
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `cancel_write_request` | Cancel your accepted write before publication starts. | write | yes | |
| `capture` | Capture a quick note into the brain — the "just remember this" write. | write | yes | |
| `delete_page` | Soft-delete a page and remove its markdown file from the source working tree (the source local_path, or sync.repo_path when the source has none). | write | | |
| `fetch` | Fetch the full text of one search result by its `id` (OpenAI deep-research contract: the search/fetch pair). | read | | |
@@ -164,8 +165,10 @@ Every non-localOnly operation on the MCP surface: 122 tools across 23 areas. **S
| `get_page` | Read a page by slug (supports optional fuzzy matching). | read | yes | |
| `get_raw_data` | Retrieve raw data for a page. | read | | |
| `get_versions` | Page version history | read | | |
| `get_write_request` | Read your durable write receipt by request_id. | write | yes | |
| `list_pages` | List pages with optional filters. | read | yes | |
| `put_page` | Write or replace a page (markdown with frontmatter). | write | yes | |
| `list_write_requests` | List your currently authorized write receipts in one source, newest first. | write | yes | |
| `put_page` | Replace a complete canonical Markdown page. | write | yes | |
| `put_raw_data` | Store raw API response data for a page | write | | |
| `resolve_slugs` | Fuzzy-resolve a partial slug to matching page slugs | read | yes | |
| `restore_page` | v0.26.5 — restore a soft-deleted page (clear deleted_at) and re-create its markdown file on disk (the counterpart to delete_page removing it; the result write_through field reports the outcome). | write | | |
@@ -240,6 +243,6 @@ Every non-localOnly operation on the MCP surface: 122 tools across 23 areas. **S
| Tool | Description | Scope | Starter | Gate |
|---|---|---|---|---|
| `add_timeline_entry` | Add timeline entry to a page. | write | yes | |
| `add_timeline_entry` | Append an entry to the canonical Markdown timeline and structured timeline store in one committed write. | write | yes | |
| `get_timeline` | Get timeline entries for a page, optionally filtered by date window | read | | |

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,95 @@
# Canonical writer enforcement
Managed persistence is an explicit activation boundary. Page bodies, frontmatter,
visibility, tags, aliases, facts, takes, timeline rows, source identity/path and
sync checkpoints require coordinator authority. The checked-in
[writer census](canonical-writers.tsv) lists every engine-method/SQL reference to
those writes plus the filesystem and git escape paths. Its test rejects a new
file or an increase in write references until the enforcement route is reviewed.
This lexical census intentionally includes comments; it supplements runtime
checks rather than proving arbitrary JavaScript safe.
Supported page, memory, tag, timeline and take operations use the durable journal.
Their receipts become committed only with the canonical transaction. Prepared
imports compare the observed page identity/revision before installing bodies,
versions, tags or chunks. A same-body hash is insufficient for a prepared no-op:
canonical metadata and additive tags must also match. Legacy hash repairs and
unchanged-file skips acquire the same page guard and compare revisions.
Source add, archive, restore, remove, purge, path rebind and managed reclone use
native exclusion and guarded topology transactions. Directory replacement also
reserves recovery space and records its beforeimage before publication. Source
incarnations fence old queued requests; lifecycle replay retains its original
outcome even after a source is removed and recreated.
Unsupported direct writers fail closed after managed activation. SQL triggers
cover pages, tags, slug aliases, free-text aliases, facts, takes, timeline entries
and sources. Physical embeddings/index telemetry remain projections. Connector
materialization, unmanaged import variants, engine migration, manual link edits,
schema link rewrites, synthesis, patterns
and phantom redirect refuse before their first canonical side effect. Legacy
maintenance that reaches a canonical engine mutation is rejected by the SQL
trigger. Extracted links are derived projections; manually authored link API
writes remain refused until a coordinator callback exists. Links authored in
Markdown are reconciled by the coordinated import transaction.
Managed Markdown sync runs on the registered filesystem owner with
`gbrain sync --source <source-id> --no-pull`. Discovery freezes the target Git
commit, source incarnation, owner epoch, topology generation and page
identities/revisions. Attached repositories import committed Git content;
`--working-tree` opts into uncommitted files and detached repositories include
them automatically. Source-relative exclusions retain their existing meaning.
Each file's bytes and fingerprint are frozen before its journal request is
admitted. Import leaves the original bytes intact unless canonical sanitization
or retained tags require an explicit recoverable file publication.
A durable cursor uses a singleton JSON-array envelope under the private
`managed-sync` operation in `op_checkpoints`; the immutable manifest is stored
separately so advancing one page never rewrites the entire discovered file list. Only one page is admitted ahead of
the scan, and the scan yields after at most 25 pages or 250 milliseconds between
page publications. Foreground requests on the same root receive service first;
after 25 foreground commits or one second of continuous foreground service, sync
earns a bounded batch even while new interactive requests continue arriving. Interruption or a pending owner leaves the cursor and source
checkpoint intact. A later invocation resumes the frozen target; a newer Git
HEAD is a separate subsequent sync. A revision/file conflict blocks the cursor.
After inspecting the conflict, `--retry-failed` can start a fresh discovery once
all earlier admitted requests are terminal. `--skip-failed` cannot advance a
managed checkpoint past failed receipts.
When PGLite already has a resident owner, the CLI authenticates before opening
the datastore, including when the owner serves stdio MCP. Sync uses the private
CLI registration and a strict options envelope; stdio credentials and legacy
shared-secret sync cannot acquire that authority. The client advances bounded
RPC slices. If the client exits, accepted page requests can finish, and repeating
the same options resumes the remaining durable cursor.
The source checkpoint commits only after the entire selected cursor is exhausted
and every admitted page has a committed receipt. It takes the source-exclusive
guard before authentication/request/page locks and checks the original anchor,
source incarnation, topology and owner epoch. Code/image importers, ignored-file
walks and Git pull/rebase remain explicitly refused in managed mode until their
own prepared publication and recovery paths exist. Remote sync retains the
original `submit_job` principal, admin/source/operation ceiling and normalized
payload; runtime options cannot expand that grant and current revocation is
checked before publication.
Filesystem helpers check managed roots before atomic writes, frontmatter backup,
schema-pack replacement, clone, staging, pull or rebase. The registry stores one
0600 record per brain/root under the private configuration directory; records
retain source incarnation, worktree and topology generation when known. Existing
ancestors are resolved through symlinks, including a not-yet-created target.
Records are refreshed when an engine connects and remain usable before a second
process opens local PGLite. Stale records conservatively refuse writes until a
verified drain and explicit administration cleanup. A shared 0600 refusal marker
inside git metadata (or `.gbrain-managed` for non-git roots) also protects supported
commands using another home. Marker existence never grants publication authority.
The native worktree lock and database ownership rows grant authority. Registry
files and markers only refuse unsupported writes; copying a marked tree may
therefore require administration cleanup. Generated durability hooks honor that
refusal. Activation must quiesce older binaries and external writers because
programs that do not implement this protocol cannot be constrained by application
checks. Direct SQL administration, external editors and arbitrary shell commands
remain outside the supported writer protocol. Migration and rollback after
managed commits require verified drain and forward repair, preserving the
journal, source incarnation and canonical revisions.

View File

@@ -0,0 +1,117 @@
# Canonical writer census for issue #5105. Counts include lexical references in comments.
# path write-reference ceiling enforcement boundary review rationale
src/core/persistence/activation.ts 0 coordinator Trusted private administration requires explicit quiescence and durable refusal records before enabling managed canonical writer enforcement.
src/core/persistence/canonical-projections.ts 9 coordinator Facts, takes, timeline, chunks and aliases install within the guarded canonical publication transaction before its receipt commits.
src/core/persistence/takes-prepare.ts 3 coordinator Semantic takes apply only through the journal publication callback after current revision, holder and authority validation.
src/commands/extract-conversation-facts.ts 4 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/commands/extract.ts 6 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/commands/frontmatter.ts 0 filesystem_guard Batch frontmatter repair preflights every target before the first backup/write.
src/commands/import.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/commands/jobs.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/commands/migrate-engine.ts 11 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
src/commands/migrations/v0_13_1.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/commands/migrations/v0_32_2.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/commands/pages.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/commands/reconcile-links.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/commands/reindex-aliases.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/commands/sources-set-path.ts 1 coordinator Managed rebind uses native exclusion, exact manifest verification and a retained source-incarnation receipt; unmanaged pointer repair remains explicit.
src/commands/sources.ts 10 coordinator Managed add/archive/restore/remove/purge/reclone route through source lifecycle; remaining legacy canonical SQL stays fenced.
src/commands/sync.ts 11 coordinator Managed Markdown sync routes to immutable page requests and an exhausted-cursor checkpoint; unsupported Git pull, code/image and connector paths refuse early.
src/core/agent-install/setup.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/atomic-write.ts 0 filesystem_guard Atomic page replacement checks the managed root before temporary bytes are written.
src/core/backfill-effective-date.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/background-work.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/backup/quarantine.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/bootstrap/verify.ts 4 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/brain-repo-durability.ts 0 filesystem_guard Hardening checks the root; generated push/rebase helpers honor shared refusal markers.
src/core/brain-writer.ts 0 filesystem_guard Frontmatter repair checks the managed root before backup or file replacement.
src/core/calibration/undo-wave.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/chronicle/extract-events.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/cycle/drift.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/cycle/extract-atoms.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/cycle/extract-facts.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/cycle/extract-takes.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/cycle/grade-takes.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/cycle/patterns.ts 0 filesystem_guard Managed brains refuse this legacy disk writer before provider or canonical file work.
src/core/cycle/phantom-redirect.ts 5 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
src/core/cycle/phases/consolidate.ts 5 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/cycle/synthesize.ts 2 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
src/core/cycle.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/destructive-guard.ts 3 coordinator Managed source archive, restore and expiry purge use native exclusion and guarded topology transitions; remaining canonical maintenance keeps its engine guard.
src/core/embedding-dim-check.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/embedding-invalidation.ts 2 projection Derived vector invalidation; signature restamping requires matching chunk context and a current text projection. Canonical fields remain guarded by SQL triggers.
src/core/embedding-migration.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/engine-constants.ts 1 contract Engine contract or explanatory SQL references; execution is in guarded engine implementations.
src/core/engine.ts 2 contract Engine contract or explanatory SQL references; execution is in guarded engine implementations.
src/core/enrichment-service.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/extract/receipt-writer.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/extract-timeline-from-meetings.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/facts/backstop.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/facts/fence-write.ts 2 filesystem_guard Source filesystem ownership is checked before editing canonical fact fences.
src/core/facts/forget.ts 3 filesystem_guard Managed callers journal withdrawal; legacy filesystem helper requires coordinated write.
src/core/facts/withdrawal.ts 2 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
src/core/facts/write-single.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/git-remote.ts 0 filesystem_guard Clone, pull and rebase entry points check the durable root refusal registry.
src/core/github-source.ts 3 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
src/core/google/google-source.ts 3 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
src/core/google/loops-extract.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/import-file.ts 15 coordinator Prepared import applies under journal publication and revision CAS; direct managed imports refuse early, and legacy source-path repair follows revision validation inside a savepoint.
src/core/last-retrieved.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/migrate.ts 13 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
src/core/minions/handlers/ingest-capture.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/onboard/checks.ts 2 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/ops/extraction.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/ops/links.ts 2 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
src/core/ops/loops.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/ops/pages.ts 5 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/output/writer.ts 6 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/page-state/materialize.ts 1 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
src/core/page-state/projection-schema.ts 1 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
src/core/page-state/projections.ts 2 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/page-state/schema.ts 2 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
src/core/page-state/tags.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/persistence/memory-mutations.ts 1 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
src/core/persistence/memory-prepare.ts 3 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
src/core/persistence/page-prepare.ts 8 coordinator Publication, including versioned tombstone restoration, is transaction-scoped under admitted authority, source/page guards and revision checks; the sixth SQL site reconciles trusted first-write provenance with prepared canonical frontmatter and the seventh records canonical source_path in that same guarded transaction. Identical canonical no-ops do not heal physical metadata. The added local-CLI purge deletePage call runs in the prepared publication callback after recorded-artifact removal, under the same source/page identity, revision and durable authority guards as receipt completion; permanent journal IDs and outcomes survive the hard delete.
src/core/persistence/semantic-pages.ts 1 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
src/core/pglite-engine/facts.ts 14 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/pglite-engine/salience.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/pglite-engine/takes.ts 6 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/pglite-engine.ts 34 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/pglite-schema.ts 1 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
src/core/postgres-engine/facts.ts 11 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/postgres-engine/forward-reference-bootstrap.ts 1 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
src/core/postgres-engine/salience.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/postgres-engine/takes.ts 6 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/postgres-engine.ts 33 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/schema-pack/mutate.ts 0 filesystem_guard The shared manifest write primitive checks its root before atomic replacement.
src/core/schema-pack/page-to-alias.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/schema-pack/page-to-link.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/schema-pack/retype.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/schema-pack/rewrite-links-batch.ts 1 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
src/core/schema-pack/sync.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/search/safe-chunks.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/source-config-sql.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
src/core/sources-ops.ts 8 coordinator Managed source constructors, removal and reclone delegate to guarded lifecycle transactions and reserved directory recovery; legacy paths remain preactivation only.
src/core/sweep.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/sync-anchor.ts 5 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/takes-write.ts 8 filesystem_guard Managed operation routes through journal; legacy atomic file helper checks the root.
src/core/think/index.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/timeline-dedup-repair.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/timeline-write-through.ts 2 filesystem_guard Managed semantic operation journals page changes; legacy atomic writes check root.
src/core/transcripts/ingest.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
src/core/workspace-push.ts 0 filesystem_guard The resolved git root is checked before staging, committing or pulling.
src/core/write-through.ts 1 filesystem_guard Source filesystem lock and canonical page SQL guards reject legacy publication.
src/eval/brainbench/seed.ts 2 isolated_eval Explicit isolated evaluation database only; never opened against the active managed brain.
src/eval/chronicle/harness.ts 2 isolated_eval Explicit isolated evaluation database only; never opened against the active managed brain.
src/eval/longmemeval/extract.ts 1 isolated_eval Explicit isolated evaluation database only; never opened against the active managed brain.
src/commands/reinit-pglite.ts 0 filesystem_guard Managed datastore registry refuses destructive reinit even offline; unmanaged rename requires the stable native lock.
src/core/persistence/maintenance.ts 0 filesystem_guard Legacy managed writers refuse before effects; unmanaged datastore backup retains native exclusion through rename.
src/commands/files.ts 0 filesystem_guard Attachment copying, redirection, restore and cleanup refuse paths in managed canonical trees before file effects.
src/commands/schema.ts 0 filesystem_guard Schema manifest scaffolding and forks check managed roots before creating files; separate config settings are control data.
src/core/ingestion/sources/inbox-folder.ts 0 filesystem_guard Inbox archive moves refuse managed canonical input paths before emitting the event or moving source bytes.
src/core/persistence/links-preparation.ts 2 coordinator Automatic graph projections publish inside canonical page transactions; delayed reconciliation locks all affected page keys and compares the originating revision.
src/core/persistence/sync-prepare.ts 3 coordinator Managed imports and deletes validate the recorded raw file hash plus enumerated page identity/revision; the source-exclusive final checkpoint requires every selected receipt to be committed.
src/commands/enrich.ts 0 early_refusal Managed batch synthesis refuses before providers until its resume cursor retains prepared writes; legacy synthesis still carries a coherent revision and exposes pending request IDs.
src/core/persistence/source-lifecycle.ts 6 coordinator Native affected roots precede topology, source, principal and receipt guards; every shared-root generation and old queued intent changes together.
src/core/persistence/topology-recovery.ts 2 coordinator Verified directory-swap recovery checks source incarnation, current logical manifest and physical identity before topology commitment; permanent receipt IDs survive deletion.
1 # Canonical writer census for issue #5105. Counts include lexical references in comments.
2 # path write-reference ceiling enforcement boundary review rationale
3 src/core/persistence/activation.ts 0 coordinator Trusted private administration requires explicit quiescence and durable refusal records before enabling managed canonical writer enforcement.
4 src/core/persistence/canonical-projections.ts 9 coordinator Facts, takes, timeline, chunks and aliases install within the guarded canonical publication transaction before its receipt commits.
5 src/core/persistence/takes-prepare.ts 3 coordinator Semantic takes apply only through the journal publication callback after current revision, holder and authority validation.
6 src/commands/extract-conversation-facts.ts 4 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
7 src/commands/extract.ts 6 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
8 src/commands/frontmatter.ts 0 filesystem_guard Batch frontmatter repair preflights every target before the first backup/write.
9 src/commands/import.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
10 src/commands/jobs.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
11 src/commands/migrate-engine.ts 11 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
12 src/commands/migrations/v0_13_1.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
13 src/commands/migrations/v0_32_2.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
14 src/commands/pages.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
15 src/commands/reconcile-links.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
16 src/commands/reindex-aliases.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
17 src/commands/sources-set-path.ts 1 coordinator Managed rebind uses native exclusion, exact manifest verification and a retained source-incarnation receipt; unmanaged pointer repair remains explicit.
18 src/commands/sources.ts 10 coordinator Managed add/archive/restore/remove/purge/reclone route through source lifecycle; remaining legacy canonical SQL stays fenced.
19 src/commands/sync.ts 11 coordinator Managed Markdown sync routes to immutable page requests and an exhausted-cursor checkpoint; unsupported Git pull, code/image and connector paths refuse early.
20 src/core/agent-install/setup.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
21 src/core/atomic-write.ts 0 filesystem_guard Atomic page replacement checks the managed root before temporary bytes are written.
22 src/core/backfill-effective-date.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
23 src/core/background-work.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
24 src/core/backup/quarantine.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
25 src/core/bootstrap/verify.ts 4 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
26 src/core/brain-repo-durability.ts 0 filesystem_guard Hardening checks the root; generated push/rebase helpers honor shared refusal markers.
27 src/core/brain-writer.ts 0 filesystem_guard Frontmatter repair checks the managed root before backup or file replacement.
28 src/core/calibration/undo-wave.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
29 src/core/chronicle/extract-events.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
30 src/core/cycle/drift.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
31 src/core/cycle/extract-atoms.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
32 src/core/cycle/extract-facts.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
33 src/core/cycle/extract-takes.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
34 src/core/cycle/grade-takes.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
35 src/core/cycle/patterns.ts 0 filesystem_guard Managed brains refuse this legacy disk writer before provider or canonical file work.
36 src/core/cycle/phantom-redirect.ts 5 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
37 src/core/cycle/phases/consolidate.ts 5 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
38 src/core/cycle/synthesize.ts 2 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
39 src/core/cycle.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
40 src/core/destructive-guard.ts 3 coordinator Managed source archive, restore and expiry purge use native exclusion and guarded topology transitions; remaining canonical maintenance keeps its engine guard.
41 src/core/embedding-dim-check.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
42 src/core/embedding-invalidation.ts 2 projection Derived vector invalidation; signature restamping requires matching chunk context and a current text projection. Canonical fields remain guarded by SQL triggers.
43 src/core/embedding-migration.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
44 src/core/engine-constants.ts 1 contract Engine contract or explanatory SQL references; execution is in guarded engine implementations.
45 src/core/engine.ts 2 contract Engine contract or explanatory SQL references; execution is in guarded engine implementations.
46 src/core/enrichment-service.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
47 src/core/extract/receipt-writer.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
48 src/core/extract-timeline-from-meetings.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
49 src/core/facts/backstop.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
50 src/core/facts/fence-write.ts 2 filesystem_guard Source filesystem ownership is checked before editing canonical fact fences.
51 src/core/facts/forget.ts 3 filesystem_guard Managed callers journal withdrawal; legacy filesystem helper requires coordinated write.
52 src/core/facts/withdrawal.ts 2 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
53 src/core/facts/write-single.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
54 src/core/git-remote.ts 0 filesystem_guard Clone, pull and rebase entry points check the durable root refusal registry.
55 src/core/github-source.ts 3 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
56 src/core/google/google-source.ts 3 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
57 src/core/google/loops-extract.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
58 src/core/import-file.ts 15 coordinator Prepared import applies under journal publication and revision CAS; direct managed imports refuse early, and legacy source-path repair follows revision validation inside a savepoint.
59 src/core/last-retrieved.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
60 src/core/migrate.ts 13 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
61 src/core/minions/handlers/ingest-capture.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
62 src/core/onboard/checks.ts 2 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
63 src/core/ops/extraction.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
64 src/core/ops/links.ts 2 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
65 src/core/ops/loops.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
66 src/core/ops/pages.ts 5 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
67 src/core/output/writer.ts 6 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
68 src/core/page-state/materialize.ts 1 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
69 src/core/page-state/projection-schema.ts 1 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
70 src/core/page-state/projections.ts 2 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
71 src/core/page-state/schema.ts 2 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
72 src/core/page-state/tags.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
73 src/core/persistence/memory-mutations.ts 1 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
74 src/core/persistence/memory-prepare.ts 3 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
75 src/core/persistence/page-prepare.ts 8 coordinator Publication, including versioned tombstone restoration, is transaction-scoped under admitted authority, source/page guards and revision checks; the sixth SQL site reconciles trusted first-write provenance with prepared canonical frontmatter and the seventh records canonical source_path in that same guarded transaction. Identical canonical no-ops do not heal physical metadata. The added local-CLI purge deletePage call runs in the prepared publication callback after recorded-artifact removal, under the same source/page identity, revision and durable authority guards as receipt completion; permanent journal IDs and outcomes survive the hard delete.
76 src/core/persistence/semantic-pages.ts 1 coordinator Publication is transaction-scoped under admitted authority, source/page guards and revision checks.
77 src/core/pglite-engine/facts.ts 14 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
78 src/core/pglite-engine/salience.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
79 src/core/pglite-engine/takes.ts 6 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
80 src/core/pglite-engine.ts 34 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
81 src/core/pglite-schema.ts 1 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
82 src/core/postgres-engine/facts.ts 11 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
83 src/core/postgres-engine/forward-reference-bootstrap.ts 1 schema Schema installation and forward-reference DDL; coordinated activation quiesces canonical writers.
84 src/core/postgres-engine/salience.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
85 src/core/postgres-engine/takes.ts 6 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
86 src/core/postgres-engine.ts 33 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
87 src/core/schema-pack/mutate.ts 0 filesystem_guard The shared manifest write primitive checks its root before atomic replacement.
88 src/core/schema-pack/page-to-alias.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
89 src/core/schema-pack/page-to-link.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
90 src/core/schema-pack/retype.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
91 src/core/schema-pack/rewrite-links-batch.ts 1 early_refusal Managed mode refuses this legacy entry point before its canonical filesystem/provider effects.
92 src/core/schema-pack/sync.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
93 src/core/search/safe-chunks.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
94 src/core/source-config-sql.ts 1 projection Derived indexing, retrieval telemetry, or source settings; canonical fields remain guarded by SQL triggers.
95 src/core/sources-ops.ts 8 coordinator Managed source constructors, removal and reclone delegate to guarded lifecycle transactions and reserved directory recovery; legacy paths remain preactivation only.
96 src/core/sweep.ts 2 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
97 src/core/sync-anchor.ts 5 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
98 src/core/takes-write.ts 8 filesystem_guard Managed operation routes through journal; legacy atomic file helper checks the root.
99 src/core/think/index.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
100 src/core/timeline-dedup-repair.ts 3 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
101 src/core/timeline-write-through.ts 2 filesystem_guard Managed semantic operation journals page changes; legacy atomic writes check root.
102 src/core/transcripts/ingest.ts 1 engine_guard Canonical table SQL trigger requires coordinator capability; legacy calls fail before canonical DB publication.
103 src/core/workspace-push.ts 0 filesystem_guard The resolved git root is checked before staging, committing or pulling.
104 src/core/write-through.ts 1 filesystem_guard Source filesystem lock and canonical page SQL guards reject legacy publication.
105 src/eval/brainbench/seed.ts 2 isolated_eval Explicit isolated evaluation database only; never opened against the active managed brain.
106 src/eval/chronicle/harness.ts 2 isolated_eval Explicit isolated evaluation database only; never opened against the active managed brain.
107 src/eval/longmemeval/extract.ts 1 isolated_eval Explicit isolated evaluation database only; never opened against the active managed brain.
108 src/commands/reinit-pglite.ts 0 filesystem_guard Managed datastore registry refuses destructive reinit even offline; unmanaged rename requires the stable native lock.
109 src/core/persistence/maintenance.ts 0 filesystem_guard Legacy managed writers refuse before effects; unmanaged datastore backup retains native exclusion through rename.
110 src/commands/files.ts 0 filesystem_guard Attachment copying, redirection, restore and cleanup refuse paths in managed canonical trees before file effects.
111 src/commands/schema.ts 0 filesystem_guard Schema manifest scaffolding and forks check managed roots before creating files; separate config settings are control data.
112 src/core/ingestion/sources/inbox-folder.ts 0 filesystem_guard Inbox archive moves refuse managed canonical input paths before emitting the event or moving source bytes.
113 src/core/persistence/links-preparation.ts 2 coordinator Automatic graph projections publish inside canonical page transactions; delayed reconciliation locks all affected page keys and compares the originating revision.
114 src/core/persistence/sync-prepare.ts 3 coordinator Managed imports and deletes validate the recorded raw file hash plus enumerated page identity/revision; the source-exclusive final checkpoint requires every selected receipt to be committed.
115 src/commands/enrich.ts 0 early_refusal Managed batch synthesis refuses before providers until its resume cursor retains prepared writes; legacy synthesis still carries a coherent revision and exposes pending request IDs.
116 src/core/persistence/source-lifecycle.ts 6 coordinator Native affected roots precede topology, source, principal and receipt guards; every shared-root generation and old queued intent changes together.
117 src/core/persistence/topology-recovery.ts 2 coordinator Verified directory-swap recovery checks source incarnation, current logical manifest and physical identity before topology commitment; permanent receipt IDs survive deletion.

View File

@@ -6,62 +6,66 @@ owns the single-writer connection.**
## How it works
PGLite is a single-writer embedded Postgres (WASM). A running `gbrain serve`
(stdio MCP) holds an open PGLite connection on the brain's data directory for
its lifetime, guarded by the data-dir lock (`<dataDir>/.gbrain-lock/`), and a
live holder is never displaced.
PGLite is a single-writer embedded Postgres (WASM). A resident owner holds
its datastore's stable external native lock until the connection closes. A
live holder is never displaced, and failed IPC never authorizes a second open.
When `gbrain sync` finds a live serve holding the lock, it does not fail —
it delegates:
The CLI resolves the selected brain before opening its datastore and delegates
to an observed resident owner through authenticated persistence IPC:
1. The CLI probes the lock file (read-only). A live `serve` holder routes the
sync over the serve's IPC socket (`<dataDir>/.gbrain-resolve.sock`,
secret-gated `sync_start` / `sync_status` / `sync_abort` kinds — the same
typed-narrow-request channel the retrieval reflex uses; raw SQL never
crosses the wire).
2. The serve process runs `performSync` on the connection it already owns
(one delegated job at a time) and the CLI polls progress (phases, banked
file counts) once a second, printing the final result exactly like a
direct sync.
3. Ctrl-C sends `sync_abort`: the job settles as a typed partial and the
next `gbrain sync` resumes from the durable checkpoint. A second Ctrl-C
exits without waiting.
4. Embeds are ALWAYS deferred under delegation (the inline embed cost gate
lives in the direct-CLI path) — the serve's idle maintenance sweep drains
pending embeds afterwards, using the serve process's environment/API keys.
`--no-embed` also suppresses that drain.
1. The CLI uses its durable local CLI registration. HTTP and stdio residents
expose this listener; a hook secret or stdio registration cannot grant CLI
authority. Selected PGLite mounts use their own datastore and registration.
2. Before managed activation, the owner runs import-only `performSync` on its
existing connection. After activation, it advances bounded managed-sync
slices through the durable journal. The client repeats slices while the
result is `writer_yield` or `writer_pending`.
3. Managed sync retains its immutable discovery manifest and cursor. If the
client exits or loses an acknowledgment, accepted page requests can finish;
repeating the same options resumes the remaining cursor.
4. Embeddings are deferred because delegation bypasses the direct CLI's inline
cost gate. The owner drains them using its configured provider and keys;
`--no-embed` suppresses that scheduling.
MCP traffic and the delegated sync share the serve's one connection, so the
agent stays *available* throughout, with degraded latency during heavy import
phases (long statements block the event loop; the import yields periodically).
MCP traffic and delegated sync share the owner's datastore. Managed sync yields
between bounded batches, but import work can still affect read latency. See
[canonical writer enforcement](canonical-writers.md) for scheduling, supported
imports and checkpoint rules, and [concurrent writes](../guides/concurrent-writes.md)
for registration, receipts and recovery.
## Limits
| Situation | Behavior |
|---|---|
| Unsupported flags (`--repo`, `--all`, `--watch`, `--workers`, `--exclude`, `--src-subpath`, `--json`, `--break-lock`, anything unclassified) | Refuses by name (default-deny — a silently dropped flag would perform the wrong sync). Drop the flag, stop the serve, or pass `--no-delegate`. |
| `serve --http` | No IPC listener — delegation unavailable; sync refuses politely. Stop the HTTP serve to sync. |
| Serve older than this gbrain version | Typed `stale_serve` refusal — restart the serve on the current version. |
| Mounted brains (`--brain`, `GBRAIN_BRAIN_ID`, `.gbrain-mount`) | Never delegate; the normal connect path applies. |
| Opt-outs | `--no-delegate` or `GBRAIN_SYNC_NO_DELEGATE=1` (client), `GBRAIN_SERVE_SYNC_IPC=0` (serve refuses to register the kinds). |
| Deadlines | The client always sends its resolved hard deadline (interactive default 3600s); the serve bounds the job even if the client dies. `--no-hard-deadline` is the only unbounded encoding. |
| Serve shutdown mid-sync | The serve aborts the job and waits a bounded settle (`GBRAIN_SERVE_SYNC_SETTLE_MS`, default 3000) for the checkpoint flush before disconnecting; the next sync resumes. |
| Unsupported flags (`--all`, `--watch`, `--workers`, `--break-lock`, anything unclassified) | Refuses by name. Supported options include `--repo`, `--source`, `--exclude`, `--src-subpath`, `--include-hidden`, and `--json`; accepting a flag does not bypass managed-mode restrictions. |
| `serve --http` or stdio MCP | Both expose authenticated persistence IPC for the local CLI registration. |
| Old or unavailable resident IPC | Refuses the connection instead of opening another engine; upgrade/restart the resident or retry the same options after recovery. |
| Mounted brains | Selected PGLite mounts delegate to their own owner. Postgres does not require PGLite IPC, but canonical worktree ownership still applies. |
| Client opt-outs | `--no-delegate` or `GBRAIN_SYNC_NO_DELEGATE=1` disables delegation; it does not permit opening an already-owned PGLite datastore. |
| Deadlines | The client sends its resolved hard deadline (interactive default 3600s); each owner call is bounded. `--no-hard-deadline` requests no sync deadline. |
| Managed imports | Use `--no-pull`. Git pull/rebase, code/image importers and ignored-file walks remain refused; see the canonical writer guide. |
| Serve shutdown mid-sync | The owner aborts the active slice and awaits its work before disconnecting. Accepted page requests and the managed cursor retain their durable state. |
Contention with a live **non-serve** holder (another sync, embed, dream) is
unchanged: bounded 1s-poll wait up to the acquire timeout — that coordination
belongs to the `gbrain-sync:*` advisory row lock, which is a DIFFERENT lock
from the PGLite data-dir lock. Confusing the two sends you debugging the
wrong surface. None of this applies to the Postgres engine, which tolerates
concurrent connections.
The older shared-secret `sync_start` / `sync_status` / `sync_abort` protocol
remains a compatibility path for unactivated brains. It has a narrower flag
set and refuses managed brains. `GBRAIN_SERVE_SYNC_IPC=0` disables that legacy
protocol; it is not a substitute for revoking a durable CLI registration.
Datastore ownership and the `gbrain-sync:*` source lease are separate. The
native lock prevents a second PGLite owner; source leases coordinate sync work.
Neither lease expiry nor PID metadata authorizes filesystem ownership takeover.
## If the serve dies mid-sync
Progress is checkpointed — re-run `gbrain sync` to resume. Two notes:
The kernel releases its native lock when the process dies. A successor must
acquire that lock and reconcile durable requests and recovery state before
publishing. Legacy `.gbrain-lock` metadata remains for compatibility and
diagnostics; deleting it cannot authorize takeover.
- The dead serve's `gbrain-sync:<source>` row lock is only auto-reclaimed
once it is ≥60s old (PID-reuse defense). If the re-run reports a dead-PID
sync lock, `gbrain sync --force-break-lock` clears it immediately.
- The dead serve's PGLite data-dir lock is reaped automatically (dead PID).
Repeat the same `gbrain sync` options to resume the managed cursor. Unexpected
file bytes keep the affected root blocked for repair, while unrelated roots
can continue. Use `gbrain sources writer status --probe --json` to inspect the
owner and recovery state before attempting administrative repair.
## Diagnosing a sync hang

View File

@@ -11,11 +11,12 @@ enforces it programmatically.
## Why this matters
The DB is a derived index over the markdown content. It exists to make
Much of the DB is a derived index over the markdown content. It exists to make
search fast, to dedup embedding-similar claims, to materialize the
cross-page graph. `gbrain sync && gbrain extract all` rebuilds the indexes
represented by intact Markdown; it does not recover DB-only knowledge,
credentials, or page revision history.
credentials, page revision history, durable receipts, or the authoritative
withdrawal ledger. Preserve those records in a database backup.
This means:
@@ -34,9 +35,11 @@ This means:
gitignored (via `gbrain.yml` `db_only` paths or per-page) and they
stay on disk but not in git. The fence respects whatever git
tracking choice you make at the page level.
- **Cross-agent collaboration is possible.** Multiple agents can write
to the same brain because the fence is the merge point, not the DB.
Git handles concurrent edits the way git handles concurrent edits.
- **Cross-agent collaboration is coordinated.** Multiple authenticated servers
can accept writes against shared Postgres. Each canonical worktree has one
designated publishing host; revision checks prevent stale replacements and
durable receipts let callers inspect or replay accepted requests. Git still
carries files between independent brains.
## The three categories
@@ -56,7 +59,7 @@ The CI gate constrains direct DB writes to the documented paths.
| **Takes** (incl. hunches, bets) | `## Takes` fenced table between `<!--- gbrain:takes:begin -->` / `:end -->` markers | `takes` | `extract takes` |
| **Facts** | `## Facts` fenced table between `<!--- gbrain:facts:begin -->` / `:end -->` markers | `facts` | `extract_facts` cycle phase |
| **Links** | Inline `[text](slug)` / `[[slug]]` in markdown body + frontmatter `direction: incoming` | `links` | `extract links` |
| **Timeline** | Dated markers anywhere in the page body — compiled truth AND the `## Timeline` section: `- **YYYY-MM-DD** \| Source — Summary` bullets, `### YYYY-MM-DD — Title` headers (FS extract), and inline `[Source: <text>, YYYY-MM-DD]` citations (one row per citation, dated by the citation, summary = the bullet/paragraph it sits in). The `<!-- timeline -->` sentinel only splits compiled_truth from timeline for storage; it does not scope extraction | `timeline_entries` | `extract timeline` + `put_page`'s `auto_timeline` |
| **Timeline** | Dated markers anywhere in the page body — compiled truth AND the `## Timeline` section: `- **YYYY-MM-DD** \| Source — Summary` bullets, `### YYYY-MM-DD — Title` headers (FS extract), and inline `[Source: <text>, YYYY-MM-DD]` citations (one row per citation, dated by the citation, summary = the bullet/paragraph it sits in). The `<!-- timeline -->` sentinel only splits compiled_truth from timeline for storage; it does not scope extraction | `timeline_entries` | `extract timeline` + canonical page publication (independent of `auto_timeline`) |
| **Tags** | Frontmatter `tags:` YAML array | `tags` | `importFromFile` (reconciles per-page on import) |
| **emotional_weight** | Recomputed from takes + tags | `pages.emotional_weight` (signal column) | `recompute_emotional_weight` cycle phase |
| **synthesis_evidence** | FK into `takes` rows (`slug#N`) inside synthesis pages | `synthesis_evidence` | `extract takes` (transitively) |
@@ -91,6 +94,8 @@ also contain knowledge absent from canonical files and must be backed up.
| `gbrain_cycle_locks` / migration ledger | Infrastructure. |
| `op_checkpoint_paths` | Sync-resume checkpoint. Append-only progress banking; a completed sync makes it irrelevant. |
| `config` (some keys) | Site-local routing config (e.g. `sync.repo_path`). |
| Withdrawal ledger and page overlays | Authoritative withdrawal decisions must survive stale imports and owner downtime. Markdown mirrors can lag. |
| Mutation journal, receipts, outbox, ownership and local registrations | Durable replay identity, publication recovery and authorization cannot be rebuilt from Markdown. |
A new derived table that holds user-knowledge MUST land FS-first.
If you're tempted to add one as "DB-only for now," the structural
@@ -100,21 +105,34 @@ reconciler.
## Page-write persistence boundary
For a file-backed `put_page`, the root worktree lock is acquired before importing
the revision. If that acquisition times out, `storage_busy` means the write was
not applied and was not queued. Canonical Markdown is staged with fsync and
renamed inside the import's database transaction, after required source-path
bookkeeping. Ordinary filesystem rejection rolls back the imported page, tags,
chunks, and version snapshot.
Page mutations first admit a durable request scoped to the authenticated
principal. Existing-page replacements require the caller's observed revision
or explicit `force`; omitting both permits creation only when absent. Repeating
the same UUID and intent returns the stored outcome without executing terminal
work again. Preconditions are checked before no-op detection.
This is not a distributed transaction between the filesystem and database. A
crash or database COMMIT failure after rename can leave the Markdown ahead of
the index. It is also not a durable write queue or a caller-supplied revision
precondition. The page operation releases its own worktree lock before optional
embedding, so a slow provider does not block other writes to the same worktree.
Embedding is page-scoped, rejects superseded page/chunk generations, and reports
failure separately without undoing a saved page or exposing provider exception
text. A lock owned by a surrounding caller remains that caller's responsibility.
The designated owner prepares outside publication locks, reserves recovery
space, then takes the native worktree lock and rechecks authority, identity,
revision and file bytes. It records recovery data before flushing and atomically
replacing the file. Canonical projections, tags, aliases, complete version
history, the terminal receipt and postpublication effects commit together in
the database. An ordinary rejected file write rolls that transaction back.
A committed receipt identifies durable canonical state. Lock contention or
owner downtime leaves accepted work queued; after the five-second synchronous
wait, `write_pending` includes the UUID and one-second retry guidance. An
uncertain publication stays `recovering` and blocks its worktree until resolved.
Recovery restores prior bytes only when the file still matches the recorded
attempt. Unexpected bytes require explicit repair. Direct filesystem readers
can observe the file/database publication interval.
Page reads return content, tags, withdrawal overlays and revision from one
database snapshot. A read begun after commitment observes that revision or a
later one. Embeddings and Git completion have separate, retryable effect states;
embedding installation checks the captured page/chunk/text/indexing context and
cannot invalidate a committed page. Search excludes unsealed text projections
until a worker rebuilds them. See [concurrent writes](../guides/concurrent-writes.md)
for receipts, capacity, activation and transfer commands.
The rebuild contract above applies only to knowledge actually preserved in
canonical files. DB-only pages, unresolved facts not written to a fence, audit
@@ -150,11 +168,16 @@ internal notes), mark the entity page's directory as `db_only` in
## The forget contract
`gbrain forget <id>` and the MCP `forget_fact` op rewrite the fence
row with strikethrough + `valid_until = today` + `context: "forgotten:
<reason>"`. The DB's `expired_at = valid_until + now()` derivation
reconstructs the forget state on every rebuild because the fence is
canonical.
`gbrain forget <id>` and the MCP `forget_fact` operation commit withdrawal to
the authoritative database ledger first. The transaction expires matching facts,
adds claim-fingerprint overlays, advances affected page revisions and invalidates
their retrieval projections. No filesystem owner is required. Stale imports and
delayed embedding work cannot undo the withdrawal.
The owner later mirrors the current logical snapshot into Markdown, retaining
strikethrough, withdrawal dates and context for historical rows. Mirroring does
not advance the logical revision again. A failed or uncertain mirror never
reverses withdrawal; direct page snapshots apply the ledger while it is pending.
Strikethrough has two semantics distinguished by context:
@@ -163,9 +186,9 @@ Strikethrough has two semantics distinguished by context:
- `~~claim~~` + `context: "forgotten: <reason>"` → row was retracted
via the forget op
Both encodings keep the row in the markdown for audit history. To
permanently delete a fact, edit the fence directly in markdown and
remove the row. The next `extract_facts` cycle wipes the DB row.
Both encodings retain history in Markdown. Withdrawal removes a fact from active
memory; history, source material and private backups may remain. Editing a fence
does not erase the withdrawal ledger or promise physical erasure.
## Disaster recovery
@@ -173,7 +196,10 @@ This example is only for a database whose affected facts, takes, links and
timeline entries have been verified to exist in canonical files. Before running
the destructive commands, stop writers and verify a restorable database backup
plus source-file backups. Do not use this recipe on unresolved DB-only facts or
assume a repository backup covers gitignored files.
assume a repository backup covers gitignored files. Preserve the authoritative
withdrawal ledger and all accepted request identities. On an activated managed
brain, use the documented drained recovery procedure; direct SQL or an older
writer cannot safely replace the coordinator.
```bash
# File-backed state only: verify restorable DB + source backups before proceeding.

View File

@@ -0,0 +1,327 @@
# Concurrent writes and durable receipts
Each accepted mutation has a durable request UUID scoped to one brain and one
authenticated principal. A response distinguishes acceptance from commitment.
Keep the UUID and original arguments until the request reaches a terminal state.
**Say to your agent:** *"Update this page without overwriting a newer revision;
use `get_page` and `put_page`, then check the durable receipt."* Or: *"Inspect my
writer owners with `gbrain sources writer status --probe --json` before changing
the setup."*
## Read, edit, and retry
Read an existing page with `get_page` and `include_content: true`. Preserve its
complete `content`, `revision`, and source. Send the edited complete content to
`put_page` with `expected_revision` equal to that revision and a newly generated
`request_id` UUID. Capture replacements, delete, restore, and version revert also
accept the revision precondition. `force: true` is an explicit overwrite choice;
it is mutually exclusive with `expected_revision`.
An omitted replacement precondition means create-only. Even byte-identical
input must pass the precondition first: an old reader cannot turn a stale write
into a successful no-op. A canonical content/tag/timeline/deletion/withdrawal
change advances the opaque logical revision. Embeddings, summaries, and other
derived rebuilds do not. Reverting an older version creates a new revision;
it does not reinstate an old revision token. Legacy partial versions preserve
fields that the old version did not record, including whether the page was deleted.
New versions record deletion state: reverting a tombstone version removes the
canonical file and hides the page; reverting a live version restores them.
Facts, takes, and canonical timeline rows commit with their page. The legacy
`auto_timeline` switch does not suppress that required projection; maintenance
may report zero newly reconciled rows because publication already installed them.
`get_page` and `fetch` assemble canonical page fields, tags, withdrawal state,
and the reported revision from one committed database snapshot. While a
publication is in progress, a reader may see the prior committed snapshot.
Separate calls can observe different committed revisions. Search ranking,
embeddings, and direct filesystem reads are outside this snapshot guarantee.
Do not regenerate a request ID because the response was lost or a waiter timed
out. Repeat the same operation, arguments, source, and UUID. A committed replay
returns its original result; a terminal conflict/failure is not executed again.
Changing the operation or arguments with the same UUID produces
`idempotency_conflict`. Resolve the conflict and use a new UUID for a new intent.
Relative times, generated capture slugs, and trusted owner/resolver defaults
are frozen at admission so retries cannot drift.
## Receipt states and errors
| State | Meaning |
| --- | --- |
| `queued` | Accepted; waiting for execution. |
| `running` | An owner is preparing or executing it. |
| `recovering` | Publication must be reconciled before work on that root can continue. |
| `committed` | The canonical mutation and its receipt committed. |
| `conflict` | A precondition or identity conflict prevented commitment. |
| `failed` | The request ended without commitment. |
| `cancelled` | Cancelled before publication began. |
Receipts include `request_id`, `state`, and `retry_after_ms`, with optional
revision, outcome, persistence status, and timestamps. Terminal receipts have
`retry_after_ms: null`. Private queued content, credential hashes, and recovery
bytes are never part of the receipt.
`write_pending` means accepted work remains outstanding. `owner_unavailable`
and `writer_lock_unavailable` do not authorize a competing owner or a fresh
request ID. `queue_capacity` refuses additional admission without evicting
existing requests. `revision_required`, `revision_conflict`,
`idempotency_conflict`, and `source_changed` require correcting the caller's
intent or authority. `recovery_required` names unresolved publication state.
Inspect the attached receipt: absence of an acknowledgment is not evidence of
absence of a write.
CLI-generated UUIDs are retained in pending/error output. If transport delivery
is ambiguous, the client reports `submission_status: "unknown"` and the original
UUID, without fabricating a queued receipt or opening another PGLite engine.
Legacy callers that omit a request ID and lose the entire acknowledgment cannot
recover exact replay identity from the content alone.
Local Unix listeners keep their existing socket addresses when they fit the
portable 103-byte limit. Longer addresses use a deterministic private directory
under `/private/tmp` on macOS or `/tmp` on Linux, independent of `HOME` and
`TMPDIR`. Both CLI discovery and resident servers derive it without opening the
database. The directory must belong to the current OS user with mode `0700`;
clients require a socket with mode `0600`. Unsafe entries are refused. Existing
credentials and hook-secret locations are unchanged. A native binding lock
serializes startup and remains held until the listener has actually closed.
Admission retries confirmed database lock/serialization aborts for up to five
seconds using the same UUID. Persistent contention returns a storage error with
that UUID and no fabricated queued receipt. Keep the ID for the next attempt.
## Frozen memory verbs
`remember` and `forget` accept optional `request_id`. Their frozen success enums
and `protocol_version: 1` are unchanged. Accepted pending memory writes use the
existing `unavailable` error with a populated suggestion and additive
`write_request`/`write_error` metadata. A pending response never claims
`status: "inserted"` or `expired: true`.
The seven-verb surface supports recovery by repeating the original verb with
the same UUID; it does not require an unavailable status helper. A committed
`forget` withdraws the source- and visibility-scoped fact from active memory
even when its physical mirror is pending. Imports and rebuilds honor the
withdrawal ledger. History and backups can remain; withdrawal is not a promise
of physical erasure. See [MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md).
Git, embeddings, and physical withdrawal mirrors report their own `effects`
states on receipt reads. Their retries never change the committed canonical
result. Mirror recovery checks the recorded bytes and blocks its worktree if
an unexpected edit needs repair. Other worktrees can continue. Recovery space
is reserved before touching a file; insufficient capacity leaves the withdrawal
effective and its physical mirror queued.
Git work runs only for repositories already opted into durability hardening.
It commits the affected file without invoking legacy hooks, then attempts a
plain push to the configured tracking remote. It never pulls or rebases source
files. An unconfigured remote is reported as a skipped push. Embeddings wait
for an enabled, configured provider and install only if the page revision and
its text projection still match.
Before managed activation, eligible `put_page` and `capture` writes also
record durable facts-extraction intent. `facts_backstop.queued` means that
intent committed with the page; the `facts-backstop` effect becomes
`dispatched` when its durable worker job is accepted. Extraction availability
is checked by that worker. The handoff is idempotent and rechecks the source,
page revision and current writer grant. Confined writers, unchanged pages,
disabled extraction and dream-generated content do not enqueue work.
After activation the legacy extractor reports `writer_coordinator_required`
and skips; it cannot bypass canonical publication. Activation also causes
previously queued extraction jobs to skip. Canonical receipts remain unchanged.
## Receipt access and explicit grant migration
`get_write_request`, `list_write_requests`, and `cancel_write_request` require
write scope and permission for the exact helper in the current operation grant.
They are on the starter/full surfaces; they do not expand the frozen verb
surface or an agent-only tool grant. Read-only callers cannot use them.
Receipts are principal-owned. Another principal's UUID, an unknown UUID, and a
request whose target is no longer accessible return the same `not_found` result.
Listing selects one source and applies current operation/source/slug fences
before pagination. It exposes neither another principal's queue nor private
input. Cancellation rechecks authority under transaction locks; publication
already in progress or committed cannot be undone by cancellation.
An upgrade does **not** widen an existing `allowedOperations` snapshot. A new
profile may include helpers that an older saved profile did not. Regrant them
explicitly only when the caller needs status access; same-verb replay remains
available under the original mutation grant.
For example, suppose the reviewed existing operation list is exactly
`remember,forget`. A trusted administrator can preview this complete replacement
list on the brain host:
```bash
gbrain auth rescope-client client-example \
--allowed-operations remember,forget,get_write_request,list_write_requests,cancel_write_request \
--dry-run --json
```
Inspect `before.allowedOperations`, the source/slug/scope restrictions, and
`before.revision`. Preserve every existing operation that should remain granted.
Then apply the reviewed full list with `--if-version` set to that observed grant
revision; for example, if it was `7`:
```bash
gbrain auth rescope-client client-example \
--allowed-operations remember,forget,get_write_request,list_write_requests,cancel_write_request \
--if-version 7 --json
```
The operation flag replaces the complete list. It is not an append flag. Omitted
source, slug, delegated-tool, budget, and surface flags preserve their axes.
If the client is pinned to `verbs`, exposing status helpers also requires an
explicitly reviewed starter/full surface within the server ceiling. New OAuth
scopes require a newly issued access token; existing tokens cannot gain scopes
by changing the client row. For resident PGLite, use the owner's authenticated
grant administration UI/API or stop the resident before ordinary
`auth rescope-client`; the local-writer commands below have their own resident
proxy.
Accepted requests retain their original authority snapshot and intersect it
with the current grant before publication and replay. Revocation, source
archive/recreation, or narrower slug/operation/holder permissions cannot be
bypassed with an old receipt or queued request. Regranting receipt helpers does
not rewrite an accepted mutation's authority snapshot.
## Local registrations and canonical ownership
For a coordinated upgrade, update and stop older writers on every host first.
Claim each filesystem source on its canonical host, then inspect writer status
and existing locks. Activation is explicit:
```bash
gbrain sources writer status --probe --json
gbrain sources writer activate --confirm-quiesced --dry-run --json
gbrain sources writer activate --confirm-quiesced --json
```
The flag asserts that older binaries, external editors and maintenance writers
have been quiesced on every host. Activation verifies all owner bindings and
native locking, rejects outstanding legacy leases and unfinished publications,
and makes local refusal records durable before enabling managed writes. Even an
expired lease needs explicit inspection and removal; elapsed time does not prove
its writer stopped. A failed activation leaves managed mode disabled. Status
reports `enabled: false` until activation commits. Run an ordinary write and
read its receipt and revision before resuming writers on the upgraded hosts.
CLI and stdio registrations are durable, separate principals. The CLI lane is
trusted local administration; stdio remains an untrusted memory caller.
Revocation survives restart. Losing a credential file or receiving a denied
response does not silently create a replacement principal.
These commands work through a credential-verified private socket when a local
PGLite owner is running:
```bash
gbrain auth local-writer list --json
gbrain auth local-writer register stdio --source-ids default \
--allowed-operations remember,forget --scopes read,write --dry-run --json
gbrain auth local-writer revoke 11111111-1111-4111-8111-111111111111 --json
gbrain sources writer status --probe --json
gbrain sources writer claim default --path /absolute/canonical/source --json
```
`register --replace` requires the complete intended grant, revokes the prior
registration, and publishes a new private credential only after database
registration is durable. Output never contains the credential. A revoked CLI
cannot replace itself through the resident socket: stop the owner and explicitly
register the replacement locally. Old private files are retained for recovery,
and their revoked credentials no longer authorize work.
PGLite has one process owner. Postgres permits multiple authenticated ingress
processes, but each canonical filesystem root has one designated host owner.
Nested sources in a shared worktree share its coordination lock. A stale
heartbeat is diagnostic information; it never authorizes taking ownership.
Filesystem-dependent work waits for its owner while database reads continue.
To move a root, prepare on its current owner and retain the returned epoch and
manifest digest. Copy the complete canonical worktree to the successor, then
accept there with the exact epoch and digest:
```bash
gbrain sources writer transfer prepare default --json
gbrain sources writer transfer accept default --path /absolute/successor/root \
--expected-epoch 1 --manifest '<prepared-sha256>' --json
```
Successful preparation places the root in its draining state and records an
exact path/content manifest. Changed
bytes, missing files, a changed epoch, or unresolved recovery refuse acceptance.
After a lost administration acknowledgment, inspect writer status and local
registrations before repeating a command; administration is not automatically
replayed as a page mutation.
Writer status reports resident ingress state, active preparations, owner epochs,
queued request counts/bytes/age, the last committed sequence for each worktree,
and recovery storage including withdrawal mirrors. Capacity entries show the
configured limit, remaining reservation and the exact configuration key to
adjust; usage at or above 80% includes expansion guidance. Blocked requests carry
a concrete next action. Diagnostics contain no request content, credentials or
private checkout paths.
## Source lifecycle
After activation, source add, archive, restore, remove, purge, path rebind and
managed reclone run through the same registered owner. They take the affected
native locks, wait for publication and withdrawal mirrors to settle, then
advance every membership in a shared root. Already accepted requests for its
old topology finish with `source_changed`; their IDs remain permanently reserved.
Removing and recreating a source gives it a new incarnation. Source removal
retains local storage and never deletes old receipt identities.
Lifecycle commands accept `--request-id` for exact replay after a lost
acknowledgment and `--expected-incarnation` to reject a recreated source. These
UUIDs share the CLI principal's page-write ID domain: reuse for a different
operation conflicts. A lifecycle receipt can be `committed`, `recovering`, or
`failed`. Keep its UUID when inspecting or retrying that exact intent. A new
attempt after a terminal failure requires a new explicit UUID. `--dry-run`
changes neither topology nor storage.
A path rebind requires identical canonical content and deletions in a fresh
candidate checkout; exclude GBrain ownership metadata when copying a candidate.
A managed reclone reserves the configured recovery capacity before cloning and
checks the full staged manifest before replacing the directory. If the old
checkout is missing, its last verified manifest must still match the logical
source; a stale remote is never accepted as recovery. Incomplete directory
replacement blocks that root until its recorded recovery finishes. Neither
recovery nor lifecycle administration reverses a committed fact withdrawal.
Physical checkout identity lives in private durable markers in and beside the
root. Copies, replaced directories, and competing homes cannot claim that same
path as separate worktrees. Keep those markers: removing them does not grant
ownership or authorize failover. Old paths retain their refusal records after
rebind or removal.
## Bounded admission and retention
The CLI routes source mutations through the current resident owner before
opening PGLite. These administrative requests require managed activation;
before activation, stop the resident owner to use legacy source commands.
`sources purge` in managed mode requires an explicit archived source ID and
`--confirm-destructive`; use `sources archived` to inspect candidates. The
automatic expiry walker still coordinates each expired source separately.
`--yes` alone does not authorize destructive managed removal. Keep the UUID
from a pending or uncertain administrative result and repeat the same command,
arguments, and `--request-id` after recovery.
Default admission limits are enforced atomically:
| Reservation | Per principal | Per brain |
| --- | ---: | ---: |
| Outstanding requests | 100 | 1,000 |
| Queued intent bytes | 32 MiB | 256 MiB |
| Lifetime request IDs | 100,000 | 1,000,000 |
| Terminal receipt reservation | 128 MiB | 1 GiB |
| Recovery bytes | — | 1 GiB, also 256 MiB per worktree |
Completion space is reserved at admission. Beforeimage/recovery bytes are
reserved before filesystem publication. Reaching a limit refuses additional
work; it does not discard an accepted request to make room. Terminal diagnostic
compaction has a default eligibility threshold of 30 days and preserves replay IDs, digests, terminal
outcomes, and frozen memory-verb result fields. Pending/recovering requests are
not evicted. Lifetime IDs and replay protection are not silently reset.

View File

@@ -145,7 +145,7 @@ matched. **create_safety** (enum): `exists` (a page for this already exists)
signal). The derivation of both is implementation-defined and may improve;
the values are frozen.
### remember(fact, provenance, ttl?, entity?, kind?, visibility?) — write
### remember(fact, provenance, ttl?, entity?, kind?, visibility?, request_id?) — write
Save ONE fact with mandatory attribution.
@@ -271,7 +271,7 @@ purpose, no dedicated status); a `max_tokens`-cut envelope parses as
`output_truncated` (warning `LLM_OUTPUT_TRUNCATED`) so a too-small output
budget is distinguishable from malformed model output.
### forget(id, reason?) — write
### forget(id, reason?, request_id?) — write
Expire a fact by its opaque string id (from `remember` or
`recall.facts[].fact_id` — never a page slug). Idempotent: re-forgetting an
@@ -280,6 +280,44 @@ already-expired fact returns `expired: false` (success); unknown id ⇒
Response: `{ id, expired, reason, protocol_version }`.
#### Durable write receipts (additive)
Write receipts distinguish accepted work from committed memory. Their public
shape is `{request_id, state, retry_after_ms, revision?, outcome?, persistence?,
compacted?, created_at?, updated_at?}`. States are `queued`, `running`,
`recovering`, `committed`, `conflict`, `failed`, and `cancelled`. Terminal
receipts have `retry_after_ms: null`. `persistence.mode` distinguishes a
filesystem-backed write from an intentional database-only write; Git progress
does not change the meaning of committed memory.
A pending write is a protocol `unavailable` error with a populated suggestion,
`protocol_version: 1`, and optional `write_request` and `write_error` fields.
It never returns a success `status` or `expired` value. `write_error` carries
the detailed concurrency reason without changing the frozen protocol error
enum. A committed receipt retains the original memory-verb success fields.
Compaction may remove diagnostics, but must preserve those frozen result fields.
The optional caller-generated UUID `request_id` identifies one write intent.
Retry the same verb with the original arguments and the same ID to recover
its outcome, including on the verbs-only surface. A terminal request is never
executed again. Corrected input requires a new ID. Clients that lose a response
without retaining its request ID cannot assume that retrying content is an
exactly-once write. A receipt never contains queued content, recovery paths or
execution credentials.
The starter/full helpers `get_write_request`, `list_write_requests`, and
`cancel_write_request` require write scope and explicit current operation
permission. Existing operation snapshots are not widened by an upgrade.
Helpers expose only the caller's currently authorized receipts; a foreign,
missing, or no-longer-accessible UUID has the same `not_found` response. Their
absence from a verb-only or agent-only grant does not prevent same-verb replay.
See [concurrent writes](../guides/concurrent-writes.md) for exact read guarantees,
bounded retention, ownership transfer, and the explicit regrant procedure.
For `forget`, a committed source- and visibility-scoped withdrawal is the
durable memory outcome. Its filesystem mirror may remain pending; stale
source imports must still respect the withdrawal.
### context_pack(entities, budget_tokens?, since?, session_id?, include_private?) — read, zero LLM
One deterministic, budget-packed bundle for a set of standing

View File

@@ -2922,7 +2922,7 @@ echo "from a pipe" | gbrain capture --stdin
SLUG=$(gbrain capture "..." --quiet)
```
For a file-backed source, the page is saved to the database and canonical Markdown before optional embedding. Ordinary file-write failures roll back the database revision; this is not a crash-atomic transaction across files and the database. A source without a configured repository can hold DB-only pages, which need a database backup. See the [persistence boundary](docs/architecture/system-of-record.md#page-write-persistence-boundary). Default slug `inbox/YYYY-MM-DD-<hash8>` so captures cluster in a predictable triage location. On thin-client installs the verb routes through MCP to the server.
Page writes return durable receipts. Replacements require the revision you read or explicit `force`; keep the request UUID when retrying. Accepted work can remain queued while its owner is unavailable, and uncertain publication has an explicit recovery state. Embedding completion is separate from canonical commitment. A source without a configured repository can hold DB-only pages, which need a database backup alongside withdrawal and receipt records. See [concurrent writes](docs/guides/concurrent-writes.md) and the [persistence boundary](docs/architecture/system-of-record.md#page-write-persistence-boundary). Default slug `inbox/YYYY-MM-DD-<hash8>` so captures cluster in a predictable triage location. On thin-client installs the verb routes through MCP to the server.
**Say to your agent:** *"Remember this: ..."* — *"Save this thought to my brain"* — *"Capture this."* And to fill an empty brain from your existing life: *"Fill my brain"* (the cold-start skill walks your email, calendar, contacts, and archives one consented step at a time).
@@ -3306,12 +3306,12 @@ gbrain sync --no-schema-pack --no-pull --no-embed --yes
shapes (`(a+)+`, `(a*)*`, …) in pack regexes, and the runtime caps
inference-regex input length (override via `GBRAIN_MAX_REGEX_INPUT_CHARS`).
Third, on a PGLite brain with a live `gbrain serve` (your agent's MCP
server), `gbrain sync` delegates the run to the serve process over its
local IPC socket — the lock owner does the work, your agent stays up,
and Ctrl-C aborts to a checkpoint the next sync resumes from. Embeds
defer to the serve's background sweep. See
server), `gbrain sync` delegates through authenticated local IPC to the
owner, whether it serves HTTP or stdio. If the client exits, accepted page
requests can finish; repeat the same options to resume the managed sync
cursor. Embeds defer to the owner's background work. See
[`docs/architecture/serve-sync-concurrency.md`](docs/architecture/serve-sync-concurrency.md)
for the limits (unsupported flags, `serve --http`) and the full triage.
for supported flags, managed-mode limits and the full triage.
**`gbrain init --migrate-only` / a schema migration fails on Windows
with `getaddrinfo ENOTFOUND`?** Run `gbrain upgrade`. Schema bring-up
@@ -6508,7 +6508,7 @@ matched. **create_safety** (enum): `exists` (a page for this already exists)
signal). The derivation of both is implementation-defined and may improve;
the values are frozen.
### remember(fact, provenance, ttl?, entity?, kind?, visibility?) — write
### remember(fact, provenance, ttl?, entity?, kind?, visibility?, request_id?) — write
Save ONE fact with mandatory attribution.
@@ -6634,7 +6634,7 @@ purpose, no dedicated status); a `max_tokens`-cut envelope parses as
`output_truncated` (warning `LLM_OUTPUT_TRUNCATED`) so a too-small output
budget is distinguishable from malformed model output.
### forget(id, reason?) — write
### forget(id, reason?, request_id?) — write
Expire a fact by its opaque string id (from `remember` or
`recall.facts[].fact_id` — never a page slug). Idempotent: re-forgetting an
@@ -6643,6 +6643,44 @@ already-expired fact returns `expired: false` (success); unknown id ⇒
Response: `{ id, expired, reason, protocol_version }`.
#### Durable write receipts (additive)
Write receipts distinguish accepted work from committed memory. Their public
shape is `{request_id, state, retry_after_ms, revision?, outcome?, persistence?,
compacted?, created_at?, updated_at?}`. States are `queued`, `running`,
`recovering`, `committed`, `conflict`, `failed`, and `cancelled`. Terminal
receipts have `retry_after_ms: null`. `persistence.mode` distinguishes a
filesystem-backed write from an intentional database-only write; Git progress
does not change the meaning of committed memory.
A pending write is a protocol `unavailable` error with a populated suggestion,
`protocol_version: 1`, and optional `write_request` and `write_error` fields.
It never returns a success `status` or `expired` value. `write_error` carries
the detailed concurrency reason without changing the frozen protocol error
enum. A committed receipt retains the original memory-verb success fields.
Compaction may remove diagnostics, but must preserve those frozen result fields.
The optional caller-generated UUID `request_id` identifies one write intent.
Retry the same verb with the original arguments and the same ID to recover
its outcome, including on the verbs-only surface. A terminal request is never
executed again. Corrected input requires a new ID. Clients that lose a response
without retaining its request ID cannot assume that retrying content is an
exactly-once write. A receipt never contains queued content, recovery paths or
execution credentials.
The starter/full helpers `get_write_request`, `list_write_requests`, and
`cancel_write_request` require write scope and explicit current operation
permission. Existing operation snapshots are not widened by an upgrade.
Helpers expose only the caller's currently authorized receipts; a foreign,
missing, or no-longer-accessible UUID has the same `not_found` response. Their
absence from a verb-only or agent-only grant does not prevent same-verb replay.
See [concurrent writes](../guides/concurrent-writes.md) for exact read guarantees,
bounded retention, ownership transfer, and the explicit regrant procedure.
For `forget`, a committed source- and visibility-scoped withdrawal is the
durable memory outcome. Its filesystem mirror may remain pending; stale
source imports must still respect the withdrawal.
### context_pack(entities, budget_tokens?, since?, session_id?, include_private?) — read, zero LLM
One deterministic, budget-packed bundle for a set of standing

100
native/locks/README.md Normal file
View File

@@ -0,0 +1,100 @@
# Native writer locks
`locks.c` is a first-party C Node-API v3 addon exposing opaque `openLock`,
nonblocking `tryLock`, and idempotent `close` operations. POSIX uses `flock`;
Windows uses `LockFileEx`. The kernel releases ownership on process death.
The TypeScript wrapper in `src/core/persistence/native-lock.ts` adds cancellable
asynchronous waiting, defaults to a 5-second deadline and 25-ms polling, and
loads the addon only when a caller needs lock capability.
The caller must supply an absolute, stable lock path in a host-controlled
directory outside any replaceable datastore or worktree. Keep the file after
release. Never unlink, rotate, copy over, or infer ownership from its age or
metadata: replacing a locked inode can permit two owners. Leaf symlinks,
reparse points and nonregular files are rejected. Directory confinement and
the filesystem's cross-process lock semantics are prerequisites supplied by
the host; this binding does not establish distributed ownership across hosts.
An unavailable addon or an OS error produces `writer_lock_unavailable`.
There is no timestamp, PID, or TTL ownership fallback. A close failure must
stop publication; it cannot be treated as successful relinquishment.
Windows IPC adds two narrowly scoped operations in `windows-ipc.h`. Named
pipes retain a nonblocking Global kernel mutex from before probing through
the listener's actual close. Windows invariant Unicode uppercase plus CNG
SHA-256 gives case and prefix aliases one identity independent of homes and
logon sessions. The environment registry and a small shared kernel owner record
prevent recursive acquisition through separate addon copies on the same thread;
opaque finalizers and cleanup release on the owning thread. Failed finalizer
release retains the handle and registry reference until verified cleanup.
Abandoned mutexes are acquired only through the kernel. Namespace permission
errors refuse binding.
For a provably dead Windows AF_UNIX listener, cleanup requires a still-held
opaque file binding claim. It opens the leaf without following reparse
points, verifies `IO_REPARSE_TAG_AF_UNIX`, and marks that exact handle for
deletion. Ordinary files, directories, other reparse tags and access errors
are refused. This avoids treating Bun's stale-socket `lstat` errors as proof
that an arbitrary filesystem entry may be removed.
## Distribution and reproducible builds
All eight addons are checked in, so source installs work with
`bun install --frozen-lockfile --ignore-scripts`. They support x64 and arm64
on Linux glibc (2.17 ABI baseline), Linux musl, macOS (13.0 deployment
target), and Windows. Supported Bun versions are tested at the repository's
minimum, 1.3.11, and release version, 1.3.13. OS compatibility also requires
the selected Bun version's own platform minimums.
Node-API headers and their upstream license are vendored from Node
v22.15.0. `darwin-abi.h` declares the narrow public Darwin LP64 ABI needed
for SDK-free cross-compilation, with constants/layout checked against Apple
XNU tag `xnu-11215.81.4`; macOS CI compiles `abi-check.c` against the actual
SDK. The x86_64 `fstat$INODE64` symbol and arm64 `fstat` symbol are distinct.
No Apple SDK is redistributed. Windows resolves its used Node-API symbols
from the running executable through `windows-napi.h`, including renamed
compiled CLIs. It never loads a separate `node.exe`. A process-wide once
guard publishes the complete function table before any API call; missing
exports refuse registration without borrowing another runtime's environment.
The Windows IPC helpers link the OS-provided `bcrypt` CNG library and remain
within Node-API v3 and the existing Windows platform minimum.
Use the pinned Zig 0.14.1 compiler. Archive URLs, SHA-256 hashes and sizes
are in `scripts/native/toolchain.json`; setup verifies them before extracting.
```sh
bun scripts/native/setup-toolchain.ts --dir .context/native-toolchain
ZIG=/absolute/path/to/the/downloaded/zig bun scripts/native/build.ts --target all --write-manifest
bun scripts/native/verify.ts
ZIG=/absolute/path/to/the/downloaded/zig bun scripts/native/build.ts --output /tmp/native-rebuilt
bun scripts/native/verify.ts --rebuilt /tmp/native-rebuilt
```
The manifest hashes every addon plus the build inputs, compiler recipe and
toolchain metadata. Commit source, all eight regenerated binaries and the
manifest together. Builds fix the source timestamp, macOS install name and
Linux build ID behavior to permit byte-for-byte comparison across output
directories. CI rebuilds each target on its platform and compares bytes.
## Runtime validation
`bun test test/native-lock.test.ts` runs real competing Bun processes,
retained-inode checks, cancellation, deadline cleanup, live-holder staleness,
SIGKILL recovery, and fail-closed missing-addon/invalid-path cases.
`test/scripts/native-lock-prebuilds.test.ts` proves source/binary tampering
fails verification and checks that the required CI matrix covers all sixteen
target/runtime pairs. Native CI also runs the tests in native musl userspace.
`bun scripts/native/compiled-smoke.ts` builds a focused executable importing
the exact production wrapper, proves two compiled processes exclude each
other, kills the holder and proves immediate handoff. Release CI additionally
passes `--binary bin/<artifact>` to require that the actual CLI executable
embeds the exact current-platform addon. That assertion verifies packaging;
the focused executable verifies compiled lock execution.
`bun scripts/native/cli-persistence-smoke.ts --binary bin/<artifact>` copies
the actual release executable outside the source checkout and exercises keyless
disk-PGLite initialization, native ownership activation, canonical publication,
revision conflicts with typed receipts, exact replay, resident stdio/CLI IPC,
and shutdown/reopen. Release CI runs it for both published Linux x64 and macOS
arm64 artifacts. Child homes and credentials are isolated; the script never
loads repository TypeScript or adjacent native files to satisfy the executable.

12
native/locks/abi-check.c Normal file
View File

@@ -0,0 +1,12 @@
/* SPDX-License-Identifier: MIT — built on native macOS against the real SDK. */
#include <stddef.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/file.h>
#include <errno.h>
_Static_assert(sizeof(struct stat) == 144, "Darwin stat ABI size");
_Static_assert(offsetof(struct stat, st_mode) == 4, "Darwin stat mode offset");
_Static_assert(offsetof(struct stat, st_size) == 96, "Darwin stat size offset");
_Static_assert(O_RDWR == 2 && O_NONBLOCK == 4 && O_NOFOLLOW == 0x100 && O_CREAT == 0x200 && O_CLOEXEC == 0x1000000, "Darwin open ABI");
_Static_assert(LOCK_EX == 2 && LOCK_NB == 4 && EAGAIN == 35 && EINTR == 4 && EINVAL == 22, "Darwin lock ABI");
int main(void) { return 0; }

59
native/locks/darwin-abi.h Normal file
View File

@@ -0,0 +1,59 @@
/* SPDX-License-Identifier: MIT
* Public 64-bit Darwin C ABI used by the lock binding. Keeping this narrow
* allows reproducible cross-builds without redistributing an Apple SDK.
* ABI source: apple-oss-distributions/xnu tag xnu-11215.81.4,
* bsd/sys/{stat.h,fcntl.h,file.h,errno.h}. Both supported macOS ABIs are LP64.
* Native macOS CI also compiles abi-check.c against the installed SDK.
*/
#ifndef GBRAIN_DARWIN_ABI_H
#define GBRAIN_DARWIN_ABI_H
#include <stddef.h>
#include <stdint.h>
extern void *malloc(size_t);
extern void *calloc(size_t, size_t);
extern void free(void *);
extern int snprintf(char *, size_t, const char *, ...);
extern void *memchr(const void *, int, size_t);
extern int open(const char *, int, ...);
extern int close(int);
extern int flock(int, int);
extern int *__error(void);
#define errno (*__error())
#define O_RDWR 0x0002
#define O_NONBLOCK 0x0004
#define O_NOFOLLOW 0x0100
#define O_CREAT 0x0200
#define O_CLOEXEC 0x1000000
#define LOCK_EX 2
#define LOCK_NB 4
#define EINTR 4
#define EINVAL 22
#define EAGAIN 35
#define EWOULDBLOCK EAGAIN
#define S_ISREG(mode) (((mode) & 0170000) == 0100000)
struct stat {
int32_t st_dev;
uint16_t st_mode;
uint16_t st_nlink;
uint64_t st_ino;
uint32_t st_uid;
uint32_t st_gid;
int32_t st_rdev;
int64_t times[8];
int64_t st_size;
int64_t st_blocks;
int32_t st_blksize;
uint32_t st_flags;
uint32_t st_gen;
int32_t spare;
int64_t reserved[2];
};
_Static_assert(sizeof(struct stat) == 144, "Darwin stat ABI size");
_Static_assert(offsetof(struct stat, st_mode) == 4, "Darwin stat ABI mode offset");
#ifdef __x86_64__
extern int fstat(int, struct stat *) __asm("_fstat$INODE64");
#else
extern int fstat(int, struct stat *);
#endif
#endif

324
native/locks/locks.c Normal file
View File

@@ -0,0 +1,324 @@
/* SPDX-License-Identifier: MIT
* Small Node-API v3 binding. All OS handles stay native; acquisition never waits.
* The stable lock file must live in a directory controlled by the owner host.
*/
#include "node_api.h"
#include <stdbool.h>
#include <stdint.h>
#ifdef __APPLE__
#include "darwin-abi.h"
#else
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#endif
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include "windows-napi.h"
typedef HANDLE os_handle;
#define INVALID_LOCK_HANDLE INVALID_HANDLE_VALUE
#else
#ifndef __APPLE__
#include <errno.h>
#include <fcntl.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <unistd.h>
#endif
typedef int os_handle;
#define INVALID_LOCK_HANDLE (-1)
#endif
typedef struct lock_state lock_state;
#ifdef _WIN32
typedef struct ipc_mutex_owner { DWORD process_id; DWORD thread_id; } ipc_mutex_owner;
#endif
typedef struct lock_handle {
os_handle handle;
bool locked;
#ifdef _WIN32
char *mutex_name;
DWORD owning_thread;
HANDLE mutex_owner_mapping;
volatile ipc_mutex_owner *mutex_owner;
bool finalized;
#endif
struct lock_handle *next;
lock_state *owner;
} lock_handle;
struct lock_state {
lock_handle *handles;
size_t references;
bool closing;
};
static napi_value fail(napi_env env, const char *action, unsigned long code) {
char message[128];
snprintf(message, sizeof(message), "Native lock %s failed (OS error %lu)", action, code);
napi_throw_error(env, "GBRAIN_NATIVE_LOCK_IO", message);
return NULL;
}
/* Do not retry close on EINTR: on Linux the descriptor has already closed,
* and retrying can close an unrelated, newly reused descriptor. */
static unsigned long close_handle(lock_handle *lock) {
if (lock->handle == INVALID_LOCK_HANDLE) return 0;
#ifdef _WIN32
if (lock->mutex_name && lock->locked) {
/* Mutex release is thread-affine. NAPI handles cannot leave their owning
* environment; an unexpected finalizer thread must not pretend to close. */
if (lock->owning_thread != GetCurrentThreadId()) return ERROR_NOT_OWNER;
lock->mutex_owner->process_id = 0;
lock->mutex_owner->thread_id = 0;
if (!ReleaseMutex(lock->handle)) {
lock->mutex_owner->process_id = GetCurrentProcessId();
lock->mutex_owner->thread_id = lock->owning_thread;
return GetLastError();
}
}
#endif
os_handle handle = lock->handle;
lock->handle = INVALID_LOCK_HANDLE;
lock->locked = false;
#ifdef _WIN32
unsigned long error = CloseHandle(handle) ? 0 : GetLastError();
if (lock->mutex_owner && !UnmapViewOfFile((const void *)lock->mutex_owner) && !error) error = GetLastError();
if (lock->mutex_owner_mapping && !CloseHandle(lock->mutex_owner_mapping) && !error) error = GetLastError();
lock->mutex_owner = NULL;
lock->mutex_owner_mapping = NULL;
return error;
#else
return close(handle) == 0 ? 0 : (unsigned long)errno;
#endif
}
static void release_state(lock_state *state) {
if (--state->references == 0) free(state);
}
static void finalize_lock(napi_env env, void *data, void *hint) {
(void)env; (void)hint;
lock_handle *lock = data;
lock_state *state = lock->owner;
#ifdef _WIN32
/* A failed release must retain both its handle and registry reference for
* owning-environment cleanup; never free the only tracked unreleased lock. */
if (close_handle(lock) != 0) { lock->finalized = true; return; }
#else
close_handle(lock);
#endif
lock_handle **cursor = &state->handles;
while (*cursor && *cursor != lock) cursor = &(*cursor)->next;
if (*cursor) *cursor = lock->next;
#ifdef _WIN32
free(lock->mutex_name);
#endif
free(lock);
release_state(state);
}
static void cleanup(void *data) {
lock_state *state = data;
state->closing = true;
#ifdef _WIN32
lock_handle **cursor = &state->handles;
while (*cursor) {
lock_handle *lock = *cursor;
unsigned long error = close_handle(lock);
if (lock->finalized && !error) {
*cursor = lock->next;
free(lock->mutex_name); free(lock);
release_state(state);
} else cursor = &lock->next;
}
#else
for (lock_handle *lock = state->handles; lock; lock = lock->next) close_handle(lock);
#endif
release_state(state);
}
static lock_handle *get_lock(napi_env env, napi_callback_info info) {
size_t argc = 1;
napi_value argv[1];
lock_state *state = NULL;
void *pointer = NULL;
if (napi_get_cb_info(env, info, &argc, argv, NULL, (void **)&state) != napi_ok ||
argc != 1 || napi_unwrap(env, argv[0], &pointer) != napi_ok) {
napi_throw_type_error(env, "GBRAIN_NATIVE_LOCK_HANDLE", "Expected an opaque native lock handle");
return NULL;
}
/* Compare against our registry before dereferencing: an object wrapped by
* another addon is not one of our handles. */
for (lock_handle *lock = state->handles; lock; lock = lock->next) {
if (lock == pointer) return lock;
}
napi_throw_type_error(env, "GBRAIN_NATIVE_LOCK_HANDLE", "Foreign native lock handle");
return NULL;
}
static napi_value open_lock(napi_env env, napi_callback_info info) {
size_t argc = 1, length = 0;
napi_value argv[1], object;
lock_state *state = NULL;
if (napi_get_cb_info(env, info, &argc, argv, NULL, (void **)&state) != napi_ok ||
argc != 1 || napi_get_value_string_utf8(env, argv[0], NULL, 0, &length) != napi_ok ||
length == 0 || length > 131072) {
napi_throw_type_error(env, "GBRAIN_NATIVE_LOCK_PATH", "Expected a nonempty lock path");
return NULL;
}
if (state->closing) return fail(env, "open during shutdown", 0);
char *path = malloc(length + 1);
if (!path) return fail(env, "allocate", 0);
if (napi_get_value_string_utf8(env, argv[0], path, length + 1, &length) != napi_ok ||
memchr(path, 0, length) != NULL) {
free(path);
napi_throw_type_error(env, "GBRAIN_NATIVE_LOCK_PATH", "Lock paths must not contain NUL");
return NULL;
}
os_handle handle;
unsigned long error = 0;
#ifdef _WIN32
int wide_length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, NULL, 0);
wchar_t *wide = wide_length > 0 ? malloc((size_t)wide_length * sizeof(wchar_t)) : NULL;
if (!wide) { free(path); return fail(env, "encode path", GetLastError()); }
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, wide, wide_length);
/* Sharing read/write lets contenders open the SAME file. Do not share
* delete: renaming/replacing this inode while it is held breaks exclusion. */
handle = CreateFileW(wide, GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL);
if (handle == INVALID_LOCK_HANDLE) error = GetLastError();
if (!error) {
BY_HANDLE_FILE_INFORMATION info;
if (!GetFileInformationByHandle(handle, &info)) error = GetLastError();
else if (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) error = ERROR_INVALID_DATA;
}
free(wide);
#else
handle = open(path, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, 0600);
if (handle == INVALID_LOCK_HANDLE) error = (unsigned long)errno;
if (!error) {
struct stat info;
if (fstat(handle, &info) != 0) error = (unsigned long)errno;
else if (!S_ISREG(info.st_mode)) error = EINVAL;
}
#endif
free(path);
lock_handle *lock = calloc(1, sizeof(*lock));
if (!lock) {
lock_handle temporary = { .handle = handle };
close_handle(&temporary);
return fail(env, "allocate", 0);
}
lock->handle = handle;
lock->owner = state;
if (error || napi_create_object(env, &object) != napi_ok) {
close_handle(lock); free(lock);
return fail(env, "open", error);
}
if (napi_wrap(env, object, lock, finalize_lock, NULL, NULL) != napi_ok) {
close_handle(lock); free(lock);
return fail(env, "wrap", 0);
}
lock->next = state->handles;
state->handles = lock;
state->references++;
return object;
}
#ifdef _WIN32
#include "windows-ipc.h"
#endif
static napi_value try_lock(napi_env env, napi_callback_info info) {
lock_handle *lock = get_lock(env, info);
if (!lock) return NULL;
if (lock->handle == INVALID_LOCK_HANDLE) return fail(env, "acquire closed handle", 0);
bool acquired = lock->locked;
if (!acquired) {
#ifdef _WIN32
if (lock->mutex_name) {
/* Windows mutexes recurse for their owning thread. Refuse a separate
* handle in this environment before asking the kernel to acquire. */
for (lock_handle *other = lock->owner->handles; other; other = other->next) {
if (other != lock && other->locked && other->mutex_name &&
strcmp(other->mutex_name, lock->mutex_name) == 0) goto lock_result;
}
DWORD result = WaitForSingleObject(lock->handle, 0);
if (result == WAIT_OBJECT_0 || result == WAIT_ABANDONED) {
/* A copied DLL has another environment registry on the same thread.
* Kernel-backed owner metadata makes recursion visible across copies.
* An abandoned owner's metadata cannot veto the kernel's handoff. */
if (result == WAIT_OBJECT_0 && lock->mutex_owner->process_id == GetCurrentProcessId() &&
lock->mutex_owner->thread_id == GetCurrentThreadId()) {
if (!ReleaseMutex(lock->handle)) return fail(env, "undo recursive IPC mutex", GetLastError());
goto lock_result;
}
lock->mutex_owner->process_id = GetCurrentProcessId();
lock->mutex_owner->thread_id = GetCurrentThreadId();
acquired = true;
lock->owning_thread = GetCurrentThreadId();
} else if (result != WAIT_TIMEOUT) return fail(env, "acquire IPC mutex", GetLastError());
} else {
OVERLAPPED offset = {0};
acquired = LockFileEx(lock->handle, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
0, 1, 0, &offset) != 0;
if (!acquired) {
DWORD error = GetLastError();
if (error != ERROR_LOCK_VIOLATION) return fail(env, "acquire", error);
}
}
#else
acquired = flock(lock->handle, LOCK_EX | LOCK_NB) == 0;
if (!acquired && errno != EWOULDBLOCK && errno != EAGAIN && errno != EINTR)
return fail(env, "acquire", (unsigned long)errno);
#endif
}
#ifdef _WIN32
lock_result:
#endif
lock->locked = acquired;
napi_value result;
if (napi_get_boolean(env, acquired, &result) != napi_ok) return NULL;
return result;
}
static napi_value close_lock(napi_env env, napi_callback_info info) {
lock_handle *lock = get_lock(env, info);
if (!lock) return NULL;
unsigned long error = close_handle(lock);
if (error) return fail(env, "close", error);
napi_value result;
if (napi_get_undefined(env, &result) != napi_ok) return NULL;
return result;
}
NAPI_MODULE_INIT() {
#ifdef _WIN32
/* Missing exports reject registration before any Node-API call. */
if (!gbrain_initialize_napi()) return NULL;
#endif
lock_state *state = calloc(1, sizeof(*state));
if (!state) return fail(env, "allocate", 0);
state->references = 1;
if (napi_add_env_cleanup_hook(env, cleanup, state) != napi_ok) {
free(state); return fail(env, "register cleanup", 0);
}
const napi_property_descriptor properties[] = {
{"openLock", NULL, open_lock, NULL, NULL, NULL, napi_default, state},
{"tryLock", NULL, try_lock, NULL, NULL, NULL, napi_default, state},
{"close", NULL, close_lock, NULL, NULL, NULL, napi_default, state},
#ifdef _WIN32
{"openIpcMutex", NULL, open_ipc_mutex, NULL, NULL, NULL, napi_default, state},
{"removeWindowsUnixSocket", NULL, remove_windows_unix_socket, NULL, NULL, NULL, napi_default, state},
#endif
};
napi_value target;
if (napi_define_properties(env, exports, sizeof(properties) / sizeof(properties[0]), properties) != napi_ok ||
napi_create_string_utf8(env, GBRAIN_NATIVE_TARGET, NAPI_AUTO_LENGTH, &target) != napi_ok ||
napi_set_named_property(env, exports, "target", target) != napi_ok) return NULL;
return exports;
}

View File

@@ -0,0 +1,40 @@
{
"version": 1,
"napi": 3,
"zig": "0.14.1",
"input_sha256": "4759bdd927c0c0fa29c2d5af30e42972a183f9be006d595144d73b6ca1111636",
"artifacts": {
"linux-x64-glibc": {
"sha256": "1a8b15c6be3f0ada433a5c1e6558b913641fde7f6daf72b9c37f54e4bfebb3fa",
"bytes": 7928
},
"linux-arm64-glibc": {
"sha256": "2a4096d1341d965cdc1832a295b3d16cf18a543b29e694b29d3d28cc93229919",
"bytes": 7976
},
"linux-x64-musl": {
"sha256": "75c63f94f7a252379a3151811c0ec5d0178b65059b134ed29b92fa066b8c1814",
"bytes": 7536
},
"linux-arm64-musl": {
"sha256": "9edaaa72d55f3cfcd9c454761b23e0fbefd9cd78849bf851f0ae18c74739d475",
"bytes": 7600
},
"darwin-x64": {
"sha256": "ab11b4c3e9bf3624b204267e0eb9b6a046cdf0fa1f800258a721960abce6feae",
"bytes": 22493
},
"darwin-arm64": {
"sha256": "015249a269deb6670619ae73b959e1d0e3bf96b131c3acb0be86df7902b0c219",
"bytes": 52288
},
"win32-x64": {
"sha256": "0b494ec94896219a3b142b6f216bf12c9ee94d27fa7e7399bcdab707ca60212e",
"bytes": 28160
},
"win32-arm64": {
"sha256": "e8315b4453dce3df775eeb76c73e8895c273390e64b16a2c255e10790fa75dd7",
"bytes": 24576
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

2641
native/locks/vendor/node-v22.15.0/LICENSE vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,615 @@
#ifndef SRC_JS_NATIVE_API_H_
#define SRC_JS_NATIVE_API_H_
// This file needs to be compatible with C compilers.
#include <stdbool.h> // NOLINT(modernize-deprecated-headers)
#include <stddef.h> // NOLINT(modernize-deprecated-headers)
// Use INT_MAX, this should only be consumed by the pre-processor anyway.
#define NAPI_VERSION_EXPERIMENTAL 2147483647
#ifndef NAPI_VERSION
#ifdef NAPI_EXPERIMENTAL
#define NAPI_VERSION NAPI_VERSION_EXPERIMENTAL
#else
// The baseline version for N-API.
// The NAPI_VERSION controls which version will be used by default when
// compilling a native addon. If the addon developer specifically wants to use
// functions available in a new version of N-API that is not yet ported in all
// LTS versions, they can set NAPI_VERSION knowing that they have specifically
// depended on that version.
#define NAPI_VERSION 8
#endif
#endif
#include "js_native_api_types.h"
// If you need __declspec(dllimport), either include <node_api.h> instead, or
// define NAPI_EXTERN as __declspec(dllimport) on the compiler's command line.
#ifndef NAPI_EXTERN
#ifdef _WIN32
#define NAPI_EXTERN __declspec(dllexport)
#elif defined(__wasm__)
#define NAPI_EXTERN \
__attribute__((visibility("default"))) \
__attribute__((__import_module__("napi")))
#else
#define NAPI_EXTERN __attribute__((visibility("default")))
#endif
#endif
#define NAPI_AUTO_LENGTH SIZE_MAX
#ifdef __cplusplus
#define EXTERN_C_START extern "C" {
#define EXTERN_C_END }
#else
#define EXTERN_C_START
#define EXTERN_C_END
#endif
EXTERN_C_START
NAPI_EXTERN napi_status NAPI_CDECL napi_get_last_error_info(
node_api_basic_env env, const napi_extended_error_info** result);
// Getters for defined singletons
NAPI_EXTERN napi_status NAPI_CDECL napi_get_undefined(napi_env env,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_null(napi_env env,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_global(napi_env env,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_boolean(napi_env env,
bool value,
napi_value* result);
// Methods to create Primitive types/Objects
NAPI_EXTERN napi_status NAPI_CDECL napi_create_object(napi_env env,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_array(napi_env env,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_array_with_length(napi_env env, size_t length, napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_double(napi_env env,
double value,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_int32(napi_env env,
int32_t value,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_uint32(napi_env env,
uint32_t value,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_int64(napi_env env,
int64_t value,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_string_latin1(
napi_env env, const char* str, size_t length, napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_string_utf8(napi_env env,
const char* str,
size_t length,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_string_utf16(napi_env env,
const char16_t* str,
size_t length,
napi_value* result);
#if NAPI_VERSION >= 10
NAPI_EXTERN napi_status NAPI_CDECL node_api_create_external_string_latin1(
napi_env env,
char* str,
size_t length,
node_api_basic_finalize finalize_callback,
void* finalize_hint,
napi_value* result,
bool* copied);
NAPI_EXTERN napi_status NAPI_CDECL
node_api_create_external_string_utf16(napi_env env,
char16_t* str,
size_t length,
node_api_basic_finalize finalize_callback,
void* finalize_hint,
napi_value* result,
bool* copied);
NAPI_EXTERN napi_status NAPI_CDECL node_api_create_property_key_latin1(
napi_env env, const char* str, size_t length, napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL node_api_create_property_key_utf8(
napi_env env, const char* str, size_t length, napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL node_api_create_property_key_utf16(
napi_env env, const char16_t* str, size_t length, napi_value* result);
#endif // NAPI_VERSION >= 10
NAPI_EXTERN napi_status NAPI_CDECL napi_create_symbol(napi_env env,
napi_value description,
napi_value* result);
#if NAPI_VERSION >= 9
NAPI_EXTERN napi_status NAPI_CDECL
node_api_symbol_for(napi_env env,
const char* utf8description,
size_t length,
napi_value* result);
#endif // NAPI_VERSION >= 9
NAPI_EXTERN napi_status NAPI_CDECL napi_create_function(napi_env env,
const char* utf8name,
size_t length,
napi_callback cb,
void* data,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_error(napi_env env,
napi_value code,
napi_value msg,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_type_error(napi_env env,
napi_value code,
napi_value msg,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_range_error(napi_env env,
napi_value code,
napi_value msg,
napi_value* result);
#if NAPI_VERSION >= 9
NAPI_EXTERN napi_status NAPI_CDECL node_api_create_syntax_error(
napi_env env, napi_value code, napi_value msg, napi_value* result);
#endif // NAPI_VERSION >= 9
// Methods to get the native napi_value from Primitive type
NAPI_EXTERN napi_status NAPI_CDECL napi_typeof(napi_env env,
napi_value value,
napi_valuetype* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_double(napi_env env,
napi_value value,
double* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_int32(napi_env env,
napi_value value,
int32_t* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_uint32(napi_env env,
napi_value value,
uint32_t* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_int64(napi_env env,
napi_value value,
int64_t* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_bool(napi_env env,
napi_value value,
bool* result);
// Copies LATIN-1 encoded bytes from a string into a buffer.
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_string_latin1(
napi_env env, napi_value value, char* buf, size_t bufsize, size_t* result);
// Copies UTF-8 encoded bytes from a string into a buffer.
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_string_utf8(
napi_env env, napi_value value, char* buf, size_t bufsize, size_t* result);
// Copies UTF-16 encoded bytes from a string into a buffer.
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_string_utf16(napi_env env,
napi_value value,
char16_t* buf,
size_t bufsize,
size_t* result);
// Methods to coerce values
// These APIs may execute user scripts
NAPI_EXTERN napi_status NAPI_CDECL napi_coerce_to_bool(napi_env env,
napi_value value,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_coerce_to_number(napi_env env,
napi_value value,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_coerce_to_object(napi_env env,
napi_value value,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_coerce_to_string(napi_env env,
napi_value value,
napi_value* result);
// Methods to work with Objects
NAPI_EXTERN napi_status NAPI_CDECL napi_get_prototype(napi_env env,
napi_value object,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_property_names(napi_env env,
napi_value object,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_set_property(napi_env env,
napi_value object,
napi_value key,
napi_value value);
NAPI_EXTERN napi_status NAPI_CDECL napi_has_property(napi_env env,
napi_value object,
napi_value key,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_property(napi_env env,
napi_value object,
napi_value key,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_delete_property(napi_env env,
napi_value object,
napi_value key,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_has_own_property(napi_env env,
napi_value object,
napi_value key,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_set_named_property(napi_env env,
napi_value object,
const char* utf8name,
napi_value value);
NAPI_EXTERN napi_status NAPI_CDECL napi_has_named_property(napi_env env,
napi_value object,
const char* utf8name,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_named_property(napi_env env,
napi_value object,
const char* utf8name,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_set_element(napi_env env,
napi_value object,
uint32_t index,
napi_value value);
NAPI_EXTERN napi_status NAPI_CDECL napi_has_element(napi_env env,
napi_value object,
uint32_t index,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_element(napi_env env,
napi_value object,
uint32_t index,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_delete_element(napi_env env,
napi_value object,
uint32_t index,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_define_properties(napi_env env,
napi_value object,
size_t property_count,
const napi_property_descriptor* properties);
// Methods to work with Arrays
NAPI_EXTERN napi_status NAPI_CDECL napi_is_array(napi_env env,
napi_value value,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_array_length(napi_env env,
napi_value value,
uint32_t* result);
// Methods to compare values
NAPI_EXTERN napi_status NAPI_CDECL napi_strict_equals(napi_env env,
napi_value lhs,
napi_value rhs,
bool* result);
// Methods to work with Functions
NAPI_EXTERN napi_status NAPI_CDECL napi_call_function(napi_env env,
napi_value recv,
napi_value func,
size_t argc,
const napi_value* argv,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_new_instance(napi_env env,
napi_value constructor,
size_t argc,
const napi_value* argv,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_instanceof(napi_env env,
napi_value object,
napi_value constructor,
bool* result);
// Methods to work with napi_callbacks
// Gets all callback info in a single call. (Ugly, but faster.)
NAPI_EXTERN napi_status NAPI_CDECL napi_get_cb_info(
napi_env env, // [in] Node-API environment handle
napi_callback_info cbinfo, // [in] Opaque callback-info handle
size_t* argc, // [in-out] Specifies the size of the provided argv array
// and receives the actual count of args.
napi_value* argv, // [out] Array of values
napi_value* this_arg, // [out] Receives the JS 'this' arg for the call
void** data); // [out] Receives the data pointer for the callback.
NAPI_EXTERN napi_status NAPI_CDECL napi_get_new_target(
napi_env env, napi_callback_info cbinfo, napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_define_class(napi_env env,
const char* utf8name,
size_t length,
napi_callback constructor,
void* data,
size_t property_count,
const napi_property_descriptor* properties,
napi_value* result);
// Methods to work with external data objects
NAPI_EXTERN napi_status NAPI_CDECL
napi_wrap(napi_env env,
napi_value js_object,
void* native_object,
node_api_basic_finalize finalize_cb,
void* finalize_hint,
napi_ref* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_unwrap(napi_env env,
napi_value js_object,
void** result);
NAPI_EXTERN napi_status NAPI_CDECL napi_remove_wrap(napi_env env,
napi_value js_object,
void** result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_external(napi_env env,
void* data,
node_api_basic_finalize finalize_cb,
void* finalize_hint,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_external(napi_env env,
napi_value value,
void** result);
// Methods to control object lifespan
// Set initial_refcount to 0 for a weak reference, >0 for a strong reference.
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_reference(napi_env env,
napi_value value,
uint32_t initial_refcount,
napi_ref* result);
// Deletes a reference. The referenced value is released, and may
// be GC'd unless there are other references to it.
NAPI_EXTERN napi_status NAPI_CDECL napi_delete_reference(napi_env env,
napi_ref ref);
// Increments the reference count, optionally returning the resulting count.
// After this call the reference will be a strong reference because its
// refcount is >0, and the referenced object is effectively "pinned".
// Calling this when the refcount is 0 and the object is unavailable
// results in an error.
NAPI_EXTERN napi_status NAPI_CDECL napi_reference_ref(napi_env env,
napi_ref ref,
uint32_t* result);
// Decrements the reference count, optionally returning the resulting count.
// If the result is 0 the reference is now weak and the object may be GC'd
// at any time if there are no other references. Calling this when the
// refcount is already 0 results in an error.
NAPI_EXTERN napi_status NAPI_CDECL napi_reference_unref(napi_env env,
napi_ref ref,
uint32_t* result);
// Attempts to get a referenced value. If the reference is weak,
// the value might no longer be available, in that case the call
// is still successful but the result is NULL.
NAPI_EXTERN napi_status NAPI_CDECL napi_get_reference_value(napi_env env,
napi_ref ref,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_open_handle_scope(napi_env env, napi_handle_scope* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_close_handle_scope(napi_env env, napi_handle_scope scope);
NAPI_EXTERN napi_status NAPI_CDECL napi_open_escapable_handle_scope(
napi_env env, napi_escapable_handle_scope* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_close_escapable_handle_scope(
napi_env env, napi_escapable_handle_scope scope);
NAPI_EXTERN napi_status NAPI_CDECL
napi_escape_handle(napi_env env,
napi_escapable_handle_scope scope,
napi_value escapee,
napi_value* result);
// Methods to support error handling
NAPI_EXTERN napi_status NAPI_CDECL napi_throw(napi_env env, napi_value error);
NAPI_EXTERN napi_status NAPI_CDECL napi_throw_error(napi_env env,
const char* code,
const char* msg);
NAPI_EXTERN napi_status NAPI_CDECL napi_throw_type_error(napi_env env,
const char* code,
const char* msg);
NAPI_EXTERN napi_status NAPI_CDECL napi_throw_range_error(napi_env env,
const char* code,
const char* msg);
#if NAPI_VERSION >= 9
NAPI_EXTERN napi_status NAPI_CDECL node_api_throw_syntax_error(napi_env env,
const char* code,
const char* msg);
#endif // NAPI_VERSION >= 9
NAPI_EXTERN napi_status NAPI_CDECL napi_is_error(napi_env env,
napi_value value,
bool* result);
// Methods to support catching exceptions
NAPI_EXTERN napi_status NAPI_CDECL napi_is_exception_pending(napi_env env,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_get_and_clear_last_exception(napi_env env, napi_value* result);
// Methods to work with array buffers and typed arrays
NAPI_EXTERN napi_status NAPI_CDECL napi_is_arraybuffer(napi_env env,
napi_value value,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_arraybuffer(napi_env env,
size_t byte_length,
void** data,
napi_value* result);
#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_external_arraybuffer(napi_env env,
void* external_data,
size_t byte_length,
node_api_basic_finalize finalize_cb,
void* finalize_hint,
napi_value* result);
#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED
NAPI_EXTERN napi_status NAPI_CDECL napi_get_arraybuffer_info(
napi_env env, napi_value arraybuffer, void** data, size_t* byte_length);
NAPI_EXTERN napi_status NAPI_CDECL napi_is_typedarray(napi_env env,
napi_value value,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_typedarray(napi_env env,
napi_typedarray_type type,
size_t length,
napi_value arraybuffer,
size_t byte_offset,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_get_typedarray_info(napi_env env,
napi_value typedarray,
napi_typedarray_type* type,
size_t* length,
void** data,
napi_value* arraybuffer,
size_t* byte_offset);
NAPI_EXTERN napi_status NAPI_CDECL napi_create_dataview(napi_env env,
size_t length,
napi_value arraybuffer,
size_t byte_offset,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_is_dataview(napi_env env,
napi_value value,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_get_dataview_info(napi_env env,
napi_value dataview,
size_t* bytelength,
void** data,
napi_value* arraybuffer,
size_t* byte_offset);
// version management
NAPI_EXTERN napi_status NAPI_CDECL napi_get_version(node_api_basic_env env,
uint32_t* result);
// Promises
NAPI_EXTERN napi_status NAPI_CDECL napi_create_promise(napi_env env,
napi_deferred* deferred,
napi_value* promise);
NAPI_EXTERN napi_status NAPI_CDECL napi_resolve_deferred(napi_env env,
napi_deferred deferred,
napi_value resolution);
NAPI_EXTERN napi_status NAPI_CDECL napi_reject_deferred(napi_env env,
napi_deferred deferred,
napi_value rejection);
NAPI_EXTERN napi_status NAPI_CDECL napi_is_promise(napi_env env,
napi_value value,
bool* is_promise);
// Running a script
NAPI_EXTERN napi_status NAPI_CDECL napi_run_script(napi_env env,
napi_value script,
napi_value* result);
// Memory management
NAPI_EXTERN napi_status NAPI_CDECL napi_adjust_external_memory(
node_api_basic_env env, int64_t change_in_bytes, int64_t* adjusted_value);
#if NAPI_VERSION >= 5
// Dates
NAPI_EXTERN napi_status NAPI_CDECL napi_create_date(napi_env env,
double time,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_is_date(napi_env env,
napi_value value,
bool* is_date);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_date_value(napi_env env,
napi_value value,
double* result);
// Add finalizer for pointer
NAPI_EXTERN napi_status NAPI_CDECL
napi_add_finalizer(napi_env env,
napi_value js_object,
void* finalize_data,
node_api_basic_finalize finalize_cb,
void* finalize_hint,
napi_ref* result);
#endif // NAPI_VERSION >= 5
#ifdef NAPI_EXPERIMENTAL
#define NODE_API_EXPERIMENTAL_HAS_POST_FINALIZER
NAPI_EXTERN napi_status NAPI_CDECL
node_api_post_finalizer(node_api_basic_env env,
napi_finalize finalize_cb,
void* finalize_data,
void* finalize_hint);
#endif // NAPI_EXPERIMENTAL
#if NAPI_VERSION >= 6
// BigInt
NAPI_EXTERN napi_status NAPI_CDECL napi_create_bigint_int64(napi_env env,
int64_t value,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_bigint_uint64(napi_env env, uint64_t value, napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_bigint_words(napi_env env,
int sign_bit,
size_t word_count,
const uint64_t* words,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_bigint_int64(napi_env env,
napi_value value,
int64_t* result,
bool* lossless);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_value_bigint_uint64(
napi_env env, napi_value value, uint64_t* result, bool* lossless);
NAPI_EXTERN napi_status NAPI_CDECL
napi_get_value_bigint_words(napi_env env,
napi_value value,
int* sign_bit,
size_t* word_count,
uint64_t* words);
// Object
NAPI_EXTERN napi_status NAPI_CDECL
napi_get_all_property_names(napi_env env,
napi_value object,
napi_key_collection_mode key_mode,
napi_key_filter key_filter,
napi_key_conversion key_conversion,
napi_value* result);
// Instance data
NAPI_EXTERN napi_status NAPI_CDECL
napi_set_instance_data(node_api_basic_env env,
void* data,
napi_finalize finalize_cb,
void* finalize_hint);
NAPI_EXTERN napi_status NAPI_CDECL
napi_get_instance_data(node_api_basic_env env, void** data);
#endif // NAPI_VERSION >= 6
#if NAPI_VERSION >= 7
// ArrayBuffer detaching
NAPI_EXTERN napi_status NAPI_CDECL
napi_detach_arraybuffer(napi_env env, napi_value arraybuffer);
NAPI_EXTERN napi_status NAPI_CDECL
napi_is_detached_arraybuffer(napi_env env, napi_value value, bool* result);
#endif // NAPI_VERSION >= 7
#if NAPI_VERSION >= 8
// Type tagging
NAPI_EXTERN napi_status NAPI_CDECL napi_type_tag_object(
napi_env env, napi_value value, const napi_type_tag* type_tag);
NAPI_EXTERN napi_status NAPI_CDECL
napi_check_object_type_tag(napi_env env,
napi_value value,
const napi_type_tag* type_tag,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_object_freeze(napi_env env,
napi_value object);
NAPI_EXTERN napi_status NAPI_CDECL napi_object_seal(napi_env env,
napi_value object);
#endif // NAPI_VERSION >= 8
EXTERN_C_END
#endif // SRC_JS_NATIVE_API_H_

View File

@@ -0,0 +1,211 @@
#ifndef SRC_JS_NATIVE_API_TYPES_H_
#define SRC_JS_NATIVE_API_TYPES_H_
// This file needs to be compatible with C compilers.
// This is a public include file, and these includes have essentially
// became part of it's API.
#include <stddef.h> // NOLINT(modernize-deprecated-headers)
#include <stdint.h> // NOLINT(modernize-deprecated-headers)
#if !defined __cplusplus || (defined(_MSC_VER) && _MSC_VER < 1900)
typedef uint16_t char16_t;
#endif
#ifndef NAPI_CDECL
#ifdef _WIN32
#define NAPI_CDECL __cdecl
#else
#define NAPI_CDECL
#endif
#endif
// JSVM API types are all opaque pointers for ABI stability
// typedef undefined structs instead of void* for compile time type safety
typedef struct napi_env__* napi_env;
// We need to mark APIs which can be called during garbage collection (GC),
// meaning that they do not affect the state of the JS engine, and can
// therefore be called synchronously from a finalizer that itself runs
// synchronously during GC. Such APIs can receive either a `napi_env` or a
// `node_api_basic_env` as their first parameter, because we should be able to
// also call them during normal, non-garbage-collecting operations, whereas
// APIs that affect the state of the JS engine can only receive a `napi_env` as
// their first parameter, because we must not call them during GC. In lieu of
// inheritance, we use the properties of the const qualifier to accomplish
// this, because both a const and a non-const value can be passed to an API
// expecting a const value, but only a non-const value can be passed to an API
// expecting a non-const value.
//
// In conjunction with appropriate CFLAGS to warn us if we're passing a const
// (basic) environment into an API that expects a non-const environment, and
// the definition of basic finalizer function pointer types below, which
// receive a basic environment as their first parameter, and can thus only call
// basic APIs (unless the user explicitly casts the environment), we achieve
// the ability to ensure at compile time that we do not call APIs that affect
// the state of the JS engine from a synchronous (basic) finalizer.
#if !defined(NAPI_EXPERIMENTAL) || \
(defined(NAPI_EXPERIMENTAL) && \
(defined(NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT) || \
defined(NODE_API_EXPERIMENTAL_BASIC_ENV_OPT_OUT)))
typedef struct napi_env__* node_api_nogc_env;
#else
typedef const struct napi_env__* node_api_nogc_env;
#endif
typedef node_api_nogc_env node_api_basic_env;
typedef struct napi_value__* napi_value;
typedef struct napi_ref__* napi_ref;
typedef struct napi_handle_scope__* napi_handle_scope;
typedef struct napi_escapable_handle_scope__* napi_escapable_handle_scope;
typedef struct napi_callback_info__* napi_callback_info;
typedef struct napi_deferred__* napi_deferred;
typedef enum {
napi_default = 0,
napi_writable = 1 << 0,
napi_enumerable = 1 << 1,
napi_configurable = 1 << 2,
// Used with napi_define_class to distinguish static properties
// from instance properties. Ignored by napi_define_properties.
napi_static = 1 << 10,
#if NAPI_VERSION >= 8
// Default for class methods.
napi_default_method = napi_writable | napi_configurable,
// Default for object properties, like in JS obj[prop].
napi_default_jsproperty = napi_writable | napi_enumerable | napi_configurable,
#endif // NAPI_VERSION >= 8
} napi_property_attributes;
typedef enum {
// ES6 types (corresponds to typeof)
napi_undefined,
napi_null,
napi_boolean,
napi_number,
napi_string,
napi_symbol,
napi_object,
napi_function,
napi_external,
napi_bigint,
} napi_valuetype;
typedef enum {
napi_int8_array,
napi_uint8_array,
napi_uint8_clamped_array,
napi_int16_array,
napi_uint16_array,
napi_int32_array,
napi_uint32_array,
napi_float32_array,
napi_float64_array,
napi_bigint64_array,
napi_biguint64_array,
} napi_typedarray_type;
typedef enum {
napi_ok,
napi_invalid_arg,
napi_object_expected,
napi_string_expected,
napi_name_expected,
napi_function_expected,
napi_number_expected,
napi_boolean_expected,
napi_array_expected,
napi_generic_failure,
napi_pending_exception,
napi_cancelled,
napi_escape_called_twice,
napi_handle_scope_mismatch,
napi_callback_scope_mismatch,
napi_queue_full,
napi_closing,
napi_bigint_expected,
napi_date_expected,
napi_arraybuffer_expected,
napi_detachable_arraybuffer_expected,
napi_would_deadlock, // unused
napi_no_external_buffers_allowed,
napi_cannot_run_js,
} napi_status;
// Note: when adding a new enum value to `napi_status`, please also update
// * `const int last_status` in the definition of `napi_get_last_error_info()'
// in file js_native_api_v8.cc.
// * `const char* error_messages[]` in file js_native_api_v8.cc with a brief
// message explaining the error.
// * the definition of `napi_status` in doc/api/n-api.md to reflect the newly
// added value(s).
typedef napi_value(NAPI_CDECL* napi_callback)(napi_env env,
napi_callback_info info);
typedef void(NAPI_CDECL* napi_finalize)(napi_env env,
void* finalize_data,
void* finalize_hint);
#if !defined(NAPI_EXPERIMENTAL) || \
(defined(NAPI_EXPERIMENTAL) && \
(defined(NODE_API_EXPERIMENTAL_NOGC_ENV_OPT_OUT) || \
defined(NODE_API_EXPERIMENTAL_BASIC_ENV_OPT_OUT)))
typedef napi_finalize node_api_nogc_finalize;
#else
typedef void(NAPI_CDECL* node_api_nogc_finalize)(node_api_nogc_env env,
void* finalize_data,
void* finalize_hint);
#endif
typedef node_api_nogc_finalize node_api_basic_finalize;
typedef struct {
// One of utf8name or name should be NULL.
const char* utf8name;
napi_value name;
napi_callback method;
napi_callback getter;
napi_callback setter;
napi_value value;
napi_property_attributes attributes;
void* data;
} napi_property_descriptor;
typedef struct {
const char* error_message;
void* engine_reserved;
uint32_t engine_error_code;
napi_status error_code;
} napi_extended_error_info;
#if NAPI_VERSION >= 6
typedef enum {
napi_key_include_prototypes,
napi_key_own_only
} napi_key_collection_mode;
typedef enum {
napi_key_all_properties = 0,
napi_key_writable = 1,
napi_key_enumerable = 1 << 1,
napi_key_configurable = 1 << 2,
napi_key_skip_strings = 1 << 3,
napi_key_skip_symbols = 1 << 4
} napi_key_filter;
typedef enum {
napi_key_keep_numbers,
napi_key_numbers_to_strings
} napi_key_conversion;
#endif // NAPI_VERSION >= 6
#if NAPI_VERSION >= 8
typedef struct {
uint64_t lower;
uint64_t upper;
} napi_type_tag;
#endif // NAPI_VERSION >= 8
#endif // SRC_JS_NATIVE_API_TYPES_H_

View File

@@ -0,0 +1,269 @@
#ifndef SRC_NODE_API_H_
#define SRC_NODE_API_H_
#if defined(BUILDING_NODE_EXTENSION) && !defined(NAPI_EXTERN)
#ifdef _WIN32
// Building native addon against node
#define NAPI_EXTERN __declspec(dllimport)
#elif defined(__wasm__)
#define NAPI_EXTERN __attribute__((__import_module__("napi")))
#endif
#endif
#include "js_native_api.h"
#include "node_api_types.h"
struct uv_loop_s; // Forward declaration.
#ifdef _WIN32
#define NAPI_MODULE_EXPORT __declspec(dllexport)
#else
#ifdef __EMSCRIPTEN__
#define NAPI_MODULE_EXPORT \
__attribute__((visibility("default"))) __attribute__((used))
#else
#define NAPI_MODULE_EXPORT __attribute__((visibility("default")))
#endif
#endif
#if defined(__GNUC__)
#define NAPI_NO_RETURN __attribute__((noreturn))
#elif defined(_WIN32)
#define NAPI_NO_RETURN __declspec(noreturn)
#else
#define NAPI_NO_RETURN
#endif
typedef napi_value(NAPI_CDECL* napi_addon_register_func)(napi_env env,
napi_value exports);
typedef int32_t(NAPI_CDECL* node_api_addon_get_api_version_func)(void);
// Used by deprecated registration method napi_module_register.
typedef struct napi_module {
int nm_version;
unsigned int nm_flags;
const char* nm_filename;
napi_addon_register_func nm_register_func;
const char* nm_modname;
void* nm_priv;
void* reserved[4];
} napi_module;
#define NAPI_MODULE_VERSION 1
#define NAPI_MODULE_INITIALIZER_X(base, version) \
NAPI_MODULE_INITIALIZER_X_HELPER(base, version)
#define NAPI_MODULE_INITIALIZER_X_HELPER(base, version) base##version
#ifdef __wasm__
#define NAPI_MODULE_INITIALIZER_BASE napi_register_wasm_v
#else
#define NAPI_MODULE_INITIALIZER_BASE napi_register_module_v
#endif
#define NODE_API_MODULE_GET_API_VERSION_BASE node_api_module_get_api_version_v
#define NAPI_MODULE_INITIALIZER \
NAPI_MODULE_INITIALIZER_X(NAPI_MODULE_INITIALIZER_BASE, NAPI_MODULE_VERSION)
#define NODE_API_MODULE_GET_API_VERSION \
NAPI_MODULE_INITIALIZER_X(NODE_API_MODULE_GET_API_VERSION_BASE, \
NAPI_MODULE_VERSION)
#define NAPI_MODULE_INIT() \
EXTERN_C_START \
NAPI_MODULE_EXPORT int32_t NODE_API_MODULE_GET_API_VERSION(void) { \
return NAPI_VERSION; \
} \
NAPI_MODULE_EXPORT napi_value NAPI_MODULE_INITIALIZER(napi_env env, \
napi_value exports); \
EXTERN_C_END \
napi_value NAPI_MODULE_INITIALIZER(napi_env env, napi_value exports)
#define NAPI_MODULE(modname, regfunc) \
NAPI_MODULE_INIT() { return regfunc(env, exports); }
// Deprecated. Use NAPI_MODULE.
#define NAPI_MODULE_X(modname, regfunc, priv, flags) \
NAPI_MODULE(modname, regfunc)
EXTERN_C_START
// Deprecated. Replaced by symbol-based registration defined by NAPI_MODULE
// and NAPI_MODULE_INIT macros.
NAPI_EXTERN void NAPI_CDECL
napi_module_register(napi_module* mod);
NAPI_EXTERN NAPI_NO_RETURN void NAPI_CDECL
napi_fatal_error(const char* location,
size_t location_len,
const char* message,
size_t message_len);
// Methods for custom handling of async operations
NAPI_EXTERN napi_status NAPI_CDECL
napi_async_init(napi_env env,
napi_value async_resource,
napi_value async_resource_name,
napi_async_context* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_async_destroy(napi_env env, napi_async_context async_context);
NAPI_EXTERN napi_status NAPI_CDECL
napi_make_callback(napi_env env,
napi_async_context async_context,
napi_value recv,
napi_value func,
size_t argc,
const napi_value* argv,
napi_value* result);
// Methods to provide node::Buffer functionality with napi types
NAPI_EXTERN napi_status NAPI_CDECL napi_create_buffer(napi_env env,
size_t length,
void** data,
napi_value* result);
#ifndef NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_external_buffer(napi_env env,
size_t length,
void* data,
node_api_basic_finalize finalize_cb,
void* finalize_hint,
napi_value* result);
#endif // NODE_API_NO_EXTERNAL_BUFFERS_ALLOWED
#if NAPI_VERSION >= 10
NAPI_EXTERN napi_status NAPI_CDECL
node_api_create_buffer_from_arraybuffer(napi_env env,
napi_value arraybuffer,
size_t byte_offset,
size_t byte_length,
napi_value* result);
#endif // NAPI_VERSION >= 10
NAPI_EXTERN napi_status NAPI_CDECL napi_create_buffer_copy(napi_env env,
size_t length,
const void* data,
void** result_data,
napi_value* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_is_buffer(napi_env env,
napi_value value,
bool* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_buffer_info(napi_env env,
napi_value value,
void** data,
size_t* length);
// Methods to manage simple async operations
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_async_work(napi_env env,
napi_value async_resource,
napi_value async_resource_name,
napi_async_execute_callback execute,
napi_async_complete_callback complete,
void* data,
napi_async_work* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_delete_async_work(napi_env env,
napi_async_work work);
NAPI_EXTERN napi_status NAPI_CDECL napi_queue_async_work(node_api_basic_env env,
napi_async_work work);
NAPI_EXTERN napi_status NAPI_CDECL
napi_cancel_async_work(node_api_basic_env env, napi_async_work work);
// version management
NAPI_EXTERN napi_status NAPI_CDECL napi_get_node_version(
node_api_basic_env env, const napi_node_version** version);
#if NAPI_VERSION >= 2
// Return the current libuv event loop for a given environment
NAPI_EXTERN napi_status NAPI_CDECL
napi_get_uv_event_loop(node_api_basic_env env, struct uv_loop_s** loop);
#endif // NAPI_VERSION >= 2
#if NAPI_VERSION >= 3
NAPI_EXTERN napi_status NAPI_CDECL napi_fatal_exception(napi_env env,
napi_value err);
NAPI_EXTERN napi_status NAPI_CDECL napi_add_env_cleanup_hook(
node_api_basic_env env, napi_cleanup_hook fun, void* arg);
NAPI_EXTERN napi_status NAPI_CDECL napi_remove_env_cleanup_hook(
node_api_basic_env env, napi_cleanup_hook fun, void* arg);
NAPI_EXTERN napi_status NAPI_CDECL
napi_open_callback_scope(napi_env env,
napi_value resource_object,
napi_async_context context,
napi_callback_scope* result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_close_callback_scope(napi_env env, napi_callback_scope scope);
#endif // NAPI_VERSION >= 3
#if NAPI_VERSION >= 4
// Calling into JS from other threads
NAPI_EXTERN napi_status NAPI_CDECL
napi_create_threadsafe_function(napi_env env,
napi_value func,
napi_value async_resource,
napi_value async_resource_name,
size_t max_queue_size,
size_t initial_thread_count,
void* thread_finalize_data,
napi_finalize thread_finalize_cb,
void* context,
napi_threadsafe_function_call_js call_js_cb,
napi_threadsafe_function* result);
NAPI_EXTERN napi_status NAPI_CDECL napi_get_threadsafe_function_context(
napi_threadsafe_function func, void** result);
NAPI_EXTERN napi_status NAPI_CDECL
napi_call_threadsafe_function(napi_threadsafe_function func,
void* data,
napi_threadsafe_function_call_mode is_blocking);
NAPI_EXTERN napi_status NAPI_CDECL
napi_acquire_threadsafe_function(napi_threadsafe_function func);
NAPI_EXTERN napi_status NAPI_CDECL napi_release_threadsafe_function(
napi_threadsafe_function func, napi_threadsafe_function_release_mode mode);
NAPI_EXTERN napi_status NAPI_CDECL napi_unref_threadsafe_function(
node_api_basic_env env, napi_threadsafe_function func);
NAPI_EXTERN napi_status NAPI_CDECL napi_ref_threadsafe_function(
node_api_basic_env env, napi_threadsafe_function func);
#endif // NAPI_VERSION >= 4
#if NAPI_VERSION >= 8
NAPI_EXTERN napi_status NAPI_CDECL
napi_add_async_cleanup_hook(node_api_basic_env env,
napi_async_cleanup_hook hook,
void* arg,
napi_async_cleanup_hook_handle* remove_handle);
NAPI_EXTERN napi_status NAPI_CDECL
napi_remove_async_cleanup_hook(napi_async_cleanup_hook_handle remove_handle);
#endif // NAPI_VERSION >= 8
#if NAPI_VERSION >= 9
NAPI_EXTERN napi_status NAPI_CDECL
node_api_get_module_file_name(node_api_basic_env env, const char** result);
#endif // NAPI_VERSION >= 9
EXTERN_C_END
#endif // SRC_NODE_API_H_

View File

@@ -0,0 +1,52 @@
#ifndef SRC_NODE_API_TYPES_H_
#define SRC_NODE_API_TYPES_H_
#include "js_native_api_types.h"
typedef struct napi_callback_scope__* napi_callback_scope;
typedef struct napi_async_context__* napi_async_context;
typedef struct napi_async_work__* napi_async_work;
#if NAPI_VERSION >= 3
typedef void(NAPI_CDECL* napi_cleanup_hook)(void* arg);
#endif // NAPI_VERSION >= 3
#if NAPI_VERSION >= 4
typedef struct napi_threadsafe_function__* napi_threadsafe_function;
#endif // NAPI_VERSION >= 4
#if NAPI_VERSION >= 4
typedef enum {
napi_tsfn_release,
napi_tsfn_abort
} napi_threadsafe_function_release_mode;
typedef enum {
napi_tsfn_nonblocking,
napi_tsfn_blocking
} napi_threadsafe_function_call_mode;
#endif // NAPI_VERSION >= 4
typedef void(NAPI_CDECL* napi_async_execute_callback)(napi_env env, void* data);
typedef void(NAPI_CDECL* napi_async_complete_callback)(napi_env env,
napi_status status,
void* data);
#if NAPI_VERSION >= 4
typedef void(NAPI_CDECL* napi_threadsafe_function_call_js)(
napi_env env, napi_value js_callback, void* context, void* data);
#endif // NAPI_VERSION >= 4
typedef struct {
uint32_t major;
uint32_t minor;
uint32_t patch;
const char* release;
} napi_node_version;
#if NAPI_VERSION >= 8
typedef struct napi_async_cleanup_hook_handle__* napi_async_cleanup_hook_handle;
typedef void(NAPI_CDECL* napi_async_cleanup_hook)(
napi_async_cleanup_hook_handle handle, void* data);
#endif // NAPI_VERSION >= 8
#endif // SRC_NODE_API_TYPES_H_

158
native/locks/windows-ipc.h Normal file
View File

@@ -0,0 +1,158 @@
/* SPDX-License-Identifier: MIT
* Windows-only IPC operations. Included after the opaque lock registry. */
#ifndef GBRAIN_WINDOWS_IPC_H
#define GBRAIN_WINDOWS_IPC_H
#include <bcrypt.h>
static char *ipc_argument(napi_env env, napi_callback_info info, lock_state **state, bool require_lock) {
size_t expected = require_lock ? 2 : 1, argc = expected, length = 0;
napi_value argv[2];
if (napi_get_cb_info(env, info, &argc, argv, NULL, (void **)state) != napi_ok ||
argc != expected || napi_get_value_string_utf8(env, argv[expected - 1], NULL, 0, &length) != napi_ok ||
length == 0 || length > 131072) {
napi_throw_type_error(env, "GBRAIN_NATIVE_IPC_PATH", "Expected a nonempty IPC name or path");
return NULL;
}
if ((*state)->closing) { fail(env, "IPC during shutdown", 0); return NULL; }
if (require_lock) {
void *pointer = NULL;
lock_handle *held = NULL;
if (napi_unwrap(env, argv[0], &pointer) == napi_ok) {
for (lock_handle *lock = (*state)->handles; lock; lock = lock->next) {
if (lock == pointer && lock->locked && !lock->mutex_name && lock->handle != INVALID_LOCK_HANDLE) held = lock;
}
}
if (!held) { fail(env, "remove IPC socket without binding claim", ERROR_NOT_OWNER); return NULL; }
}
char *text = malloc(length + 1);
if (!text) { fail(env, "allocate", 0); return NULL; }
if (napi_get_value_string_utf8(env, argv[expected - 1], text, length + 1, &length) != napi_ok ||
memchr(text, 0, length) != NULL) {
free(text);
napi_throw_type_error(env, "GBRAIN_NATIVE_IPC_PATH", "IPC names and paths must not contain NUL");
return NULL;
}
return text;
}
static napi_value open_ipc_mutex(napi_env env, napi_callback_info info) {
lock_state *state = NULL;
char *name = ipc_argument(env, info, &state, false);
if (!name) return NULL;
size_t length = strlen(name);
bool valid = length > 9 && (name[0] == '\\' || name[0] == '/') &&
(name[1] == '\\' || name[1] == '/') && (name[2] == '.' || name[2] == '?') &&
(name[3] == '\\' || name[3] == '/') && (name[4] == 'p' || name[4] == 'P') &&
(name[5] == 'i' || name[5] == 'I') && (name[6] == 'p' || name[6] == 'P') &&
(name[7] == 'e' || name[7] == 'E') && (name[8] == '\\' || name[8] == '/') &&
name[9] != '\\' && name[9] != '/';
if (!valid) {
free(name);
napi_throw_type_error(env, "GBRAIN_NATIVE_IPC_PATH", "Expected a Windows named-pipe address");
return NULL;
}
int wide_count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name + 9, -1, NULL, 0);
wchar_t *wide = wide_count > 0 ? malloc((size_t)wide_count * sizeof(wchar_t)) : NULL;
if (!wide) { unsigned long error = GetLastError(); free(name); return fail(env, "encode IPC name", error); }
int converted = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name + 9, -1, wide, wide_count);
free(name);
if (!converted) { unsigned long error = GetLastError(); free(wide); return fail(env, "encode IPC name", error); }
int upper_count = LCMapStringEx(LOCALE_NAME_INVARIANT, LCMAP_UPPERCASE, wide, wide_count - 1, NULL, 0, NULL, NULL, 0);
wchar_t *upper = upper_count > 0 ? malloc((size_t)upper_count * sizeof(wchar_t)) : NULL;
if (!upper) { unsigned long error = GetLastError(); free(wide); return fail(env, "normalize IPC name", error); }
int normalized = LCMapStringEx(LOCALE_NAME_INVARIANT, LCMAP_UPPERCASE, wide, wide_count - 1, upper, upper_count, NULL, NULL, 0);
free(wide);
if (!normalized) { unsigned long error = GetLastError(); free(upper); return fail(env, "normalize IPC name", error); }
unsigned char digest[32];
BCRYPT_ALG_HANDLE algorithm = NULL;
BCRYPT_HASH_HANDLE hash = NULL;
NTSTATUS status = BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, NULL, 0);
if (status >= 0) status = BCryptCreateHash(algorithm, &hash, NULL, 0, NULL, 0, 0);
if (status >= 0) status = BCryptHashData(hash, (PUCHAR)upper, (ULONG)((size_t)upper_count * sizeof(wchar_t)), 0);
if (status >= 0) status = BCryptFinishHash(hash, digest, sizeof(digest), 0);
if (hash) BCryptDestroyHash(hash);
if (algorithm) BCryptCloseAlgorithmProvider(algorithm, 0);
free(upper);
if (status < 0) return fail(env, "hash IPC name", (unsigned long)status);
name = malloc(65);
if (!name) return fail(env, "allocate", 0);
const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < sizeof(digest); i++) { name[i * 2] = hex[digest[i] >> 4]; name[i * 2 + 1] = hex[digest[i] & 15]; }
name[64] = 0;
/* A global kernel name is independent of HOME, TMPDIR and logon session.
* Default object security remains in force; inaccessible collisions refuse. */
wchar_t full_name[96] = L"Global\\gbrain-ipc-";
size_t prefix = wcslen(full_name);
for (size_t i = 0; i < 64; i++) full_name[prefix + i] = (wchar_t)name[i];
full_name[prefix + 64] = 0;
HANDLE handle = CreateMutexW(NULL, FALSE, full_name);
if (!handle) { unsigned long error = GetLastError(); free(name); return fail(env, "open IPC mutex", error); }
/* File mappings are kernel-backed, with no mutable filesystem pathname.
* Same-thread copies share a Local record; cross-session exclusion is still
* the Global mutex, so no Global file-mapping privilege is required. */
wchar_t record_name[112] = L"Local\\gbrain-ipc-owner-";
prefix = wcslen(record_name);
for (size_t i = 0; i < 64; i++) record_name[prefix + i] = (wchar_t)name[i];
record_name[prefix + 64] = 0;
HANDLE mapping = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, sizeof(ipc_mutex_owner), record_name);
if (!mapping) { unsigned long error = GetLastError(); CloseHandle(handle); free(name); return fail(env, "open IPC owner record", error); }
ipc_mutex_owner *owner = MapViewOfFile(mapping, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, sizeof(ipc_mutex_owner));
if (!owner) { unsigned long error = GetLastError(); CloseHandle(mapping); CloseHandle(handle); free(name); return fail(env, "map IPC owner record", error); }
lock_handle *lock = calloc(1, sizeof(*lock));
if (!lock) { UnmapViewOfFile(owner); CloseHandle(mapping); CloseHandle(handle); free(name); return fail(env, "allocate", 0); }
lock->handle = handle;
lock->mutex_name = name;
lock->owner = state;
lock->mutex_owner_mapping = mapping;
lock->mutex_owner = owner;
napi_value object;
if (napi_create_object(env, &object) != napi_ok ||
napi_wrap(env, object, lock, finalize_lock, NULL, NULL) != napi_ok) {
close_handle(lock); free(name); free(lock);
return fail(env, "wrap IPC mutex", 0);
}
lock->next = state->handles;
state->handles = lock;
state->references++;
return object;
}
static napi_value remove_windows_unix_socket(napi_env env, napi_callback_info info) {
lock_state *state = NULL;
char *path = ipc_argument(env, info, &state, true);
if (!path) return NULL;
int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, NULL, 0);
wchar_t *wide = count > 0 ? malloc((size_t)count * sizeof(wchar_t)) : NULL;
if (!wide) { free(path); return fail(env, "encode IPC path", GetLastError()); }
if (!MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, wide, count)) {
unsigned long error = GetLastError(); free(path); free(wide); return fail(env, "encode IPC path", error);
}
free(path);
/* Inspect the leaf itself, never a reparse target. Withhold delete sharing
* so replacement cannot change the verified object before disposition. */
HANDLE handle = CreateFileW(wide, DELETE | FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, NULL);
unsigned long error = handle == INVALID_HANDLE_VALUE ? GetLastError() : 0;
free(wide);
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) {
napi_value result;
return napi_get_boolean(env, false, &result) == napi_ok ? result : NULL;
}
if (error) return fail(env, "open stale IPC socket", error);
FILE_ATTRIBUTE_TAG_INFO tag;
/* IO_REPARSE_TAG_AF_UNIX is 0x80000023 in the Windows SDK. Other reparse
* points, ordinary files and directories must remain untouched. */
if (!GetFileInformationByHandleEx(handle, FileAttributeTagInfo, &tag, sizeof(tag))) error = GetLastError();
else if (!(tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) ||
(tag.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) || tag.ReparseTag != 0x80000023UL) error = ERROR_INVALID_DATA;
if (!error) {
FILE_DISPOSITION_INFO disposition = { .DeleteFile = TRUE };
if (!SetFileInformationByHandle(handle, FileDispositionInfo, &disposition, sizeof(disposition))) error = GetLastError();
}
if (!CloseHandle(handle) && !error) error = GetLastError();
if (error) return fail(env, "remove stale IPC socket", error);
napi_value result;
return napi_get_boolean(env, true, &result) == napi_ok ? result : NULL;
}
#endif

View File

@@ -0,0 +1,69 @@
/* SPDX-License-Identifier: MIT
* Bind Node-API to the executable that created the environment. A normal
* node.exe import can load a different runtime into Bun (or a compiled CLI).
*/
#ifndef GBRAIN_WINDOWS_NAPI_H
#define GBRAIN_WINDOWS_NAPI_H
#include <windows.h>
#include "node_api.h"
#define GBRAIN_NAPI_SYMBOLS(X) \
X(napi_get_cb_info) \
X(napi_unwrap) \
X(napi_throw_type_error) \
X(napi_throw_error) \
X(napi_get_value_string_utf8) \
X(napi_create_object) \
X(napi_wrap) \
X(napi_get_boolean) \
X(napi_get_undefined) \
X(napi_add_env_cleanup_hook) \
X(napi_define_properties) \
X(napi_create_string_utf8) \
X(napi_set_named_property)
typedef struct {
#define GBRAIN_NAPI_DECLARE(name) __typeof__(&name) name;
GBRAIN_NAPI_SYMBOLS(GBRAIN_NAPI_DECLARE)
#undef GBRAIN_NAPI_DECLARE
} gbrain_napi_api;
static INIT_ONCE gbrain_napi_once = INIT_ONCE_STATIC_INIT;
static gbrain_napi_api gbrain_napi;
static BOOL CALLBACK gbrain_bind_napi(PINIT_ONCE once, PVOID parameter, PVOID *context) {
(void)once; (void)parameter; (void)context;
HMODULE executable = GetModuleHandleW(NULL);
if (!executable) return FALSE;
gbrain_napi_api resolved = {0};
#define GBRAIN_NAPI_RESOLVE(name) \
resolved.name = (__typeof__(resolved.name))GetProcAddress(executable, #name); \
if (!resolved.name) return FALSE;
GBRAIN_NAPI_SYMBOLS(GBRAIN_NAPI_RESOLVE)
#undef GBRAIN_NAPI_RESOLVE
/* Publish only the complete table. InitOnce supplies the cross-thread
* barrier; module registrations never share an environment or its handles. */
gbrain_napi = resolved;
return TRUE;
}
static bool gbrain_initialize_napi(void) {
return InitOnceExecuteOnce(&gbrain_napi_once, gbrain_bind_napi, NULL, NULL) != 0;
}
#define napi_get_cb_info gbrain_napi.napi_get_cb_info
#define napi_unwrap gbrain_napi.napi_unwrap
#define napi_throw_type_error gbrain_napi.napi_throw_type_error
#define napi_throw_error gbrain_napi.napi_throw_error
#define napi_get_value_string_utf8 gbrain_napi.napi_get_value_string_utf8
#define napi_create_object gbrain_napi.napi_create_object
#define napi_wrap gbrain_napi.napi_wrap
#define napi_get_boolean gbrain_napi.napi_get_boolean
#define napi_get_undefined gbrain_napi.napi_get_undefined
#define napi_add_env_cleanup_hook gbrain_napi.napi_add_env_cleanup_hook
#define napi_define_properties gbrain_napi.napi_define_properties
#define napi_create_string_utf8 gbrain_napi.napi_create_string_utf8
#define napi_set_named_property gbrain_napi.napi_set_named_property
#undef GBRAIN_NAPI_SYMBOLS
#endif

View File

@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.50.5.0",
"version": "0.51.0.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {

View File

@@ -144,6 +144,7 @@
"chokidar": "^4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"detect-libc": "2.0.4",
"eventsource-parser": "^3.0.8",
"exifr": "^7.1.3",
"express": "^5.1.0",
@@ -176,7 +177,7 @@
"bun": ">=1.3.11"
},
"license": "MIT",
"version": "0.50.5.0",
"version": "0.51.0.0",
"overrides": {
"@ai-sdk/provider-utils": "4.0.33",
"@hono/node-server": "^2.0.5",

View File

@@ -1,6 +1,6 @@
{
"name": "gbrain-coding",
"version": "0.50.5.0",
"version": "0.51.0.0",
"description": "Brain-first coding agent working inside a repo: retrieval, routing, ingest discipline, correction hygiene. Default persona for the claude-code harness bridge; also published as the gbrain-coding marketplace variant. (persona variant of the gbrain plugin — 20 skills)",
"author": {
"name": "Garry Tan",

View File

@@ -1,6 +1,6 @@
{
"name": "gbrain-coding",
"version": "0.50.5.0",
"version": "0.51.0.0",
"description": "Brain-first coding agent working inside a repo: retrieval, routing, ingest discipline, correction hygiene. Default persona for the claude-code harness bridge; also published as the gbrain-coding marketplace variant. (persona variant of the gbrain plugin — 20 skills)",
"author": {
"name": "Garry Tan",

View File

@@ -1,4 +1,4 @@
<!-- gbrain-plugin-tree-stamp: 0.50.5.0 -->
<!-- gbrain-plugin-tree-stamp: 0.51.0.0 -->
# gbrain-coding (generated persona variant — do not hand-edit)
Brain-first coding agent working inside a repo: retrieval, routing, ingest discipline, correction hygiene. Default persona for the claude-code harness bridge; also published as the gbrain-coding marketplace variant.

View File

@@ -1,6 +1,6 @@
{
"name": "gbrain-daily",
"version": "0.50.5.0",
"version": "0.51.0.0",
"description": "Personal knowledge-brain daily use: meetings, tasks, briefings, reading, research. Published as the gbrain-daily marketplace variant. (persona variant of the gbrain plugin — 19 skills)",
"author": {
"name": "Garry Tan",

View File

@@ -1,6 +1,6 @@
{
"name": "gbrain-daily",
"version": "0.50.5.0",
"version": "0.51.0.0",
"description": "Personal knowledge-brain daily use: meetings, tasks, briefings, reading, research. Published as the gbrain-daily marketplace variant. (persona variant of the gbrain plugin — 19 skills)",
"author": {
"name": "Garry Tan",

View File

@@ -1,4 +1,4 @@
<!-- gbrain-plugin-tree-stamp: 0.50.5.0 -->
<!-- gbrain-plugin-tree-stamp: 0.51.0.0 -->
# gbrain-daily (generated persona variant — do not hand-edit)
Personal knowledge-brain daily use: meetings, tasks, briefings, reading, research. Published as the gbrain-daily marketplace variant.

View File

@@ -1,4 +1,4 @@
<!-- gbrain-plugin-tree-stamp: 0.50.5.0 -->
<!-- gbrain-plugin-tree-stamp: 0.51.0.0 -->
# gbrain plugin skill tree (generated — do not hand-edit)
This tree is the curated skill set for the gbrain Codex and Claude Code
@@ -9,7 +9,7 @@ addition/exclusion).
## MCP surface note (read once)
The plugin's MCP server runs `gbrain serve --surface starter` — the
26-op daily-driver surface (the seven memory verbs + daily
29-op daily-driver surface (the seven memory verbs + daily
brain ops + capture). 22
bundled skills reference gbrain operations beyond that surface; every one of
them has a first-class `gbrain` CLI path, which is the primary way skills

View File

@@ -48,6 +48,10 @@ ALLOWED=(
"src/mcp/tool-defs.ts" # pure helper; takes ops as parameter, never exposes them
"src/core/minions/tools/brain-allowlist.ts" # subagent registry; has its own opt-in allowlist (separate from localOnly)
"src/commands/capture.ts" # local CLI tool; not network-exposed
"src/commands/recall.ts" # local CLI delegates forget through the frozen operation before acquiring an engine
"src/commands/takes-mutation.ts" # local CLI adapter; trusted execution or authenticated persistence IPC only
"src/core/persistence/administration.ts" # trusted-admin grant diagnostics; does not expose an operation transport
"src/core/persistence/provider.ts" # authenticated local registrations; shared dispatch enforces localOnly and the immutable trust lane
"src/commands/enrich.ts" # local CLI tool; calls put_page handler with remote=false, not network-exposed
"src/commands/book-mirror.ts" # local CLI tool; not network-exposed
"src/commands/tools-json.ts" # gbrain --tools-json introspection; full op list IS the purpose

View File

@@ -36,6 +36,7 @@ GBRAIN_HOME_DIR="$BUILD_DIR/home"
trap 'rm -rf "$BUILD_DIR"' EXIT
mkdir -p "$BUILD_DIR/scripts" "$GBRAIN_HOME_DIR"
cp -R "$REPO_ROOT/src" "$BUILD_DIR/src"
cp -R "$REPO_ROOT/native" "$BUILD_DIR/native"
# Shared operation/queue boundaries can reach embedded bootstrap assets even
# from an engine-only import. Keep the compiled graph's file imports available.
cp -R "$REPO_ROOT/templates" "$BUILD_DIR/templates"

View File

@@ -192,6 +192,7 @@ if [ "$NO_SHARD" = "1" ]; then
if [ "$DIFF" = "1" ]; then
RUN_PHASES_CMD='echo "[runner] guards + typecheck"
bash scripts/check-jsonb-pattern.sh
bash scripts/check-bun-test-timeout.sh
bash scripts/check-progress-to-stdout.sh
bash scripts/check-trailing-newline.sh
bash scripts/check-wasm-embedded.sh
@@ -209,7 +210,7 @@ if [ -z "$SELECTED" ]; then
else
printf "%s\n" "$SELECTED" > /tmp/e2e-selected.txt
DATABASE_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer \
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer_test \
GBRAIN_PGBOUNCER_DIRECT_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \
GBRAIN_CI_REQUIRE_PGBOUNCER=1 \
GBRAIN_TEST_DB=1 \
@@ -218,6 +219,7 @@ fi'
else
RUN_PHASES_CMD='echo "[runner] guards + typecheck"
bash scripts/check-jsonb-pattern.sh
bash scripts/check-bun-test-timeout.sh
bash scripts/check-progress-to-stdout.sh
bash scripts/check-trailing-newline.sh
bash scripts/check-wasm-embedded.sh
@@ -230,7 +232,7 @@ echo "[runner] unit (unsharded, DATABASE_URL unset)"
env -u DATABASE_URL bash scripts/run-unit-shard.sh
echo "[runner] e2e (unsharded)"
DATABASE_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer \
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer_test \
GBRAIN_PGBOUNCER_DIRECT_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \
GBRAIN_CI_REQUIRE_PGBOUNCER=1 \
GBRAIN_TEST_DB=1 \
@@ -252,6 +254,7 @@ fi'
fi
RUN_PHASES_CMD="echo \"[runner] guards + typecheck (run once before sharding)\"
bash scripts/check-jsonb-pattern.sh
bash scripts/check-bun-test-timeout.sh
bash scripts/check-progress-to-stdout.sh
bash scripts/check-trailing-newline.sh
bash scripts/check-wasm-embedded.sh
@@ -291,7 +294,7 @@ printf '%s\\n' 1 2 3 4 | xargs -P4 -I{} sh -c '
if [ -s /tmp/e2e-selected.txt ]; then
SHARD=\${shard}/4 \\
DATABASE_URL=postgresql://postgres:postgres@postgres-\${shard}:5432/gbrain_test \\
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer \\
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer_test \\
GBRAIN_PGBOUNCER_DIRECT_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \\
GBRAIN_CI_REQUIRE_PGBOUNCER=1 \\
GBRAIN_TEST_DB=1 \\
@@ -299,7 +302,7 @@ printf '%s\\n' 1 2 3 4 | xargs -P4 -I{} sh -c '
else
SHARD=\${shard}/4 \\
DATABASE_URL=postgresql://postgres:postgres@postgres-\${shard}:5432/gbrain_test \\
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer \\
GBRAIN_PGBOUNCER_URL=postgresql://postgres:postgres@pgbouncer:5432/gbrain_pgbouncer_test \\
GBRAIN_PGBOUNCER_DIRECT_URL=postgresql://postgres:postgres@postgres-1:5432/gbrain_test \\
GBRAIN_CI_REQUIRE_PGBOUNCER=1 \\
GBRAIN_TEST_DB=1 \\

View File

@@ -147,7 +147,14 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
// Agent-job scope fences over real Postgres.
"src/core/ops/jobs.ts": ["test/e2e/jobs-agent-scope-postgres.test.ts", "test/e2e/delegated-grants-withdrawal.test.ts", "test/e2e/delegated-http-worker.test.ts"],
// postgres.js bind paths + JSONB shapes + parity vs PGLite.
"src/core/db-lock.ts": ["test/e2e/db-lock-acquisition-token.test.ts"],
"src/core/lease-schema.ts": ["test/e2e/db-lock-acquisition-token.test.ts"],
"src/core/persistence/**": ["test/e2e/persistence-chaos.test.ts", "test/e2e/persistence-runtime-matrix.test.ts"],
"src/core/pool-budget.ts": ["test/e2e/persistence-runtime-matrix.test.ts"],
"src/core/connection-manager.ts": ["test/e2e/persistence-runtime-matrix.test.ts", "test/e2e/pgbouncer-teardown.test.ts"],
"src/core/postgres-engine.ts": [
"test/e2e/persistence-chaos.test.ts",
"test/e2e/db-lock-acquisition-token.test.ts",
"test/e2e/chunk-canonical-text-privacy.test.ts",
"test/e2e/engine-content-privacy.test.ts", "test/e2e/remote-privacy-journeys.test.ts",
"test/e2e/read-enrichment-privacy.test.ts", "test/e2e/legacy-chunk-privacy.test.ts",
@@ -165,6 +172,7 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
],
// PGLite bootstrap path + parity guard.
"src/core/pglite-engine.ts": [
"test/e2e/persistence-chaos.test.ts",
"test/e2e/chunk-canonical-text-privacy.test.ts",
"test/e2e/engine-content-privacy.test.ts", "test/e2e/remote-privacy-journeys.test.ts",
"test/e2e/read-enrichment-privacy.test.ts", "test/e2e/legacy-chunk-privacy.test.ts",

View File

@@ -47,6 +47,8 @@ const EXTRA_FLAGS: Record<string, string[]> = {
embed: ['--pace', '--pace-max-concurrency'],
// sync shares the same pace surface via env/config plus CLI passthrough.
sync: ['--pace', '--pace-max-concurrency'],
// Deferred persistence routing reaches runForget in recall.ts two levels deep.
forget: ['--reason', '--request-id'],
};
/**
@@ -56,7 +58,7 @@ const EXTRA_FLAGS: Record<string, string[]> = {
* scanning the router bleeds takes/quarantine flags into jobs (whose case
* block imports it for the `jobs stats` thin-client route).
*/
const EXCLUDED_MODULES = ['thin-client-routing.ts'];
const EXCLUDED_MODULES = ['thin-client-routing.ts', 'persistence-delegate.ts'];
function isExcludedModule(p: string): boolean {
// Basename comparison is path-separator agnostic: on Windows p ends in

File diff suppressed because one or more lines are too long

68
scripts/native/build.ts Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bun
/** Rebuild the first-party lock addon with one pinned, cross-platform compiler. */
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
export const targets = {
'linux-x64-glibc': 'x86_64-linux-gnu.2.17',
'linux-arm64-glibc': 'aarch64-linux-gnu.2.17',
'linux-x64-musl': 'x86_64-linux-musl',
'linux-arm64-musl': 'aarch64-linux-musl',
'darwin-x64': 'x86_64-macos.13.0',
'darwin-arm64': 'aarch64-macos.13.0',
'win32-x64': 'x86_64-windows-gnu',
'win32-arm64': 'aarch64-windows-gnu',
} as const;
export type NativeTarget = keyof typeof targets;
const repo = resolve(import.meta.dir, '../..');
const nativeDir = join(repo, 'native/locks');
export const buildInputs = [
'native/locks/locks.c', 'native/locks/darwin-abi.h', 'native/locks/abi-check.c',
'native/locks/windows-napi.h', 'native/locks/windows-ipc.h', 'native/locks/vendor/node-v22.15.0/node_api.h',
'native/locks/vendor/node-v22.15.0/node_api_types.h',
'native/locks/vendor/node-v22.15.0/js_native_api.h',
'native/locks/vendor/node-v22.15.0/js_native_api_types.h',
'native/locks/vendor/node-v22.15.0/LICENSE',
'scripts/native/build.ts', 'scripts/native/toolchain.json',
];
export function inputDigest(): string {
const hash = createHash('sha256');
for (const file of buildInputs) hash.update(file).update('\0').update(readFileSync(join(repo, file))).update('\0');
return hash.digest('hex');
}
function build(target: NativeTarget, output: string, zig: string): void {
mkdirSync(output, { recursive: true });
const args = ['cc', '-target', targets[target], '-shared', '-fPIC', '-O2', '-s',
'-DNAPI_VERSION=3', '-DBUILDING_NODE_EXTENSION', `-DGBRAIN_NATIVE_TARGET="${target}"`,
'-Inative/locks/vendor/node-v22.15.0', '-ffile-prefix-map=.=gbrain',
'native/locks/locks.c', '-o', join(output, `${target}.node`)];
if (target.startsWith('darwin-')) args.push('-ffreestanding', '-nostdlib', '-Wl,-undefined,dynamic_lookup', `-Wl,-install_name,@rpath/gbrain-lock-${target}.node`);
else if (target.startsWith('win32-')) args.push('-lbcrypt');
else if (!target.startsWith('win32-')) args.push('-Wl,--build-id=none');
execFileSync(zig, args, { cwd: repo, stdio: 'inherit', env: { ...process.env, SOURCE_DATE_EPOCH: '1745280000' } });
if (target.startsWith('win32-')) rmSync(join(output, 'locks.lib'), { force: true });
}
if (import.meta.main) {
const args = process.argv.slice(2);
const value = (name: string) => { const i = args.indexOf(name); return i < 0 ? undefined : args[i + 1]; };
const requested = value('--target') ?? 'all';
const selected = requested === 'all' ? Object.keys(targets) as NativeTarget[] : [requested as NativeTarget];
if (selected.some(target => !(target in targets))) throw new Error(`Unknown target: ${requested}`);
const zig = value('--zig') ?? process.env.ZIG ?? 'zig';
if (execFileSync(zig, ['version'], { encoding: 'utf8' }).trim() !== '0.14.1') throw new Error('Native builds require Zig 0.14.1');
const output = resolve(value('--output') ?? join(nativeDir, 'prebuilds'));
for (const target of selected) { build(target, output, zig); console.log(`Built ${target}`); }
if (args.includes('--write-manifest')) {
if (requested !== 'all') throw new Error('--write-manifest requires --target all');
const artifacts = Object.fromEntries(Object.keys(targets).map(target => {
const bytes = readFileSync(join(output, `${target}.node`));
return [target, { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length }];
}));
writeFileSync(join(nativeDir, 'manifest.json'), JSON.stringify({ version: 1, napi: 3, zig: '0.14.1', input_sha256: inputDigest(), artifacts }, null, 2) + '\n');
}
}

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env bun
/** Exercise the executable being released, with no source imports or operator state. */
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { chmodSync, copyFileSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
const args = process.argv.slice(2);
if (args.length !== 2 || args[0] !== '--binary') throw new Error('Usage: bun scripts/native/cli-persistence-smoke.ts --binary <release executable>');
const root = mkdtempSync(join(tmpdir(), 'gbrain-release-persistence-'));
const binary = join(root, process.platform === 'win32' ? 'gbrain.exe' : 'gbrain');
const database = process.platform === 'win32' ? join(root, 'brain.pglite')
: join(root, 'long-界'.repeat(18), 'brain.pglite');
const checkout = join(root, 'pages');
const childTemp = join(root, 'tmp');
// Copying the artifact away from the checkout also rules out adjacent source
// files or native prebuilds accidentally satisfying a broken release bundle.
const env: Record<string, string> = {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: root, USERPROFILE: root, GBRAIN_HOME: root,
XDG_CONFIG_HOME: join(root, 'config'), XDG_CACHE_HOME: join(root, 'cache'), XDG_STATE_HOME: join(root, 'state'),
TMPDIR: childTemp, TMP: childTemp, TEMP: childTemp,
LANG: 'C.UTF-8', LC_ALL: 'C',
GBRAIN_BRAIN_ID: 'host', GBRAIN_SOURCE: 'default',
GBRAIN_NO_BANNER: '1', GBRAIN_SKIP_STARTUP_HOOKS: '1', GBRAIN_BACKUP_CHECK: '0', GBRAIN_SWEEP: '0',
GBRAIN_INIT_SKIP_EMBED_CHECK: '1',
};
// Never spread process.env: release jobs may have publishing/provider tokens,
// database URLs, Git configuration overrides, or an unrelated operator home.
for (const name of ['SystemRoot', 'WINDIR', 'ComSpec']) if (process.env[name]) env[name] = process.env[name]!;
type OwnedChild = { exitCode: number | null; exited: Promise<number>; kill(signal?: NodeJS.Signals | number): void };
const children = new Set<OwnedChild>();
const outputLimit = 2 * 1024 * 1024;
async function collect(stream: ReadableStream<Uint8Array>, child: OwnedChild, append?: (part: string) => void): Promise<string> {
const reader = stream.getReader(), decoder = new TextDecoder();
let text = '', bytes = 0;
try {
for (;;) {
const next = await reader.read();
if (next.done) return text;
bytes += next.value.byteLength;
if (bytes > outputLimit) { child.kill('SIGKILL'); throw new Error('Release CLI exceeded its bounded output allowance.'); }
const part = decoder.decode(next.value, { stream: true });
text += part; append?.(part);
}
} finally { reader.releaseLock(); }
}
async function run(argv: string[], expected: number | null = 0, timeoutMs = 60000) {
const label = argv[0] === 'call' ? `call ${argv[3]}` : argv.slice(0, 3).join(' ');
console.log(`[release-cli] ${label}`);
const child = Bun.spawn([binary, ...argv], { cwd: root, env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
children.add(child);
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs);
try {
const [stdout, stderr, code] = await Promise.all([collect(child.stdout, child), collect(child.stderr, child), child.exited]);
assert.equal(timedOut, false, `CLI timed out: ${argv.slice(0, 2).join(' ')}`);
if (expected !== null) assert.equal(code, expected, `${argv.slice(0, 2).join(' ')} exited ${code}: ${stdout.slice(-6000)}\n${stderr.slice(-6000)}`);
return { stdout, stderr, code };
} finally { clearTimeout(timer); if (child.exitCode !== null) children.delete(child); }
}
function json(text: string): Record<string, any> {
assert(text.trim(), 'The release CLI returned an empty JSON response.');
const value = JSON.parse(text);
assert(value && typeof value === 'object' && !Array.isArray(value), 'CLI must emit one JSON object.');
return value;
}
async function call(operation: string, params: Record<string, unknown>, expected = 0) {
const result = await run(['call', '--source', 'default', operation, JSON.stringify(params)], expected);
assert(result.stdout.trim(), `${operation} returned no JSON receipt: ${result.stderr.slice(-6000)}`);
return json(result.stdout);
}
function committed(result: Record<string, any>, requestId: string): string {
assert.equal(result.state, 'committed');
assert.equal(result.request_id, requestId);
assert.equal(result.write_request?.request_id, requestId);
assert.equal(result.write_request?.state, 'committed');
assert.equal(result.persistence?.mode, 'filesystem');
assert.match(result.revision, /^[0-9a-f-]{36}$/i);
return result.revision;
}
async function readPage(slug: string, revision: string, sentinel: string) {
const page = await call('get_page', { slug, include_content: true, source_id: 'default' });
assert.equal(page.revision, revision);
assert.equal(typeof page.content, 'string');
assert(page.content.includes(sentinel), 'Reopened canonical page lost the expected content.');
assert(readFileSync(join(checkout, `${slug}.md`), 'utf8').includes(sentinel), 'Canonical file does not match the acknowledged write.');
}
function ownerPid(): number { return JSON.parse(readFileSync(join(database, '.gbrain-lock', 'lock'), 'utf8')).pid; }
let owner: Bun.Subprocess<'pipe', 'pipe', 'pipe'> | undefined;
let ownerReads: Promise<unknown>[] = [];
async function stopOwner() {
if (!owner) return;
if (owner.exitCode === null) await owner.stdin.end();
let forced = false;
const timer = setTimeout(() => { forced = true; owner?.kill('SIGKILL'); }, 30000);
try {
const code = await owner.exited;
await Promise.all(ownerReads);
assert.equal(forced, false, 'Resident CLI did not drain and release its datastore.');
assert.equal(code, 0, 'Resident CLI shutdown failed.');
} finally { clearTimeout(timer); children.delete(owner); owner = undefined; ownerReads = []; }
}
try {
mkdirSync(checkout); mkdirSync(childTemp); mkdirSync(dirname(database), { recursive: true });
copyFileSync(resolve(args[1]), binary); chmodSync(binary, 0o700);
await run(['init', '--pglite', '--path', database, '--non-interactive', '--no-embedding', '--json'], 0, 120000);
const config = JSON.parse(readFileSync(join(root, '.gbrain', 'config.json'), 'utf8'));
assert.equal(config.engine, 'pglite'); assert.equal(config.database_path, database); assert.equal(config.embedding_disabled, true);
await run(['auth', 'local-writer', 'register', 'cli', '--json']);
await run(['auth', 'local-writer', 'register', 'stdio', '--json']);
const claimed = json((await run(['sources', 'writer', 'claim', 'default', '--path', checkout, '--json'])).stdout);
assert.equal(claimed.claimed, true);
const activated = json((await run(['sources', 'writer', 'activate', '--confirm-quiesced', '--json'])).stdout);
assert.equal(activated.enabled, true);
const status = json((await run(['sources', 'writer', 'status', '--probe', '--json'])).stdout);
assert.equal(status.native_lock?.acquired, true); assert.equal(status.native_lock?.released, true);
assert.equal(status.native_lock?.napi, 3);
const slug = 'notes/release-smoke';
const firstId = randomUUID();
const first = { slug, source_id: 'default', request_id: firstId, content: '# Release example\n\nInitial canonical release sentinel.\n' };
const firstRevision = committed(await call('put_page', first), firstId);
await readPage(slug, firstRevision, 'Initial canonical release sentinel.');
assert.equal(committed(await call('put_page', first), firstId), firstRevision, 'Same-ID replay created a new revision.');
const updateId = randomUUID();
const update = { ...first, request_id: updateId, expected_revision: firstRevision, content: '# Release example\n\nUpdated canonical release sentinel.\n' };
const updateRevision = committed(await call('put_page', update), updateId);
assert.notEqual(updateRevision, firstRevision);
const staleId = randomUUID();
const refused = await call('put_page', { ...update, request_id: staleId, content: '# Release example\n\nStale content must never publish.\n' }, 1);
assert.equal(refused.error, 'revision_conflict');
assert.equal(refused.write_request?.request_id, staleId); assert.equal(refused.write_request?.state, 'conflict');
await readPage(slug, updateRevision, 'Updated canonical release sentinel.');
owner = Bun.spawn([binary, 'serve'], { cwd: root, env, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
children.add(owner);
let frames = '', ownerErrors = '';
ownerReads = [collect(owner.stdout, owner, part => { frames += part; }), collect(owner.stderr, owner, part => { ownerErrors += part; })];
// The stream readers must be observed immediately even when readiness fails.
for (const read of ownerReads) void read.catch(() => {});
owner.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {
protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'release-smoke', version: '1' },
} }) + '\n');
const deadline = performance.now() + 60000;
for (;;) {
const response = frames.split('\n').slice(0, -1).filter(Boolean).map(line => json(line)).find(frame => frame.id === 1);
if (response) { assert(!response.error, JSON.stringify(response.error)); break; }
assert.equal(owner.exitCode, null, `Resident CLI exited before initialization: ${ownerErrors.slice(-6000)}`);
assert(performance.now() < deadline, `Resident CLI initialization timed out: ${ownerErrors.slice(-6000)}`);
await delay(25);
}
owner.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
assert.equal(ownerPid(), owner.pid);
// MCP initialization precedes the separate persistence listener's boot. Use
// an authenticated read through the release executable as its readiness
// probe; never retry a mutation or mistake a queued receipt for readiness.
const persistenceDeadline = performance.now() + 60000;
const socketPath = join(database, '.gbrain-persistence.sock');
if (process.platform !== 'win32') assert(Buffer.byteLength(socketPath) > 103, 'Release smoke must exercise the long Unix address fallback.');
const diagnostics = () => `legacy_socket_path_bytes=${Buffer.byteLength(socketPath)}; owner stderr: ${ownerErrors.slice(-6000)}`;
for (;;) {
assert.equal(owner.exitCode, null, `Resident CLI exited before persistence readiness: ${diagnostics()}`);
assert(!ownerErrors.includes('[persistence-ipc] listener unavailable'), `Resident persistence listener failed to bind: ${diagnostics()}`);
assert(performance.now() < persistenceDeadline, `Resident persistence readiness timed out: ${diagnostics()}`);
const probe = await run(['call', '--source', 'default', 'get_page', JSON.stringify({ slug, include_content: true, source_id: 'default' })],
null, Math.max(1, Math.min(15000, persistenceDeadline - performance.now())));
const result = json(probe.stdout);
if (probe.code === 0) {
assert.equal(result.revision, updateRevision);
assert(result.content?.includes('Updated canonical release sentinel.'), `Readiness probe returned the wrong page: ${diagnostics()}`);
assert.equal(ownerPid(), owner.pid, 'Readiness probe replaced the resident datastore owner.');
break;
}
assert(probe.code === 1 && result.error === 'owner_unavailable' && result.submission_status === 'not_sent',
`Readiness probe failed: ${probe.stdout.slice(-6000)}\n${probe.stderr.slice(-6000)}\n${diagnostics()}`);
await delay(25);
}
const residentId = randomUUID();
const resident = { ...update, request_id: residentId, expected_revision: updateRevision, content: '# Release example\n\nResident canonical release sentinel.\n' };
const residentRevision = committed(await call('put_page', resident), residentId);
assert.equal(ownerPid(), owner.pid, 'Delegated write replaced the resident datastore owner.');
await readPage(slug, residentRevision, 'Resident canonical release sentinel.');
assert.equal(committed(await call('put_page', resident), residentId), residentRevision);
assert.equal(ownerPid(), owner.pid);
await stopOwner();
await readPage(slug, residentRevision, 'Resident canonical release sentinel.');
assert.equal(committed(await call('put_page', resident), residentId), residentRevision, 'Receipt did not survive resident shutdown and reopen.');
console.log(JSON.stringify({ ok: true, target: status.native_lock.target, binary: 'release artifact',
legacy_socket_path_bytes: Buffer.byteLength(socketPath), checks: [...(process.platform === 'win32' ? [] : ['long-unicode-ipc-path']), 'keyless-init', 'native-probe', 'filesystem-publication', 'durable-replay', 'revision-conflict', 'authenticated-owner-readiness', 'resident-ipc', 'shutdown-reopen'] }));
} finally {
try { await stopOwner(); }
finally {
for (const child of children) { if (child.exitCode === null) child.kill('SIGKILL'); await child.exited; }
await Promise.allSettled(ownerReads);
rmSync(root, { recursive: true, force: true });
}
}

View File

@@ -0,0 +1,14 @@
/** Focused entrypoint to exercise the exact production import in --compile. */
import { nativeLockCapability, tryAcquireNativeLock } from '../../src/core/persistence/native-lock.ts';
const [path, ready, mode] = process.argv.slice(2);
const lock = await tryAcquireNativeLock(path);
if (lock && mode === 'hold') {
await Bun.write(ready, 'held');
process.stdin.resume();
await new Promise<void>(resolve => {
process.stdin.once('data', () => resolve());
process.stdin.once('end', () => resolve());
});
}
console.log(JSON.stringify({ capability: await nativeLockCapability(), acquired: lock !== null }));
await lock?.release();

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bun
/** Native addon packaging and two-process exclusion in a real compiled binary. */
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { nativeLockCapability } from '../../src/core/persistence/native-lock.ts';
const root = mkdtempSync(join(tmpdir(), 'gbrain-native-compiled-'));
const arg = process.argv.indexOf('--binary');
const binary = join(root, process.platform === 'win32' ? 'probe.exe' : 'probe');
const { target } = await nativeLockCapability();
const lockPath = join(root, 'writer.lock'), ready = join(root, 'ready');
const children: Bun.Subprocess[] = [];
const env = { ...process.env, GBRAIN_SKIP_STARTUP_HOOKS: '1', HOME: root, GBRAIN_HOME: root };
try {
if (arg >= 0) {
// This is a packaging assertion, not a substitute for a CLI operation
// test: the exact production addon must be embedded in the release file.
const release = readFileSync(resolve(process.argv[arg + 1]));
const addon = readFileSync(resolve(import.meta.dir, `../../native/locks/prebuilds/${target}.node`));
if (!release.includes(addon)) throw new Error(`Release executable omitted native payload ${target}`);
}
execFileSync(process.execPath, ['build', '--compile', '--no-compile-autoload-bunfig', '--outfile', binary,
resolve(import.meta.dir, 'compiled-probe.ts')], { stdio: 'inherit' });
const holder = Bun.spawn([binary, lockPath, ready, 'hold'], { env, stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
children.push(holder);
const deadline = performance.now() + 15000;
while (!existsSync(ready) && holder.exitCode === null && performance.now() < deadline) await delay(10);
if (!existsSync(ready)) {
if (holder.exitCode === null) { holder.kill(9); await holder.exited; }
throw new Error(`Compiled lock holder did not acquire: ${await new Response(holder.stderr).text()}`);
}
const probe = () => JSON.parse(execFileSync(binary, [lockPath, ready, 'probe'], { env, encoding: 'utf8', timeout: 15000 }));
if (probe().acquired !== false) throw new Error('Compiled writers both acquired one lock');
holder.kill(9);
await holder.exited;
if (probe().acquired !== true) throw new Error('Compiled writer lock survived process death');
if (!existsSync(lockPath)) throw new Error('Compiled locking unlinked its stable lock file');
console.log(`Compiled native lock smoke passed: ${target}`);
} finally {
for (const child of children) { if (child.exitCode === null) child.kill(9); await child.exited; }
rmSync(root, { recursive: true, force: true });
}

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env bun
/** Download the exact compiler archive pinned in the checked-in manifest. */
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import toolchain from './toolchain.json';
const args = process.argv.slice(2);
const index = args.indexOf('--dir');
const directory = resolve(index < 0 ? '.context/native-toolchain' : args[index + 1]);
const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : process.platform;
const arch = process.arch === 'x64' ? 'x86_64' : process.arch === 'arm64' ? 'aarch64' : process.arch;
const key = `${arch}-${platform}` as keyof typeof toolchain.archives;
const archiveInfo = toolchain.archives[key];
if (!archiveInfo) throw new Error(`No pinned Zig archive for ${key}`);
mkdirSync(directory, { recursive: true });
const archive = join(directory, archiveInfo.tarball.split('/').at(-1)!);
if (!existsSync(archive)) {
const response = await fetch(archiveInfo.tarball);
if (!response.ok) throw new Error(`Compiler download failed: HTTP ${response.status}`);
writeFileSync(archive, new Uint8Array(await response.arrayBuffer()));
}
if (createHash('sha256').update(readFileSync(archive)).digest('hex') !== archiveInfo.shasum) throw new Error('Pinned compiler checksum mismatch');
execFileSync('tar', ['-xf', archive, '-C', directory], { stdio: 'inherit' });
const extracted = readdirSync(directory, { withFileTypes: true }).find(entry => entry.isDirectory() && entry.name.startsWith('zig-'));
if (!extracted) throw new Error('Compiler archive did not contain Zig');
const binary = join(directory, extracted.name, process.platform === 'win32' ? 'zig.exe' : 'zig');
if (execFileSync(binary, ['version'], { encoding: 'utf8' }).trim() !== toolchain.version) throw new Error('Extracted compiler version mismatch');
if (args.includes('--github-path')) {
if (!process.env.GITHUB_PATH || !process.env.GITHUB_ENV) throw new Error('GitHub environment files are missing');
appendFileSync(process.env.GITHUB_PATH, dirname(binary) + '\n');
appendFileSync(process.env.GITHUB_ENV, `ZIG=${binary}\n`);
}
console.log(binary);

View File

@@ -0,0 +1,35 @@
{
"version": "0.14.1",
"archives": {
"x86_64-linux": {
"tarball": "https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz",
"shasum": "24aeeec8af16c381934a6cd7d95c807a8cb2cf7df9fa40d359aa884195c4716c",
"size": "49086504"
},
"aarch64-linux": {
"tarball": "https://ziglang.org/download/0.14.1/zig-aarch64-linux-0.14.1.tar.xz",
"shasum": "f7a654acc967864f7a050ddacfaa778c7504a0eca8d2b678839c21eea47c992b",
"size": "44954692"
},
"x86_64-macos": {
"tarball": "https://ziglang.org/download/0.14.1/zig-x86_64-macos-0.14.1.tar.xz",
"shasum": "b0f8bdfb9035783db58dd6c19d7dea89892acc3814421853e5752fe4573e5f43",
"size": "51044512"
},
"aarch64-macos": {
"tarball": "https://ziglang.org/download/0.14.1/zig-aarch64-macos-0.14.1.tar.xz",
"shasum": "39f3dc5e79c22088ce878edc821dedb4ca5a1cd9f5ef915e9b3cc3053e8faefa",
"size": "45903552"
},
"x86_64-windows": {
"tarball": "https://ziglang.org/download/0.14.1/zig-x86_64-windows-0.14.1.zip",
"shasum": "554f5378228923ffd558eac35e21af020c73789d87afeabf4bfd16f2e6feed2c",
"size": "82229343"
},
"aarch64-windows": {
"tarball": "https://ziglang.org/download/0.14.1/zig-aarch64-windows-0.14.1.zip",
"shasum": "b5aac0ccc40dd91e8311b1f257717d8e3903b5fefb8f659de6d65a840ad1d0e7",
"size": "78125379"
}
}
}

29
scripts/native/verify.ts Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bun
/** Verify source-to-manifest freshness and the exact vendored binary set. */
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { inputDigest, targets } from './build.ts';
import manifest from '../../native/locks/manifest.json';
const root = resolve(import.meta.dir, '../../native/locks/prebuilds');
if (manifest.version !== 1 || manifest.napi !== 3 || manifest.zig !== '0.14.1') throw new Error('Native prebuild manifest format mismatch');
if (manifest.input_sha256 !== inputDigest()) throw new Error('Native lock source changed: rebuild all prebuilds and their manifest');
const expected = Object.keys(targets).sort();
if (JSON.stringify(Object.keys(manifest.artifacts).sort()) !== JSON.stringify(expected)) throw new Error('Native artifact target set is incomplete');
if (JSON.stringify(readdirSync(root).sort()) !== JSON.stringify(expected.map(target => `${target}.node`).sort())) throw new Error('Native prebuild directory must contain exactly the eight declared addons');
for (const target of expected) {
const bytes = readFileSync(join(root, `${target}.node`));
const record = manifest.artifacts[target as keyof typeof manifest.artifacts];
if (bytes.length !== record.bytes || createHash('sha256').update(bytes).digest('hex') !== record.sha256) throw new Error(`Native prebuild integrity mismatch: ${target}`);
}
const i = process.argv.indexOf('--rebuilt');
if (i >= 0) {
const rebuilt = resolve(process.argv[i + 1]);
const files = readdirSync(rebuilt).filter(file => file.endsWith('.node'));
if (!files.length) throw new Error('No rebuilt binaries were supplied');
for (const file of files) {
if (!readFileSync(join(rebuilt, file)).equals(readFileSync(join(root, file)))) throw new Error(`Native rebuild was not byte-identical: ${file}`);
}
}
console.log(`Verified ${expected.length} native lock prebuilds`);

View File

@@ -0,0 +1,167 @@
# Persistence validation
Run the complete gate from a source install (`bun install --frozen-lockfile
--ignore-scripts` works):
```sh
bun --no-env-file scripts/persistence/validate.ts --engine=pglite
DATABASE_URL=postgres://test-user:test-password@localhost:5432/gbrain_test \
bun --no-env-file scripts/persistence/validate.ts --engine=postgres
```
Postgres requires a test-shaped database URL and permission to create and drop
databases. Every phase gets a fresh, randomly named `gbrain_persistence_test_*`
database. Successful runs drop only those databases. Failed runs retain their
synthetic scratch directories and databases for inspection. PGLite uses temporary disk
datastores, reopened in separate processes. Child homes and writer lock paths
are temporary; no operator brain or provider credentials enter children.
The default gate executes, per engine:
| Workload | Required result |
| --- | --- |
| 1,000 seeded schedules (seed 5105) | 100 executions of each of the ten cases below; every schedule checks exact journal counter conservation and no leftover claimable work |
| Eight actual SIGKILL boundaries | Acknowledged requests survive reopening; unfinished file effects recover; committed DB/file/receipt state stays committed; original request replay returns the same outcome |
| 10,000 logical writes | Four independent producer processes, four principals and four source roots; every request commits once; every canonical snapshot and file matches the receipt; zero pending requests or unresolved recovery records |
The eight executed crash boundaries are `admitted`, `prepared`,
`before_publication`, `staging_flushed`, `after_publication`, `before_commit`,
`after_commit`, and `after_response`. The flushed boundary writes its event
synchronously and blocks the child before rename. The response boundary serves
a real fixture HTTP receipt; the parent fully reads and verifies it before
SIGKILL. Recovery verifies that no recorded or unaccounted temporary sibling
remains. Partial or unexpected staging bytes preserve quota and require explicit
recovery; a committed receipt is never reversed to resolve them.
Run just these process crashes with `--schedules=0 --operations=0`; its manifest
correctly reports `full_gate: false`. Input hashes include the atomic writer,
staging helper, recovery models, journal, coordinator and effect sinks.
The ten schedule families are concurrent identical/changed-intent replay;
competing creates and replacements using one revision; cancellation versus publication; rollback/lost response
at all five coordinator hooks; obsolete claim renewal/release; FIFO within
each root with unrelated-root progress; MVCC/serialized coherent reads;
unexpected external file bytes blocking recovery; concurrent quota admission;
and revocation after acceptance. A seeded PRNG varies principals, roots,
payloads, concurrent widths and submission order. Real production coordinator
hooks control transaction and filesystem boundaries. This is a bounded
schedule sample, not exhaustive model checking.
The crash cases kill a process after admission, durable prepare, immediately
before publication, after staging flush/close, after rename, before commit,
after commit, and after a delivered response. The
PGLite case exercises the datastore owner's death. The Postgres case kills
the client/owner process while the database server remains running. These
are process-crash RPO=0 checks; they do not simulate power loss, storage
controller failure or database-server loss.
Postgres runs two resident consumers and producers with independent database
connections. PGLite has one resident owner; producer processes submit through
a **fixture-only loopback endpoint** into the real admission API. That
endpoint does not test production authentication or MCP/IPC framing; the
existing receipt and transport suites cover those contracts. Both engines
use the real consumer, authority checks, kernel locks, coordinator, durable
files and database transactions. Four writes per producer stay in flight;
every seventeenth logical write is replayed under the same request ID.
The default manifest is `.context/persistence-<engine>-manifest.json`. It
contains the actual completed case counts, crash outcomes, runtime/platform,
latency distributions (p50/p95/p99/max), concurrent canonical-read checks, throughput, peak resident RSS,
duplicate replay count, per-phase source hashes and final accounting results. A failed run writes a
failed manifest. Smaller runs (`--schedules=50 --operations=64`) are useful
for iteration and always report `full_gate: false`; `--no-crashes` does too.
Use `--seed=...` for another reproducible sample and `--manifest=...` to keep
multiple records. Performance numbers describe a synthetic body+timeline+tag
workload with a durable file per write; they exclude provider calls, Git
publication and remote network latency. Compare like-for-like runtime,
storage and process counts before setting or changing latency budgets.
On failure, the manifest includes the last cached state of each producer's
at-most-four active requests and a bounded owner snapshot: queue states and
ages, root ownership epochs, counters, consumer activity and error codes.
It excludes content, filesystem paths, authority, credentials and error
messages. Owner diagnostics have a two-second budget; an unavailable owner
adds a timeout marker and never changes the original failure or the
120-second receipt deadline.
The runner writes `<manifest>.retained.json` with mode `0600`, listing the
retained scratch root, worker PIDs and exact cleanup commands. After inspection,
verify those workers have stopped, run the listed database commands using the
original loopback test `DATABASE_URL` in the environment, then remove the
listed scratch directory. The metadata contains generated database names,
never a connection URL. Keep the retained directory private: its original
`config.json` files contain the test connection URL. Do not upload it with
the diagnostic manifest. Successful runs retain no fixtures.
`persistence-validation.yml` runs the full gate on Linux x64 for both engines
under Bun 1.3.11 and 1.3.13 and uploads every manifest. Native OS/architecture
coverage is separately required by `native-locks.yml`; its configured matrix
must not be mistaken for locally executed runtime evidence.
All stress fixtures explicitly activate managed persistence after registering
canonical roots. Workers assert that activation remains enabled and use a
synthetic host identity confined to the runner's temporary home. Matrix read
probes are seeded before activation; the measured writes use the coordinator.
Known permanent transaction failures stay failed after conditional filesystem
recovery, allowing the next request for that root to proceed.
The same workflow executes `scripts/persistence/matrix.ts`, requiring both
`DATABASE_URL` (direct test connection) and `GBRAIN_PGBOUNCER_URL` (a real
transaction-mode pooler with wildcard database routing).
Both supplied database names must pass the test-safety guard. Administrative
CREATE/DROP statements use the direct server's `postgres` maintenance database,
so another E2E shard resetting the shared test database cannot terminate this
connection. Every engine connection still uses a fresh generated test database.
The 24 cells cover
direct/pooler transport, RLS on/off under a non-superuser role, ordinary pools
1/2/3 and shared pools versus a separate direct pool of size one. Each cell
proves short control progress while the production bulk reservation API
holds every permitted long-running slot. Size one keeps canonical work
queued with `writer_pool_capacity`; sizes two and three commit the same
request after bulk work drains. A separate fixture checks manifest-verified
transfer between distinct host identities/checkouts, stale-owner refusal,
retained coordination paths across root replacement and source-incarnation
fencing. The default matrix manifest is
`.context/persistence-runtime-matrix.json`; missing mandatory URLs fail the
standalone gate. The ordinary E2E entry skips outside a configured pooler
lane and refuses to skip when `GBRAIN_CI_REQUIRE_PGBOUNCER=1`.
The heavy process worker allows 90 minutes; its CI job allows 110 minutes.
This accommodates disk-PGLite durability on slower VM storage without
reducing the 10,000 actual mutation requirement.
The required read-latency lane runs `scripts/persistence/performance.ts
--engine=pglite` (or `--engine=postgres` with the same guarded test URL).
It keeps the existing heavy workload's 500-page text corpus, 200 hybrid
searches per phase, four writers and 50% p99 regression budget. Three fresh
child processes/databases each measure idle reads followed by reads with
public `put_page` writes; the gate compares the median loaded p99 to the
median idle p99 on the same runner. Each run requires actual committed
writes, zero failed reads/writes and at least 90% coverage of the read
window by the union of in-flight public mutation intervals. An idle gap
cannot be hidden by a late writer completion. Actual writes must commit
during the read window. Both phases yield one event-loop turn between
queries (outside individual query timing), so PGLite's immediate promise
chain cannot starve resident-consumer timers and fabricate overlap using
only queued requests. Corpus seeding uses the same
public mutation path. This is keyless keyword search through `hybridSearch`,
without a provider or remote embedding latency.
The manifest records all three runs, exact source hashes, storage/runtime
and runner characteristics, admission/completion distributions, queue age,
recovery bytes, RSS, throughput and Postgres activity samples. The harness
measures durable admission when the public handler's top-level queued journal
transaction resolves, and completion when its terminal committed receipt is
observed. Nested savepoints never count as admission. The same harness proxy
observes warmup and pressure writes; measurement buffers reset after warmup.
Every completed write must have its own earlier admission observation, and
missing or incomplete timing distributions invalidate the aggregate gate.
Pool gauges are explicitly a tracked SQL subset; `pg_stat_activity` separately records
active/idle sessions in the fresh fixture database, including the sampler.
PGLite keeps the original in-memory read-latency storage model; the separate
10,000-write durability lane uses disk storage. Smaller corpus options are
recorded as `full_gate: false`. `tests/heavy/read_latency_under_sync.sh`
retains its optional `STRICT_LATENCY=1` interface and now runs the same
three-sample harness; sample validity always fails closed. The required CI
lane always enforces the unmodified 50% threshold on both Bun versions and
both engines.

View File

@@ -0,0 +1,73 @@
import type { BrainEngine } from '../../src/core/engine.ts';
import type { WriteRequest } from '../../src/core/persistence/model.ts';
export const DIAGNOSTIC_BUDGET_MS = 2_000;
export type DiagnosticResult<T> = { status: 'ok'; value: T } | { status: 'timeout' } | { status: 'error'; code: string };
export function diagnosticCode(value: unknown): string | null {
return typeof value === 'string' && /^[a-zA-Z0-9_]{1,64}$/.test(value) ? value : null;
}
export function diagnosticError(error: unknown): string {
return diagnosticCode((error as { code?: unknown } | null)?.code) ?? 'diagnostic_error';
}
/** A stalled diagnostic must never extend the original gate or hide its failure. */
export async function boundedDiagnostic<T>(task: () => Promise<T>, milliseconds = DIAGNOSTIC_BUDGET_MS): Promise<DiagnosticResult<T>> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve().then(task).then(value => ({ status: 'ok' as const, value }), error => ({ status: 'error' as const, code: diagnosticError(error) })),
new Promise<{ status: 'timeout' }>(resolve => { timer = setTimeout(() => resolve({ status: 'timeout' }), milliseconds); }),
]);
} finally { clearTimeout(timer); }
}
const uuid = (value: unknown) => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value) ? value : null;
const number = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? value : null;
const decimal = (value: unknown) => /^\d+$/.test(String(value)) ? String(value) : null;
function timestamp(value: unknown): string | null {
if (!(typeof value === 'string' || value instanceof Date)) return null;
const date = new Date(value); return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
/** Explicit allowlist: no intent, body, path, authority, tokens or error messages. */
export function receiptDiagnostic(row: Partial<WriteRequest> | null) {
if (!row) return null;
return { id: uuid(row.id), request_id: uuid(row.request_id), worktree_id: uuid(row.worktree_id),
sequence: decimal(row.sequence), state: diagnosticCode(row.state), error_code: diagnosticCode(row.error_code),
blocked_reason: diagnosticCode(row.blocked_reason), created_at: timestamp(row.created_at), updated_at: timestamp(row.updated_at),
completed_at: timestamp(row.completed_at), claim_expires_at: timestamp(row.claim_expires_at),
publication_started: row.publication_started === true, has_recovery: row.recovery != null,
recovery_bytes: decimal(row.recovery_bytes), intent_bytes: decimal(row.intent_bytes) };
}
export interface ActiveSoakRequest { requestId: string; index: number; startedAt: number; receipt: Partial<WriteRequest> | null; }
export function soakFailureDiagnostic(principal: number, completed: number, active: Iterable<ActiveSoakRequest>) {
return { at: new Date().toISOString(), principal, completed, active: [...active].slice(0, 4).map(row => ({
request_id: uuid(row.requestId), index: row.index, elapsed_ms: Math.max(0, performance.now() - row.startedAt), receipt: receiptDiagnostic(row.receipt),
})) };
}
/** Only computed counts, UUIDs and states leave the synthetic owner. SQL never selects content or paths. */
export async function ownerDatabaseDiagnostic(engine: Pick<BrainEngine, 'executeRaw'>) {
const [row] = await engine.executeRaw<{ queue: Record<string, unknown>[]; roots: Record<string, unknown>[]; counters: Record<string, unknown> | null }>(`
WITH pending AS (SELECT worktree_id,state,blocked_reason,created_at FROM persistence_requests
WHERE state IN ('queued','running','recovering'))
SELECT COALESCE((SELECT jsonb_agg(q) FROM (SELECT state,blocked_reason,count(*)::text AS count,
EXTRACT(EPOCH FROM now()-min(created_at))*1000 AS oldest_ms FROM pending GROUP BY state,blocked_reason
ORDER BY state,blocked_reason LIMIT 32) q),'[]'::jsonb) AS queue,
COALESCE((SELECT jsonb_agg(w) FROM (SELECT id,owner_host_id,owner_epoch::text,state,heartbeat_at,
(SELECT count(*)::text FROM pending p WHERE p.worktree_id=worktrees.id) AS pending
FROM persistence_worktrees worktrees ORDER BY id LIMIT 16) w),'[]'::jsonb) AS roots,
(SELECT jsonb_build_object('outstanding_count',outstanding_count::text,'intent_bytes',intent_bytes::text,
'recovery_bytes',recovery_bytes::text,'lifetime_ids',lifetime_ids::text) FROM persistence_counters WHERE key='brain') AS counters`);
return { queue: (row?.queue ?? []).map(q => ({ state: diagnosticCode(q.state), blocked_reason: diagnosticCode(q.blocked_reason),
count: decimal(q.count), oldest_ms: number(Number(q.oldest_ms)) })),
roots: (row?.roots ?? []).map(r => ({ id: uuid(r.id), owner_host_id: uuid(r.owner_host_id), owner_epoch: decimal(r.owner_epoch),
state: diagnosticCode(r.state), heartbeat_at: timestamp(r.heartbeat_at), pending: decimal(r.pending) })),
counters: row?.counters ? Object.fromEntries(['outstanding_count', 'intent_bytes', 'recovery_bytes', 'lifetime_ids']
.map(key => [key, decimal(row.counters![key])])) : null };
}
export function retentionMetadata(scratch: string, databases: string[]) {
// Only names created by this harness can appear in an executable cleanup command.
if (!databases.every(name => /^gbrain_persistence_test_[0-9a-f]{32}$/.test(name))) throw new Error('Invalid retained fixture database name');
return { version: 1, scratch_root: scratch, databases,
cleanup: { instruction: 'After inspection and after all listed worker processes have stopped, remove only these retained synthetic fixtures. Keep config.json private; it contains the test connection URL.',
database_commands: databases.map(name => `psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -c 'DROP DATABASE IF EXISTS "${name}" WITH (FORCE)'`),
remove_scratch_argv: ['rm', '-rf', '--', scratch] } };
}

View File

@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import type { BrainEngine } from '../../src/core/engine.ts';
import { claimWorktree, type WorktreeBinding } from '../../src/core/persistence/ownership.ts';
import { admitWrite, type WriteAdmission } from '../../src/core/persistence/journal.ts';
import { publishMutation, type PreparedMutation } from '../../src/core/persistence/coordinator.ts';
import type { Principal, WriteAuthority, WriteRequest } from '../../src/core/persistence/model.ts';
import { assertSafeE2eDatabaseUrl } from '../../test/helpers/db-guard.ts';
import { activatePersistence } from '../../src/core/persistence/activation.ts';
import { localHostId, persistenceHome } from '../../src/core/persistence/identity.ts';
/** Synthetic fixtures only. The runner supplies a fresh datastore and scratch home. */
export interface HarnessConfig {
kind: 'pglite' | 'postgres'; root: string; dataDir: string; databaseUrl?: string;
hostId: string; seed: number; schedules: number; operations: number;
sourceIds: string[]; principalIds: string[];
poolSize?: number; seedReadProbe?: boolean;
}
/** Synthetic host switching is confined to the runner's fresh child home. */
export function selectFixtureHost(hostId: string): void {
const home = process.env.GBRAIN_PERSISTENCE_FIXTURE_HOME;
assert(home && home === process.env.GBRAIN_HOME, 'Fixture host identity requires the isolated runner home');
const relativeHome = relative(resolve(home), persistenceHome());
assert(relativeHome && !relativeHome.startsWith('..') && !relativeHome.startsWith('/'), 'Fixture identity must remain inside its scratch home');
mkdirSync(persistenceHome(), { recursive: true, mode: 0o700 });
const path = join(persistenceHome(), 'host.json');
if (!existsSync(path) || JSON.parse(readFileSync(path, 'utf8')).id !== hostId) {
const staged = `${path}.${randomUUID()}.fixture`;
writeFileSync(staged, JSON.stringify({ version: 1, id: hostId }), { mode: 0o600 });
renameSync(staged, path);
}
assert.equal(localHostId(), hostId);
}
export async function openEngine(config: HarnessConfig, initialize = false): Promise<BrainEngine> {
selectFixtureHost(config.hostId);
const engine: BrainEngine = config.kind === 'pglite' ? new PGLiteEngine() : new PostgresEngine();
if (engine instanceof PostgresEngine) {
assertSafeE2eDatabaseUrl(config.databaseUrl!);
await engine.connect({ database_url: config.databaseUrl!, poolSize: config.poolSize ?? 4 });
} else await engine.connect({ database_path: config.dataDir });
if (initialize) await engine.initSchema();
else assert.equal((await engine.executeRaw<{ enabled: boolean }>('SELECT enabled FROM persistence_brain WHERE singleton=1'))[0].enabled,
true, 'Every stress worker must exercise activated managed persistence');
return engine;
}
export async function initializeFixtures(engine: BrainEngine, config: HarnessConfig): Promise<void> {
for (let i = 0; i < config.sourceIds.length; i++) {
const root = join(config.root, `source-${i}`); mkdirSync(root, { recursive: true });
await engine.executeRaw('INSERT INTO sources(id,name,local_path) VALUES($1,$1,$2)', [config.sourceIds[i], root]);
await claimWorktree(engine, config.sourceIds[i], root, config.hostId);
}
if (config.seedReadProbe) for (const sourceId of config.sourceIds.slice(0, 2)) {
await engine.putPage('rls-probe', { type: 'note', title: 'RLS fixture', compiled_truth: sourceId, timeline: '', frontmatter: {} }, { sourceId });
}
assert.equal((await activatePersistence(engine, { confirmQuiesced: true })).enabled, true);
for (const id of config.principalIds) await engine.executeRaw(`INSERT INTO persistence_local_writers
(id,lane,credential_hash,grant_ceiling) VALUES($1::uuid,'cli',$2,$3::text::jsonb)`,
[id, `synthetic-${id}`, JSON.stringify({ sourceIds: config.sourceIds, scopes: ['read', 'write'], operations: null, slugPrefixes: null })]);
}
export interface FixtureSource { id: string; incarnation: string; binding: WorktreeBinding; root: string; }
export async function fixtures(engine: BrainEngine, config: HarnessConfig): Promise<FixtureSource[]> {
const result: FixtureSource[] = [];
for (let i = 0; i < config.sourceIds.length; i++) {
const [source] = await engine.executeRaw<{ incarnation: string }>('SELECT incarnation FROM sources WHERE id=$1', [config.sourceIds[i]]);
const [binding] = await engine.executeRaw<WorktreeBinding>(`SELECT s.*,w.owner_host_id,w.owner_epoch,w.state,
h.local_path,h.coordination_path FROM persistence_source_bindings s
JOIN persistence_worktrees w ON w.id=s.worktree_id
JOIN persistence_host_bindings h ON h.worktree_id=w.id AND h.host_id=$2::uuid WHERE s.source_id=$1`, [config.sourceIds[i], config.hostId]);
result.push({ id: config.sourceIds[i], incarnation: source.incarnation, binding, root: join(config.root, `source-${i}`) });
}
return result;
}
export function admission(config: HarnessConfig, source: FixtureSource, slug: string, content: string,
principalIndex = 0, options: Partial<WriteAdmission> = {}): WriteAdmission {
const principal: Principal = { kind: 'local_cli', id: config.principalIds[principalIndex] };
const authority: WriteAuthority = { version: 1, principal, remote: false, sourceId: source.id,
sourceIncarnation: source.incarnation, scopes: ['read', 'write'], operations: null, slugPrefixes: null };
return { principal, authority, sourceId: source.id, sourceIncarnation: source.incarnation,
operation: 'put_page', slug, pageId: null, requestId: randomUUID(),
worktreeId: source.binding.worktree_id, topologyGeneration: source.binding.topology_generation,
callerIntent: { content }, intent: { content }, ...options };
}
export function prepared(row: WriteRequest, sources: FixtureSource[], observedRevision: string | null = null,
file = false): PreparedMutation {
const source = sources.find(s => s.id === row.source_id)!;
const content = String(row.intent!.content);
return { observedRevision,
...(file ? { file: { root: source.root, path: join(source.root, `${row.slug}.md`), content } } : {}),
apply: async tx => {
await tx.putPage(row.slug, { type: 'note', title: row.slug, compiled_truth: content,
timeline: `timeline:${content}`, frontmatter: {} }, { sourceId: source.id });
await tx.addTag(row.slug, `tag:${content}`, { sourceId: source.id });
return { status: 'written' };
} };
}
export async function publish(engine: BrainEngine, row: WriteRequest, sources: FixtureSource[], config: HarnessConfig): Promise<WriteRequest> {
return publishMutation(engine, row, prepared(row, sources), config.hostId);
}
/** Derive all counters from durable rows, independently of update statements. */
export async function assertConservation(engine: BrainEngine): Promise<void> {
const rows = await engine.executeRaw<Record<string, string | number>>(`WITH r AS (
SELECT *,state IN ('queued','running','recovering') AS pending FROM persistence_requests
), expected AS (
SELECT 'brain' AS key,COUNT(*) FILTER(WHERE pending) AS outstanding_count,
COALESCE(SUM(intent_bytes) FILTER(WHERE pending),0) AS intent_bytes,COUNT(*) AS lifetime_ids,
COALESCE(SUM(terminal_reservation),0) AS terminal_bytes,COALESCE(SUM(recovery_bytes),0) AS recovery_bytes FROM r
UNION ALL SELECT 'principal:'||principal_kind||':'||principal_id,
COUNT(*) FILTER(WHERE pending),COALESCE(SUM(intent_bytes) FILTER(WHERE pending),0),COUNT(*),SUM(terminal_reservation),0
FROM r GROUP BY principal_kind,principal_id
UNION ALL SELECT 'worktree:'||worktree_id::text,0,0,0,0,SUM(recovery_bytes)
FROM r WHERE worktree_id IS NOT NULL GROUP BY worktree_id
) SELECT COALESCE(c.key,e.key) AS key,
COALESCE(c.outstanding_count,0)-COALESCE(e.outstanding_count,0) AS outstanding_count,
COALESCE(c.intent_bytes,0)-COALESCE(e.intent_bytes,0) AS intent_bytes,
COALESCE(c.lifetime_ids,0)-COALESCE(e.lifetime_ids,0) AS lifetime_ids,
COALESCE(c.terminal_bytes,0)-COALESCE(e.terminal_bytes,0) AS terminal_bytes,
COALESCE(c.recovery_bytes,0)-COALESCE(e.recovery_bytes,0) AS recovery_bytes
FROM persistence_counters c FULL JOIN expected e ON e.key=c.key`);
for (const row of rows) for (const key of ['outstanding_count', 'intent_bytes', 'lifetime_ids', 'terminal_bytes', 'recovery_bytes']) {
assert.equal(Number(row[key]), 0, `${row.key} ${key} must equal durable request accounting`);
}
}
export async function assertCommittedSnapshot(engine: BrainEngine, row: WriteRequest): Promise<void> {
assert.equal(row.state, 'committed');
const snapshot = await engine.readPageSnapshot(row.slug, { sourceId: row.source_id });
assert(snapshot);
assert.equal(snapshot.revision, row.outcome!.revision);
assert.equal(snapshot.page.compiled_truth, String(row.intent!.content));
assert.equal(snapshot.page.timeline, `timeline:${row.intent!.content}`);
assert(snapshot.tags.includes(`tag:${row.intent!.content}`));
}
export function random(seed: number): () => number {
let state = seed >>> 0;
return () => { state += 0x6D2B79F5; let n = Math.imul(state ^ state >>> 15, state | 1);
n ^= n + Math.imul(n ^ n >>> 7, n | 61); return ((n ^ n >>> 14) >>> 0) / 4294967296; };
}
export function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>(r => { resolve = r; });
return { promise, resolve };
}
export function distribution(values: number[]) {
const sorted = values.toSorted((a, b) => a - b);
const at = (p: number) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))] ?? 0;
return { count: values.length, p50_ms: at(.5), p95_ms: at(.95), p99_ms: at(.99), max_ms: at(1) };
}
export async function timedAdmission(engine: BrainEngine, input: WriteAdmission, timings: number[]): Promise<WriteRequest> {
const started = performance.now(); const row = await admitWrite(engine, input); timings.push(performance.now() - started); return row;
}

View File

@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import type { PostgresEngine } from '../../src/core/postgres-engine.ts';
import { publicationConcurrency } from '../../src/core/persistence/pool-capacity.ts';
import { admitWrite, claimNextWrite, renewWriteClaim } from '../../src/core/persistence/journal.ts';
import { publishMutation } from '../../src/core/persistence/coordinator.ts';
import { PersistenceConsumer } from '../../src/core/persistence/consumer.ts';
import { acceptWriterTransfer, getWorktreeBinding, prepareWriterTransfer } from '../../src/core/persistence/ownership.ts';
import { tryAcquireNativeLock } from '../../src/core/persistence/native-lock.ts';
import { admission, assertCommittedSnapshot, assertConservation, deferred, fixtures, openEngine, prepared, selectFixtureHost, type HarnessConfig } from './harness.ts';
export interface RuntimeCase extends HarnessConfig { rls: boolean; dual: boolean; role: string; route: 'direct' | 'pgbouncer'; }
async function bounded<T>(work: Promise<T>, label: string, timeoutMs = 10_000): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try { return await Promise.race([work, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} did not make progress`)), timeoutMs); })]); }
finally { clearTimeout(timer); }
}
export async function runtimeCase(config: RuntimeCase) {
const engine = await openEngine(config) as PostgresEngine; const sources = await fixtures(engine, config);
const releases: (() => void)[] = []; const holds: Promise<unknown>[] = []; const errors: string[] = [];
try {
assert.equal(engine.sql.options.max, config.poolSize);
assert.equal(engine.connectionManager!.isDualPoolActive(), config.dual);
const direct = await engine.connectionManager!.ddl();
assert.equal(direct === engine.sql, !config.dual);
if (config.dual) assert.equal(direct.options.max, 1, 'one direct control connection is sufficient');
const a = admission(config, sources[0], 'capacity', 'capacity-value'); await admitWrite(engine, a);
if (config.poolSize === 1) {
assert.equal(publicationConcurrency(engine), 0);
const consumer = new PersistenceConsumer(engine, { engine: 'postgres' }, async (_e, row) => prepared(row, sources, null, true),
{ hostId: config.hostId, onError: error => errors.push(String(error)) });
await consumer.tick(); await consumer.stop();
const [row] = await engine.executeRaw<any>('SELECT * FROM persistence_requests WHERE request_id=$1::uuid', [a.requestId]);
assert.equal(row.state, 'queued'); assert.equal(row.blocked_reason, 'writer_pool_capacity');
assert.equal(await engine.getPage(a.slug, { sourceId: a.sourceId }), null);
assert.equal(existsSync(join(sources[0].root, 'capacity.md')), false);
} else {
// This is the production bulk-reservation API, including direct-size-one fallback.
for (let i = 0; i < config.poolSize! - 1; i++) {
const acquired = deferred(); const release = deferred(); releases.push(release.resolve);
holds.push(engine.withReservedConnection(async connection => { await connection.executeRaw('SELECT 1'); acquired.resolve(); await release.promise; }));
await bounded(acquired.promise, 'bulk reservation');
}
await assert.rejects(engine.withReservedConnection(async () => {}), { code: 'writer_pool_capacity' });
assert.equal((await bounded(engine.executeRawDirect<{ n: number }>('SELECT 1 AS n'), 'direct control read'))[0].n, 1);
const claim = await bounded(claimNextWrite(engine, config.hostId), 'claim with saturated bulk budget'); assert(claim);
assert.equal(await bounded(renewWriteClaim({ executeRaw: engine.executeRawDirect.bind(engine) }, claim.id, claim.execution_token!), 'claim renewal'), true);
const queued = await bounded(publishMutation(engine, claim, prepared(claim, sources, null, true), config.hostId), 'publication capacity refusal');
assert.equal(queued.state, 'queued'); assert.equal(queued.blocked_reason, 'writer_pool_capacity');
assert.equal(existsSync(join(sources[0].root, 'capacity.md')), false);
for (const release of releases) release(); await Promise.all(holds);
const retry = await claimNextWrite(engine, config.hostId); assert(retry); assert.equal(retry.id, claim.id);
await assertCommittedSnapshot(engine, await publishMutation(engine, retry, prepared(retry, sources, null, true), config.hostId));
}
assert.deepEqual(errors, []); await assertConservation(engine);
// Probe actual RLS under a non-superuser/non-bypass role, through scoped reads.
await engine.executeRaw(`GRANT USAGE ON SCHEMA public TO ${config.role}`);
await engine.executeRaw(`GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${config.role}`);
for (const table of ['sources', 'tags', 'fact_withdrawals', 'slug_aliases']) {
await engine.executeRaw(`CREATE POLICY persistence_fixture_read ON ${table} FOR SELECT TO ${config.role} USING (true)`);
}
await engine.executeRaw(`CREATE POLICY persistence_fixture_scope ON pages FOR SELECT TO ${config.role}
USING (current_setting('app.scopes',true)='*' OR source_id=ANY(string_to_array(current_setting('app.scopes',true),',')))`);
await engine.executeRaw(`ALTER TABLE pages ${config.rls ? 'ENABLE' : 'DISABLE'} ROW LEVEL SECURITY`);
await engine.transaction(async tx => {
await tx.executeRaw(`SET LOCAL ROLE ${config.role}`);
const [role] = await tx.executeRaw<{ rolsuper: boolean; rolbypassrls: boolean }>('SELECT rolsuper,rolbypassrls FROM pg_roles WHERE rolname=current_user');
assert.equal(role.rolsuper, false); assert.equal(role.rolbypassrls, false);
await tx.executeRaw("SELECT set_config('app.scopes',$1,true)", [sources[0].id]);
const visible = await tx.executeRaw<{ source_id: string }>("SELECT source_id FROM pages WHERE slug='rls-probe' ORDER BY source_id");
assert.equal(visible.length, config.rls ? 1 : 2, 'RLS probe must enforce the configured table policy');
const snapshot = await tx.readPageSnapshot('rls-probe', { sourceId: sources[1].id }); assert(snapshot);
assert.equal(snapshot.page.compiled_truth, sources[1].id);
const [scope] = await tx.executeRaw<{ value: string }>("SELECT current_setting('app.scopes') AS value");
assert.equal(scope.value, sources[0].id, 'nested scoped read must restore the enclosing transaction setting');
});
return { route: config.route, rls: config.rls, ordinary_pool: config.poolSize, direct_pool: config.dual ? 1 : null,
dual_pool: config.dual, actual_rls_role: true, capacity: config.poolSize === 1 ? 'queued_with_guidance' : 'committed_after_bulk_drain',
control_progress: true, counters_conserved: true };
} finally { for (const release of releases) release(); await Promise.allSettled(holds); await engine.disconnect(); }
}
export async function ownershipCases(config: HarnessConfig) {
const engine = await openEngine(config); const sources = await fixtures(engine, config); const source = sources[0];
const successor = randomUUID(); const successorRoot = join(config.root, 'successor'); const originalPath = join(source.root, 'owner.md');
writeFileSync(originalPath, 'original'); mkdirSync(successorRoot); cpSync(source.root, successorRoot, { recursive: true });
try {
const input = admission(config, source, 'owner', 'replacement'); const accepted = await admitWrite(engine, input);
assert.equal(await claimNextWrite(engine, successor), null, 'nonowner accepts durable work but cannot execute it');
const offer = await prepareWriterTransfer(engine, source.id, config.hostId);
selectFixtureHost(successor);
writeFileSync(join(successorRoot, 'owner.md'), 'wrong-checkout');
await assert.rejects(acceptWriterTransfer(engine, source.id, successorRoot, offer.owner_epoch, offer.manifest.digest, successor), { code: 'writer_manifest_mismatch' });
writeFileSync(join(successorRoot, 'owner.md'), 'original');
await acceptWriterTransfer(engine, source.id, successorRoot, offer.owner_epoch, offer.manifest.digest, successor);
assert.equal(await claimNextWrite(engine, config.hostId), null, 'returning old owner cannot claim queued work');
const row = await claimNextWrite(engine, successor); assert(row); assert.equal(row.id, accepted.id);
const rebound = await getWorktreeBinding(engine, source.id, successor); assert(rebound);
assert.equal(Number(rebound.owner_epoch), Number(offer.owner_epoch) + 1);
const movedSources = sources.map(s => s.id === source.id ? { ...s, root: successorRoot, binding: rebound } : s);
const stale = await publishMutation(engine, row, prepared(row, sources, null, true), config.hostId);
assert.equal(stale.state, 'queued'); assert.equal(readFileSync(originalPath, 'utf8'), 'original');
const next = await claimNextWrite(engine, successor); assert(next);
await assertCommittedSnapshot(engine, await publishMutation(engine, next, prepared(next, movedSources, null, true), successor));
assert.equal(readFileSync(join(successorRoot, 'owner.md'), 'utf8'), 'replacement'); assert.equal(readFileSync(originalPath, 'utf8'), 'original');
// The external coordination inode survives directory replacement, while
// the physical-root stamp refuses publication into the substituted copy.
const coordinationPath = rebound.coordination_path!;
const held = await tryAcquireNativeLock(coordinationPath); assert(held);
try {
renameSync(successorRoot, `${successorRoot}-retired`); mkdirSync(successorRoot);
cpSync(`${successorRoot}-retired`, successorRoot, { recursive: true });
assert.equal(await tryAcquireNativeLock(coordinationPath), null, 'replacing the root must not create a second kernel lock');
} finally { await held.release(); }
assert.equal((await getWorktreeBinding(engine, source.id, successor))!.coordination_path, coordinationPath);
await admitWrite(engine, admission(config, source, 'after-replacement', 'preserved-owner'));
const replacement = await claimNextWrite(engine, successor); assert(replacement);
const refused = await publishMutation(engine, replacement, prepared(replacement, movedSources, null, true), successor);
assert.equal(refused.state, 'failed'); assert.equal(refused.error_code, 'recovery_required');
assert.equal(existsSync(join(successorRoot, 'after-replacement.md')), false);
assert.equal(readFileSync(join(successorRoot, 'owner.md'), 'utf8'), 'replacement');
// Restore the original inode without discarding the unexpected copy.
const restoreLock = await tryAcquireNativeLock(coordinationPath); assert(restoreLock);
try {
renameSync(successorRoot, `${successorRoot}-substituted`);
renameSync(`${successorRoot}-retired`, successorRoot);
} finally { await restoreLock.release(); }
await admitWrite(engine, admission(config, source, 'after-restoration', 'preserved-owner'));
const restored = await claimNextWrite(engine, successor); assert(restored);
await assertCommittedSnapshot(engine, await publishMutation(engine, restored, prepared(restored, movedSources, null, true), successor));
const oldSource = sources[1]; const obsolete = admission(config, oldSource, 'recreated', 'obsolete'); await admitWrite(engine, obsolete);
await assert.rejects(engine.executeRaw('DELETE FROM sources WHERE id=$1', [oldSource.id]), /writer_coordinator_required/);
// Explicit database-administrator fault injection: ordinary topology writes
// remain refused, while incarnation checks still fence a privileged recreate.
await engine.transaction(async tx => {
await tx.executeRaw("SELECT set_config('gbrain.topology_change','on',true),set_config('gbrain.write_sources',$1,true)", [JSON.stringify([oldSource.id])]);
await tx.executeRaw('DELETE FROM sources WHERE id=$1', [oldSource.id]);
await tx.executeRaw('INSERT INTO sources(id,name,local_path) VALUES($1,$1,$2)', [oldSource.id, oldSource.root]);
});
selectFixtureHost(config.hostId);
const obsoleteClaim = await claimNextWrite(engine, config.hostId); assert(obsoleteClaim);
const rejected = await publishMutation(engine, obsoleteClaim, prepared(obsoleteClaim, sources), config.hostId);
assert.equal(rejected.state, 'conflict'); assert.equal(rejected.error_code, 'source_changed');
assert.equal(await engine.getPage('recreated', { sourceId: oldSource.id }), null);
await assertConservation(engine);
return { nonowner_admission: true, manifest_mismatch_refused: true, owner_transfer: true, stale_owner_refused: true,
root_replacement_retains_lock_path: true, substituted_root_refused: true, original_inode_restoration_resumes: true,
ordinary_topology_write_refused: true, source_incarnation_fenced: true,
source_recreate_fault: 'fixture database administrator transaction with explicit topology and source capability', counters_conserved: true };
} finally { await engine.disconnect(); }
}

View File

@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import postgres from 'postgres';
import { assertSafeE2eDatabaseUrl } from '../../test/helpers/db-guard.ts';
import { spawnWorker } from './validate.ts';
import type { RuntimeCase } from './matrix-cases.ts';
export async function runRuntimeMatrix(options: { directUrl: string; pooledUrl: string; manifest?: string }) {
assertSafeE2eDatabaseUrl(options.directUrl); assertSafeE2eDatabaseUrl(options.pooledUrl);
const scratch = mkdtempSync(join(tmpdir(), 'gbrain-persistence-matrix-')); const home = join(scratch, 'home'); mkdirSync(home);
// Other E2E shards disconnect clients of their shared test database between
// files. Keep only CREATE/DROP DATABASE/ROLE control on the maintenance DB;
// all engine activity uses the fresh test databases recorded below.
const controlUrl = new URL(options.directUrl); controlUrl.pathname = '/postgres';
const admin = postgres(controlUrl.toString(), { max: 1, onnotice() {} });
const databases: string[] = []; const roles: string[] = []; const children: ReturnType<typeof spawnWorker>[] = [];
const manifest: Record<string, any> = { version: 1, runtime: `bun-${Bun.version}`, platform: process.platform,
architecture: process.arch, managed_persistence: true, started_at: new Date().toISOString(), status: 'running', cases: [], ownership: null };
function start(path: string, role: string, env: Record<string, string> = {}) {
const child = spawnWorker(path, home, role, [], env); children.push(child); return child;
}
try {
for (const route of ['direct', 'pgbouncer'] as const) for (const rls of [false, true]) for (const poolSize of [1, 2, 3]) for (const dual of [false, true]) {
const token = randomUUID().replaceAll('-', ''); const database = `gbrain_persistence_test_${token}`; const role = `gbrain_persistence_test_role_${token}`;
await admin.unsafe(`CREATE DATABASE ${database}`); databases.push(database);
await admin.unsafe(`CREATE ROLE ${role} NOLOGIN NOSUPERUSER NOBYPASSRLS`); roles.push(role);
const direct = new URL(options.directUrl); direct.pathname = `/${database}`;
const pooled = new URL(options.pooledUrl); pooled.pathname = `/${database}`;
const root = join(scratch, token); mkdirSync(root);
const config: RuntimeCase = { kind: 'postgres', root, dataDir: join(root, 'unused'), databaseUrl: direct.toString(),
hostId: randomUUID(), seed: 5105, schedules: 0, operations: 0, poolSize: 3, seedReadProbe: true,
sourceIds: Array.from({ length: 4 }, (_, i) => `matrix-test-${i}`), principalIds: Array.from({ length: 4 }, () => randomUUID()), route, rls, dual, role };
const path = join(root, 'config.json'); writeFileSync(path, JSON.stringify(config), { mode: 0o600 });
await start(path, 'initialize').done();
config.databaseUrl = route === 'pgbouncer' ? pooled.toString() : direct.toString(); config.poolSize = poolSize;
writeFileSync(path, JSON.stringify(config), { mode: 0o600 });
const result = await start(path, 'runtime-matrix', { GBRAIN_RLS_SCOPE_BINDING: rls ? '1' : '0',
GBRAIN_DISABLE_DIRECT_POOL: dual ? '0' : '1', GBRAIN_DIRECT_DATABASE_URL: direct.toString(), GBRAIN_DIRECT_POOL_SIZE: '1',
// Nonstandard fixture pooler port: use the documented explicit override.
GBRAIN_PREPARE: route === 'pgbouncer' ? 'false' : 'true' }).done();
manifest.cases.push(result.result);
process.stderr.write(`[persistence matrix] ${route}, RLS=${rls}, ordinary=${poolSize}, dual=${dual}: passed\n`);
if (manifest.ownership === null) {
// Another fresh database keeps queued pool-size-one work out of transfer assertions.
const ownershipDb = `gbrain_persistence_test_${randomUUID().replaceAll('-', '')}`;
await admin.unsafe(`CREATE DATABASE ${ownershipDb}`); databases.push(ownershipDb);
const ownerUrl = new URL(options.directUrl); ownerUrl.pathname = `/${ownershipDb}`;
const ownerRoot = join(scratch, 'ownership'); mkdirSync(ownerRoot);
const owner = { ...config, root: ownerRoot, databaseUrl: ownerUrl.toString(), poolSize: 3 };
const ownerPath = join(ownerRoot, 'config.json'); writeFileSync(ownerPath, JSON.stringify(owner), { mode: 0o600 });
await start(ownerPath, 'initialize').done(); manifest.ownership = (await start(ownerPath, 'ownership-matrix').done()).result;
}
}
assert.equal(manifest.cases.length, 24); manifest.status = 'passed'; manifest.full_gate = true; return manifest;
} catch (error) { manifest.status = 'failed'; manifest.full_gate = false; manifest.failure = String(error); throw error; }
finally {
await Promise.allSettled(children.map(child => child.kill()));
for (const database of databases) await admin.unsafe(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`);
for (const role of roles) await admin.unsafe(`DROP ROLE IF EXISTS ${role}`); await admin.end();
manifest.finished_at = new Date().toISOString();
if (options.manifest) { mkdirSync(dirname(resolve(options.manifest)), { recursive: true }); writeFileSync(options.manifest, `${JSON.stringify(manifest, null, 2)}\n`); }
rmSync(scratch, { recursive: true, force: true });
}
}
if (import.meta.main) {
const manifest = process.argv.find(arg => arg.startsWith('--manifest='))?.slice('--manifest='.length) ?? '.context/persistence-runtime-matrix.json';
assert(process.env.DATABASE_URL && process.env.GBRAIN_PGBOUNCER_URL, 'DATABASE_URL and GBRAIN_PGBOUNCER_URL are required; mandatory matrix cases cannot skip');
process.stdout.write(`${JSON.stringify(await runRuntimeMatrix({ directUrl: process.env.DATABASE_URL, pooledUrl: process.env.GBRAIN_PGBOUNCER_URL, manifest }), null, 2)}\n`);
}

View File

@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { availableParallelism, cpus, loadavg, tmpdir, totalmem } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import postgres from 'postgres';
import { assertSafeE2eDatabaseUrl } from '../../test/helpers/db-guard.ts';
import { childEnvironment } from './validate.ts';
import { summarizeReadRuns } from './read-metrics.ts';
import type { ReadWorkloadOptions } from './read-workload.ts';
export async function runReadPerformance(options: ReadWorkloadOptions & { manifest?: string; thresholdPct?: number }) {
const engine = options.engine ?? 'pglite';
const requested = { pages: options.pages ?? 500, queries: options.queries ?? 200, writers: options.writers ?? 4,
writesPerWriter: options.writesPerWriter ?? 25, runs: 3, thresholdPct: options.thresholdPct ?? 50 };
for (const [key, value] of Object.entries(requested)) assert(Number.isSafeInteger(value) && value > 0, `Invalid ${key}`);
const scratch = mkdtempSync(join(tmpdir(), 'gbrain-read-performance-'));
const runs: Record<string, any>[] = []; const databases: string[] = [];
let admin: ReturnType<typeof postgres> | undefined;
function sourceHashes() {
return Object.fromEntries(['scripts/persistence/performance.ts', 'scripts/persistence/read-workload.ts', 'scripts/persistence/read-metrics.ts', 'scripts/persistence/read-admission.ts',
'tests/heavy/_read_latency_workload.ts', 'src/core/persistence/coordinator.ts', 'src/core/persistence/journal.ts',
'src/core/persistence/consumer.ts', 'src/core/persistence/activation.ts', 'src/core/persistence/page-mutations.ts', 'src/core/search/hybrid.ts',
'src/core/pglite-engine.ts', 'src/core/postgres-engine.ts'].map(file =>
[file, createHash('sha256').update(readFileSync(resolve(import.meta.dir, '../..', file))).digest('hex')]));
}
const manifest: Record<string, any> = { version: 1, engine, managed_persistence: true, runtime: `bun-${Bun.version}`, platform: process.platform,
architecture: process.arch, started_at: new Date().toISOString(), status: 'running', requested,
storage: engine === 'pglite' ? 'in-memory PGLite (original heavy workload)' : 'fresh PostgreSQL database per run',
environment: { logical_cpus: availableParallelism(), cpu_model: cpus()[0]?.model, memory_bytes: totalmem(), load_average_at_start: loadavg() },
source_hashes: sourceHashes() };
try {
if (engine === 'postgres') { assert(options.databaseUrl, 'Explicit test DATABASE_URL is required');
assertSafeE2eDatabaseUrl(options.databaseUrl); admin = postgres(options.databaseUrl, { max: 1, onnotice() {} }); }
for (let i = 0; i < requested.runs; i++) {
assert.deepEqual(sourceHashes(), manifest.source_hashes, 'Workload source changed between measurement runs');
const home = join(scratch, `run-${i}`); mkdirSync(home);
const env = { ...childEnvironment(home), BRAIN_PAGES: String(requested.pages), NUM_QUERIES: String(requested.queries),
NUM_WRITERS: String(requested.writers), WRITES_PER_WRITER: String(requested.writesPerWriter),
GBRAIN_TEST_PERF_ENGINE: engine, STRICT: '0', THRESHOLD_PCT: String(requested.thresholdPct),
GBRAIN_PGLITE_CLOSE_WATCHDOG_MS: '20000', GBRAIN_PGLITE_CLOSE_WATCHDOG_GRACE_MS: '10000' };
if (admin) {
const name = `gbrain_persistence_test_${randomUUID().replaceAll('-', '')}`; await admin.unsafe(`CREATE DATABASE ${name}`); databases.push(name);
const url = new URL(options.databaseUrl!); url.pathname = `/${name}`; Object.assign(env, { DATABASE_URL: url.toString() });
}
process.stderr.write(`[read performance] ${engine}: independent run ${i + 1}/3\n`);
const child = Bun.spawn([process.execPath, '--no-env-file', resolve(import.meta.dir, '../../tests/heavy/_read_latency_workload.ts')],
{ env, stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' });
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 900_000);
try {
const [output, code] = await Promise.all([new Response(child.stdout).text(), child.exited]);
let result: Record<string, any> | undefined;
for (const line of output.split('\n')) { try { const value = JSON.parse(line); if (typeof value.ok === 'boolean') result = value; } catch { /* schema diagnostics */ } }
assert.deepEqual(sourceHashes(), manifest.source_hashes, 'Workload source changed during measurement');
runs.push(result ?? { ok: false, error: `Workload produced no result (exit=${code}, timeout=${timedOut})`, diagnostic_tail: output.slice(-4000) });
if (code !== 0 || timedOut) { runs.at(-1)!.ok = false; runs.at(-1)!.process_error = `exit=${code}, timeout=${timedOut}`; }
process.stderr.write(`[read performance] run ${i + 1}: valid=${runs.at(-1)!.ok}, idle_p99=${result?.phase_a?.p99_ms}, ` +
`loaded_p99=${result?.phase_b?.p99_ms}, overlap=${result?.overlap_pct}, writes=${result?.phase_b?.writes_completed}\n`);
} finally { clearTimeout(timer); if (child.exitCode === null) child.kill('SIGKILL'); await child.exited; }
}
Object.assign(manifest, summarizeReadRuns(runs, requested.thresholdPct));
manifest.status = manifest.verdict === 'pass' ? 'passed' : 'failed';
manifest.full_gate = requested.pages === 500 && requested.queries === 200 && requested.writers === 4 &&
requested.writesPerWriter === 25 && requested.thresholdPct === 50 && manifest.verdict === 'pass';
} catch (error) { manifest.status = 'failed'; manifest.verdict = 'fail'; manifest.ok = false; manifest.full_gate = false;
manifest.error = String(error); manifest.runs = runs; }
finally {
if (admin) { for (const database of databases) await admin.unsafe(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`); await admin.end(); }
manifest.finished_at = new Date().toISOString();
if (options.manifest) { mkdirSync(dirname(resolve(options.manifest)), { recursive: true }); writeFileSync(options.manifest, `${JSON.stringify(manifest, null, 2)}\n`); }
rmSync(scratch, { recursive: true, force: true });
}
return manifest;
}
if (import.meta.main) {
const args = new Map(process.argv.slice(2).map(arg => { const [key, ...value] = arg.replace(/^--/, '').split('='); return [key, value.join('=')]; }));
for (const key of args.keys()) assert(['engine', 'manifest', 'pages', 'queries', 'writers', 'writes-per-writer', 'threshold', 'informational'].includes(key), `Unknown option: ${key}`);
const engine = args.get('engine') ?? 'pglite'; assert(engine === 'pglite' || engine === 'postgres');
const result = await runReadPerformance({ engine, databaseUrl: process.env.DATABASE_URL, pages: Number(args.get('pages') ?? 500),
queries: Number(args.get('queries') ?? 200), writers: Number(args.get('writers') ?? 4), writesPerWriter: Number(args.get('writes-per-writer') ?? 25),
thresholdPct: Number(args.get('threshold') ?? 50), manifest: args.get('manifest') ?? `.context/persistence-read-${engine}.json` });
process.stdout.write(`${JSON.stringify(result)}\n`);
process.exitCode = !result.ok || !args.has('informational') && result.verdict !== 'pass' ? 1 : 0;
}

View File

@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import type { BrainEngine } from '../../src/core/engine.ts';
/** Record every phase identically; discard only completed warmup observations. */
export class WriteTimingRecorder {
readonly admissionMs: number[] = [];
readonly completionMs: number[] = [];
readonly intervals: [number, number][] = [];
private readonly starts = new Map<string, number>();
private readonly admissions = new Map<string, number>();
private readonly completions = new Set<string>();
start(requestId: string, at: number): void {
assert(!this.starts.has(requestId), 'workload writes require distinct request IDs');
this.starts.set(requestId, at);
}
admitted(requestId: string, at: number): void {
const started = this.starts.get(requestId);
if (started === undefined || this.admissions.has(requestId)) return;
assert(at >= started, 'durable admission cannot precede public invocation');
this.admissions.set(requestId, at); this.admissionMs.push(at - started);
}
complete(requestId: string, at: number): void {
const started = this.starts.get(requestId); const admitted = this.admissions.get(requestId);
assert(started !== undefined && admitted !== undefined && admitted >= started && admitted <= at,
'a completed public write must have an earlier observed durable admission');
assert(!this.completions.has(requestId), 'a terminal receipt may be counted only once');
this.completions.add(requestId); this.intervals.push([started, at]); this.completionMs.push(at - started);
}
reset(): void {
assert(this.starts.size === this.completions.size, 'cannot discard an unfinished warmup write');
this.starts.clear(); this.admissions.clear(); this.completions.clear();
this.admissionMs.length = 0; this.completionMs.length = 0; this.intervals.length = 0;
}
}
/**
* Observe the resolved top-level transaction used by journal admission. A
* transaction callback or nested savepoint can still roll back, so neither
* constitutes durable admission. Keep this wrapper in the harness: the public
* mutation handler and its transaction implementation remain unchanged.
*/
export function observeAdmissionTransactions(engine: BrainEngine,
observed: (requestId: string, completedAt: number) => void): BrainEngine {
const original = engine.transaction;
let wrapped: BrainEngine;
async function transaction<T>(this: BrainEngine, run: (tx: BrainEngine) => Promise<T>): Promise<T> {
const result = await original.call(this, run) as T;
if (this === wrapped && result && typeof result === 'object') {
const row = result as Record<string, unknown>;
if (row.state === 'queued' && typeof row.request_id === 'string') observed(row.request_id, performance.now());
}
return result;
}
// Route warmup and pressure calls through one stable observer without
// replacing engine methods. Transaction clones keep their own receiver
// and scoped connection; only the original wrapper may emit an observation.
wrapped = new Proxy(engine, { get(target, property, receiver) {
return property === 'transaction' ? transaction : Reflect.get(target, property, receiver);
} });
return wrapped;
}

View File

@@ -0,0 +1,27 @@
/** Measure the union of in-flight public mutations, including idle gaps. */
export function overlapPercent(start: number, end: number, intervals: [number, number][]): number {
if (end <= start) return 0;
const spans = intervals.map(([a, b]) => [Math.max(start, a), Math.min(end, b)] as const)
.filter(([a, b]) => b > a).sort((a, b) => a[0] - b[0]);
let covered = 0; let cursor = start;
for (const [a, b] of spans) { covered += Math.max(0, b - Math.max(cursor, a)); cursor = Math.max(cursor, b); }
return Math.min(100, 100 * covered / (end - start));
}
/** Three independent runs are mandatory; invalid work cannot pass a latency gate. */
export function summarizeReadRuns(runs: Record<string, any>[], thresholdPct = 50) {
const median = (values: number[]) => values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)];
const valid = runs.length === 3 && runs.every(run => run.ok && run.overlap_pct >= 90 &&
run.phase_b?.writes_completed > 0 && run.phase_b?.writes_committed_during_reads > 0 && run.phase_b?.writes_failed === 0 && run.phase_a?.queries_run > 0 &&
run.phase_a.queries_run === run.phase_b.queries_run &&
run.admission?.count === run.phase_b.writes_completed && run.commit?.count === run.phase_b.writes_completed &&
['phase_a', 'phase_b', 'admission', 'commit'].every(phase =>
['p50_ms', 'p95_ms', 'p99_ms'].every(key => Number.isFinite(run[phase][key]) && run[phase][key] > 0)));
const phase = (name: string) => Object.fromEntries(['p50_ms', 'p95_ms', 'p99_ms'].map(key =>
[key, median(runs.map(run => run[name]?.[key] ?? NaN))]));
const a = phase('phase_a'); const b = phase('phase_b'); const delta = 100 * (b.p99_ms / a.p99_ms - 1);
return { ok: valid, phase_a: a, phase_b: { ...b, writes_completed: runs.reduce((sum, run) => sum + (run.phase_b?.writes_completed ?? 0), 0) },
overlap_pct: Math.min(...runs.map(run => run.overlap_pct)), delta_p99_pct: delta, threshold_pct: thresholdPct,
verdict: valid && Number.isFinite(delta) && delta <= thresholdPct ? 'pass' : 'fail',
comparison: 'median of three loaded p99 values versus median of three idle p99 values on the same runner', runs };
}

View File

@@ -0,0 +1,152 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { platform } from 'node:os';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import type { OperationContext } from '../../src/core/ops/contract.ts';
import { operationsByName } from '../../src/core/operations.ts';
import { hybridSearch } from '../../src/core/search/hybrid.ts';
import { disposePersistenceConsumer } from '../../src/core/persistence/service.ts';
import { getWriteRequest } from '../../src/core/persistence/journal.ts';
import { readLocalWriter } from '../../src/core/persistence/identity.ts';
import { activatePersistence } from '../../src/core/persistence/activation.ts';
import { assertSafeE2eDatabaseUrl } from '../../test/helpers/db-guard.ts';
import { distribution } from './harness.ts';
import { overlapPercent } from './read-metrics.ts';
import { observeAdmissionTransactions, WriteTimingRecorder } from './read-admission.ts';
export interface ReadWorkloadOptions {
engine?: 'pglite' | 'postgres'; databaseUrl?: string; pages?: number; queries?: number; writers?: number; writesPerWriter?: number;
}
const queries = ['lorem ipsum', 'consequat', 'voluptatem', 'aspernatur', 'magna', 'reprehenderit', 'commodo',
'inventore', 'fixture page', 'section 1', 'section 2', 'page 100', 'doloremque', 'architecto', 'incididunt'];
function page(i: number, prefix: string) {
const pad = String(i).padStart(5, '0');
const body = `# ${prefix} Page ${i}\n\nDeterministic page for read-latency measurement. Body has stable text so search-index work is consistent run-to-run.\n\n` +
'Section 1: lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam quis nostrud exercitation ullamco laboris.\n\n' +
'Section 2: sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium totam rem aperiam eaque ipsa quae.\n\n' +
`Reference: ${prefix}-${pad}.`;
return { slug: `${prefix.toLowerCase()}/page-${pad}`, content: `---\ntype: note\ntitle: ${prefix} Page ${i}\n---\n${body}\n` };
}
/** Original heavy query corpus; all fixture and pressure writes use public put_page. */
export async function runReadLatencyWorkload(options: ReadWorkloadOptions = {}) {
const kind = options.engine ?? 'pglite'; const pages = options.pages ?? 500; const count = options.queries ?? 200;
const writerCount = options.writers ?? 4; const cap = 4 * (options.writesPerWriter ?? 25);
for (const [name, value] of Object.entries({ pages, queries: count, writers: writerCount, cap })) assert(Number.isSafeInteger(value) && value > 0, `Invalid workload ${name}`);
const at = performance.now(); const result: Record<string, any> = { ok: false, platform: platform(), engine: kind, runtime: `bun-${Bun.version}`,
phase_a: null, phase_b: null, overlap_pct: 0, write_path: 'public put_page handler', verdict: 'informational' };
let stop = false; let metricsTimer: ReturnType<typeof setInterval> | undefined;
let metricsWork: Promise<void> | undefined; let backgroundFailure: unknown;
const writers: Promise<void>[] = [];
const timings = new WriteTimingRecorder();
const { intervals, admissionMs, completionMs } = timings;
const engine = observeAdmissionTransactions(kind === 'postgres' ? new PostgresEngine() : new PGLiteEngine(), (requestId, now) => {
timings.admitted(requestId, now);
});
const samples: { at_ms: number; queue_count: number; queue_age_ms: number; recovery_bytes: number; rss_bytes: number; pool: unknown }[] = [];
// Production search can degrade when one lexical arm fails. A benchmark
// must not count that cheaper, partial read as a successful measurement.
const keyword = engine.searchKeyword; const titles = engine.searchTitles;
engine.searchKeyword = async function(query, opts) {
try { return await keyword.call(this, query, opts); } catch (error) { backgroundFailure = error; throw error; }
};
engine.searchTitles = async function(query, opts) {
try { return await titles.call(this, query, opts); } catch (error) { backgroundFailure = error; throw error; }
};
const put = operationsByName.put_page;
const ctx: OperationContext = { engine, config: { engine: kind }, sourceId: 'default', remote: false, dryRun: false,
logger: { info() {}, warn() {}, error() {} } };
async function write(i: number, prefix: string) {
const requestId = randomUUID(); timings.start(requestId, performance.now());
let receipt: Record<string, unknown>;
try { receipt = await put.handler(ctx, { ...page(i, prefix), request_id: requestId }) as Record<string, unknown>; }
catch (error) {
if ((error as { code?: string }).code !== 'write_pending') throw error;
const principal = { kind: 'local_cli' as const, id: (await readLocalWriter(engine, 'cli')).id };
const deadline = performance.now() + 120_000;
for (;;) {
const row = await getWriteRequest(engine, principal, requestId); assert(row, 'publicly accepted write disappeared');
if (row.state === 'committed') { receipt = row as unknown as Record<string, unknown>; break; }
assert(['queued', 'running', 'recovering'].includes(row.state), `public mutation failed: ${row.error_code}: ${row.error_message}`);
assert(performance.now() < deadline, 'public mutation did not complete'); await Bun.sleep(25);
}
}
assert.equal(receipt!.state, 'committed', 'pending receipt cannot count as a completed write');
timings.complete(requestId, performance.now());
}
try {
if (engine instanceof PostgresEngine) { assertSafeE2eDatabaseUrl(options.databaseUrl!); await engine.connect({ database_url: options.databaseUrl, poolSize: 4 }); }
else await engine.connect({});
await engine.initSchema();
assert.equal((await activatePersistence(engine, { confirmQuiesced: true })).enabled, true);
process.stderr.write(`[_read_latency] ${kind}: seeding ${pages} pages through public mutations\n`);
let seedIndex = 0;
await Promise.all(Array.from({ length: 4 }, async () => { for (;;) { const i = seedIndex++; if (i >= pages) return; await write(i, 'Fixture'); } }));
assert((await hybridSearch(engine, 'lorem ipsum', { limit: 10 })).length > 0, 'read fixture must contain searchable canonical projections');
async function readPhase() {
const timings: number[] = [];
for (let i = 0; i < count; i++) { if (backgroundFailure) throw backgroundFailure;
const started = performance.now(); await hybridSearch(engine, queries[i % queries.length], { limit: 10 });
if (backgroundFailure) throw backgroundFailure; timings.push(performance.now() - started);
// PGLite can resolve the whole read loop through microtasks. Give the
// resident consumer/renewal timers a turn between queries in BOTH
// phases; a queued request alone is not concurrent writer evidence.
await Bun.sleep(0);
}
return { ...distribution(timings), queries_run: timings.length };
}
result.phase_a = await readPhase();
// Exercise the identical observer during all warmup writes. Begin the
// measured phase with empty buffers, not a newly enabled closure branch.
timings.reset();
const sample = async () => {
const [row] = await engine.executeRaw<{ pending: number; age: string; recovery: string; database_sessions?: unknown }>(`SELECT
COUNT(*) FILTER(WHERE state IN ('queued','running','recovering'))::integer AS pending,
COALESCE(EXTRACT(EPOCH FROM (now()-MIN(created_at) FILTER(WHERE state IN ('queued','running','recovering'))))*1000,0)::text AS age,
COALESCE(SUM(recovery_bytes),0)::text AS recovery
${kind === 'postgres' ? `, (SELECT json_build_object('total', count(*), 'active', count(*) FILTER(WHERE state='active'),
'idle', count(*) FILTER(WHERE state='idle'), 'idle_in_transaction', count(*) FILTER(WHERE state='idle in transaction'))
FROM pg_stat_activity WHERE datname=current_database()) AS database_sessions` : ''} FROM persistence_requests`);
samples.push({ at_ms: performance.now() - at, queue_count: row.pending, queue_age_ms: Number(row.age), recovery_bytes: Number(row.recovery),
rss_bytes: process.memoryUsage().rss, pool: engine instanceof PostgresEngine ? {
tracked_subset: engine.getPoolDiagnostics(), database_sessions: row.database_sessions,
scope: 'fresh fixture database; active count includes this sampler; tracked gauges cover a SQL subset' } : null });
};
await sample();
metricsTimer = setInterval(() => { if (!metricsWork) metricsWork = sample().catch(error => { backgroundFailure = error; }).finally(() => { metricsWork = undefined; }); }, 250);
const queryStart = performance.now(); let completed = 0; let failed = 0;
for (let writer = 0; writer < writerCount; writer++) writers.push((async () => {
for (let i = 0; !stop && i < cap; i++) { await write(pages + writer * cap + i, `WriterW${writer}`); completed++; }
})().catch(error => { failed++; backgroundFailure = error; stop = true; }));
result.phase_b = await readPhase(); const queryEnd = performance.now(); stop = true; await Promise.all(writers);
if (backgroundFailure) throw backgroundFailure;
await metricsWork; await sample();
result.phase_b.writes_completed = completed; result.phase_b.writes_failed = failed;
result.phase_b.writes_committed_during_reads = intervals.filter(([, end]) => end <= queryEnd).length;
result.phase_b.writer_end_ms = Math.max(0, ...intervals.map(([, end]) => end - queryStart));
result.overlap_pct = overlapPercent(queryStart, queryEnd, intervals);
result.overlap_basis = 'union of actual public mutation intervals from invocation through terminal receipt';
result.query_scheduling = 'one event-loop yield between queries in both phases, excluded from individual query latency';
result.admission = distribution(admissionMs); result.commit = distribution(completionMs);
result.admission_basis = 'public invocation through resolved top-level queued journal transaction; nested savepoints excluded';
result.commit_basis = 'public invocation through observed terminal committed receipt';
assert.equal(admissionMs.length, completed, 'every completed write needs an observed durable admission');
result.metrics = samples; result.throughput_writes_per_second = completed * 1000 / (performance.now() - queryStart);
result.peak_queue_age_ms = Math.max(...samples.map(sample => sample.queue_age_ms));
result.peak_recovery_bytes = Math.max(...samples.map(sample => sample.recovery_bytes));
result.peak_rss_bytes = Math.max(...samples.map(sample => sample.rss_bytes));
for (const p of ['p50', 'p95', 'p99']) result[`delta_${p}_pct`] = 100 * (result.phase_b[`${p}_ms`] / result.phase_a[`${p}_ms`] - 1);
result.brain_page_count = Number((await engine.executeRaw<{ n: number }>('SELECT count(*)::integer AS n FROM pages'))[0].n);
assert(result.phase_b.writes_committed_during_reads > 0, 'actual writes must commit while reads are still running');
assert(result.overlap_pct >= 90, `insufficient sustained overlap: ${result.overlap_pct}%`);
result.ok = true;
} catch (error) { result.error = String(error); }
finally {
stop = true; clearInterval(metricsTimer); await Promise.allSettled(writers); await metricsWork;
try { await disposePersistenceConsumer(engine); await engine.disconnect(); }
catch (error) { result.ok = false; result.error = `${result.error ?? ''} shutdown: ${error}`; }
result.elapsed_ms = performance.now() - at;
}
return result;
}

View File

@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { admitWrite, claimNextWrite, getWriteRequest, getWriteRequestById, releaseUnpublishedClaim, renewWriteClaim } from '../../src/core/persistence/journal.ts';
import { cancelWriteRequest } from '../../src/core/persistence/control.ts';
import { publishMutation, recoverPublication, type PublicationHooks } from '../../src/core/persistence/coordinator.ts';
import { admission, assertCommittedSnapshot, assertConservation, deferred, distribution, fixtures, openEngine,
prepared, publish, random, timedAdmission, type HarnessConfig } from './harness.ts';
import type { WriteRequest } from '../../src/core/persistence/model.ts';
export const SCHEDULE_CASES = ['duplicate_intent', 'create_conflict', 'cancel_publish', 'publication_rollback',
'stale_claim', 'root_fifo', 'coherent_read', 'blocked_recovery', 'admission_quota', 'revoked_authority'] as const;
const boundaries = ['prepared', 'before_publication', 'after_publication', 'before_commit', 'after_commit'] as const;
/** Seeded operation order with real transaction / filesystem barriers, no mocked engine. */
export async function runSchedules(config: HarnessConfig) {
const engine = await openEngine(config); const sources = await fixtures(engine, config);
const choose = random(config.seed); const cases = Object.fromEntries(SCHEDULE_CASES.map(c => [c, 0]));
const boundaryCases = Object.fromEntries(boundaries.map(c => [c, 0]));
const admissionMs: number[] = []; const commitMs: number[] = []; const started = performance.now();
const claim = async () => { const row = await claimNextWrite(engine, config.hostId); assert(row, 'expected a claimable head'); return row; };
const commit = async (row: WriteRequest) => { const at = performance.now(); const done = await publish(engine, row, sources, config);
commitMs.push(performance.now() - at); await assertCommittedSnapshot(engine, done); return done; };
try {
for (let i = 0; i < config.schedules; i++) {
const type = SCHEDULE_CASES[i % SCHEDULE_CASES.length];
const source = sources[Math.floor(choose() * sources.length)];
const other = sources[(sources.indexOf(source) + 1) % sources.length];
const principal = Math.floor(choose() * config.principalIds.length);
const slug = `schedule-${config.seed}-${i}`; const body = `value-${Math.floor(choose() * 1e9)}`;
const a = admission(config, source, slug, body, principal);
const accept = () => timedAdmission(engine, a, admissionMs);
if (type === 'duplicate_intent') {
const copies = await Promise.all(Array.from({ length: 2 + Math.floor(choose() * 4) }, accept));
assert.equal(new Set(copies.map(r => r.id)).size, 1);
await assert.rejects(admitWrite(engine, { ...a, callerIntent: { content: `${body}-different` } }), { code: 'idempotency_conflict' });
const terminal = await cancelWriteRequest(engine, a.principal, a.requestId!); assert.equal(terminal!.state, 'cancelled');
assert.deepEqual((await accept()).outcome, terminal!.outcome);
// The same caller UUID in another principal's namespace remains independent.
const independent = admission(config, other, slug, body, (principal + 1) % config.principalIds.length, { requestId: a.requestId });
assert.notEqual((await admitWrite(engine, independent)).id, terminal!.id); await commit(await claim());
} else if (type === 'create_conflict') {
const b = { ...a, requestId: randomUUID(), callerIntent: { content: `${body}-second` }, intent: { content: `${body}-second` } };
await Promise.all(choose() < .5 ? [accept(), admitWrite(engine, b)] : [admitWrite(engine, b), accept()]);
const created = await commit(await claim()); const loser = await publish(engine, await claim(), sources, config);
assert.equal(loser.state, 'conflict'); assert.equal(loser.error_code, 'page_identity_changed');
const snapshot = (await engine.readPageSnapshot(slug, { sourceId: source.id }))!;
const replacements = ['left', 'right'].map(side => ({ ...a, pageId: snapshot.page.id, requestId: randomUUID(),
callerIntent: { content: `${body}-${side}`, expected_revision: created.outcome!.revision },
intent: { content: `${body}-${side}`, expected_revision: created.outcome!.revision } }));
await Promise.all(replacements.map(input => admitWrite(engine, input)));
const winner = await claim();
await assertCommittedSnapshot(engine, await publishMutation(engine, winner, prepared(winner, sources, snapshot.revision), config.hostId));
const stale = await claim(); const conflict = await publishMutation(engine, stale, prepared(stale, sources, snapshot.revision), config.hostId);
assert.equal(conflict.state, 'conflict'); assert.equal(conflict.error_code, 'revision_conflict');
} else if (type === 'cancel_publish') {
await accept(); const row = await claim();
const cancel = () => cancelWriteRequest(engine, a.principal, a.requestId!);
const write = () => publish(engine, row, sources, config);
await Promise.all(choose() < .5 ? [cancel(), write()] : [write(), cancel()]);
const final = (await getWriteRequestById(engine, row.id))!;
assert(['committed', 'cancelled'].includes(final.state));
if (final.state === 'committed') await assertCommittedSnapshot(engine, final);
else assert.equal(await engine.readPageSnapshot(slug, { sourceId: source.id }), null);
} else if (type === 'publication_rollback') {
const boundary = boundaries[Math.floor(i / SCHEDULE_CASES.length) % boundaries.length];
const path = join(source.root, `${slug}.md`); writeFileSync(path, 'original');
await accept(); const row = await claim();
const result = await publishMutation(engine, row, prepared(row, sources, null, true), config.hostId,
{ boundary: async name => { if (name === boundary) throw new Error(`injected:${boundary}`); } });
if (boundary === 'after_commit') { await assertCommittedSnapshot(engine, result); assert.equal(readFileSync(path, 'utf8'), body); }
else {
assert.equal(readFileSync(path, 'utf8'), 'original'); assert.equal(await engine.readPageSnapshot(slug, { sourceId: source.id }), null);
if (result.state === 'queued') {
const retry = await claim(); const done = await publishMutation(engine, retry, prepared(retry, sources, null, true), config.hostId);
await assertCommittedSnapshot(engine, done);
} else assert.equal(result.state, 'failed');
}
assert.equal((await getWriteRequestById(engine, row.id))!.recovery, null); boundaryCases[boundary]++;
} else if (type === 'stale_claim') {
await accept(); const old = await claim(); await releaseUnpublishedClaim(engine, old, 'test_reprepare'); const current = await claim();
assert.notEqual(old.execution_token, current.execution_token);
assert.equal(await renewWriteClaim(engine, old.id, old.execution_token!), false);
await releaseUnpublishedClaim(engine, old, 'stale_release');
assert.equal((await getWriteRequestById(engine, old.id))!.execution_token, current.execution_token); await commit(current);
} else if (type === 'root_fifo') {
const first = await accept();
const second = await admitWrite(engine, { ...a, slug: `${slug}-next`, requestId: randomUUID() });
const unrelated = await admitWrite(engine, admission(config, other, slug, body, principal));
const claimed = (await Promise.all([claimNextWrite(engine, config.hostId), claimNextWrite(engine, config.hostId), claimNextWrite(engine, config.hostId)])).filter((r): r is WriteRequest => r !== null);
assert.deepEqual(new Set(claimed.map(r => r.id)), new Set([first.id, unrelated.id]));
if (engine.kind === 'pglite') for (const row of claimed) await commit(row);
else await Promise.all(claimed.map(commit));
assert.equal((await claim()).id, second.id); await commit((await getWriteRequestById(engine, second.id))!);
} else if (type === 'coherent_read') {
const initial = { ...a, requestId: randomUUID(), callerIntent: { content: `${body}-old` }, intent: { content: `${body}-old` } };
await admitWrite(engine, initial); await commit(await claim());
const old = (await engine.readPageSnapshot(slug, { sourceId: source.id }))!;
await timedAdmission(engine, { ...a, pageId: old.page.id }, admissionMs);
const row = await claim(); const entered = deferred(); const resume = deferred();
const hooks: PublicationHooks = { boundary: async name => { if (name === 'before_commit') { entered.resolve(); await resume.promise; } } };
const writing = publishMutation(engine, row, prepared(row, sources, old.revision), config.hostId, hooks);
await entered.promise;
let readFinished = false;
const reading = engine.readPageSnapshot(slug, { sourceId: source.id }).then(value => { readFinished = true; return value; });
if (engine.kind === 'postgres') assert.deepEqual(await reading, old, 'MVCC reader must see the old complete content, timeline, tags and revision');
else { await Promise.resolve(); assert.equal(readFinished, false, 'PGLite serializes the reader behind its transaction'); }
resume.resolve(); const done = await writing; await reading; await assertCommittedSnapshot(engine, done);
} else if (type === 'blocked_recovery') {
const path = join(source.root, `${slug}.md`); writeFileSync(path, 'original'); await accept(); const row = await claim();
const result = await publishMutation(engine, row, prepared(row, sources, null, true), config.hostId,
{ boundary: async name => { if (name === 'after_publication') { writeFileSync(path, 'external-change'); throw new Error('injected ambiguous rollback'); } } });
assert.equal(result.state, 'recovering'); assert.equal(result.blocked_reason, 'unexpected_file_bytes');
const next = await admitWrite(engine, { ...a, slug: `${slug}-next`, requestId: randomUUID() });
const otherRow = await admitWrite(engine, admission(config, other, slug, body, principal));
assert.equal((await claim()).id, otherRow.id); await commit((await getWriteRequestById(engine, otherRow.id))!);
assert.equal(await claimNextWrite(engine, config.hostId), null, 'unknown bytes must block the entire root');
await assertConservation(engine); writeFileSync(path, body);
const recovered = await recoverPublication(engine, row.id, config.hostId);
assert.equal(recovered.state, 'failed', 'A known failed transaction body must not run again after recovery');
assert.equal(recovered.error_code, 'storage_error');
assert.equal(readFileSync(path, 'utf8'), 'original');
assert.equal((await claim()).id, next.id); await commit((await getWriteRequestById(engine, next.id))!);
} else if (type === 'admission_quota') {
const attempts = Array.from({ length: 3 + Math.floor(choose() * 4) }, () => ({ ...a, requestId: randomUUID() }));
const results = await Promise.allSettled(attempts.map(input => admitWrite(engine, input, { principalOutstanding: 2 })));
assert.equal(results.filter(r => r.status === 'fulfilled').length, 2);
for (let n = 0; n < results.length; n++) {
const result = results[n];
if (result.status === 'rejected') assert.equal(result.reason.code, 'queue_capacity');
else assert.equal((await admitWrite(engine, attempts[n], { principalOutstanding: 2 })).id, result.value.id);
}
for (const input of attempts) if (await getWriteRequest(engine, input.principal, input.requestId)) await cancelWriteRequest(engine, input.principal, input.requestId);
} else {
await accept(); const row = await claim();
await engine.executeRaw('UPDATE persistence_local_writers SET revoked_at=now() WHERE id=$1::uuid', [a.principal.id]);
try {
const result = await publish(engine, row, sources, config); assert.equal(result.state, 'failed'); assert.equal(result.error_code, 'permission_denied');
assert.equal(await engine.readPageSnapshot(slug, { sourceId: source.id }), null);
} finally { await engine.executeRaw('UPDATE persistence_local_writers SET revoked_at=NULL WHERE id=$1::uuid', [a.principal.id]); }
}
await assertConservation(engine);
assert.equal(await claimNextWrite(engine, config.hostId), null, `schedule ${i} left unresolved work`);
cases[type]++;
if ((i + 1) % 100 === 0) process.stderr.write(`[persistence] ${config.kind}: ${i + 1}/${config.schedules} schedules verified\n`);
}
return { executed: config.schedules, seed: config.seed, cases, boundaries: boundaryCases, duration_ms: performance.now() - started,
admission: distribution(admissionMs), publication: distribution(commitMs), accounting_checks: config.schedules };
} finally { await engine.disconnect(); }
}

View File

@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { availableParallelism, loadavg, tmpdir, totalmem } from 'node:os';
import { basename, dirname, join, resolve } from 'node:path';
import postgres from 'postgres';
import { assertSafeE2eDatabaseUrl } from '../../test/helpers/db-guard.ts';
import { distribution, type HarnessConfig } from './harness.ts';
import { keylessBrainEnv } from '../../test/helpers/provider-env.ts';
import { boundedDiagnostic, diagnosticError, retentionMetadata } from './failure-diagnostics.ts';
export interface ValidationOptions {
engine: 'pglite' | 'postgres'; schedules?: number; operations?: number; seed?: number;
crashes?: boolean; databaseUrl?: string; manifest?: string;
}
interface Event { event: string; [key: string]: any; }
export const CRASH_BOUNDARIES = ['admitted', 'prepared', 'before_publication', 'staging_flushed',
'after_publication', 'before_commit', 'after_commit', 'after_response'] as const;
export function childEnvironment(home: string): Record<string, string> {
// Preserve the runtime executable/search paths, never an operator's brain or provider configuration.
const env = Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value !== undefined &&
!/^(GBRAIN_|CONDUCTOR_|MCP_|OPENCLAW_|ANTHROPIC_|OPENAI_|DATABASE_URL$)/.test(key))) as Record<string, string>;
return keylessBrainEnv(env, home, { GBRAIN_CI_DISABLE_TEST_ENV_FILE: '1', GBRAIN_PERSISTENCE_FIXTURE_HOME: home });
}
export function spawnWorker(configPath: string, home: string, role: string, args: string[] = [], environment: Record<string, string> = {}) {
const child = Bun.spawn([process.execPath, '--no-env-file', resolve(import.meta.dir, 'worker.ts'), role, configPath, ...args],
{ env: { ...childEnvironment(home), ...environment }, stdin: 'ignore', stdout: 'pipe', stderr: 'inherit' });
const events: Event[] = []; const waiters = new Set<() => void>(); let ended = false; let tail = '';
const reading = (async () => {
const decoder = new TextDecoder(); let pending = '';
for await (const chunk of child.stdout) {
pending += decoder.decode(chunk, { stream: true });
for (;;) { const newline = pending.indexOf('\n'); if (newline < 0) break;
const line = pending.slice(0, newline); pending = pending.slice(newline + 1); tail = `${tail}\n${line}`.slice(-16000);
try { const event = JSON.parse(line); if (typeof event.event === 'string') events.push(event); } catch { /* driver startup diagnostics */ }
for (const wake of waiters) wake();
}
}
ended = true; for (const wake of waiters) wake();
})();
async function event(name: string, timeoutMs = 5_400_000): Promise<Event> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const index = events.findIndex(e => e.event === name); if (index >= 0) return events.splice(index, 1)[0];
const failure = events.find(e => e.event === 'failure'); if (failure) throw new Error(`${role}: ${failure.message}`);
if (ended) throw new Error(`${role} exited before ${name} (exit=${await child.exited}): ${tail}`);
assert(Date.now() < deadline, `${role} timed out before ${name}`);
await new Promise<void>(done => {
const timer = setTimeout(wake, Math.min(1000, deadline - Date.now()));
function wake() { clearTimeout(timer); waiters.delete(wake); done(); } waiters.add(wake);
});
}
}
async function done(): Promise<Event> { const result = await event('done'); assert.equal(await child.exited, 0, `${role} failed`); await reading; return result; }
return { child, event, done,
failureDiagnostics: () => events.filter(event => event.event === 'failure' && event.diagnostics).slice(0, 1)
.map(event => ({ role, pid: child.pid, ...event.diagnostics })),
async kill() { if (child.exitCode === null) child.kill('SIGKILL'); await child.exited; await reading; } };
}
/** Every PostgreSQL phase owns a new database; disk PGLite always runs in children. */
export async function runValidation(options: ValidationOptions) {
const counts = { schedules: options.schedules ?? 1000, operations: options.operations ?? 10_000 };
for (const [key, value] of Object.entries(counts)) assert(Number.isSafeInteger(value) && value >= 0, `Invalid ${key}`);
assert(Number.isSafeInteger(options.seed ?? 5105) && (options.seed ?? 5105) >= 0 && (options.seed ?? 5105) <= 0xFFFFFFFF, 'Invalid unsigned 32-bit seed');
const scratch = mkdtempSync(join(tmpdir(), 'gbrain-persistence-validation-')); const home = join(scratch, 'home'); mkdirSync(home);
const children: ReturnType<typeof spawnWorker>[] = []; const databases: string[] = [];
const ownerUrls: string[] = [];
let admin: ReturnType<typeof postgres> | undefined;
let originalFailure = false;
const manifest: Record<string, any> = { version: 1, engine: options.engine, runtime: `bun-${Bun.version}`,
platform: process.platform, architecture: process.arch, seed: options.seed ?? 5105,
environment: { logical_cpus: availableParallelism(), memory_bytes: totalmem(), load_average_at_start: loadavg() },
started_at: new Date().toISOString(), status: 'running', requested: { ...counts, crashes: options.crashes !== false },
managed_persistence: true, scope: 'activated real journal/coordinator; fixture-only loopback transport for PGLite producers', crash_cases: [], phase_inputs: {} };
const at = performance.now();
try {
if (options.engine === 'postgres') {
assert(options.databaseUrl, 'Postgres validation requires an explicit test DATABASE_URL');
assertSafeE2eDatabaseUrl(options.databaseUrl); admin = postgres(options.databaseUrl, { max: 1, onnotice() {} });
}
async function phase(name: string): Promise<{ config: HarnessConfig; path: string }> {
const root = join(scratch, name); mkdirSync(root);
manifest.phase_inputs[name] = Object.fromEntries(['scripts/persistence/harness.ts', 'scripts/persistence/schedules.ts',
'scripts/persistence/worker.ts', 'scripts/persistence/validate.ts', 'scripts/persistence/failure-diagnostics.ts',
'src/core/atomic-write.ts', 'src/core/persistence/staging.ts', 'src/core/persistence/model.ts',
'src/core/persistence/effect-recovery.ts', 'src/core/persistence/effect-model.ts', 'src/core/persistence/effects.ts',
'src/core/persistence/coordinator.ts', 'src/core/persistence/consumer.ts',
'src/core/persistence/journal.ts', 'src/core/persistence/activation.ts', 'src/core/persistence/filesystem-guard.ts',
'src/core/persistence/identity.ts', 'src/core/persistence/ownership.ts', 'src/core/pglite-engine.ts', 'src/core/postgres-engine.ts'].map(file =>
[file, createHash('sha256').update(readFileSync(resolve(import.meta.dir, '../..', file))).digest('hex')]));
let databaseUrl: string | undefined;
if (admin) {
const database = `gbrain_persistence_test_${randomUUID().replaceAll('-', '')}`;
await admin.unsafe(`CREATE DATABASE ${database}`); databases.push(database);
const url = new URL(options.databaseUrl!); url.pathname = `/${database}`; databaseUrl = url.toString();
}
const config: HarnessConfig = { kind: options.engine, root, dataDir: join(root, 'data'), databaseUrl,
hostId: randomUUID(), seed: options.seed ?? 5105, ...counts,
sourceIds: Array.from({ length: 4 }, (_, i) => `persistence-test-${i}`), principalIds: Array.from({ length: 4 }, () => randomUUID()) };
const path = join(root, 'config.json'); writeFileSync(path, JSON.stringify(config), { mode: 0o600 }); return { config, path };
}
function start(path: string, role: string, ...args: string[]) { const child = spawnWorker(path, home, role, args); children.push(child); return child; }
if (options.crashes !== false) for (const boundary of CRASH_BOUNDARIES) {
const { path } = await phase(`crash-${boundary}`); const requestId = randomUUID();
const child = start(path, 'crash', boundary, requestId);
const reached = await child.event(boundary === 'after_response' ? 'response_ready' : 'boundary');
assert.equal(reached.boundary, boundary); assert.equal(reached.requestId, requestId);
if (boundary === 'after_response') {
const response = await fetch(reached.url, { signal: AbortSignal.timeout(5_000) });
assert(response.ok); const receipt = await response.json();
assert.equal(receipt.request_id, requestId); assert.equal(receipt.state, 'committed');
assert.equal(receipt.persistence.mode, 'filesystem');
}
await child.kill();
const recovered = await start(path, 'recover', boundary, requestId).done();
assert.equal(recovered.result.boundary, boundary);
manifest.crash_cases.push({ ...recovered.result, ...(boundary === 'after_response' ? { response_read_before_kill: true } : {}) });
process.stderr.write(`[persistence] ${options.engine}: SIGKILL/${boundary} durable recovery verified\n`);
}
if (counts.schedules) {
const { path } = await phase('schedules'); await start(path, 'initialize').done();
manifest.schedules = (await start(path, 'schedules').done()).result;
}
if (counts.operations) {
const { path } = await phase('soak'); await start(path, 'initialize').done(); const started = performance.now();
const owners = Array.from({ length: options.engine === 'postgres' ? 2 : 1 }, () => start(path, 'owner'));
const ready = await Promise.all(owners.map(owner => owner.event('ready')));
ownerUrls.push(...ready.map(owner => owner.url));
const results = await Promise.all(Array.from({ length: 4 }, (_, i) => start(path, 'producer', String(i), ready[0].url).done()));
const ownerResults = await Promise.all(ready.map(async owner => {
const response = await fetch(new URL('stop', owner.url), { method: 'POST' }); assert(response.ok); return response.json();
}));
for (const owner of owners) assert.equal(await owner.child.exited, 0, 'resident must drain and close successfully');
for (const owner of ownerResults) assert.deepEqual(owner.errors, [], 'resident storage errors require investigation');
const verification = (await start(path, 'verify-soak').done()).result;
assert.equal(results.reduce((sum, row) => sum + row.result.completed, 0), counts.operations);
const duration = performance.now() - started;
manifest.soak = { ...verification, producer_processes: 4, owner_processes: owners.length,
producer_admission: options.engine === 'postgres' ? 'independent database clients' : 'resident fixture loopback endpoint',
duration_ms: duration, operations_per_second: counts.operations / (duration / 1000),
duplicate_replays: results.reduce((sum, row) => sum + row.result.replays, 0),
admission: distribution(results.flatMap(row => row.result.admission_ms)),
caller_completion: distribution(results.flatMap(row => row.result.completion_ms)),
concurrent_canonical_read: distribution(ownerResults.flatMap(owner => owner.concurrent_read_ms)),
peak_owner_rss_bytes: Math.max(...ownerResults.map(owner => owner.peak_rss_bytes)) };
}
manifest.status = 'passed';
const executedBoundaries = manifest.crash_cases.map((entry: { boundary: string }) => entry.boundary);
if (options.crashes !== false) assert.deepEqual(executedBoundaries, [...CRASH_BOUNDARIES]);
manifest.full_gate = counts.schedules >= 1000 && counts.operations >= 10_000
&& executedBoundaries.length === CRASH_BOUNDARIES.length && CRASH_BOUNDARIES.every((boundary, index) => executedBoundaries[index] === boundary)
&& manifest.crash_cases.every((entry: { staging_cleanup_verified?: boolean }) => entry.staging_cleanup_verified === true)
&& manifest.crash_cases.find((entry: { boundary: string }) => entry.boundary === 'staging_flushed')?.flushed_before_rename_verified === true
&& manifest.crash_cases.find((entry: { boundary: string }) => entry.boundary === 'staging_flushed')?.unexpected_staging_preserved === true
&& manifest.crash_cases.find((entry: { boundary: string }) => entry.boundary === 'after_response')?.response_read_before_kill === true;
return manifest;
} catch (error) {
originalFailure = true; manifest.status = 'failed'; manifest.full_gate = false; manifest.failure = String(error);
// Cached producer state is already available; a stuck owner adds only a
// bounded timeout marker. URLs and credentials never enter this manifest.
manifest.failure_diagnostics = await boundedDiagnostic(async () => ({
workers: children.flatMap(child => child.failureDiagnostics()),
owners: await Promise.all(ownerUrls.map(url => boundedDiagnostic(async () => {
const response = await fetch(new URL('diagnostics', url), { signal: AbortSignal.timeout(1_500) });
assert(response.ok, 'Owner diagnostic endpoint failed'); return response.json();
}, 1_750))),
}));
throw error;
}
finally {
const stopChildren = async () => {
const stopped = await Promise.allSettled(children.map(child => child.kill()));
const failed = stopped.find(result => result.status === 'rejected');
if (failed?.status === 'rejected') throw failed.reason;
};
const shutdown = originalFailure ? await boundedDiagnostic(stopChildren) : (await stopChildren(), { status: 'ok' as const });
if (admin && !originalFailure) for (const database of databases) await admin.unsafe(`DROP DATABASE IF EXISTS ${database} WITH (FORCE)`);
if (admin) {
if (originalFailure) manifest.admin_close_diagnostic = await boundedDiagnostic(() => admin!.end({ timeout: 1 }));
else await admin.end();
}
if (manifest.status === 'failed') {
const retainedPath = options.manifest ? `${resolve(options.manifest)}.retained.json` : join(scratch, 'retained.json');
try {
mkdirSync(dirname(retainedPath), { recursive: true });
writeFileSync(retainedPath, `${JSON.stringify({ ...retentionMetadata(scratch, databases),
worker_pids: children.map(child => child.child.pid), worker_shutdown: shutdown }, null, 2)}\n`, { mode: 0o600 });
manifest.failure_artifacts = { retained: true, metadata_file: basename(retainedPath) };
process.stderr.write(`[persistence] Failed fixtures retained. Private cleanup metadata: ${retainedPath}\n`);
} catch (error) {
manifest.failure_artifacts = { retained: true, metadata_error_code: diagnosticError(error) };
// The scratch directory survives even when the report volume is full.
process.stderr.write(`[persistence] Could not write cleanup metadata; synthetic scratch retained at ${scratch}\n`);
}
}
manifest.finished_at = new Date().toISOString(); manifest.duration_ms = performance.now() - at;
try {
if (options.manifest) { mkdirSync(dirname(resolve(options.manifest)), { recursive: true }); writeFileSync(options.manifest, `${JSON.stringify(manifest, null, 2)}\n`); }
} catch (error) {
if (!originalFailure) throw error;
process.stderr.write(`[persistence] Manifest write failed (${diagnosticError(error)}); original failure preserved.\n`);
}
if (!originalFailure) rmSync(scratch, { recursive: true, force: true });
}
}
if (import.meta.main) {
const args = new Map(process.argv.slice(2).map(arg => { const [key, ...value] = arg.replace(/^--/, '').split('='); return [key, value.join('=')]; }));
for (const key of args.keys()) assert(['engine', 'schedules', 'operations', 'seed', 'no-crashes', 'manifest'].includes(key), `Unknown option: ${key}`);
const engine = args.get('engine') ?? 'pglite'; assert(engine === 'pglite' || engine === 'postgres');
const result = await runValidation({ engine, schedules: Number(args.get('schedules') ?? 1000), operations: Number(args.get('operations') ?? 10_000),
seed: Number(args.get('seed') ?? 5105), crashes: !args.has('no-crashes'), databaseUrl: process.env.DATABASE_URL,
manifest: args.get('manifest') ?? `.context/persistence-${engine}-manifest.json` });
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}

View File

@@ -0,0 +1,214 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync, readdirSync, writeFileSync, writeSync } from 'node:fs';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { admitWrite, claimNextWrite, getWriteRequest, getWriteRequestById, receiptFor } from '../../src/core/persistence/journal.ts';
import { publishMutation, recoverPublication } from '../../src/core/persistence/coordinator.ts';
import { PersistenceConsumer } from '../../src/core/persistence/consumer.ts';
import { admission, assertCommittedSnapshot, assertConservation, distribution, fixtures, initializeFixtures,
openEngine, prepared, type HarnessConfig } from './harness.ts';
import { runSchedules } from './schedules.ts';
import type { WriteRequest } from '../../src/core/persistence/model.ts';
import { boundedDiagnostic, diagnosticError, ownerDatabaseDiagnostic, soakFailureDiagnostic, type ActiveSoakRequest } from './failure-diagnostics.ts';
const [mode, configPath, argument, extra] = process.argv.slice(2);
const config: HarnessConfig = JSON.parse(readFileSync(configPath, 'utf8'));
const emit = (event: Record<string, unknown>) => process.stdout.write(`${JSON.stringify(event)}\n`);
const hold = () => { setInterval(() => {}, 1000); return new Promise<never>(() => {}); };
function synchronousCrashBoundary(event: Record<string, unknown>): never {
const bytes = Buffer.from(`${JSON.stringify(event)}\n`);
let offset = 0;
while (offset < bytes.length) {
const written = writeSync(1, bytes, offset, bytes.length - offset);
assert(written > 0); offset += written;
}
// No Promise/microtask return: the atomic utility cannot advance to rename.
const blocked = new Int32Array(new SharedArrayBuffer(4));
for (;;) Atomics.wait(blocked, 0, 0);
}
async function main() {
if (mode === 'initialize') {
const engine = await openEngine(config, true); await initializeFixtures(engine, config); await engine.disconnect(); emit({ event: 'done' });
} else if (mode === 'schedules') {
emit({ event: 'done', result: await runSchedules(config) });
} else if (mode === 'runtime-matrix') {
const { runtimeCase } = await import('./matrix-cases.ts');
emit({ event: 'done', result: await runtimeCase(config as import('./matrix-cases.ts').RuntimeCase) });
} else if (mode === 'ownership-matrix') {
const { ownershipCases } = await import('./matrix-cases.ts');
emit({ event: 'done', result: await ownershipCases(config) });
} else if (mode === 'crash') {
const engine = await openEngine(config, true); await initializeFixtures(engine, config);
const sources = await fixtures(engine, config); const source = sources[0];
writeFileSync(join(source.root, 'crash.md'), 'original');
const input = admission(config, source, 'crash', 'replacement', 0, { requestId: extra });
const row = await admitWrite(engine, input);
const stop = async () => { emit({ event: 'boundary', boundary: argument, rowId: row.id, requestId: row.request_id }); await hold(); };
if (argument === 'admitted') await stop();
const claimed = await claimNextWrite(engine, config.hostId); assert(claimed);
const committed = await publishMutation(engine, claimed, prepared(claimed, sources, null, true), config.hostId,
{ boundary: async boundary => { if (boundary === argument) await stop(); },
stagingFlushed: () => {
if (argument === 'staging_flushed') synchronousCrashBoundary({ event: 'boundary', boundary: argument, rowId: row.id, requestId: row.request_id });
} });
if (argument === 'after_response') {
assert.equal(committed.state, 'committed');
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch() { return Response.json(receiptFor(committed)); } });
emit({ event: 'response_ready', boundary: argument, requestId: row.request_id, url: server.url.toString() });
await hold(); // The parent reads and validates the actual HTTP body before SIGKILL.
}
throw new Error(`Boundary ${argument} was not reached`);
} else if (mode === 'recover') {
const engine = await openEngine(config); const sources = await fixtures(engine, config);
try {
const principal = { kind: 'local_cli' as const, id: config.principalIds[0] };
let row = await getWriteRequest(engine, principal, extra); assert(row, 'RPO=0: acknowledged request must survive SIGKILL');
const initialState = row.state; const path = join(sources[0].root, 'crash.md');
const initialFile = readFileSync(path, 'utf8');
await assertConservation(engine);
const committedBoundary = argument === 'after_commit' || argument === 'after_response';
const staged = row.recovery?.staging?.publication;
if (argument === 'staging_flushed') {
assert(staged, 'flushed file must be named in the durable recovery record');
assert.equal(readFileSync(staged.path, 'utf8'), 'replacement');
assert.equal(initialFile, 'original', 'synchronous flush boundary must precede rename');
const reserved = Number(row.recovery_bytes);
// A third-party edit after the actual SIGKILL must never be mistaken
// for owned staging, even when it has exactly the attempted byte size.
writeFileSync(staged.path, 'unexpected!');
row = await recoverPublication(engine, row.id, config.hostId);
assert.equal(row.state, 'recovering'); assert.equal(row.blocked_reason, 'unexpected_staging_bytes');
assert.equal(readFileSync(staged.path, 'utf8'), 'unexpected!');
assert.equal(Number(row.recovery_bytes), reserved); assert(reserved > 0);
assert.equal(readFileSync(path, 'utf8'), 'original'); await assertConservation(engine);
writeFileSync(staged.path, 'replacement'); // Explicit fixture repair; production never guesses these bytes.
}
if (committedBoundary) { await assertCommittedSnapshot(engine, row); assert.equal(initialFile, 'replacement'); }
else assert.equal(await engine.readPageSnapshot('crash', { sourceId: sources[0].id }), null, 'uncommitted canonical changes must roll back');
if (row.recovery) row = await recoverPublication(engine, row.id, config.hostId);
if (staged) assert.equal(existsSync(staged.path), false, 'recovery must remove the recorded stage before releasing quota');
if (!committedBoundary) {
assert.equal(row.state, 'queued'); assert.equal(readFileSync(path, 'utf8'), 'original');
const retry = await claimNextWrite(engine, config.hostId); assert(retry); assert.equal(retry.id, row.id);
row = await publishMutation(engine, retry, prepared(retry, sources, null, true), config.hostId);
}
await assertCommittedSnapshot(engine, row); assert.equal(readFileSync(path, 'utf8'), 'replacement');
const replay = await admitWrite(engine, admission(config, sources[0], 'crash', 'replacement', 0, { requestId: extra }));
assert.equal(replay.id, row.id); assert.deepEqual(replay.outcome, row.outcome);
assert.equal((await getWriteRequestById(engine, row.id))!.recovery, null); await assertConservation(engine);
assert.deepEqual(readdirSync(sources[0].root).filter(name => name.includes('.tmp.')), [], 'no unaccounted temporary siblings remain');
emit({ event: 'done', result: { boundary: argument, initial_state: initialState, initial_file: initialFile,
retained_request: true, terminal_state: row.state, replay_preserved: true, counters_conserved: true,
staging_cleanup_verified: true, ...(argument === 'staging_flushed' ? {
flushed_before_rename_verified: true, unexpected_staging_preserved: true } : {}) } });
} finally { await engine.disconnect(); }
} else if (mode === 'owner') {
const engine = await openEngine(config); const sources = await fixtures(engine, config);
const errors: string[] = []; const errorCodes: string[] = []; let peakRss = process.memoryUsage().rss;
const readMs: number[] = []; let reading: Promise<void> | undefined;
const readTimer = setInterval(() => {
if (reading) return;
reading = (async () => {
const [row] = await engine.executeRaw<WriteRequest>("SELECT * FROM persistence_requests WHERE state='committed' ORDER BY sequence DESC LIMIT 1");
if (row) { const at = performance.now(); await assertCommittedSnapshot(engine, row); readMs.push(performance.now() - at); }
})().catch(error => { errors.push(`concurrent canonical read: ${error}`); errorCodes.push(diagnosticError(error)); }).finally(() => { reading = undefined; });
}, 1000);
const sample = setInterval(() => { peakRss = Math.max(peakRss, process.memoryUsage().rss); }, 100);
const consumer = new PersistenceConsumer(engine, { engine: config.kind }, async (_engine, row) => prepared(row, sources, null, true),
{ hostId: config.hostId, concurrency: config.kind === 'postgres' ? 2 : 1, pollMs: 250,
onError: error => { errors.push(String(error)); errorCodes.push(diagnosticError(error)); process.stderr.write(`[persistence owner] ${error}\n`); } });
const unregister = engine.registerBeforeDisconnect(() => consumer.stop());
const server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/submit' && request.method === 'POST') {
const input = await request.json() as { index: number; principal: number; requestId: string };
const a = admission(config, sources[input.principal % sources.length], `soak-${input.index}`, `body-${input.index}`, input.principal, { requestId: input.requestId });
return Response.json(await admitWrite(engine, a));
}
if (url.pathname === '/receipt') {
const row = await getWriteRequest(engine, { kind: 'local_cli', id: config.principalIds[Number(url.searchParams.get('principal'))] }, url.searchParams.get('id')!);
return Response.json(row);
}
if (url.pathname === '/diagnostics') {
return Response.json({ at: new Date().toISOString(), pid: process.pid, consumer: consumer.status(),
error_count: errors.length, recent_error_codes: errorCodes.slice(-8), canonical_read_in_flight: reading !== undefined,
peak_rss_bytes: peakRss, database: await boundedDiagnostic(() => ownerDatabaseDiagnostic(engine), 1_000) });
}
if (url.pathname === '/stop' && request.method === 'POST') {
clearInterval(readTimer); await reading;
await consumer.stop(); unregister(); clearInterval(sample); await engine.disconnect();
setTimeout(() => server.stop(true), 25);
return Response.json({ errors, peak_rss_bytes: peakRss, concurrent_read_ms: readMs });
}
return new Response('fixture endpoint only', { status: 404 });
} });
consumer.start(); emit({ event: 'ready', url: server.url.toString() });
} else if (mode === 'producer') {
const principal = Number(argument); const ownerUrl = extra;
const engine = config.kind === 'postgres' ? await openEngine(config) : undefined;
const sources = engine ? await fixtures(engine, config) : undefined;
const admissionMs: number[] = []; const completionMs: number[] = []; let replays = 0;
async function submit(index: number, requestId: string): Promise<WriteRequest> {
if (engine) return admitWrite(engine, admission(config, sources![principal % sources!.length], `soak-${index}`, `body-${index}`, principal, { requestId }));
const response = await fetch(new URL('submit', ownerUrl), { method: 'POST', body: JSON.stringify({ index, principal, requestId }) });
assert(response.ok, `resident fixture admission failed: ${response.status}`); return response.json() as Promise<WriteRequest>;
}
const read = async (requestId: string): Promise<WriteRequest | null> => engine
? getWriteRequest(engine, { kind: 'local_cli', id: config.principalIds[principal] }, requestId)
: fetch(new URL(`receipt?principal=${principal}&id=${requestId}`, ownerUrl)).then(r => r.json()) as Promise<WriteRequest | null>;
let completed = 0;
const active = new Map<string, ActiveSoakRequest>();
try {
// Four independent producers each keep four logical writes in flight.
const indexes = Array.from({ length: config.operations }, (_, i) => i).filter(i => i % config.principalIds.length === principal);
let cursor = 0;
await Promise.all(Array.from({ length: 4 }, async () => {
for (;;) {
const offset = cursor++; if (offset >= indexes.length) return;
const index = indexes[offset]; const requestId = randomUUID(); const at = performance.now();
const observation: ActiveSoakRequest = { requestId, index, startedAt: at, receipt: null }; active.set(requestId, observation);
const row = await submit(index, requestId); admissionMs.push(performance.now() - at);
observation.receipt = row;
if (index % 17 === 0) { assert.equal((await submit(index, requestId)).id, row.id); replays++; }
const deadline = performance.now() + 120_000;
for (;;) {
const current = await read(requestId); observation.receipt = current; assert(current, 'accepted request disappeared');
if (current.state === 'committed') { assert(current.outcome?.revision); break; }
assert(['queued', 'running', 'recovering'].includes(current.state),
`soak request ${current.request_id} terminated ${current.state}: ${current.error_code}: ${current.error_message}`);
assert(performance.now() < deadline, 'soak receipt did not commit within 120 seconds');
await Bun.sleep(100);
}
completionMs.push(performance.now() - at); completed++;
active.delete(requestId);
if (completed % 250 === 0) process.stderr.write(`[persistence] ${config.kind}: producer ${principal} verified ${completed} committed writes\n`);
}
}));
emit({ event: 'done', result: { principal, completed, replays, admission_ms: admissionMs, completion_ms: completionMs } });
} catch (error) {
// Publish cached state before disconnect: a stuck connection must not hide
// the original failure while the driver collects bounded owner diagnostics.
try { emit({ event: 'failure', message: String(error), diagnostics: soakFailureDiagnostic(principal, completed, active.values()) }); }
catch { /* Preserve the original error even if its diagnostic cannot be emitted. */ }
throw error;
} finally { await engine?.disconnect(); }
} else if (mode === 'verify-soak') {
const engine = await openEngine(config); const sources = await fixtures(engine, config);
try {
const rows = await engine.executeRaw<WriteRequest>('SELECT * FROM persistence_requests ORDER BY sequence');
assert.equal(rows.length, config.operations); assert.equal(new Set(rows.map(row => row.request_id)).size, config.operations);
for (const row of rows) {
await assertCommittedSnapshot(engine, row); assert.equal(row.recovery, null);
const source = sources.find(s => s.id === row.source_id)!;
assert.equal(readFileSync(join(source.root, `${row.slug}.md`), 'utf8'), row.intent!.content);
}
await assertConservation(engine);
emit({ event: 'done', result: { verified: rows.length, committed: rows.length, file_checks: rows.length,
snapshot_checks: rows.length, counters_conserved: true, pending: 0, unresolved_recovery: 0,
accepted_to_commit: distribution(rows.map(row => new Date(row.completed_at!).getTime() - new Date(row.created_at).getTime())) } });
} finally { await engine.disconnect(); }
} else throw new Error(`Unknown persistence worker role: ${mode}`);
}
try { await main(); }
catch (error) { emit({ event: 'failure', message: String(error) }); throw error; }

View File

@@ -1,3 +1,4 @@
import { installPageProjection, readProjectionSnapshot } from '../src/core/page-state/projections.ts';
/**
* scripts/run-eval-canary.ts — hermetic CLI retrieval-quality canary.
*
@@ -117,7 +118,8 @@ export async function seedCanaryCorpus(engine: BrainEngine, queries: LegacyQrels
embedding: basisEmbedding(q.embedding_dim, EMBEDDING_DIMENSIONS),
token_count: 10,
};
await engine.upsertChunks(slug, [chunk]);
const snapshot = (await readProjectionSnapshot(engine, slug, 'default', { allowUnsealed: true }))!;
await installPageProjection(engine, snapshot, [chunk], { seal: true });
}
}
}

View File

@@ -368,7 +368,8 @@ strip_ansi() {
}
# bun_summary_count: parses Bun's summary lines (one per `bun test` invocation
# inside a shard — there's only one when we pass an explicit file list).
# inside a shard). Grouped unit shards emit an authoritative aggregate so child
# Bun invocations inside tests cannot inflate the reported totals.
# Looks for ` N pass` / ` N fail` / ` N skip` patterns and sums them across
# all summary blocks the shard emitted. `bun test` prints these near the end
# of its output. Format: leading whitespace + integer + space + label.
@@ -376,8 +377,14 @@ bun_summary_count() {
local label="$1"; local file="$2"
if [ ! -f "$file" ]; then echo 0; return; fi
strip_ansi "$file" | awk -v label="$label" '
/^__gbrain_unit_shard__ / {
for (i = 2; i <= NF; i++) {
split($i, pair, "=")
if (pair[1] == label) { aggregate = pair[2]; have_aggregate = 1 }
}
}
$1 ~ /^[0-9]+$/ && $2 == label { total += $1 }
END { print total + 0 }
END { print have_aggregate ? aggregate + 0 : total + 0 }
'
}
@@ -695,7 +702,11 @@ for i in $(seq 1 "$N"); do
# Warn-pass gate: rescue-eligible kills (OOM signature / external kill)
# are excluded so they reach the serial rescue queue below instead of
# being absolved without a re-run.
if [ "$fail_count" = "0" ] && [ "$inline_fails" = "0" ] && [ "$idle_secs" -ge 300 ] \
grouped_incomplete=0
if grep -q '^__gbrain_unit_group_start__ ' "$SHARD_LOG" && ! grep -q '^__gbrain_unit_shard__ .* rc=0$' "$SHARD_LOG"; then
grouped_incomplete=1
fi
if [ "$grouped_incomplete" = "0" ] && [ "$fail_count" = "0" ] && [ "$inline_fails" = "0" ] && [ "$idle_secs" -ge 300 ] \
&& [ "$shard_oom" = "0" ] && [ "$shard_external_kill" = "0" ]; then
# Completion evidence (fail-closed): warn-pass additionally requires
# every assigned file to have STARTED (its file-header appears in the

View File

@@ -9,8 +9,8 @@
# the runner container, each pinned to its own postgres shard for the
# downstream E2E phase.
#
# Sequential bun processes within a shard (one bun test invocation with the
# shard's file list); parallel across shards (4 of these run concurrently).
# One Bun process per file within a shard;
# parallel across shards (4 of these run concurrently).
set -euo pipefail
@@ -93,7 +93,70 @@ fi
TEST_TIMEOUT_MS=$((60000 * MULT))
echo "[unit-shard ${RUNNER_SHARD:-(unsharded)}] running ${#files[@]} files (timeout=${TEST_TIMEOUT_MS}ms)"
if [ -n "$MAX_CONC" ]; then
exec bun test --max-concurrency="$MAX_CONC" --timeout="$TEST_TIMEOUT_MS" "${files[@]}"
fi
exec bun test --timeout="$TEST_TIMEOUT_MS" "${files[@]}"
# Do not retain runtime/module state across file boundaries. Keep the existing
# worker count and attempt every selected file after ordinary failures.
GROUP_SIZE=1
GROUPS_TOTAL=$(( (${#files[@]} + GROUP_SIZE - 1) / GROUP_SIZE ))
GROUP_LOG_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gbrain-unit-groups.XXXXXX")
trap 'rm -rf "$GROUP_LOG_DIR"' EXIT
TEST_ARGS=(test)
[ -z "$MAX_CONC" ] || TEST_ARGS+=("--max-concurrency=$MAX_CONC")
TEST_ARGS+=("--timeout=$TEST_TIMEOUT_MS")
TOTAL_RC=0
TOTAL_PASS=0
TOTAL_FAIL=0
TOTAL_SKIP=0
COMPLETE_GROUPS=0
ESC=$(printf '\033')
for ((offset=0, group=1; offset<${#files[@]}; offset+=GROUP_SIZE, group++)); do
group_files=("${files[@]:offset:GROUP_SIZE}")
log="$GROUP_LOG_DIR/$group.log"
printf '%s\n' "${group_files[@]}" > "$GROUP_LOG_DIR/$group.assigned"
echo "__gbrain_unit_group_start__ group=$group/$GROUPS_TOTAL files=${#group_files[@]}"
set +e
bun "${TEST_ARGS[@]}" "${group_files[@]}" 2>&1 | tee "$log"
statuses=("${PIPESTATUS[@]}")
set -e
bun_rc=${statuses[0]}
tee_rc=${statuses[1]}
sed "s/${ESC}\\[[0-9;]*[a-zA-Z]//g" "$log" > "$GROUP_LOG_DIR/$group.clean"
# Child tests can print their own Bun summaries. Use the final block, require
# its exact selected-file count, and independently check every file header.
read -r summary_ok pass_count fail_count skip_count missing_count < <(awk '
FNR == NR { expected[$0] = 1; selected++; next }
{
header = $0; sub(/^::group::/, "", header); sub(/:$/, "", header)
if (header in expected) seen[header] = 1
}
$1 ~ /^[0-9]+$/ && $2 == "pass" { p = $1; have_p = 1 }
$1 ~ /^[0-9]+$/ && $2 == "fail" { f = $1; have_f = 1 }
$1 ~ /^[0-9]+$/ && $2 == "skip" { s = $1 }
/^Ran [0-9]+ tests? across [0-9]+ files?\./ {
valid = have_p && have_f && $5 == selected
last_p = p; last_f = f; last_s = s
p = f = s = have_p = have_f = 0
}
END {
for (file in expected) if (!(file in seen)) missing++
ok = valid && !missing
print ok + 0, ok ? last_p + 0 : 0, ok ? last_f + 0 : 0, ok ? last_s + 0 : 0, missing + 0
}
' "$GROUP_LOG_DIR/$group.assigned" "$GROUP_LOG_DIR/$group.clean")
group_rc=0
if [ "$bun_rc" -ne 0 ] || [ "$tee_rc" -ne 0 ] || [ "$summary_ok" -ne 1 ] || [ "$fail_count" -ne 0 ]; then
group_rc=1
TOTAL_RC=1
fi
if [ "$summary_ok" -eq 1 ]; then
COMPLETE_GROUPS=$((COMPLETE_GROUPS + 1))
TOTAL_PASS=$((TOTAL_PASS + pass_count))
TOTAL_FAIL=$((TOTAL_FAIL + fail_count))
TOTAL_SKIP=$((TOTAL_SKIP + skip_count))
else
echo "[unit-shard] group $group/$GROUPS_TOTAL incomplete: missing or mismatched Bun summary/file census; missing_headers=$missing_count" >&2
fi
echo "__gbrain_unit_group__ group=$group/$GROUPS_TOTAL files=${#group_files[@]} bun_rc=$bun_rc tee_rc=$tee_rc complete=$summary_ok pass=$pass_count fail=$fail_count skip=$skip_count rc=$group_rc"
done
# Distinct from native Bun summary syntax: consumers must not count these twice.
echo "__gbrain_unit_shard__ groups=$GROUPS_TOTAL complete_groups=$COMPLETE_GROUPS files=${#files[@]} pass=$TOTAL_PASS fail=$TOTAL_FAIL skip=$TOTAL_SKIP rc=$TOTAL_RC"
exit "$TOTAL_RC"

View File

@@ -47,6 +47,7 @@ test/brainstorm-timeout.test.ts orchestrator entry-point wrap (CV11 single-point
test/build-llms.test.ts CLAUDE.md restructure content contracts 5 readFileSync
test/build-llms.test.ts build-llms generator 7 readFileSync
test/canonical-migration-command.test.ts canonical migration command (single home: ai/defaults.ts) 5 doctor-source-helper
test/canonical-writer-inventory.test.ts (file-level) 2 readFileSync
test/chat-key-plane.test.ts #3944 both planner surfaces share the helper (source guard) 2 readFileSync
test/check-bootstrap-guards.test.ts check-grok-pin.sh 10 readFileSync
test/check-bootstrap-guards.test.ts check-opencode-pin.sh 6 readFileSync
@@ -261,6 +262,7 @@ test/scripts/classify-tests.test.ts classify-tests detectors 18 bun-file,doctor-
test/scripts/coverage-diff-gate.test.ts exemptions 3 readFileSync
test/scripts/e2e-wiring.test.ts e2e file claim ratchet 5 readFileSync
test/scripts/e2e-wiring.test.ts selected-e2e job wiring 6 readFileSync
test/scripts/native-lock-prebuilds.test.ts native lock distribution integrity 2 readFileSync
test/scripts/run-verify-parallel.test.ts guard registration ⇒ execution coverage 1 readFileSync
test/scripts/test-shard.slow.test.ts test-shard.sh — LPT balance contract 5 readFileSync
test/scripts/test-shard.slow.test.ts test-shard.sh — exclusion contract 6 readFileSync
Can't render this file because it contains an unexpected character in line 44 and column 63.

View File

@@ -0,0 +1,68 @@
# Upgrade to v0.51.0.0: coordinated concurrent writes
This upgrade adds schema migrations 150–159, durable write receipts, canonical revisions and explicit writer activation. Update every process that writes to this brain as one coordinated upgrade. Preserve existing agent identity, source routing, access grants, capture settings and enrichment preferences.
## Before changing the brain
1. Inventory HTTP and stdio servers, CLIs, sync workers, maintenance jobs, connectors and external file editors on every host. Stop older writers and wait for in-flight work to settle. An unreachable owner needs external fencing before a verified transfer; a stale heartbeat is insufficient.
2. Verify restorable backups of canonical worktrees, including private/gitignored files, and the database. Withdrawal decisions, receipts, registrations, credentials and intentional DB-only knowledge cannot be rebuilt from Markdown. Keep the backups until post-upgrade verification succeeds.
3. Record `gbrain sources list --json`, current operation/source/slug grants and writer status. Keep credentials private. Do not replace revoked registrations or broaden grants to make an upgrade pass.
## Upgrade and inspect
Upgrade the host binary and first-party clients using their existing installation method. If upgrading from before v0.50.5.0, also follow that release's security upgrade instructions in [CHANGELOG.md](../../CHANGELOG.md), including service restarts and any named host-environment or guardrails-path corrections; preserve the existing authorization ceiling. Supported native validation covers Bun 1.3.11 and 1.3.13; source installs use the bundled native prebuild and require no compiler or install-time download. Missing native support must be corrected before ownership or publication.
```bash
gbrain apply-migrations --yes
gbrain sources writer status --probe --json
gbrain auth local-writer list --json
```
Migrations add canonical identity/revision guards, the journal and local principals, exact lease tokens, text-projection seals, recoverable effects, managed writer guards, source topology and complete version deletion state. Previously unverified search projections are withheld until sanitized rebuild. Direct page reads remain available.
PGLite binds its single installation and uses separate durable CLI and stdio registrations. A live resident owner receives supported CLI operations through authenticated private IPC; failed IPC does not authorize a second datastore open. Postgres allows multiple request servers, with one designated publishing host per canonical worktree. On that host, claim each configured filesystem source using its actual local path:
```bash
gbrain sources writer claim default --path /absolute/canonical/source --json
```
Inspect every affected source, owner epoch, native capability and outstanding recovery record. Resolve recovery before moving a source or activating. Intentional DB-only sources remain DB-only.
## Activate after every writer is ready
The confirmation flag asserts that older binaries, external editors and maintenance writers are quiesced on every host. Preview first:
```bash
gbrain sources writer activate --confirm-quiesced --dry-run --json
```
After verifying all owners and the preview, activate and inspect the result:
```bash
gbrain sources writer activate --confirm-quiesced --json
gbrain sources writer status --probe --json
```
Managed enforcement allows coordinated writers and refuses unsupported canonical mutation paths. An expired lease does not prove a stopped writer. Do not delete a live coordination file or edit SQL/metadata to bypass a refusal.
## Verify clients and a canonical write
Read an existing page with content and retain its `revision`. For a new replacement, pass `expected_revision` and a newly generated UUID `request_id`. Omit both revision and force only when creating an absent page. Use `force: true` solely for an intentional overwrite.
Keep the same ID, operation, arguments and source across response loss or token refresh. Inspect its receipt or repeat the original verb until terminal. `queued`, `running` and `recovering` are accepted pending states, not successful completion. The synchronous wait is five seconds with one-second polling guidance; owner downtime does not expire work. A committed replay returns the original result. Conflicting intent under the same ID is rejected.
Verify that a committed write can be read at its returned revision or later, with matching content and tags. Check its independent Git/embedding/mirror effects through the receipt. Withdrawn facts remain inactive even while a filesystem mirror is pending; ordinary search withholds stale projections until rebuilding completes. Healing and rebuilding compare their original chunk set and indexing context before installation, so delayed work cannot erase a newer completed projection. Embedding completion commits vectors and their provenance/context stamps together. Contextual reindexing also checks the originating projection, stored generation and source policy; superseded work returns a retryable outcome without replacing chunk text. Unsealed nonempty projections must be rebuilt before contextual provider work. Complete protected fences are filtered before either body column is split into chunks; direct historical page reads retain withdrawn history.
New grants include receipt tools. Older saved operation snapshots do not widen. If a caller needs `get_write_request`, `list_write_requests` or `cancel_write_request`, preview a complete replacement operation list preserving existing grants, then apply it with `auth rescope-client --if-version` using the observed revision. Follow the exact [receipt-grant example](../../docs/guides/concurrent-writes.md#receipt-access-and-explicit-grant-migration). The seven-tool memory surface instead recovers by repeating `remember` or `forget` with its original ID.
## Recovery and rollback
Local `gbrain delete <slug> --purge` uses the same durable journal. It removes the recorded file before committing hard deletion; a removal failure retains the prior page state. Preserve the original request ID: replaying a completed purge returns its stored outcome and cannot delete a recreated page. Purge remains local-only, and does not remove copies in history, exports or other retained records.
Use writer status for the blocked request, owner epoch, queue age/capacity and exact next action. Temporary capacity pressure queues work; permanent IDs are never evicted to admit new work. Increase reviewed quotas when approaching capacity. Keep recovery files and prior bytes until the owner verifies cleanup.
Rollback to old writers is supported only before managed activation and accepted managed writes. Afterwards use forward repair, or a fully drained downgrade with verified canonical state and retained withdrawal/receipt records. Never infer a safe rollback from a lost response. An unexpected file change requires explicit recovery; do not overwrite it speculatively.
See [concurrent writes and durable receipts](../../docs/guides/concurrent-writes.md) for the complete contract, source lifecycle, transfer and diagnostic commands.
**Say to your agent:** "Coordinate the concurrent-write upgrade across my writers, preserve my grants and memory preferences, then verify one committed page and its receipt before resuming work."

View File

@@ -138,6 +138,7 @@
"migrations/v0.5.0.md": "5e0dabc451595295c4d971e19bcb33c258a127223d25859d8321cb7e1ce60711",
"migrations/v0.50.0.0.md": "f221a6c907437a7500d2ad8ee9fdc5e14821514d4c1e72164b7aafac0bd5d89b",
"migrations/v0.50.2.0.md": "ff1aab621479eeda5f210e026169ba584a1c402536cabddc7c387a617934b6f1",
"migrations/v0.51.0.0.md": "c07ea78c4887791425358cf3eed5681f867e8d30caf4d639773f08d70578d4cf",
"migrations/v0.7.0.md": "97c2740445a10b1c5c7123c17dbd625fa27a94095b85d27c2b278da756c4c59a",
"migrations/v0.8.0.md": "1919ff8b8f3680612ff888e7cfcc0d86ece5d5304ae19af4497bdf40b050561a",
"migrations/v0.8.1.md": "fad7341cfb5e02545fb8a23221d12ab395fc3d8db15d1d8ee8a18844aea6563a",

View File

@@ -712,7 +712,16 @@ async function main() {
return;
}
// Local engine path (unchanged behavior for local installs).
// The live PGLite owner exposes canonical operations over a dedicated
// local socket. Delegate before opening a competing engine connection.
{
const { runDelegatedCliOperation } = await import('./commands/persistence-delegate.ts');
if (await runDelegatedCliOperation(op.name, params, cfgPre, {
brain: cliOpts.brain, timeoutMs: cliOpts.timeoutMs ?? undefined,
}, formatResult)) return;
}
// No live serve owns the selected brain; connect through the normal lock path.
const engine = await connectEngine();
// #2084: the teardown contract (bounded drain of every background-work sink,
// bounded disconnect, computed-deadline backstop) lives in finishCliTeardown
@@ -819,10 +828,8 @@ async function main() {
// (leaves facts/cache/eval-capture writes racing teardown). The finally's
// drain bounds teardown; the hard-deadline timer armed at teardown entry
// bounds a hung one.
if (e instanceof OperationError) {
console.error(`Error [${e.code}]: ${e.message}`);
if (e.suggestion) console.error(` Fix: ${e.suggestion}`);
} else {
const { reportPersistenceCliError } = await import('./commands/persistence-delegate.ts');
if (!await reportPersistenceCliError(e, params.json === true || !!(e as OperationError)?.writeRequest)) {
console.error(e instanceof Error ? e.message : String(e));
}
setCliExitVerdict(1);
@@ -853,15 +860,11 @@ function printCliOnlyHelp(command: string) {
* Timeout policy (ENG-4): user override via --timeout=Ns wins; otherwise
* 180s for `think` (LLM calls), 30s for everything else.
*
* Error policy (CDX-4): callRemoteTool's hardening pass guarantees every
* thrown value reaches us as a RemoteMcpError. The switch below is
* exhaustively typed (TS `never` check); adding a new reason variant fails
* compilation until this dispatcher knows what to render.
* Error policy: callRemoteTool normalizes every failure to RemoteMcpError;
* the exhaustive switch requires a renderer for every reason variant.
*
* Renderer policy: the MCP tool result is unpacked via unpackToolResult
* (which JSON.parses the text content) and handed to the SAME formatResult
* the local-engine path uses. Renderer parity is enforced by data shape,
* not by per-command audit.
* Renderer policy: unpackToolResult parses MCP text and shares formatResult
* with the local-engine path, enforcing parity through the result shape.
*/
async function runThinClientRouted(
op: Operation,
@@ -903,6 +906,11 @@ async function runThinClientRouted(
maybePrintConceptNudge(op.name, params);
} catch (e: unknown) {
if (e instanceof RemoteMcpError) {
const { reportPersistenceCliError } = await import('./commands/persistence-delegate.ts');
if (await reportPersistenceCliError(e, params.json === true)) {
process.off('SIGINT', onSigint);
process.exit(sigintController.signal.aborted ? 130 : 1);
}
const url = cfg.remote_mcp!.mcp_url;
switch (e.reason) {
case 'config':
@@ -2060,6 +2068,13 @@ async function handleCliOnly(command: string, args: string[]) {
}
}
// Local deferred connections must not bypass the remote installation route.
if (command === 'capture' || command === 'forget' || command === 'call' || command === 'sources' && ['writer', 'add', 'remove', 'archive', 'restore', 'purge', 'set-path', 'reclone'].includes(args[0]) || command === 'takes' && ['add', 'update', 'supersede', 'resolve'].includes(args[0]) && !hasHelpFlag(args)) {
const { runDeferredPersistenceCommand } = await import('./commands/persistence-delegate.ts');
await runDeferredPersistenceCommand(command, args, connectEngine);
return;
}
// cathedral-6: `agent register` guards run PRE-connectEngine. A thin client
// would otherwise build a scratch PGLite and mint dead credentials into it;
// a live PGLite serve holds the single-writer lock, so connectEngine would
@@ -2878,6 +2893,7 @@ async function handleCliOnly(command: string, args: string[]) {
// refused (exit verdict set inside); false falls through unchanged.
if (command === 'sync') {
const cfgSync = loadConfig();
if (await (await import('./commands/sync-persistence-delegate.ts')).maybeDelegateSyncToPersistence(cfgSync, args)) return;
if (cfgSync?.engine === 'pglite' && cfgSync.database_path && !cfgSync.database_url) {
const { maybeDelegateSyncToServe } = await import('./commands/sync-delegate.ts');
if (await maybeDelegateSyncToServe(cfgSync.database_path, args)) return;
@@ -3067,11 +3083,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runServe(engine, args);
return; // serve doesn't disconnect
}
case 'call': {
const { runCall } = await import('./commands/call.ts');
await runCall(engine, args);
break;
}
case 'sweep': {
// [CX2-5] Trusted local sweep entry — succeeds precisely because no
// live serve holds the PGLite lock (connectEngine acquired it above).
@@ -3274,11 +3285,6 @@ async function handleCliOnly(command: string, args: string[]) {
break;
}
// v0.38 — Capture: single human-facing entrypoint for ingestion.
case 'capture': {
const { runCapture } = await import('./commands/capture.ts');
await runCapture(engine, args);
break;
}
case 'conversation-parser': {
// v0.41.13.0 — debug + introspection CLI for the new parser
// cathedral. `scan <slug>` requires a connected brain; the
@@ -3383,12 +3389,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runRecall(engine, args);
break;
}
case 'forget': {
// v0.31: shorthand for expireFact. `gbrain forget <fact-id>`.
const { runForget } = await import('./commands/recall.ts');
await runForget(engine, args);
break;
}
case 'notability-eval': {
// v0.31.2: notability gate eval suite. Two subcommands:
// gbrain notability-eval mine — sample paragraphs, write candidates

View File

@@ -1134,10 +1134,15 @@ Usage:
only — stdio use is not logged). Automation-shaped
clients (>90% context_pack/delta) are flagged.
gbrain auth revoke-client <client_id> Hard-delete an OAuth 2.1 client (cascades to tokens + codes)
gbrain auth local-writer list|register|revoke Manage durable local CLI/stdio writers (see --help)
gbrain auth test <url> --token <token> Smoke-test a remote MCP server
`;
export async function runAuth(args: string[]): Promise<void> {
if (args[0] === 'local-writer') {
const { runPersistenceAdminCli } = await import('./persistence-admin.ts');
return runPersistenceAdminCli('local-writer', args.slice(1));
}
// #4083 follow-up: print usage whenever --help/-h appears ANYWHERE in
// args, before dispatching to a subcommand. Without this early return,
// `gbrain auth create foo --help` (or revoke/register-client/... +

View File

@@ -40,6 +40,9 @@
*/
import * as fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { OperationError } from '../core/ops/contract.ts';
import { isWriteReceipt } from '../core/persistence/types.ts';
import * as path from 'node:path';
import type { BrainEngine } from '../core/engine.ts';
import { MinionQueue } from '../core/minions/queue.ts';
@@ -351,6 +354,32 @@ This page was generated by \`gbrain book-mirror\`. Each chapter analysis came fr
// ── main entry ─────────────────────────────────────────────
/** Freeze the replacement precondition before chapter providers run. */
export async function prepareBookMirrorPublication(engine: BrainEngine, slug: string) {
const snapshot = await engine.readPageSnapshot(slug, { sourceId: 'default', includeDeleted: true });
const requestId = randomUUID();
return async (content: string) => {
const putPageOp = operations.find(op => op.name === 'put_page');
if (!putPageOp) throw new Error('internal: put_page operation not registered');
// viaSubagent intentionally omitted: this is the trusted local CLI publication.
const receipt = await putPageOp.handler({ engine, config: loadConfig() || { engine: engine.kind },
logger: { info: console.log, warn: console.warn, error: console.error }, dryRun: false, remote: false,
cliOpts: getCliOptions(), sourceId: 'default' }, {
slug, content, request_id: requestId, ...(snapshot ? { expected_revision: snapshot.revision } : {}),
});
if (!isWriteReceipt(receipt)) throw new OperationError('storage_error', 'Book publication returned no durable write receipt.');
if (receipt.state !== 'committed') {
const code = ['queued', 'running', 'recovering'].includes(receipt.state) ? 'write_pending' : 'storage_error';
const error = new OperationError(code, `Book publication is ${receipt.state}.`,
`Inspect get_write_request with request_id '${requestId}' before repeating publication.`);
error.writeRequest = receipt;
error.writeError = code;
throw error;
}
return receipt;
};
}
export async function runBookMirrorCmd(engine: BrainEngine, args: string[]): Promise<void> {
const flags = parseFlags(args);
@@ -410,6 +439,8 @@ export async function runBookMirrorCmd(engine: BrainEngine, args: string[]): Pro
}
}
const publish = await prepareBookMirrorPublication(engine, targetSlug);
// Submit fan-out: N children, no aggregator. Each child gets read-only
// tools so the codex HIGH-1 prompt-injection vector is closed at the
// tool-allowlist layer rather than at allowedSlugPrefixes scope.
@@ -506,30 +537,7 @@ export async function runBookMirrorCmd(engine: BrainEngine, args: string[]): Pro
chapterAnalyses: analyses,
});
// Operator-trust put_page — viaSubagent is NOT set, so the namespace
// check doesn't fire. The CLI is the trusted writer.
const putPageOp = operations.find(op => op.name === 'put_page');
if (!putPageOp) {
throw new Error('internal: put_page operation not registered');
}
await putPageOp.handler(
{
engine,
config: loadConfig() || { engine: 'postgres' },
logger: { info: console.log, warn: console.warn, error: console.error },
dryRun: false,
remote: false, // local CLI caller — operator trust path
cliOpts: getCliOptions(),
sourceId: 'default', // v0.34 D4: required field; book-mirror is single-source by design
// viaSubagent intentionally omitted — operator trust path.
// allowedSlugPrefixes intentionally omitted — operator can write anywhere.
},
{
slug: targetSlug,
content: assembled,
},
);
await publish(assembled);
process.stderr.write(`\nwrote: ${targetSlug} (${chapters.length} chapter sections, ${assembled.length} bytes)\n`);
process.stdout.write(JSON.stringify({

View File

@@ -3,6 +3,10 @@ import { handleToolCall } from '../mcp/server.ts';
import { resolveSourceWithTier, localFederatedSourceIds } from '../core/source-resolver.ts';
import { bigintToStringReplacer } from '../core/utils.ts';
import { writeStdoutFinal } from '../core/cli-force-exit.ts';
import { loadConfig } from '../core/config.ts';
import { getCliOptions } from '../core/cli-options.ts';
import { maybeDelegateLocalOperation } from '../core/persistence/local-client.ts';
import { reportPersistenceCliError } from './persistence-delegate.ts';
/**
* `gbrain call <tool> <json>` — trusted local op-dispatch surface.
@@ -14,7 +18,7 @@ import { writeStdoutFinal } from '../core/cli-force-exit.ts';
* env / dotfile / path-match all work.
*/
export async function runCall(
engine: BrainEngine,
engine: BrainEngine | (() => Promise<BrainEngine>),
args: string[],
// Test seam — production always uses the awaited-delivery writer (#3423).
out: (payload: string) => Promise<void> = writeStdoutFinal,
@@ -52,6 +56,26 @@ export async function runCall(
}
const params = jsonStr ? JSON.parse(jsonStr) : {};
if (!params || typeof params !== 'object' || Array.isArray(params)) throw new Error('Tool parameters must be a JSON object.');
// Parse and submit before acquiring PGLite. Keep the generated request ID
// on the direct path as well, and never reconnect after ambiguous delivery.
const wireParams = { ...params };
try {
const cli = getCliOptions();
const delegated = await maybeDelegateLocalOperation(tool, wireParams, loadConfig(), {
brain: cli.brain, source: explicitSource, timeoutMs: cli.timeoutMs ?? undefined,
});
if (wireParams.request_id !== undefined) params.request_id = wireParams.request_id;
if (delegated.handled) {
await out(JSON.stringify(delegated.result, bigintToStringReplacer, 2) + '\n');
return;
}
} catch (error) {
if (await reportPersistenceCliError(error, true, out)) return;
throw error;
}
try {
const connected = typeof engine === 'function' ? await engine() : engine;
// Resolve through the canonical 6-tier chain. resolveSourceWithTier()
// throws if an explicit/env/dotfile id refers to a non-registered source.
// #3874: mirror cli.ts's makeContext — when the source resolved via a
@@ -59,10 +83,10 @@ export async function runCall(
// `config.federated = true` source (#2561 parity). Without this,
// `gbrain call query ...` silently saw a narrower brain than
// `gbrain query ...`.
const resolved = await resolveSourceWithTier(engine, explicitSource);
const resolved = await resolveSourceWithTier(connected, explicitSource);
const sourceId = resolved.source_id;
const localFederated = await localFederatedSourceIds(engine, resolved.source_id, resolved.tier);
const result = await handleToolCall(engine, tool, params, {
const localFederated = await localFederatedSourceIds(connected, resolved.source_id, resolved.tier);
const result = await handleToolCall(connected, tool, params, {
sourceId,
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
});
@@ -72,4 +96,8 @@ export async function runCall(
// Awaited delivery (#3423): a >64KiB payload piped to a slow reader loses
// its tail to the exit grace under queued stdout writes.
await out(JSON.stringify(result, bigintToStringReplacer, 2) + '\n');
} catch (error) {
if (await reportPersistenceCliError(error, true, out)) return;
throw error;
}
}

View File

@@ -35,7 +35,7 @@ import type { BrainEngine } from '../core/engine.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult, RemoteMcpError } from '../core/mcp-client.ts';
import { computeContentHash } from '../core/ingestion/types.ts';
import { operations } from '../core/operations.ts';
import { operations, OperationError } from '../core/operations.ts';
import type { OperationContext } from '../core/operations.ts';
import { resolveSourceWithTier } from '../core/source-resolver.ts';
// Pure content helpers moved to core (shared with the capture MCP op — the
@@ -50,12 +50,12 @@ import {
explicitCaptureType,
mergeCaptureFrontmatter,
} from '../core/capture-content.ts';
import {
loadActivePackForWriteVocabulary,
packDeclaresPageType,
undeclaredPageTypeMessage,
undeclaredPageTypeSuggestion,
} from '../core/schema-pack/write-vocabulary.ts';
import { randomUUID } from 'node:crypto';
import { parseMutationPrecondition } from '../core/persistence/preconditions.ts';
import { isWriteReceipt, type WriteReceipt } from '../core/persistence/types.ts';
import { maybeDelegateLocalOperation } from '../core/persistence/local-client.ts';
import { getCliOptions } from '../core/cli-options.ts';
import { reportPersistenceCliError } from './persistence-delegate.ts';
export { detectBinaryNullByte, normalizeForHash, mergeCaptureFrontmatter } from '../core/capture-content.ts';
@@ -68,6 +68,9 @@ interface RunOpts {
source?: string;
quiet?: boolean;
json?: boolean;
expected_revision?: string;
request_id?: string;
force?: boolean;
// v0.42.x — Life Chronicle (#2390): manual `--type event` frontmatter sugar.
who?: string; // comma-separated entity slugs
what?: string;
@@ -85,6 +88,15 @@ function parseArgs(args: string[]): RunOpts | { help: true; positional: string |
if (a === '--quiet' || a === '-q') { opts.quiet = true; continue; }
if (a === '--json') { opts.json = true; continue; }
if (a === '--stdin') { opts.stdin = true; continue; }
if (a === '--force') { opts.force = true; continue; }
const mutationFlag = /^--(request-id|expected-revision)(?:=(.*))?$/.exec(a);
if (mutationFlag) {
const value = mutationFlag[2] ?? args[++i];
if (!value || value.startsWith('--')) throw new OperationError('invalid_params', `${mutationFlag[1]} requires a UUID.`);
if (mutationFlag[1] === 'request-id') opts.request_id = value;
else opts.expected_revision = value;
continue;
}
if (a === '--file') {
const v = args[++i];
if (v) opts.filePath = v;
@@ -111,7 +123,7 @@ function parseArgs(args: string[]): RunOpts | { help: true; positional: string |
if (a === '--where') { const v = args[++i]; if (v) opts.where = v; continue; }
if (a === '--kind') { const v = args[++i]; if (v) opts.kind = v; continue; }
if (a === '--depth') { const v = args[++i]; if (v) opts.depth = v; continue; }
if (a.startsWith('--')) continue; // unknown flag, ignore
if (a.startsWith('--')) throw new OperationError('invalid_params', `Unsupported capture option '${a}'.`);
positional.push(a);
}
if (positional.length > 0) {
@@ -141,6 +153,9 @@ Options:
registration scopes the source).
--quiet, -q Print just the slug on stdout (for shell pipelines)
--json JSON output for agents
--request-id UUID Retry the same logical capture with its original UUID
--expected-revision UUID Replace only this version of an existing page
--force Explicitly replace an existing page without a revision
--help, -h Show this help
Notes:
@@ -152,9 +167,8 @@ Notes:
before hashing). The daemon's 24h LRU dedup uses this hash.
- source_kind in the DB is ALWAYS 'capture-cli' for invocations of this
command. --source maps to the source_id DB column, NOT to source_kind.
Different --type values write to the SAME slug for the same content
(slug = content hash), so a later capture with a different --type
overwrites the prior page.
Replacing an existing slug requires --expected-revision or --force.
Keep --request-id unchanged when retrying the same capture.
Examples:
gbrain capture "remember to follow up on the X deal"
@@ -219,6 +233,8 @@ interface CaptureResult {
path?: string;
source_kind: string;
captured_at: string;
revision?: string;
write_request?: WriteReceipt;
}
function printReceipt(result: CaptureResult, quiet: boolean, json: boolean): void {
@@ -238,9 +254,11 @@ function printReceipt(result: CaptureResult, quiet: boolean, json: boolean): voi
console.log(` file: ${result.path}`);
}
console.log(` captured_at: ${result.captured_at}`);
if (result.revision) console.log(` revision: ${result.revision}`);
if (result.write_request) console.log(` request_id: ${result.write_request.request_id}`);
}
export async function runCapture(engine: BrainEngine | null, args: string[]): Promise<void> {
export async function runCapture(engine: BrainEngine | null, args: string[], options: { getEngine?: () => Promise<BrainEngine> } = {}): Promise<void> {
const parsed = parseArgs(args);
if ('help' in parsed) {
console.log(HELP);
@@ -336,191 +354,92 @@ export async function runCapture(engine: BrainEngine | null, args: string[]): Pr
process.exit(1);
}
// CV15: route source resolution through the canonical 6-tier chain
// (flag → env → dotfile → local_path → brain_default → seed_default).
// resolveSourceWithTier handles the assertSourceExists check and throws
// a friendly error BEFORE put_page is called if the source is missing.
// Only run on the LOCAL path — thin-client has no engine handle to
// probe the sources table; CV7 above already rejected explicit --source
// on thin-client. Implicit source resolution on thin-client uses
// 'default' (the server's auth layer scopes the actual write).
let resolvedSourceId = 'default';
if (!isThinClient(cfg) && engine) {
try {
const { source_id } = await resolveSourceWithTier(engine, parsed.source ?? null);
resolvedSourceId = source_id;
} catch (e) {
// assertSourceExists throws "Source 'X' not found. Available sources: ..."
console.error(`gbrain capture: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
}
}
// #4655: fail-loud vocabulary check for an EXPLICIT page type (--type flag
// or a frontmatter `type:` in the input) against the active schema pack.
// Best-effort pack load — no resolvable pack means no check. The
// default-'note' path is never checked, so bare `gbrain capture` keeps
// working even under packs that don't declare 'note'.
if (!isThinClient(cfg) && engine) {
const explicitType = explicitCaptureType(rawBody, parsed.type);
if (explicitType) {
const activePack = await loadActivePackForWriteVocabulary({
engine,
remote: false,
sourceId: resolvedSourceId,
});
if (activePack && !packDeclaresPageType(activePack, explicitType)) {
console.error(`gbrain capture: ${undeclaredPageTypeMessage(explicitType, activePack, 'capture')}`);
console.error(` ${undeclaredPageTypeSuggestion(activePack)}`);
process.exit(1);
}
}
}
// CV8 (CLI side): content_hash for the RECEIPT comes from the normalized
// rawBody, NOT the assembled fullContent which contains a timestamp.
// The daemon's 24h LRU dedup keys on this hash; identical captures must
// produce identical hashes. The DB content_hash (importFromContent at
// src/core/import-file.ts) gets the same treatment in Phase 3d.
const slug = parsed.slug ?? defaultSlug(normalizedBody, new Date(), parsed.type);
const fullContent = buildContent(rawBody, parsed);
const capturedAt = new Date().toISOString();
// Raw input and explicit options are the idempotency intent. The owner
// materializes its default slug and capture timestamp once after admission;
// retrying a CLI invocation must not produce a different digest.
const contentHash = computeContentHash(normalizedBody);
// Thin-client install: route through put_page over MCP. The server's
// write-through plumbing handles disk persistence. Per CV6 trust gate,
// the server overrides ANY provenance params we send to `mcp:put_page`
// — so we deliberately do NOT thread source_kind/source_uri/ingested_via
// through the wire (would be discarded server-side, and we don't want
// to suggest the values reached the DB column when they didn't).
if (isThinClient(cfg)) {
let raw: unknown;
try {
raw = await callRemoteTool(
cfg!,
'put_page',
{ slug, content: fullContent },
{ timeoutMs: 30_000 },
);
} catch (e) {
// A2/T1: detect server-side FK violation and rewrite to friendly hint.
// RemoteMcpError wraps the server's error envelope; the underlying
// PG message is in the wrapped string.
const hint = maybeRewriteSourceFkError(e, parsed.source ?? resolvedSourceId);
if (hint) {
console.error(`gbrain capture: ${hint}`);
} else if (e instanceof RemoteMcpError) {
console.error(`gbrain capture: remote put_page failed: ${e.message}`);
console.error('Run `gbrain remote doctor` to diagnose the connection.');
} else {
console.error(
`gbrain capture: remote put_page failed: ${e instanceof Error ? e.message : String(e)}`,
);
console.error('Run `gbrain remote doctor` to diagnose the connection.');
}
process.exit(1);
}
const remoteResult = unpackToolResult<{
slug: string;
status?: string;
chunks?: number;
write_through?: { written: boolean; path?: string };
}>(raw);
const result: CaptureResult = {
slug: remoteResult.slug,
status: remoteResult.status,
chunks: remoteResult.chunks,
content_hash: contentHash,
written: remoteResult.write_through?.written ?? false,
path: remoteResult.write_through?.path,
// CV3: source_kind ALWAYS 'capture-cli' for capture invocations,
// regardless of --source. --source maps to source_id (the DB FK),
// not the ingestion-channel taxonomy. Conflating these was the
// root cause of WARN-8's audit-trail labeling problem.
source_kind: 'capture-cli',
captured_at: capturedAt,
};
printReceipt(result, parsed.quiet ?? false, parsed.json ?? false);
return;
}
// Local install: route through put_page operation directly so we
// exercise the same write-through path the MCP server uses.
if (!engine) {
console.error('gbrain capture: engine not connected');
process.exit(1);
}
const putPageOp = operations.find((o) => o.name === 'put_page');
if (!putPageOp) {
console.error('gbrain capture: put_page operation missing (gbrain build issue)');
process.exit(1);
}
const ctx: OperationContext = {
engine,
config: cfg ?? { engine: 'pglite' as const },
logger: {
info: (msg: string) => { process.stderr.write(`[capture] ${msg}\n`); },
warn: (msg: string) => { process.stderr.write(`[capture] WARN: ${msg}\n`); },
error: (msg: string) => { process.stderr.write(`[capture] ERROR: ${msg}\n`); },
},
dryRun: false,
remote: false,
// v0.39.3.0 CV15: thread the resolved source from the canonical 6-tier
// chain (was `parsed.source ?? 'default'` pre-fix, which silently
// ignored env / dotfile / local_path / brain_default tiers — divergent
// from every other CLI op's behavior).
sourceId: resolvedSourceId,
};
const capturedAt = new Date().toISOString();
let resolvedSourceId = 'default';
let requestId: string | undefined;
try {
// v0.39.3.0 WARN-8: pass provenance params to put_page. CV3 source_kind
// is always 'capture-cli'; ingested_via is 'put_page' (the write API),
// source_uri identifies the file path or stdin marker.
const sourceUri = parsed.filePath
? `file://${parsed.filePath}`
: parsed.stdin
? 'stdin'
: 'cli-positional';
const result = (await putPageOp.handler(ctx, {
slug,
content: fullContent,
const precondition = parseMutationPrecondition(parsed as unknown as Record<string, unknown>);
requestId = precondition.request_id ?? randomUUID();
const params: Record<string, unknown> = {
content: rawBody,
...precondition,
request_id: requestId,
...(parsed.slug ? { slug: parsed.slug } : {}),
...(parsed.type ? { type: parsed.type } : {}),
...(parsed.who ? { who: parsed.who } : {}),
...(parsed.what ? { what: parsed.what } : {}),
...(parsed.where ? { where: parsed.where } : {}),
...(parsed.kind ? { kind: parsed.kind } : {}),
...(parsed.depth ? { depth: parsed.depth } : {}),
source_kind: 'capture-cli',
source_uri: sourceUri,
source_uri: parsed.filePath ? `file://${parsed.filePath}` : parsed.stdin ? 'stdin' : 'cli-positional',
ingested_via: 'capture-cli',
})) as {
slug: string;
status?: string;
chunks?: number;
write_through?: { written: boolean; path?: string; skipped?: string };
};
printReceipt(
{
slug: result.slug,
status: result.status,
chunks: result.chunks,
content_hash: contentHash,
written: result.write_through?.written ?? false,
path: result.write_through?.path,
// CV3: source_kind is the channel taxonomy, NOT the DB source FK.
source_kind: 'capture-cli',
captured_at: capturedAt,
},
parsed.quiet ?? false,
parsed.json ?? false,
);
} catch (e) {
// A2: detect FK violation on sources table and rewrite to friendly hint.
// resolveSourceWithTier above usually catches missing sources upstream,
// but a TOCTOU race (source deleted between pre-flight and put_page) or
// an explicit --source bypass would surface here.
const hint = maybeRewriteSourceFkError(e, parsed.source ?? resolvedSourceId);
if (hint) {
console.error(`gbrain capture: ${hint}`);
let result: Record<string, unknown>;
if (isThinClient(cfg)) {
const raw = await callRemoteTool(cfg!, 'capture', params, { timeoutMs: getCliOptions().timeoutMs ?? 30_000 });
result = unpackToolResult<Record<string, unknown>>(raw);
} else {
console.error(
`gbrain capture: put_page failed: ${e instanceof Error ? e.message : String(e)}`,
);
const cli = getCliOptions();
const delegated = await maybeDelegateLocalOperation('capture', params, cfg, {
brain: cli.brain, source: parsed.source ?? null, timeoutMs: cli.timeoutMs ?? undefined,
});
if (delegated.handled) result = delegated.result as Record<string, unknown>;
else {
if (!engine && options.getEngine) engine = await options.getEngine();
if (!engine) throw new OperationError('owner_unavailable', 'Capture requires a connected engine or a local persistence owner.');
const resolved = await resolveSourceWithTier(engine, parsed.source ?? null);
resolvedSourceId = resolved.source_id;
const captureOp = operations.find(operation => operation.name === 'capture');
if (!captureOp) throw new OperationError('unavailable', 'The capture operation is missing; upgrade this installation.');
const ctx: OperationContext = {
engine, config: cfg ?? { engine: 'pglite' }, sourceId: resolvedSourceId,
remote: false, dryRun: false,
logger: {
info: (message: string) => process.stderr.write(`[capture] ${message}\n`),
warn: (message: string) => process.stderr.write(`[capture] WARN: ${message}\n`),
error: (message: string) => process.stderr.write(`[capture] ERROR: ${message}\n`),
},
};
result = await captureOp.handler(ctx, params) as Record<string, unknown>;
}
}
process.exit(1);
const receipt = isWriteReceipt(result.write_request) ? result.write_request : undefined;
if (receipt && receipt.state !== 'committed') {
// Accepted is a real receipt, but never a false claim that capture
// finished. Quiet pipelines must not receive a made-up page slug.
if (parsed.json) console.log(JSON.stringify(result, null, 2));
else console.error(`Capture ${receipt.state}; request_id ${receipt.request_id}. Retry the same request ID for its result.`);
return;
}
const persistence = result.persistence as { file_written?: boolean } | undefined;
const writeThrough = result.write_through as { written?: boolean; path?: string } | undefined;
printReceipt({
slug: result.slug as string,
status: result.status as string | undefined,
chunks: result.chunks as number | undefined,
content_hash: contentHash,
written: persistence?.file_written ?? writeThrough?.written ?? false,
path: writeThrough?.path,
source_kind: 'capture-cli',
captured_at: receipt?.created_at ?? capturedAt,
...(receipt ? { write_request: receipt } : {}),
...(typeof result.revision === 'string' ? { revision: result.revision } : {}),
}, parsed.quiet ?? false, parsed.json ?? false);
} catch (error) {
if (await reportPersistenceCliError(error, parsed.json ?? false)) return;
const hint = maybeRewriteSourceFkError(error, parsed.source ?? resolvedSourceId);
console.error(`gbrain capture: ${hint ?? (error instanceof Error ? error.message : String(error))}`);
if (requestId) console.error(`Retry the same capture with --request-id ${requestId}.`);
if (parsed.json && error instanceof RemoteMcpError && error.detail?.write_request) {
console.log(JSON.stringify({ ...error.detail, request_id: requestId }, null, 2));
}
const { setCliExitVerdict } = await import('../core/cli-force-exit.ts');
setCliExitVerdict(1);
}
}

View File

@@ -1,3 +1,6 @@
import { sanitizeRemoteBody } from '../core/remote-body.ts';
import { readProjectionSnapshot, installPageProjection, installPageEmbeddings } from '../core/page-state/projections.ts';
import { PageRevisionConflictError } from '../core/page-state/types.ts';
import type { BrainEngine } from '../core/engine.ts';
import { currentEmbeddingSignature } from '../core/embedding.ts';
import type { ChunkInput } from '../core/types.ts';
@@ -978,26 +981,31 @@ async function embedPage(
quiet?: boolean,
) {
const opts = sourceId ? { sourceId } : undefined;
const page = await engine.getPage(slug, opts);
if (!page) {
const initial = await engine.readPageSnapshot(slug, opts);
if (!initial) {
throw new Error(`Page not found: ${slug}`);
}
const origin = await readProjectionSnapshot(engine, slug, initial.page.source_id, { allowUnsealed: true });
if (!origin || origin.snapshot.revision !== initial.revision || origin.snapshot.page.id !== initial.page.id
|| origin.snapshot.sourceIncarnation !== initial.sourceIncarnation) return;
const snapshot = origin.snapshot;
const page = snapshot.page;
// Get existing chunks or create new ones.
// In dryRun, we still chunk the text locally to count what WOULD be
// embedded — but we never write chunks or call the embedding model.
let chunks = await engine.getChunks(slug, opts);
let chunks = page.text_projection_revision === snapshot.revision ? origin.chunks : [];
if (chunks.length === 0) {
const inputs: ChunkInput[] = [];
// #4530: respect the active embedding model's per-input token limit.
const chunkOpts = { maxTokens: resolveMaxChunkTokens() };
const chunkOpts = { maxTokens: origin.maxChunkTokens };
if (page.compiled_truth.trim()) {
for (const c of chunkText(page.compiled_truth, chunkOpts)) {
for (const c of chunkText(sanitizeRemoteBody(page.compiled_truth), chunkOpts)) {
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'compiled_truth' });
}
}
if (page.timeline.trim()) {
for (const c of chunkText(page.timeline, chunkOpts)) {
for (const c of chunkText(sanitizeRemoteBody(page.timeline), chunkOpts)) {
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'timeline' });
}
}
@@ -1011,14 +1019,19 @@ async function embedPage(
}
if (inputs.length > 0) {
await engine.upsertChunks(slug, inputs, opts);
chunks = await engine.getChunks(slug, opts);
try {
await installPageProjection(engine, origin, inputs, { seal: true });
} catch (error) {
if (!(error instanceof PageRevisionConflictError)) throw error;
return;
}
chunks = await engine.getChunks(slug, { sourceId: page.source_id });
}
} else if (!dryRun) {
// SUP-3874: legacy chunks may predate the model input-cap. Split them
// before the embed call so one oversized row can't fail the page/sweep.
const healed = await healOversizedPageChunks(engine, slug, {
sourceId,
sourceId: page.source_id,
onSplit: (n) => serr(` ${slug}: split ${n} oversized chunk(s) to fit embedding input limit`),
});
if (healed.changed) chunks = healed.chunks;
@@ -1056,12 +1069,14 @@ async function embedPage(
// swallowed: the page stays NULL exactly as before, but the run now
// reports it (result.failures → non-zero exit) instead of pretending
// success. Abort (shutdown) still propagates.
const prepared = await readProjectionSnapshot(engine, slug, page.source_id);
if (!prepared || prepared.snapshot.revision !== snapshot!.revision || prepared.chunks.some((chunk, i) => chunk.id !== chunks[i]?.id || chunk.chunk_text !== chunks[i]?.chunk_text)) return;
let embeddings: (Float32Array | null)[];
let failed = 0;
let firstError: unknown;
try {
({ embeddings, failed, firstError } = await embedPageTexts(
wrapChunkTextsForStoredMode(page, toEmbed),
wrapChunkTextsForStoredMode(prepared.snapshot.page, toEmbed),
signal ? { abortSignal: signal } : {},
));
} catch (e: unknown) {
@@ -1084,26 +1099,15 @@ async function embedPage(
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
}));
await engine.upsertChunks(slug, updated, opts);
// v0.41.31: stamp provenance so a later model/dims swap is detectable as
// stale. embedPage is the per-slug path used by `gbrain embed <slug>` AND
// by `gbrain sync`'s post-import embed step (runEmbedCore({slugs})).
// Guard: only stamp when EVERY chunk was (re)embedded this pass. If some
// chunks were preserved from a prior embed (unknown/old provenance), the
// page is mixed — don't claim it's current. `embed --all` fully re-embeds
// such a page and then stamps it. #3037: a partial failure leaves failed
// chunks NULL, so don't stamp then either.
if (failed === 0 && toEmbed.length === chunks.length) {
// D9 honesty: no stamp when the gateway is unconfigured — a wrong
// signature is worse than none (NULL = unknown provenance).
const stampSig = currentEmbeddingSignature();
if (stampSig) {
await engine.setPageEmbeddingSignature(slug, { sourceId, signature: stampSig });
}
// #3507: a fully re-embedded per_chunk_synopsis page landed at the
// title tier — keep the stamped mode honest.
await restampIfDemotedToTitleTier(engine, page, slug, page.source_id);
}
const fullyEmbedded = failed === 0 && toEmbed.length === chunks.length;
// Vectors and their completion stamps share the page guard. A later
// contextual rebuild must not be relabeled by this attempt's restamp.
if (!await engine.transaction(async tx => {
if (!await installPageEmbeddings(tx, prepared, updated,
fullyEmbedded ? currentEmbeddingSignature() ?? undefined : undefined)) return false;
if (fullyEmbedded) await restampIfDemotedToTitleTier(tx, prepared.snapshot.page, slug, page.source_id);
return true;
})) return;
result.embedded += toEmbed.length - failed;
if (failed > 0) {
recordFailure(result, failed, slug, firstError);
@@ -1226,7 +1230,10 @@ async function embedAll(
// target the correct (source_id, slug) row, not the 'default' source.
const pageSourceId = page.source_id;
const pageOpts = pageSourceId ? { sourceId: pageSourceId } : undefined;
const chunks = await observed(pacer, () => engine.getChunks(page.slug, pageOpts));
const prepared = await observed(pacer, () => readProjectionSnapshot(engine, page.slug, pageSourceId));
if (!prepared) return;
page = prepared.snapshot.page;
const chunks = prepared.chunks;
const toEmbed = chunks; // staleOnly path handled above via embedAllStale
result.total_chunks += chunks.length;
@@ -1272,26 +1279,13 @@ async function embedAll(
embedding: embeddingMap.get(c.chunk_index) ?? undefined,
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
}));
await observed(pacer, () => engine.upsertChunks(page.slug, updated, pageOpts));
// v0.41.31: stamp embedding provenance so a later model swap is
// detectable as stale. #3037: not on partial failure — failed chunks
// stay NULL under unknown provenance. D9: no stamp without a gateway
// (signature undefined) — a wrong stamp is worse than none.
if (failed === 0) {
if (signature) {
await observed(pacer, () =>
engine.setPageEmbeddingSignature(page.slug, { sourceId: pageSourceId, signature }),
);
}
// #3507: --all fully re-embeds; a per_chunk_synopsis page landed at
// the title tier — keep the stamped mode honest. #3037: gated on
// failed === 0 — a partially-failed page was NOT fully re-embedded,
// so restamping would make contextual_retrieval_mode lie again
// (the exact #3461 bug).
await observed(pacer, () =>
restampIfDemotedToTitleTier(engine, page, page.slug, pageSourceId),
);
}
// Partial failures retain their old context; a full completion stamps
// its vectors and title-tier convention in the same guarded transaction.
if (!await observed(pacer, () => engine.transaction(async tx => {
if (!await installPageEmbeddings(tx, prepared, updated, failed === 0 ? signature : undefined)) return false;
if (failed === 0) await restampIfDemotedToTitleTier(tx, prepared.snapshot.page, page.slug, pageSourceId);
return true;
}))) return;
result.embedded += toEmbed.length - failed;
if (failed > 0) {
recordFailure(result, failed, page.slug, firstError);
@@ -1362,39 +1356,11 @@ async function embedAll(
* contract (including `pages_processed`, which embedPage's own dry-run
* branch increments for exactly this "examined, didn't write" case).
*
* Race note (review catch, three rounds — ACCEPTED RESIDUAL RISK, not
* fully closed): between listing a page and writing its chunks, a
* concurrent writer (sync, another `put_page`) could change or chunk the
* SAME page. Two mitigations, both bounded — full atomicity (a
* transaction/version-guarded conditional write inside `upsertChunks`)
* would need a new engine primitive shared by every `upsertChunks` caller,
* which is out of scope for a chunkless-page safety net:
* 1. Immediately before writing, re-fetch the LIVE page via `getPage`
* and build `inputs` from ITS CURRENT content, not the batch-list
* snapshot — closes the "content changed but still chunkless"
* sub-case, not just the "chunks appeared" one.
* 2. Re-check `getChunks` right after that same fetch — skip (don't
* overwrite) if chunks now exist AT THE TIME OF THE CHECK.
* What this does NOT close: a writer that inserts chunks in the gap
* BETWEEN step 2's check and the `upsertChunks` call immediately below it
* (no intervening `await` other than that one call, but `upsertChunks`
* itself is not conditioned on the check — this is still check-then-write,
* not compare-and-swap) can still have its chunks overwritten — HONESTLY:
* `upsertChunks` treats its input as the full desired chunk set for that
* page and deletes any existing chunk_index absent from it, so a
* concurrent writer's chunks landing in that exact gap CAN be replaced
* with this sweep's stale-content chunks (embedding NULL). This is the
* SAME check-then-write window `embedPage`'s existing single-page
* chunkless branch already ships with today (that branch doesn't even
* have step 2's re-check) — no new race CLASS is introduced, and the
* window here is a single sequential getPage+getChunks+upsertChunks
* instead of spanning a whole batch. The blast radius is bounded: the
* page is NOT deleted or corrupted, just re-chunked from a stale
* snapshot, and the NEXT write to that page (sync, another edit) that
* actually chunks it restores correct content — this sweep's own
* predicate is idempotent and doesn't compound the drift. Closing this
* fully (true atomicity) is tracked as a follow-up, not blocking this
* safety net.
* Capture the live canonical page, complete unsealed chunk set and indexing
* context under one short page guard, then chunk outside the transaction.
* Installation compares that originating snapshot under the same guard before
* changing any row. A newer canonical edit, completed projection or changed
* chunking context supersedes this attempt without counting it as healed.
*
* Per-page failure isolation (review catch): one malformed/oversized
* chunkless page must not abort the sweep and, with it, the entire
@@ -1441,17 +1407,17 @@ async function healChunklessPages(
let pagesHealed = 0;
let budgetExceeded = false;
const buildInputs = (compiledTruth: string, timeline: string): ChunkInput[] => {
const buildInputs = (compiledTruth: string, timeline: string, maxTokens = resolveMaxChunkTokens()): ChunkInput[] => {
const inputs: ChunkInput[] = [];
// #4530: respect the active embedding model's per-input token limit.
const chunkOpts = { maxTokens: resolveMaxChunkTokens() };
const chunkOpts = { maxTokens };
if (compiledTruth.trim()) {
for (const c of chunkText(compiledTruth, chunkOpts)) {
for (const c of chunkText(sanitizeRemoteBody(compiledTruth), chunkOpts)) {
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'compiled_truth' });
}
}
if (timeline.trim()) {
for (const c of chunkText(timeline, chunkOpts)) {
for (const c of chunkText(sanitizeRemoteBody(timeline), chunkOpts)) {
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'timeline' });
}
}
@@ -1493,20 +1459,13 @@ async function healChunklessPages(
continue;
}
// Re-fetch the LIVE page + re-check chunks immediately before
// writing (see race note above): chunk CURRENT content, and skip
// rather than clobber if a concurrent writer already chunked this
// page since we listed it.
const [livePage, stillChunkless] = await Promise.all([
observed(activePacer, () => engine.getPage(page.slug, { sourceId: page.source_id })),
observed(activePacer, () => engine.getChunks(page.slug, { sourceId: page.source_id })),
]);
if (!livePage || stillChunkless.length > 0) continue;
const inputs = buildInputs(livePage.compiled_truth, livePage.timeline);
const prepared = await observed(activePacer, () => readProjectionSnapshot(engine, page.slug, page.source_id, { allowUnsealed: true }));
if (!prepared || prepared.chunks.length > 0) continue;
const inputs = buildInputs(prepared.snapshot.page.compiled_truth, prepared.snapshot.page.timeline, prepared.maxChunkTokens);
if (inputs.length === 0) continue;
await observed(activePacer, () =>
engine.upsertChunks(page.slug, inputs, { sourceId: page.source_id }),
installPageProjection(engine, prepared, inputs, { seal: true }),
);
pagesHealed++;
try {
@@ -1515,6 +1474,7 @@ async function healChunklessPages(
if (!(e instanceof AbortError)) throw e;
}
} catch (e) {
if (e instanceof PageRevisionConflictError) continue;
if (isAborted(signal)) break;
recordFailure(result, 1, page.slug, e);
serr(`\n [embed] chunkless-page heal failed for ${page.slug}: ${e instanceof Error ? e.message : e}`);
@@ -1947,16 +1907,16 @@ async function embedAllStale(
// silently stripping contextual prefixes — `embed --stale` is the
// NORMAL post-model-migration path, so raw-text embedding here
// quietly converted whole corpora to the unwrapped convention.
const pageRow = await observed(pacer, () => engine.getPage(slug, { sourceId: keySourceId }));
// #3037: per-chunk failure isolation — one bad chunk costs one
// chunk, not the whole page's siblings. The wrapped texts feed the
// fan-out too, so an isolation retry never strips the prefixes.
const prepared = await observed(pacer, () => readProjectionSnapshot(engine, slug, keySourceId));
if (!prepared) return;
const selected = new Map(stale.map(c => [c.chunk_index, c]));
const existing = prepared.chunks;
stale = existing.filter(c => selected.get(c.chunk_index)?.chunk_text === c.chunk_text)
.map(c => ({ ...selected.get(c.chunk_index)!, ...c }));
if (!stale.length) return;
const pageRow = prepared.snapshot.page;
const { embeddings, failed, firstError } = await embedPageTexts(
wrapChunkTextsForStoredMode(pageRow, stale),
{ abortSignal: effectiveSignal },
);
// Re-fetch existing chunks and merge to avoid deleting non-stale chunks.
const existing = await observed(pacer, () => engine.getChunks(slug, { sourceId: keySourceId }));
wrapChunkTextsForStoredMode(pageRow, stale), { abortSignal: effectiveSignal });
const staleIdxToEmbedding = new Map<number, Float32Array>();
for (let j = 0; j < stale.length; j++) {
const emb = embeddings[j];
@@ -1972,26 +1932,17 @@ async function embedAllStale(
embedding: staleIdxToEmbedding.get(c.chunk_index) ?? undefined,
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
}));
await observed(pacer, () => engine.upsertChunks(slug, merged, { sourceId: keySourceId }));
// Stamp provenance from DB state, not this batch (#4825): the keyset
// drain has no page alignment, so a page straddling a batch boundary
// is never wholly in one batch — the batch that lands its last chunk
// stamps it. Preserved chunks of other provenance keep the page
// unstamped; #3037: failed chunks stay NULL, so skip the round trip.
if (stamp && failed === 0) {
await observed(pacer, () => stampIfPageProvenanceComplete(engine, slug, keySourceId, stamp));
}
// #3507: a FULLY re-embedded per_chunk_synopsis page landed at the
// title tier — keep the stamped mode honest. Partially-stale pages
// stay stamped as-is (mixed provenance; reindex sweeps fix them).
// #3037: `failed === 0` is part of "fully re-embedded" — if the
// per-chunk isolation left some chunks NULL, restamping would make
// contextual_retrieval_mode lie again (the exact #3461 bug).
if (failed === 0 && stale.length === existing.length) {
await observed(pacer, () =>
restampIfDemotedToTitleTier(engine, pageRow, slug, keySourceId),
);
}
// The last batch stamps from complete DB provenance (#4825).
// Keep both stamps with vector installation so later contextual
// work cannot commit between installation and title-tier demotion.
if (!await observed(pacer, () => engine.transaction(async tx => {
if (!await installPageEmbeddings(tx, prepared, merged)) return false;
if (stamp && failed === 0) await stampIfPageProvenanceComplete(tx, slug, keySourceId, stamp);
if (failed === 0 && stale.length === existing.length) {
await restampIfDemotedToTitleTier(tx, prepared.snapshot.page, slug, keySourceId);
}
return true;
}))) return;
result.embedded += stale.length - failed;
if (failed > 0) {
recordFailure(result, failed, slug, firstError);

View File

@@ -29,9 +29,12 @@
* fans out one job per source when --source is omitted.
*/
import { randomUUID } from 'node:crypto';
import type { BrainEngine } from '../core/engine.ts';
import type { EnrichCandidate, PageType } from '../core/types.ts';
import { operations } from '../core/operations.ts';
import { operations, OperationError } from '../core/operations.ts';
import { assertUnmanagedCanonicalWriter } from '../core/persistence/maintenance.ts';
import type { WriteReceipt } from '../core/persistence/types.ts';
import type { OperationContext } from '../core/operations.ts';
import { configureGatewayIfUninitialized, isAvailable, chat, getChatModel, withBudgetTracker } from '../core/ai/gateway.ts';
import { BudgetTracker, BudgetExhausted, loadPricingOverrides, type BudgetReason } from '../core/budget/budget-tracker.ts';
@@ -167,6 +170,8 @@ export interface EnrichResult {
/** #2504 — first pool failure ('slug: message'), so pages_failed > 0 always
* carries a WHY (pool.failures was previously write-only). */
first_failure?: string;
/** Accepted publication IDs remain inspectable after a pending or failed run. */
write_requests?: WriteReceipt[];
}
// ---------------------------------------------------------------------------
@@ -362,11 +367,13 @@ async function enrichOneLocked(ctx: EnrichOneCtx, candidate: EnrichCandidate): P
const { engine, sourceId } = ctx;
const slug = candidate.slug;
const page = await engine.getPage(slug, { sourceId });
if (!page) {
const snapshot = await engine.readPageSnapshot(slug, { sourceId });
if (!snapshot) {
ctx.result.pages_skipped_disappeared++;
return;
}
const page = snapshot.page;
const requestId = randomUUID();
const kind = inferEnrichKind(page.type, slug);
const evidence = await retrieveEvidence(engine, sourceId, slug, page.title || slug);
@@ -424,7 +431,7 @@ async function enrichOneLocked(ctx: EnrichOneCtx, candidate: EnrichCandidate): P
// auto-link + disk write-through fire, exactly like `gbrain capture`. The
// retrieved context was sanitized in buildEnrichPrompt; the synthesized body
// is the model's grounded output.
const tags = await engine.getTags(slug, { sourceId }).catch(() => [] as string[]);
const tags = snapshot.tags;
const newFrontmatter: Record<string, unknown> = {
...page.frontmatter,
// Provenance survives write-through (it only overrides ingested_via /
@@ -452,7 +459,7 @@ async function enrichOneLocked(ctx: EnrichOneCtx, candidate: EnrichCandidate): P
remote: false,
sourceId,
};
await putPageOp.handler(opCtx, { slug, content });
await putPageOp.handler(opCtx, { slug, content, expected_revision: snapshot.revision, request_id: requestId });
ctx.result.pages_enriched++;
ctx.done.add(completedKey(sourceId, slug));
@@ -468,6 +475,7 @@ export async function runEnrichCore(
signal?: AbortSignal,
): Promise<EnrichResult> {
if (!opts.sourceId) throw new Error('runEnrichCore: opts.sourceId is required');
if (!opts.dryRun) await assertUnmanagedCanonicalWriter(engine, 'enrich');
const result: EnrichResult = {
candidates_considered: 0,
@@ -580,6 +588,11 @@ export async function runEnrichCore(
}
result.pages_failed = pool.errored;
const writeRequests = pool.failures.flatMap(f => f.error instanceof OperationError && f.error.writeRequest ? [f.error.writeRequest] : []);
if (writeRequests.length) {
result.write_requests = writeRequests;
for (const receipt of writeRequests) process.stderr.write(`[enrich:${sourceId}] Write request ${receipt.request_id}: ${receipt.state}; inspect get_write_request before repeating enrichment.\n`);
}
// #2504 — pool.failures used to be write-only: an operator saw
// pages_failed:N with zero reason anywhere (the pricing hard-fail looked
@@ -885,6 +898,7 @@ function addInto(agg: EnrichResult, r: EnrichResult): void {
if (r.first_failure && agg.first_failure === undefined) {
agg.first_failure = r.first_failure;
}
if (r.write_requests?.length) (agg.write_requests ??= []).push(...r.write_requests);
}
/**

View File

@@ -1,3 +1,4 @@
import { assertManagedFilesystemWrite } from '../core/persistence/filesystem-guard.ts';
import { readFileSync, readdirSync, statSync, lstatSync, existsSync, writeFileSync, unlinkSync, mkdirSync, copyFileSync } from 'fs';
import { join, relative, extname, basename, dirname, resolve } from 'path';
import { createHash } from 'crypto';
@@ -299,6 +300,7 @@ async function uploadRaw(engine: BrainEngine, args: string[]) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- identity comparison only (skip self-copy when source already IS the dest); no fs path is derived from this expression
if (resolve(dest) !== resolve(filePath)) {
mkdirSync(destDir, { recursive: true });
assertManagedFilesystemWrite(dest);
copyFileSync(filePath, dest);
}
const hash = fileHash(filePath);
@@ -364,6 +366,7 @@ async function uploadRaw(engine: BrainEngine, args: string[]) {
});
// Write pointer next to the original file
pointerPath = filePath + '.redirect.yaml';
assertManagedFilesystemWrite(pointerPath);
writeFileSync(pointerPath, pointer);
console.error(`Pointer written: ${pointerPath}`);
}
@@ -649,6 +652,7 @@ async function mirrorFiles(args: string[]) {
prefix: basename(dir) + '/',
file_count: uploaded,
});
assertManagedFilesystemWrite(dir);
writeFileSync(join(dir, '.supabase'), marker);
console.log(`Mirrored ${uploaded} files. Marker written to ${dir}/.supabase`);
@@ -660,6 +664,7 @@ async function unmirrorFiles(args: string[]) {
const markerPath = join(dir, '.supabase');
if (existsSync(markerPath)) {
assertManagedFilesystemWrite(markerPath);
unlinkSync(markerPath);
console.log(`Removed mirror marker from ${dir}. Files remain in storage.`);
} else {
@@ -727,6 +732,7 @@ async function redirectFiles(args: string[]) {
mime: mimeType || 'application/octet-stream',
uploaded: new Date().toISOString(),
});
assertManagedFilesystemWrite(filePath);
writeFileSync(filePath + '.redirect.yaml', pointer);
unlinkSync(filePath);
redirected++;
@@ -777,6 +783,7 @@ async function restoreFiles(args: string[]) {
try {
const storagePath = info.storage_path || info.path; // v0.9 or legacy format
const data = await storage.download(storagePath);
assertManagedFilesystemWrite(originalPath);
writeFileSync(originalPath, data);
unlinkSync(redirectPath);
restored++;
@@ -816,7 +823,7 @@ async function cleanFiles(args: string[]) {
}
if (stat.isSymbolicLink()) continue;
if (stat.isDirectory()) findAndClean(full);
else if (entry.endsWith('.redirect.yaml') || entry.endsWith('.redirect')) { unlinkSync(full); cleaned++; }
else if (entry.endsWith('.redirect.yaml') || entry.endsWith('.redirect')) { assertManagedFilesystemWrite(full); unlinkSync(full); cleaned++; }
}
}
findAndClean(dir);

View File

@@ -1,3 +1,4 @@
import { assertManagedFilesystemWrite } from '../core/persistence/filesystem-guard.ts';
/**
* gbrain frontmatter — Frontmatter validation, audit, and auto-repair.
*
@@ -206,6 +207,7 @@ async function runValidate(rest: string[]): Promise<void> {
const brainRoot = findBrainRoot(resolved);
const files = collectFiles(resolved);
if (flags.fix && !flags.dryRun) for (const file of files) assertManagedFilesystemWrite(file);
const results: FileValidation[] = [];
const backupRunId = makeFrontmatterBackupRunId();
@@ -226,6 +228,7 @@ async function runValidate(rest: string[]): Promise<void> {
const { content: fixed, fixes } = autoFixFrontmatter(content, { filePath: file });
result.fixesApplied = fixes;
if (fixes.length > 0 && !flags.dryRun) {
assertManagedFilesystemWrite(file);
result.backupPath = createFrontmatterBackup(file, { sourcePath: resolved, runId: backupRunId });
writeFileSync(file, fixed, 'utf8');
}
@@ -506,6 +509,7 @@ async function runGenerate(args: string[]): Promise<void> {
const newContent = fm + '\n' + content;
// Safety: write a centralized backup first.
createFrontmatterBackup(absPath, { sourcePath: brainRoot, runId: backupRunId });
assertManagedFilesystemWrite(absPath);
writeFileSync(absPath, newContent, 'utf-8');
written++;
}

View File

@@ -2542,8 +2542,10 @@ export async function registerBuiltinHandlers(
const slug = typeof job.data.slug === 'string' ? job.data.slug : '';
if (!slug) throw new Error('facts-absorb job requires data.slug');
const sourceId = typeof job.data.sourceId === 'string' ? job.data.sourceId : 'default';
const page = await engine.getPage(slug, { sourceId });
if (!page) return { skipped: 'page_missing', slug, sourceId };
const { readFactsBackstopJobPage } = await import('../core/persistence/effect-facts.ts');
const input = await readFactsBackstopJobPage(engine, job.data);
if ('skipped' in input) return { skipped: input.skipped, slug, sourceId };
const page = input.page;
const { runFactsBackstop, coerceNotabilityFilter } = await import('../core/facts/backstop.ts');
const KNOWN_SOURCES = ['sync:import', 'mcp:put_page', 'mcp:extract_facts', 'file_upload', 'code_import', 'hook:writeback'] as const;
const source = (KNOWN_SOURCES as readonly string[]).includes(job.data.source as string)

View File

@@ -1,3 +1,4 @@
import { assertLegacyEngineMigration, assertUnmanagedCanonicalWriter } from '../core/persistence/maintenance.ts';
/**
* Engine migration: transfer brain data between PGLite and Postgres.
*
@@ -24,6 +25,7 @@ import { registerCleanup } from '../core/process-cleanup.ts';
import { autopilotPausedMarkerPath, autopilotLockPath, markerHolderAlive, MIGRATE_PAUSE_MARKER_PREFIX } from '../core/autopilot-paths.ts';
export { MIGRATE_PAUSE_MARKER_PREFIX };
import { listLiveLocks } from '../core/db-lock.ts';
import { queuePageProjection } from '../core/page-state/projections.ts';
interface MigrateOpts {
targetEngine: 'postgres' | 'pglite';
@@ -518,8 +520,9 @@ export async function copyPageToTarget(
);
}
// Copy chunks with embeddings.
const chunks = await source.getChunksWithEmbeddings(page.slug, sourceOpts);
// Migration preserves stored data even when it is not a verified search
// projection. The target rebuilds sanitized text under its new revision.
const chunks = await source.getChunksWithEmbeddings(page.slug, { ...sourceOpts, includeUnsealed: true });
if (chunks.length > 0) {
await target.upsertChunks(page.slug, chunks.map(c => ({
chunk_index: c.chunk_index,
@@ -558,6 +561,8 @@ export async function copyPageToTarget(
await target.putRawData(page.slug, rd.source, rd.data, sourceOpts);
}
await queuePageProjection(target, page.source_id ?? 'default', page.slug, 'engine_migration');
return {
chunks: chunks.length,
tags: tags.length,
@@ -795,6 +800,8 @@ export async function quiesceAutopilot(engine?: BrainEngine): Promise<(() => voi
}
export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]): Promise<void> {
await assertUnmanagedCanonicalWriter(sourceEngine, 'engine migration');
await assertLegacyEngineMigration(sourceEngine);
const opts = parseArgs(args);
const config = loadConfig();
if (!config) {
@@ -845,6 +852,13 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
const targetEngine = await createEngine(targetConfig);
await targetEngine.connect(targetConfig);
await targetEngine.initSchema();
try {
await assertUnmanagedCanonicalWriter(targetEngine, 'engine migration');
await assertLegacyEngineMigration(targetEngine);
} catch (error) {
try { await targetEngine.disconnect(); } finally { resumeAutopilot(); }
throw error;
}
// Load or create manifest for resume. Checked BEFORE the non-empty-target
// guard below: a manifest matching this exact target means the target's

View File

@@ -0,0 +1,135 @@
/** Engine-free parsing and delegation for explicitly local writer administration. */
import { resolve } from 'node:path';
import type { BrainEngine } from '../core/engine.ts';
import { getCliOptions } from '../core/cli-options.ts';
import { finishCliTeardown, setCliExitVerdict, writeStdoutFinal } from '../core/cli-force-exit.ts';
import { isThinClient, loadConfig, toEngineConfig } from '../core/config.ts';
import { resolveBrainId } from '../core/brain-resolver.ts';
import { loadMounts } from '../core/brain-registry.ts';
import { OperationError } from '../core/ops/contract.ts';
import { maybeDelegateLocalAdministration, persistenceConfigForBrain } from '../core/persistence/local-client.ts';
import { runPersistenceAdministration } from '../core/persistence/administration.ts';
import type { PersistenceAdminOperation } from '../core/persistence/admin-contract.ts';
import { reportPersistenceCliError } from './persistence-delegate.ts';
export const WRITER_HELP = `Usage:
gbrain sources writer status [<source>] [--probe] [--json]
gbrain sources writer claim <source> --path <directory> [--dry-run] [--json]
gbrain sources writer activate --confirm-quiesced [--dry-run] [--json]
gbrain sources writer transfer prepare <source> [--dry-run] [--json]
gbrain sources writer transfer accept <source> --path <worktree-root> --expected-epoch <n> --manifest <sha256> [--dry-run] [--json]
Use --brain <id> to select a database. Prepare drains the current owner and records
an exact manifest; accept requires that epoch and matching bytes on the successor.
Before activation, upgrade and stop older writers on every host, claim every
filesystem source, and inspect/release remaining legacy locks. --confirm-quiesced
records that operator intent; --dry-run performs the same checks without enabling.
No command takes over an owner based on a stale heartbeat.`;
export const LOCAL_WRITER_HELP = `Usage:
gbrain auth local-writer list [--limit <1-1000>] [--before <uuid>] [--json]
gbrain auth local-writer register <cli|stdio> [--source-ids <csv>] [--scopes read,write]
[--allowed-operations <csv>] [--slug-prefixes <csv>] [--replace] [--dry-run] [--json]
gbrain auth local-writer revoke <uuid> [--dry-run] [--json]
Use --brain <id> to select a database. Registrations default to all sources and
read/write operations. Existing grants never widen silently: --replace requires
the complete intended grant and revokes the prior registration. Credentials stay
in private local files. CLI is the trusted administration lane; stdio stays remote.
A revoked CLI cannot replace itself through a running owner. Stop that owner and
explicitly register --replace locally to authorize a new principal.`;
type Group = 'writer' | 'local-writer';
export function parsePersistenceAdminArgs(group: Group, args: string[]): {
operation: PersistenceAdminOperation; params: Record<string, unknown>; brain?: string; json: boolean;
} {
const positional: string[] = [];
const params: Record<string, unknown> = {};
let brain: string | undefined, json = false;
const values: Record<string, string> = {
'--path': 'path', '--source': 'source_id', '--expected-epoch': 'expected_epoch', '--manifest': 'manifest',
'--source-ids': 'source_ids', '--scopes': 'scopes', '--allowed-operations': 'allowed_operations',
'--slug-prefixes': 'slug_prefixes', '--limit': 'limit', '--before': 'before',
};
const arrays = new Set(['source_ids', 'scopes', 'allowed_operations', 'slug_prefixes']);
const seen = new Set<string>();
for (let i = 0; i < args.length; i++) {
const token = args[i];
if (!token.startsWith('-')) { positional.push(token); continue; }
const equal = token.indexOf('=');
const flag = equal < 0 ? token : token.slice(0, equal);
if (seen.has(flag)) throw new OperationError('invalid_params', `Duplicate option ${flag}.`);
seen.add(flag);
if (['--json', '--dry-run', '--replace', '--probe', '--confirm-quiesced'].includes(flag)) {
if (equal >= 0) throw new OperationError('invalid_params', `${flag} does not accept a value.`);
if (flag === '--json') json = true;
else params[flag.slice(2).replaceAll('-', '_')] = true;
continue;
}
if (flag !== '--brain' && !values[flag]) throw new OperationError('invalid_params', `Unknown administration option: ${flag}.`);
const value = equal >= 0 ? token.slice(equal + 1) : args[++i];
if (value === undefined || value.startsWith('--')) throw new OperationError('invalid_params', `${flag} requires a value.`);
if (flag === '--brain') { brain = value; continue; }
const key = values[flag];
params[key] = arrays.has(key) ? value.split(',').map(part => part.trim()).filter(Boolean)
: key === 'limit' ? Number(value) : key === 'path' ? resolve(value) : value;
}
let operation: PersistenceAdminOperation;
if (group === 'writer') {
const verb = positional.shift();
if (verb === 'status') operation = 'writer_status';
else if (verb === 'claim') operation = 'writer_claim';
else if (verb === 'activate') operation = 'writer_activate';
else if (verb === 'transfer') {
const phase = positional.shift();
if (phase !== 'prepare' && phase !== 'accept') throw new OperationError('invalid_params', 'Transfer requires prepare or accept.');
operation = phase === 'prepare' ? 'writer_transfer_prepare' : 'writer_transfer_accept';
} else throw new OperationError('invalid_params', 'Writer administration requires status, claim, activate, or transfer.');
const source = positional.shift();
if (source !== undefined) {
if (params.source_id !== undefined) throw new OperationError('invalid_params', 'Specify the source once.');
params.source_id = source;
}
} else {
const verb = positional.shift();
if (verb === 'list') operation = 'local_writer_list';
else if (verb === 'register') { operation = 'local_writer_register'; params.lane = positional.shift(); }
else if (verb === 'revoke') { operation = 'local_writer_revoke'; params.id = positional.shift(); }
else throw new OperationError('invalid_params', 'Local writer administration requires list, register, or revoke.');
}
if (positional.length) throw new OperationError('invalid_params', `Unexpected argument: ${positional[0]}.`);
return { operation, params, brain, json };
}
export async function runPersistenceAdminCli(group: Group, args: string[], connected?: BrainEngine): Promise<void> {
if (!args.length || args.some(arg => arg === '--help' || arg === '-h')) {
console.log(group === 'writer' ? WRITER_HELP : LOCAL_WRITER_HELP);
return;
}
let owned: BrainEngine | undefined;
try {
const parsed = parsePersistenceAdminArgs(group, args);
const brainId = resolveBrainId(parsed.brain ?? getCliOptions().brain);
const config = persistenceConfigForBrain(loadConfig(), brainId, brainId === 'host' ? [] : loadMounts());
if (!config) throw new OperationError('invalid_params', 'No brain is configured. Run gbrain init first.');
if (isThinClient(config)) throw new OperationError('permission_denied', 'Writer administration runs locally on the selected brain host; an ordinary remote token is not administration authority.');
const delegated = connected ? { handled: false as const } : await maybeDelegateLocalAdministration(parsed.operation, parsed.params, config,
{ timeoutMs: getCliOptions().timeoutMs ?? undefined });
let result: unknown;
if (delegated.handled) result = delegated.result;
else {
if (!connected) {
const { createEngine } = await import('../core/engine-factory.ts');
owned = await createEngine(toEngineConfig(config));
await owned.connect(toEngineConfig(config));
}
result = await runPersistenceAdministration(connected ?? owned!, parsed.operation, parsed.params);
}
await writeStdoutFinal(JSON.stringify(result, null, 2) + '\n');
} catch (error) {
if (!await reportPersistenceCliError(error, args.includes('--json'))) {
console.error(error instanceof Error ? error.message : String(error));
setCliExitVerdict(1);
}
} finally { if (owned) await finishCliTeardown({ engine: owned, drainTimeoutMs: 1000 }); }
}

View File

@@ -0,0 +1,79 @@
/** CLI-only commands may parse/read input before lazily connecting to a local engine. */
import type { BrainEngine } from '../core/engine.ts';
import type { GBrainConfig } from '../core/config.ts';
import { OperationError } from '../core/ops/contract.ts';
import { finishCliTeardown, setCliExitVerdict, writeStdoutFinal } from '../core/cli-force-exit.ts';
import { maybeDelegateLocalOperation } from '../core/persistence/local-client.ts';
import { PersistenceIpcTransportError } from '../core/persistence/ipc.ts';
import { RemoteMcpError } from '../core/mcp-client.ts';
export async function reportPersistenceCliError(error: unknown, json = false,
out: (payload: string) => Promise<void> = writeStdoutFinal): Promise<boolean> {
if (!(error instanceof OperationError || error instanceof PersistenceIpcTransportError
|| error instanceof RemoteMcpError && (error.detail?.request_id || error.detail?.write_request))) return false;
const detail = error.toJSON();
if (json) await out(JSON.stringify(detail, null, 2) + '\n');
console.error(error instanceof OperationError || error instanceof RemoteMcpError
? `Error [${detail.error}]: ${detail.message}` : error.message);
if (detail.suggestion) console.error(`Fix: ${detail.suggestion}`);
if (!json && error instanceof RemoteMcpError) {
console.error(`Request: ${error.detail?.request_id ?? error.detail?.write_request?.request_id}`);
}
setCliExitVerdict(1);
return true;
}
/** Shared operation CLI lane; false alone authorizes the caller's normal connect path. */
export async function runDelegatedCliOperation(
operation: string,
params: Record<string, unknown>,
config: GBrainConfig | null,
options: { brain?: string | null; timeoutMs?: number },
render: (operation: string, result: unknown, params: Record<string, unknown>) => string,
): Promise<boolean> {
try {
const delegated = await maybeDelegateLocalOperation(operation, params, config, options);
if (!delegated.handled) return false;
const output = render(operation, delegated.result, params);
if (output) await writeStdoutFinal(output);
if ((delegated.result as { status?: unknown } | null)?.status === 'error') setCliExitVerdict(1);
return true;
} catch (error) {
if (await reportPersistenceCliError(error, params.json === true)) return true;
throw error;
}
}
export async function runDeferredPersistenceCommand(
command: 'capture' | 'forget' | 'call' | 'sources' | 'takes',
args: string[],
connect: () => Promise<BrainEngine>,
): Promise<void> {
let connected: BrainEngine | null = null;
const getEngine = async () => connected ??= await connect();
try {
if (command === 'takes') {
const { runTakesMutation } = await import('./takes-mutation.ts');
await runTakesMutation(getEngine, args);
} else if (command === 'sources') {
if (args[0] === 'writer') {
const { runPersistenceAdminCli } = await import('./persistence-admin.ts');
await runPersistenceAdminCli('writer', args.slice(1));
} else {
const { runSourceLifecycleCli } = await import('./sources-lifecycle.ts');
await runSourceLifecycleCli(args, getEngine);
}
} else if (command === 'capture') {
const { runCapture } = await import('./capture.ts');
await runCapture(null, args, { getEngine });
} else if (command === 'forget') {
const { runForget } = await import('./recall.ts');
await runForget(getEngine, args);
} else {
const { runCall } = await import('./call.ts');
await runCall(getEngine, args);
}
} finally {
if (connected) await finishCliTeardown({ engine: connected, drainTimeoutMs: 1000 });
}
}

View File

@@ -717,10 +717,10 @@ function factRowToJson(r: FactRow): Record<string, unknown> {
};
}
export async function runForget(engine: BrainEngine, args: string[]): Promise<void> {
export async function runForget(engine: BrainEngine | (() => Promise<BrainEngine>), args: string[]): Promise<void> {
const idArg = args.find(a => /^\d+$/.test(a));
if (!idArg) {
process.stderr.write('Usage: gbrain forget <fact-id> [--reason <text>]\n');
process.stderr.write('Usage: gbrain forget <fact-id> [--reason <text>] [--source <id>] [--request-id <uuid>] [--json]\n');
process.exit(1);
}
const id = parseInt(idArg, 10);
@@ -730,6 +730,31 @@ export async function runForget(engine: BrainEngine, args: string[]): Promise<vo
let reason: string | undefined = undefined;
const idx = args.indexOf('--reason');
if (idx >= 0 && idx + 1 < args.length) reason = args[idx + 1];
const requestIndex = args.findIndex(arg => arg === '--request-id' || arg.startsWith('--request-id='));
const sourceIndex = args.findIndex(arg => arg === '--source' || arg.startsWith('--source='));
const requestValue = requestIndex < 0 ? undefined : args[requestIndex].startsWith('--request-id=')
? args[requestIndex].slice('--request-id='.length) : args[requestIndex + 1];
const sourceValue = sourceIndex < 0 ? undefined : args[sourceIndex].startsWith('--source=')
? args[sourceIndex].slice('--source='.length) : args[sourceIndex + 1];
const { parseWriteRequestId } = await import('../core/persistence/preconditions.ts');
const { randomUUID } = await import('node:crypto');
const { OperationError, operations } = await import('../core/operations.ts');
const { reportPersistenceCliError } = await import('./persistence-delegate.ts');
const json = args.includes('--json');
let requestId: string;
try {
if (requestIndex >= 0 && (!requestValue || requestValue.startsWith('--'))) {
throw new OperationError('invalid_params', '--request-id requires a UUID.');
}
if (sourceIndex >= 0 && (!sourceValue || sourceValue.startsWith('--'))) {
throw new OperationError('invalid_params', '--source requires a source ID.');
}
requestId = parseWriteRequestId(requestValue) ?? randomUUID();
} catch (error) {
if (await reportPersistenceCliError(error, json)) return;
throw error;
}
const params: Record<string, unknown> = { id: String(id), request_id: requestId, ...(reason !== undefined ? { reason } : {}) };
// v0.33: thin-client routing. Without this, `gbrain forget <id>` on a
// thin-client install would call the local fence helper against the empty
@@ -737,35 +762,45 @@ export async function runForget(engine: BrainEngine, args: string[]): Promise<vo
// remote brain.
const cfg = loadConfig();
if (isThinClient(cfg)) {
const params: Record<string, unknown> = { id };
if (reason !== undefined) params.reason = reason;
const raw = await callRemoteTool(cfg!, 'forget_fact', params, { timeoutMs: 30_000 });
const result = unpackToolResult<{ id: number; expired: boolean }>(raw);
if (!result.expired) {
process.stderr.write(`No active fact with id=${id}\n`);
process.exit(1);
try {
if (sourceValue) throw new OperationError('invalid_params', '--source cannot override the remote memory writer grant.');
const raw = await callRemoteTool(cfg!, 'forget', params, { timeoutMs: 30_000 });
const result = unpackToolResult<{ id: string; expired: boolean }>(raw);
if (json) console.log(JSON.stringify(result, null, 2));
else process.stdout.write(result.expired ? `Forgot fact id=${id}\n` : `Fact id=${id} was already withdrawn\n`);
} catch (error) {
if (await reportPersistenceCliError(error, json)) return;
console.error(error instanceof Error ? error.message : String(error));
console.error(`Retry the same forget with --request-id ${requestId}.`);
const { setCliExitVerdict } = await import('../core/cli-force-exit.ts');
setCliExitVerdict(1);
}
process.stdout.write(`Forgot fact id=${id}\n`);
return;
}
// v0.32.2: route through forgetFactInFence so the forget rewrites the
// page's `## Facts` fence and survives `gbrain rebuild`. Legacy rows
// fall back to the legacy DB-only expire path; the helper handles
// the fallback internally.
const { forgetFactInFence } = await import('../core/facts/forget.ts');
const result = await forgetFactInFence(engine, id, { reason });
if (!result.ok && result.path === 'not_found') {
process.stderr.write(`No fact with id=${id}\n`);
process.exit(1);
try {
const { maybeDelegateLocalOperation } = await import('../core/persistence/local-client.ts');
const { getCliOptions } = await import('../core/cli-options.ts');
const cli = getCliOptions();
const source = sourceValue ?? null;
const delegated = await maybeDelegateLocalOperation('forget', params, cfg, {
brain: cli.brain, source, timeoutMs: cli.timeoutMs ?? undefined,
});
let result: { id: string; expired: boolean };
if (delegated.handled) result = delegated.result as typeof result;
else {
const connected = typeof engine === 'function' ? await engine() : engine;
const sourceId = await resolveSourceId(connected, source);
const op = operations.find(operation => operation.name === 'forget')!;
result = await op.handler({ engine: connected, config: cfg ?? { engine: 'pglite' }, remote: false,
dryRun: false, sourceId, logger: { info: console.log, warn: console.warn, error: console.error } }, params) as typeof result;
}
if (json) console.log(JSON.stringify(result, null, 2));
else process.stdout.write(result.expired ? `Forgot fact id=${id}\n` : `Fact id=${id} was already withdrawn\n`);
} catch (error) {
if (await reportPersistenceCliError(error, json)) return;
throw error;
}
if (!result.ok && result.path === 'already_expired') {
process.stderr.write(`Fact id=${id} is already expired\n`);
process.exit(1);
}
const suffix = result.path === 'fence' ? '' : ' (legacy DB-only — will not survive gbrain rebuild)';
process.stdout.write(`Forgot fact id=${id}${suffix}\n`);
}
function renderToday(rows: FactRow[]): string {

Some files were not shown because too many files have changed in this diff Show More