diff --git a/.forgejo/workflows/delta-gate.yml b/.forgejo/workflows/delta-gate.yml new file mode 100644 index 00000000..c320594e --- /dev/null +++ b/.forgejo/workflows/delta-gate.yml @@ -0,0 +1,148 @@ +name: Delta Gate + +# On-demand candidate-vs-control gate on the capped functional CI lane +# (label: gate-functional). That lane is Bun-only host-mode — there is no +# Node.js runtime available to it, so this workflow deliberately avoids every +# JS-based action (checkout/setup-node/setup-bun/upload-artifact all require +# one) and does everything with plain git + bun in shell steps instead. +# +# Verdict lines a caller should grep for in the run log: +# COLLECTED patch= control= — collection-truncation guard inputs +# NEW-RED-COUNT: — failures on candidate absent from control +# DELTA-GATE: CLEAN | NEW REDS | INVALID | STOPPED-BY-REGISTRY-TRIPWIRE +# +# The lane's own housekeeping stops the runner and drops a marker file when +# host pressure (I/O, registry latency, disk budget) trips — never ours to +# interpret as a red or a green. The final step checks for that marker before +# it says anything about pass/fail. + +on: + workflow_dispatch: + inputs: + candidate: + description: 'Candidate ref (branch or sha) to gate' + required: true + type: string + control: + description: 'Control sha to diff against' + required: true + type: string + # workflow_dispatch needs Actions-unit write on the dispatching credential; + # push does not (it runs from the pushed ref's own tree), so a plain push + # to a release or CI branch is the fallback trigger while that grant is + # outstanding — see the ref-resolution step below for what it gates against. + push: + branches: ['rel/**', 'ci/**'] + +concurrency: + group: delta-gate + cancel-in-progress: false + +jobs: + delta-gate: + name: Delta gate — candidate vs control + runs-on: gate-functional + timeout-minutes: 120 + steps: + - name: Resolve candidate/control refs + id: refs + run: | + candidate="${{ github.event.inputs.candidate }}" + control="${{ github.event.inputs.control }}" + # workflow_dispatch supplies both explicitly; a push event carries + # neither — fall back to the pushed commit as candidate and the + # last released, known-good tip (10.4.9) as control, so a plain + # push still produces a meaningful gate instead of an empty ref. + if [ -z "$candidate" ]; then candidate="${{ github.sha }}"; fi + if [ -z "$control" ]; then control="eec90bdd"; fi + echo "candidate=$candidate" >> "$GITHUB_OUTPUT" + echo "control=$control" >> "$GITHUB_OUTPUT" + echo "Resolved (trigger=${{ github.event_name }}): candidate=$candidate control=$control" + + - name: Clean any residue from a prior run + run: rm -rf "ob-cand-${{ github.run_id }}" "ob-ctrl-${{ github.run_id }}" "/tmp/ob-${{ github.run_id }}-"* + + - name: Clone + test — candidate + id: patch + run: | + set -o pipefail + git clone --quiet "https://source.soulcraft.com/soulcraftlabs/open-brainy.git" "ob-cand-${{ github.run_id }}" + cd "ob-cand-${{ github.run_id }}" + git checkout --quiet "${{ steps.refs.outputs.candidate }}" + git log --oneline -1 + bun install + rc=0 + bun x vitest run > "/tmp/ob-${{ github.run_id }}-patch.log" 2>&1 || rc=$? + echo "PATCH-RC:$rc" + grep -aE "Tests .*(passed|failed)" "/tmp/ob-${{ github.run_id }}-patch.log" | tail -1 + grep -aE "^ FAIL |^\s+×" "/tmp/ob-${{ github.run_id }}-patch.log" | sed -E "s/ [0-9]+ms$//" | sed -E "s/^\s+//" | sort -u > "/tmp/ob-${{ github.run_id }}-patch.fail" + echo "PATCH-FAILING:$(wc -l < "/tmp/ob-${{ github.run_id }}-patch.fail")" + + - name: Clone + test — control + id: control + run: | + set -o pipefail + git clone --quiet "https://source.soulcraft.com/soulcraftlabs/open-brainy.git" "ob-ctrl-${{ github.run_id }}" + cd "ob-ctrl-${{ github.run_id }}" + git checkout --quiet "${{ steps.refs.outputs.control }}" + git log --oneline -1 + bun install + rc=0 + bun x vitest run > "/tmp/ob-${{ github.run_id }}-control.log" 2>&1 || rc=$? + echo "CONTROL-RC:$rc" + grep -aE "Tests .*(passed|failed)" "/tmp/ob-${{ github.run_id }}-control.log" | tail -1 + grep -aE "^ FAIL |^\s+×" "/tmp/ob-${{ github.run_id }}-control.log" | sed -E "s/ [0-9]+ms$//" | sed -E "s/^\s+//" | sort -u > "/tmp/ob-${{ github.run_id }}-control.fail" + echo "CONTROL-FAILING:$(wc -l < "/tmp/ob-${{ github.run_id }}-control.fail")" + + - name: Delta gate verdict + if: always() + run: | + set -o pipefail + + # The lane's own tripwire wins over anything we would otherwise say: + # a bare failure/timeout above with this marker present is host + # pressure, never a real red and never a real green. + if [ -f /srv/gate-lane/TRIPWIRE-STOPPED ]; then + echo "DELTA-GATE: STOPPED-BY-REGISTRY-TRIPWIRE" + head -1 /srv/gate-lane/TRIPWIRE-STOPPED + exit 3 + fi + + patch_log="/tmp/ob-${{ github.run_id }}-patch.log" + control_log="/tmp/ob-${{ github.run_id }}-control.log" + patch_fail="/tmp/ob-${{ github.run_id }}-patch.fail" + control_fail="/tmp/ob-${{ github.run_id }}-control.fail" + + if [ ! -s "$patch_log" ] || [ ! -s "$control_log" ]; then + echo "DELTA-GATE: INVALID — a leg produced no log (see the two steps above for the real cause)" + exit 2 + fi + + pt=$(grep -aoE "\(([0-9]+)\)$" "$patch_log" | tail -1 | tr -d "()") + ct=$(grep -aoE "\(([0-9]+)\)$" "$control_log" | tail -1 | tr -d "()") + echo "COLLECTED patch=${pt:-0} control=${ct:-0}" + if [ "${pt:-0}" -lt 3000 ] || [ "${ct:-0}" -lt 3000 ]; then + echo "DELTA-GATE: INVALID — truncated collection" + exit 2 + fi + + echo "=== NEW REDS ===" + comm -23 "$patch_fail" "$control_fail" + new=$(comm -23 "$patch_fail" "$control_fail" | wc -l) + echo "NEW-RED-COUNT:$new" + + echo "=== full candidate fail list ===" + cat "$patch_fail" + echo "=== full control fail list ===" + cat "$control_fail" + + if [ "$new" -eq 0 ]; then + echo "DELTA-GATE: CLEAN" + else + echo "DELTA-GATE: NEW REDS" + exit 1 + fi + + - name: Clean up (mind the lane's disk budget) + if: always() + run: rm -rf "ob-cand-${{ github.run_id }}" "ob-ctrl-${{ github.run_id }}" "/tmp/ob-${{ github.run_id }}-"* diff --git a/src/brainy.ts b/src/brainy.ts index 64e8707e..ffc2d5cd 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -1040,6 +1040,17 @@ export class Brainy implements BrainyInterface { } } + /** + * Factory hook for the generation store, so an engine built on top of this + * reference implementation can substitute a `GenerationStore` that keeps + * the same behavioural contract (for example, one backed by a native + * implementation) — overriding it never changes this engine's own + * behaviour, since the default implementation is unchanged. + */ + protected createGenerationStore(storage: BaseStorage): GenerationStore { + return new GenerationStore(storage) + } + /** * Initialize Brainy. * @@ -1297,7 +1308,7 @@ export class Brainy implements BrainyInterface { // guarantees indexes never observe rolled-back state. Reader-mode // instances skip recovery (readers never write; the next writer // repairs). - this.generationStore = new GenerationStore(this.storage) + this.generationStore = this.createGenerationStore(this.storage) const generationOpenResult = await step( 'generation-store.open', 'reading the generation manifest and committed ranges, opening the fact log and the ' + diff --git a/src/plugin.ts b/src/plugin.ts index 027a5367..64abfe26 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -460,8 +460,10 @@ export interface MetadataIndexProvider { * @param params - The find params, already normalized by `find()` * (natural-language parsed, `connected` anchors resolved to canonical ids, * an empty `where` dropped). - * @param hiddenIds - Ids this read must not return; apply BEFORE paging so - * `limit` stays exact. + * @param hiddenIds - Ids this read must not return. The contract is the ANSWER, not the + * mechanism: a provider may subtract this set before paging, or derive the + * same exclusion from the params' visibility tiers itself — either way the + * page must equal the engine's own answer with none of these ids in it. * @param graphIndex - The active graph provider, for a `connected` plan. * @returns The page's ids plus the stage that emptied it, or `null`. */ diff --git a/tests/integration/generation-store-factory.test.ts b/tests/integration/generation-store-factory.test.ts new file mode 100644 index 00000000..08b62619 --- /dev/null +++ b/tests/integration/generation-store-factory.test.ts @@ -0,0 +1,101 @@ +/** + * @module tests/integration/generation-store-factory + * @description Pins the `createGenerationStore` protected factory hook on + * `Brainy` ({@link Brainy.createGenerationStore}). The hook exists so an + * engine built on top of this reference implementation can substitute a + * `GenerationStore` that keeps the same behavioural contract; this suite + * proves two things: + * + * 1. A subclass overriding the hook is the ONLY path that constructs the + * generation store — it is called exactly once, with the same storage + * instance `performInit` holds — and the store the brain actually uses + * is the one the override returned. + * 2. The default (non-overridden) path is unaffected — proven here by + * confirming the base class still produces a plain `GenerationStore` + * wired to `brain.storage`, and separately by running the existing + * `db-mvcc` and `brainy-core.integration` suites unmodified against this + * change (they exercise generation-store behaviour end to end). + */ + +import { describe, it, expect, afterEach } from 'vitest' +import { Brainy } from '../../src/brainy.js' +import { GenerationStore } from '../../src/db/generationStore.js' +import type { BaseStorage } from '../../src/storage/baseStorage.js' + +/** Typed access to the brain's private storage + generation-store fields (test injection point). */ +function internalsOf(brain: Brainy): { storage: BaseStorage; generationStore: GenerationStore } { + return brain as unknown as { storage: BaseStorage; generationStore: GenerationStore } +} + +/** + * A `GenerationStore` subclass that counts its own construction and + * remembers the storage instance it was built with, so the test can prove + * the hook is the sole construction path without mocking the module. + */ +class SpyGenerationStore extends GenerationStore { + static constructCount = 0 + static lastStorage: BaseStorage | undefined + + constructor(storage: BaseStorage) { + super(storage) + SpyGenerationStore.constructCount++ + SpyGenerationStore.lastStorage = storage + } +} + +/** A Brainy subclass overriding the factory hook — stands in for an engine built on the reference. */ +class BrainyWithSpyStore extends Brainy { + hookCallCount = 0 + hookStorageArg: BaseStorage | undefined + + protected override createGenerationStore(storage: BaseStorage): GenerationStore { + this.hookCallCount++ + this.hookStorageArg = storage + return new SpyGenerationStore(storage) + } +} + +describe('Brainy.createGenerationStore — protected factory hook', () => { + const brains: Brainy[] = [] + + afterEach(async () => { + SpyGenerationStore.constructCount = 0 + SpyGenerationStore.lastStorage = undefined + for (const brain of brains.splice(0)) { + try { + await brain.close() + } catch { + // already closed by the test + } + } + }) + + it('a subclass override is the sole construction path: called once, same storage instance, its store is the one the brain uses', async () => { + const brain = new BrainyWithSpyStore({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + brains.push(brain) + + // Called exactly once, through the hook. + expect(brain.hookCallCount).toBe(1) + expect(SpyGenerationStore.constructCount).toBe(1) + + // Same storage instance the base class holds — not a copy, not a different adapter. + const { storage, generationStore } = internalsOf(brain) + expect(brain.hookStorageArg).toBe(storage) + expect(SpyGenerationStore.lastStorage).toBe(storage) + + // The store the brain actually uses is the one the override returned. + expect(generationStore).toBeInstanceOf(SpyGenerationStore) + }) + + it('the default (non-overridden) path still produces a plain GenerationStore wired to the same storage', async () => { + const brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } }) + await brain.init() + brains.push(brain) + + const { storage, generationStore } = internalsOf(brain) + expect(generationStore).toBeInstanceOf(GenerationStore) + // The default implementation constructs from the same storage the brain holds. + expect((generationStore as unknown as { storage: BaseStorage }).storage).toBe(storage) + }) +})