mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 17:32:37 +00:00
Compare commits
3
Commits
v0.45.19.0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a90547e6fe | ||
|
|
4922905fb9 | ||
|
|
3ebda1fc87 |
@@ -96,7 +96,9 @@ jobs:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
- name: Run Tier 1 E2E tests
|
||||
run: bun test --timeout=60000 test/e2e/mechanical.test.ts test/e2e/mcp.test.ts
|
||||
# job-isolation rides tier1 deliberately: e2e.yml runs only explicitly
|
||||
# NAMED files (no glob) — an unwired e2e file is silent coverage loss.
|
||||
run: bun test --timeout=60000 test/e2e/mechanical.test.ts test/e2e/mcp.test.ts test/e2e/job-isolation.test.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
|
||||
# #3485 preload guard: this job intentionally tests against a DB.
|
||||
|
||||
@@ -18,6 +18,11 @@ on:
|
||||
# label so we don't fan out on unrelated label changes.
|
||||
types: [labeled, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run_grok_door:
|
||||
description: 'Run the grok-door job (pre-secret posture: label or this input only)'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -122,11 +127,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Open the hermes opt-in door here so binary/auth absence — not the
|
||||
# opt-in var — is what skips (same posture as the claude/codex doors).
|
||||
# Open the hermes/grok opt-in doors here so binary/auth absence — not
|
||||
# the opt-in var — is what skips (same posture as the claude/codex
|
||||
# doors). The grok door's keyless tier additionally self-skips without
|
||||
# a grok binary, which a stock runner does not have.
|
||||
GBRAIN_REAL_HERMES_E2E: '1'
|
||||
GBRAIN_REAL_GROK_E2E: '1'
|
||||
# Pin so a provisioned runner's grok version-shape test asserts against
|
||||
# the supported version (and a colliding community `grok` binary fails
|
||||
# loud instead of running the keyless tier confusingly).
|
||||
GROK_VERSION: "1.0.4"
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
@@ -141,7 +155,8 @@ jobs:
|
||||
for f in \
|
||||
test/e2e/bootstrap-real-claude.serial.test.ts \
|
||||
test/e2e/bootstrap-real-codex.serial.test.ts \
|
||||
test/e2e/install-real-hermes.serial.test.ts; do
|
||||
test/e2e/install-real-hermes.serial.test.ts \
|
||||
test/e2e/install-real-grok.serial.test.ts; do
|
||||
[ -f "$f" ] && files+=("$f")
|
||||
done
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
@@ -184,6 +199,8 @@ jobs:
|
||||
GBRAIN_REAL_HERMES_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
@@ -277,11 +294,13 @@ jobs:
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-hermes.serial.test.ts > door.txt 2>&1 || EXIT=$?
|
||||
tail -40 door.txt
|
||||
# Preserve the FULL bun output UNCONDITIONALLY (upload stays
|
||||
# failure-gated): the zero-pass failure class below exits with the
|
||||
# summary as its only trace, and bun prints failure details before
|
||||
# the summary, so the 40-line tail can drop exactly the lines a
|
||||
# paid-CI triage needs.
|
||||
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
# Preserve the FULL bun output for the failure artifact — bun
|
||||
# prints failure details before the summary, so the 40-line tail
|
||||
# above can drop exactly the lines a paid-CI triage needs.
|
||||
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# This job provisions the binary + auth above, so the door must
|
||||
@@ -336,3 +355,243 @@ jobs:
|
||||
- name: Remove hermes credentials (unconditional)
|
||||
if: always()
|
||||
run: rm -f ~/.hermes/.env
|
||||
|
||||
# Grok door e2e (xAI Grok Build): PROVISIONS the real grok binary via the
|
||||
# pinned npm package (registry integrity verified — stronger than the
|
||||
# curl-installer path; both pins live in docs/mcp/GROK-CLI-PIN.md, enforced
|
||||
# against this file by scripts/check-grok-pin.sh in `bun run verify`).
|
||||
# KEYLESS-FIRST ordering (deliberate divergence from hermes-door): grok's
|
||||
# mcp add/list/doctor run keyless, so the compat tier runs and banks its
|
||||
# coverage BEFORE the secret precondition — a missing XAI_API_KEY still
|
||||
# fails this job loudly, but only after the free tier proved the install
|
||||
# surface, so pre-secret runs are diagnostic instead of pure red.
|
||||
#
|
||||
# PRE-SECRET GATING POSTURE: `real-agent-e2e` label or the run_grok_door
|
||||
# dispatch input ONLY — deliberately NOT `schedule` and NOT the generic
|
||||
# `heavy-tests` label, so an absent XAI_API_KEY secret cannot paint nightly
|
||||
# heavy runs (or unrelated heavy-labeled PRs) red. The commit that lands
|
||||
# AFTER an admin creates the XAI_API_KEY secret (an external prerequisite,
|
||||
# not a code change) re-adds: the schedule leg, the heavy-tests label leg,
|
||||
# a default-on dispatch, and a latest-version canary matrix leg
|
||||
# (continue-on-error, schedule-scoped, own timeout) so the pinned lane
|
||||
# stays deterministic while the canary tracks what users actually run.
|
||||
grok-door:
|
||||
name: Grok door e2e (real binary, keyless-first)
|
||||
if: |
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(github.event.pull_request.labels.*.name, 'real-agent-e2e')) ||
|
||||
(github.event_name == 'workflow_dispatch' &&
|
||||
inputs.run_grok_door == true)
|
||||
runs-on: ubuntu-latest
|
||||
# Measured local door wall-time: keyless tier ~29s + one-time compiled
|
||||
# gbrain build (~2-4 min) + npm install (~10s); paid SMOKE turn budget
|
||||
# 2 x 240s. 20 min = measured + >50% headroom (GROK-CLI-PIN.md).
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# Pin values documented in docs/mcp/GROK-CLI-PIN.md — update them
|
||||
# together, deliberately, after reviewing upstream changes
|
||||
# (scripts/check-grok-pin.sh fails `bun run verify` on drift).
|
||||
GROK_VERSION: "1.0.4"
|
||||
GROK_NPM_PACKAGE: "@xai-official/grok"
|
||||
GROK_NPM_INTEGRITY: "sha512-Nu3SFXTqwvCQr/LQFwrQYgngJhUQwX2h9ZSgzW4HowidjbPBWtMVO0xI88d2z6/zlDSNaT5YP/uk+2DthKQMsg=="
|
||||
# Per-platform payload pins: the wrapper's integrity covers only the
|
||||
# wrapper tarball; the binary that EXECUTES is the platform sub-package.
|
||||
GROK_NPM_LINUX_X64_INTEGRITY: "sha512-Dan2LfKcFBiabuDGHaGgMT8Ndzibo2ljvSjh4MlpV5117JL+S/0KMbdyYpk+13d7t+4znniW1cm+rRwUGSAvtw=="
|
||||
GROK_NPM_LINUX_ARM64_INTEGRITY: "sha512-zGK42Eq3ZmIa7cSVnl6CiJ4cxTCMsNLQCmCoLJhy5eZXfAvZ1DA3K3HXmKCj4OScX8SalYlp7mx8HWl9Y6gytw=="
|
||||
GBRAIN_REAL_GROK_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
- run: bun install
|
||||
|
||||
- name: Prepare evidence dir
|
||||
run: |
|
||||
echo "GBRAIN_E2E_EVIDENCE_DIR=$RUNNER_TEMP/grok-door-evidence" >> "$GITHUB_ENV"
|
||||
mkdir -p "$RUNNER_TEMP/grok-door-evidence"
|
||||
|
||||
# Compile gbrain ONCE for both bun test invocations below —
|
||||
# ensureCompiledGbrain's cache is per-process, so without this the
|
||||
# keyless and paid runs each pay the 2-4 min compile.
|
||||
- name: Build gbrain (compile once for both door runs)
|
||||
run: |
|
||||
bun build --compile --outfile "$RUNNER_TEMP/gbrain-door-bin" src/cli.ts
|
||||
echo "GBRAIN_COMPILED_BIN=$RUNNER_TEMP/gbrain-door-bin" >> "$GITHUB_ENV"
|
||||
|
||||
# SECRETLESS provisioning: the npm registry verifies the per-platform
|
||||
# payload against its integrity metadata; the pre-check pins that the
|
||||
# registry still serves the SAME integrity we observed (a re-published
|
||||
# 1.0.4 becomes a loud re-pin decision, not silently different code
|
||||
# running next to secrets in later steps). Version assert lives here
|
||||
# too — before any secret-bearing step.
|
||||
- name: Install grok (pinned npm package)
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
served=$(npm view "$GROK_NPM_PACKAGE@$GROK_VERSION" dist.integrity 2>/dev/null || true)
|
||||
if [ "$served" != "$GROK_NPM_INTEGRITY" ]; then
|
||||
echo "::error::grok npm integrity drift for $GROK_NPM_PACKAGE@$GROK_VERSION — registry serves '$served', pinned '$GROK_NPM_INTEGRITY'. Re-pin deliberately: update the stamps in docs/mcp/GROK-CLI-PIN.md + this workflow after reviewing upstream (see the pin doc's re-observation checklist)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# The platform sub-package is the binary that actually runs — pin it
|
||||
# too (per-arch; ubuntu-latest is x64 today, arm64 pinned for a
|
||||
# future runner switch).
|
||||
arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64) plat_pkg="$GROK_NPM_PACKAGE-linux-x64"; plat_pin="$GROK_NPM_LINUX_X64_INTEGRITY" ;;
|
||||
aarch64|arm64) plat_pkg="$GROK_NPM_PACKAGE-linux-arm64"; plat_pin="$GROK_NPM_LINUX_ARM64_INTEGRITY" ;;
|
||||
*) echo "::error::unsupported runner arch for the grok payload pin: $arch" >&2; exit 1 ;;
|
||||
esac
|
||||
plat_served=$(npm view "$plat_pkg@$GROK_VERSION" dist.integrity 2>/dev/null || true)
|
||||
if [ "$plat_served" != "$plat_pin" ]; then
|
||||
echo "::error::grok platform payload integrity drift for $plat_pkg@$GROK_VERSION — registry serves '$plat_served', pinned '$plat_pin'. Re-pin deliberately (GROK-CLI-PIN.md stamps + this workflow)." >&2
|
||||
exit 1
|
||||
fi
|
||||
npm install -g "$GROK_NPM_PACKAGE@$GROK_VERSION"
|
||||
if ! command -v grok >/dev/null 2>&1; then
|
||||
echo "::error::grok did not resolve on PATH after npm install" >&2
|
||||
exit 1
|
||||
fi
|
||||
version_output=$(grok --version)
|
||||
echo "$version_output"
|
||||
# Observed shape: `grok 1.0.4 (buildhash)` (GROK-CLI-PIN.md).
|
||||
if ! printf '%s' "$version_output" | grep -qF "grok $GROK_VERSION"; then
|
||||
echo "::error::grok version drift — expected 'grok $GROK_VERSION' in: $version_output (see docs/mcp/GROK-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# KEYLESS TIER FIRST — banks the free compat coverage (documented-shape
|
||||
# registration, TOML schema, doctor handshake proving the seven-verb
|
||||
# surface) before anything needs the secret. XAI_API_KEY is absent from
|
||||
# this step by construction, so the paid describe self-skips.
|
||||
- name: Run grok door tests (keyless tier)
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-grok.serial.test.ts > door-keyless.txt 2>&1 || EXIT=$?
|
||||
tail -40 door-keyless.txt
|
||||
cp door-keyless.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# Exact expected shape for this tier: 4 keyless tests pass, the
|
||||
# 1 paid test skips. Zero-pass or partial-pass refuses green.
|
||||
pass_count=$(grep -Eo '[0-9]+ pass' door-keyless.txt | tail -1 | grep -Eo '^[0-9]+' || true)
|
||||
if [ -z "$pass_count" ] || [ "$pass_count" -lt 4 ]; then
|
||||
echo "::error::grok door keyless tier expected 4 passing tests, summary shows '${pass_count:-none}' — refusing to go green (see docs/mcp/GROK-CLI-PIN.md triage table)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Preconditions (secret present)
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$XAI_API_KEY" ]; then
|
||||
echo "::error::XAI_API_KEY secret is empty — the keyless tier above already ran (its coverage is banked); the paid SMOKE needs the secret. Admin: create the XAI_API_KEY repo/environment secret (console.x.ai), then re-run. Fork PRs get no secrets from GitHub." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Named bad-key preflight: key-rot fails HERE, at a step named for it,
|
||||
# instead of surfacing as a confusing SMOKE failure (GROK-CLI-PIN.md
|
||||
# triage table). One minimal paid probe.
|
||||
- name: Auth preflight (bad-key tripwire)
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
export GROK_HOME="$RUNNER_TEMP/grok-preflight-home"
|
||||
mkdir -p "$GROK_HOME"
|
||||
# Verbatim kill-switch homes (update together): seedGrokConfig in
|
||||
# test/helpers/agent-harness.ts and scenarioGrokInstall in
|
||||
# scripts/dx-explore.ts.
|
||||
printf '[cli]\nauto_update = false\n' > "$GROK_HOME/config.toml"
|
||||
# This step runs the third-party agent binary directly: never hand
|
||||
# it the WRITABLE step-metadata files (appending to GITHUB_ENV/PATH
|
||||
# poisons the later secret-bearing steps — the same channel
|
||||
# grokChildEnv scrubs for test-spawned children), and kill its web
|
||||
# tools like the door SMOKE does.
|
||||
out=$(env -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_OUTPUT -u GITHUB_STATE -u GITHUB_STEP_SUMMARY \
|
||||
grok -p "reply with exactly: PREFLIGHT-OK" --output-format plain --disable-web-search 2>&1) || {
|
||||
echo "::error::grok auth preflight failed — the XAI_API_KEY secret is present but rejected (rotate it at console.x.ai; see GROK-CLI-PIN.md triage table). Output: ${out:0:300}" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "auth preflight ok"
|
||||
|
||||
- name: Run grok door tests (full — paid SMOKE included)
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
EXIT=0
|
||||
bun test --timeout=600000 test/e2e/install-real-grok.serial.test.ts > door.txt 2>&1 || EXIT=$?
|
||||
tail -40 door.txt
|
||||
cp door.txt "$GBRAIN_E2E_EVIDENCE_DIR/" 2>/dev/null || true
|
||||
if [ "$EXIT" -ne 0 ]; then
|
||||
exit "$EXIT"
|
||||
fi
|
||||
# PAID-SENTINEL: with the key present, a skipping paid tier must
|
||||
# never read as green (the split-gating false-green class). The
|
||||
# grep target is the suite's literal skip log — mirrored in
|
||||
# test/e2e/install-real-grok.serial.test.ts (change together).
|
||||
if grep -q 'SKIP paid tier' door.txt; then
|
||||
echo "::error::grok door paid tier skipped despite a present XAI_API_KEY — hasGrokAuth() gate drift; refusing to go green" >&2
|
||||
exit 1
|
||||
fi
|
||||
pass_count=$(grep -Eo '[0-9]+ pass' door.txt | tail -1 | grep -Eo '^[0-9]+' || true)
|
||||
if [ -z "$pass_count" ] || [ "$pass_count" -lt 5 ]; then
|
||||
echo "::error::grok door full run expected 5 passing tests (6 once the JSON tool-call test lands), summary shows '${pass_count:-none}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-update tripwire: the seeded [cli] auto_update=false is the whole
|
||||
# kill-switch (no env form observed) — a version that MOVED mid-job
|
||||
# means it failed and the pins above are no longer what just ran.
|
||||
- name: Version re-check (mid-job drift tripwire)
|
||||
if: always()
|
||||
run: |
|
||||
if command -v grok >/dev/null 2>&1; then
|
||||
version_output=$(grok --version || true)
|
||||
if ! printf '%s' "$version_output" | grep -qF "grok $GROK_VERSION"; then
|
||||
echo "::error::grok version moved mid-job — auto-update kill-switch failed (expected 'grok $GROK_VERSION', got: $version_output)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Scrub credentials from evidence (defensive)
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
env:
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
# Same triple as hermes-door: filenames, symlinks, content. Auth is
|
||||
# env-only here (the job never writes the key to disk — stronger
|
||||
# than the hermes .env posture), so the content grep is the layer
|
||||
# that matters for grok-written logs on the failure path.
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type f \( -name '.env' -o -name '*.env' -o -name 'mcp_credentials.json' \) -exec rm -f {} + 2>/dev/null || true
|
||||
find "$GBRAIN_E2E_EVIDENCE_DIR" -type l -delete 2>/dev/null || true
|
||||
if [ -n "$XAI_API_KEY" ]; then
|
||||
grep -rlF "$XAI_API_KEY" "$GBRAIN_E2E_EVIDENCE_DIR" 2>/dev/null | while IFS= read -r f; do
|
||||
echo "::warning::removing evidence file containing the API key: ${f#"$GBRAIN_E2E_EVIDENCE_DIR"/}" >&2
|
||||
rm -f "$f"
|
||||
done
|
||||
fi
|
||||
- name: Upload grok door evidence
|
||||
if: failure() && env.GBRAIN_E2E_EVIDENCE_DIR != ''
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: grok-door-evidence
|
||||
path: ${{ env.GBRAIN_E2E_EVIDENCE_DIR }}
|
||||
retention-days: 14
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Auth travels env-only, but grok MAY persist derived credentials after
|
||||
# an authed turn (the inventory is pending — GROK-CLI-PIN.md); remove
|
||||
# the known candidate unconditionally so nothing outlives the job even
|
||||
# on a future self-hosted runner.
|
||||
- name: Remove grok credentials (unconditional)
|
||||
if: always()
|
||||
run: |
|
||||
rm -f ~/.grok/mcp_credentials.json
|
||||
rm -rf "$RUNNER_TEMP/grok-preflight-home"
|
||||
# Cancellation/timeout bypasses the suite's afterAll cleanup — the
|
||||
# hermetic homes carry no key file (env-only auth) but may hold
|
||||
# grok-derived credentials once the authed inventory lands.
|
||||
rm -rf /tmp/gb-grok-* 2>/dev/null || true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.19.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.46.1.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. -->
|
||||
|
||||
+140
@@ -2,6 +2,146 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.46.1.0] - 2026-08-15
|
||||
|
||||
**A stuck job can no longer take down your whole worker.** Field reports from
|
||||
a production deployment ([#5](https://github.com/garrytan-agents/gbrain/issues/5),
|
||||
[#6](https://github.com/garrytan-agents/gbrain/issues/6)) showed two
|
||||
compounding failure modes: a handler that ignored its abort signal could only
|
||||
be "force-evicted" (abandoned but still running, still holding connections),
|
||||
and abandoned probe/renewal queries starved the connection pool until the
|
||||
worker killed itself with a misleading "DB unreachable" — while the database
|
||||
sat at a fraction of capacity. This release fixes the starvation class and
|
||||
adds real per-job blast-radius control.
|
||||
|
||||
### Added
|
||||
- **`gbrain jobs work --job-isolation process`** (also
|
||||
`gbrain jobs supervisor --job-isolation process`, env
|
||||
`GBRAIN_JOB_ISOLATION`): each claimed job runs in its own child process.
|
||||
A stuck handler is group-SIGKILLed for real instead of abandoned, a crash
|
||||
or memory blowup takes one job instead of all N, and the OS reclaims every
|
||||
leaked resource when the child dies. The worker keeps claiming, renewing,
|
||||
and recording; handler-error semantics (unrecoverable → dead, rate-lease →
|
||||
no attempt burned, backoff otherwise) are preserved across the boundary.
|
||||
Worker shutdown gives children the drain window to finish and report — a
|
||||
routine deploy never burns a job attempt. Recommended for long-running
|
||||
LLM-bound handlers; see the new section in `docs/guides/minions-deployment.md`.
|
||||
- **Health-probe verdicts that name the failing layer.** When the worker's DB
|
||||
probe fails, it now disambiguates via the direct session lane and says
|
||||
`pool_starved` ("server IS reachable; the fault is in the
|
||||
transaction-pooler path") or `server_unreachable` — instead of the blanket
|
||||
"DB unreachable" that historically sent operators debugging database
|
||||
capacity while the real fault was client-side. A startup warning also makes
|
||||
single-pool mode (direct-lane kill switch) loud instead of silent, and
|
||||
`docs/guides/queue-operations-runbook.md` gains a verdict-interpretation
|
||||
table.
|
||||
- `GBRAIN_POOL_MAX_LIFETIME_S`: explicit client-pool connection max-lifetime
|
||||
knob (0 disables; default stays the per-connection 30–60min jitter).
|
||||
|
||||
### Fixed
|
||||
- **Timed-out DB probes and lock renewals are now cancelled, not abandoned.**
|
||||
Every place that raced a query against a timer (health probe, minion lock
|
||||
renewal, cycle-drain renewal, submit-time queue probes, DB-lock refresh)
|
||||
previously let the losing query keep running on a checked-out connection —
|
||||
under pool exhaustion each abandoned racer held a slot and made the
|
||||
exhaustion worse, starving the lock heartbeat first. All five sites now
|
||||
abort the query via its cancellation signal so the slot is released.
|
||||
- Long-running maintenance holds (index rebuilds, non-transactional
|
||||
migrations, backfill write batches) now reserve from the direct session
|
||||
lane instead of pinning the worker's shared pool — capped so reserved
|
||||
holds always leave a direct-lane slot for the claim/renewal heartbeats,
|
||||
and falling back to the previous behavior when the direct lane is
|
||||
unavailable.
|
||||
|
||||
Full operational detail: `docs/guides/minions-deployment.md` (isolation
|
||||
sizing: connections, memory, spawn cost) and
|
||||
`docs/guides/queue-operations-runbook.md` (probe verdicts).
|
||||
|
||||
## [0.46.0.0] - 2026-08-14
|
||||
|
||||
**Your other agents' sessions become brain knowledge.** Until now only Claude
|
||||
Code sessions flowed into the brain automatically; every Codex rollout,
|
||||
OpenClaw session, and Hermes conversation on your disk — often years of
|
||||
decisions — was invisible. `gbrain transcripts ingest` imports them all as
|
||||
readable conversation pages with provenance back to the exact session file,
|
||||
and the facts pipeline makes them answer "what did I decide about X, in
|
||||
whichever agent I said it" as one query. Consumer chat exports (ChatGPT and
|
||||
Claude.ai `conversations.json`) import through the same door.
|
||||
|
||||
- **One command, six formats.** `gbrain transcripts ingest <path-or-glob>`
|
||||
auto-detects Claude Code JSONL, Codex rollouts, OpenClaw sessions, the
|
||||
Hermes SQLite store (read from a lock-safe copy), and extracted
|
||||
ChatGPT/Claude.ai exports. No arguments shows what it WOULD import across
|
||||
your harness directories; `--all` imports the discovered set;
|
||||
`gbrain transcripts status` shows the found-vs-imported gap per harness.
|
||||
- **Safe by default.** Secrets are redacted before anything is written
|
||||
(bodies, titles, speaker labels, and session metadata; plus your
|
||||
`harvest-private-patterns.txt` rules), message content that mimics
|
||||
conversation formatting cannot forge speakers or timestamps, and imports
|
||||
are a readable text-turn archive by design — tool payloads and thinking
|
||||
blocks never land in pages (one-line placeholders mark where they
|
||||
happened). Embedding is off by default for bulk backfills
|
||||
(opt in with the embed flag, or run the embed backfill later).
|
||||
- **Free to re-run.** Unchanged sessions skip on content hash; long sessions
|
||||
split into searchable parts that reconcile themselves when a session
|
||||
shrinks; interrupted runs converge on the next pass, healing any half-done
|
||||
writes. `--since last` resumes from the previous complete run and never
|
||||
advances past files it could not fully read.
|
||||
- **Facts on demand.** `--facts` extracts through the shipped
|
||||
conversation-facts pipeline under a budget cap; imported pages also flow
|
||||
into the existing scheduled backfill when that cycle phase is enabled.
|
||||
|
||||
### Added
|
||||
- `gbrain transcripts ingest` and `gbrain transcripts status` subcommands
|
||||
(engine-free `--help`), with discovery mode, `--all`, `--dry-run`,
|
||||
`--format`, `--limit`, `--since <iso|last>`, `--source-id`, `--facts`,
|
||||
`--max-cost-usd`, `--embed`, `--json`, `--quiet`.
|
||||
- Transcript-adapter seam at `src/core/transcripts/` (session-granular
|
||||
contract with per-file diagnostics and drift alarms; dated spec targets per
|
||||
host format) and adapters for Codex, OpenClaw, Hermes, ChatGPT export, and
|
||||
Claude.ai export; the shipped Claude Code parser gains an additive
|
||||
timestamp-preserving mode, regression-pinned for the hook lane.
|
||||
- Batch `slugs` selector on the conversation-facts extraction core (one
|
||||
invocation per import run; an empty list is a no-op, never a full-corpus
|
||||
walk).
|
||||
- Write-back fidelity e2e through the raw adapter path (gold-extractor
|
||||
seam), pinning cross-harness continuity in one source.
|
||||
|
||||
### Changed
|
||||
- `skills/conversation-archive` now routes the covered formats to the native
|
||||
importer and states the native-vs-manual privacy delta.
|
||||
- The fixture-privacy gate also scans the new transcript fixture corpus.
|
||||
|
||||
### Fixed
|
||||
- PGLite `putRawData` now detects a missing page like the Postgres engine
|
||||
(integrity failures abort instead of silently no-opping).
|
||||
|
||||
### To take advantage of v0.46.0.0
|
||||
Upgrade, then run `gbrain transcripts ingest` with no arguments to see every
|
||||
importable session log on the machine, and `gbrain transcripts ingest --all`
|
||||
to import them. Unzip consumer exports first and pass the extracted
|
||||
`conversations.json`. On PGLite, stop `gbrain serve` for the import (the
|
||||
single-writer lock error names the PID if you forget). Run
|
||||
`gbrain transcripts status` any time to see what's still waiting.
|
||||
## [0.45.20.0] - 2026-08-14
|
||||
|
||||
**Grok Build joins the supported-client roster.** xAI's `grok` CLI can now wire a gbrain brain in one command, and — like Hermes before it — the install path is proven against the real binary, not written from docs: every asserted flag, config shape, and exit-code quirk was observed against a pinned Grok Build install, recorded in a machine-checked pin document, and exercised by a real-binary e2e door that CI can run.
|
||||
|
||||
### Added
|
||||
|
||||
- **Grok Build install support.** `grok mcp add gbrain -- gbrain serve --surface verbs` wires the seven-verb memory surface into xAI's coding agent; [docs/mcp/GROK.md](docs/mcp/GROK.md) carries the full guide — registration, direct TOML config, the trust-gated vendor-config fallback (an existing Claude Code registration may already work), verification via `grok mcp doctor` (the honest probe: the add itself is lazy and always exits 0), headless auth, model pinning, auto-update pinning for reproducible environments, cron pairing, and troubleshooting (including the colliding community `grok` binary and where Grok actually discovers skills). `INSTALL_FOR_AGENTS.md` gains the matching "If you are Grok Build" block; the guide is honest that this is the brain-only install — the `gbrain bootstrap` personal-agent path doesn't support Grok yet.
|
||||
- **`gbrain claw-test --live --agent grok`.** Grok is the third registered agent runner, so guide-following friction runs and `gbrain friction diff --base <hermes-run> --compare <grok-run>` work out of the box. The runner records a version preamble in every transcript (a mis-bound community binary is diagnosable after the fact) and warns loudly when the operator's `~/.claude.json` registers gbrain — Grok reads vendor MCP configs for trusted folders, a contamination channel no other supported agent has.
|
||||
- **A real-binary "door" e2e for Grok** (`test/e2e/install-real-grok.serial.test.ts`), split-gated so the free tier needs no API key: version pin, the documented registration shape end-to-end, the seven-verb handshake proven keyless, a vendor-fallback provenance guard, and the direct-TOML surface all run with just the binary; the paid recall smoke (a per-run nonce fact, web search disabled) additionally needs `XAI_API_KEY`. A label-gated `grok-door` CI job provisions the pinned npm package (wrapper AND per-platform payload integrities pre-checked against the pin doc), banks the keyless coverage before any secret is required, and refuses green on a silently-skipped paid tier.
|
||||
- **`docs/mcp/GROK-CLI-PIN.md`** — the observed-behavior pin (config schema verbatim, lazy-add semantics, honest doctor discriminator, keyless TUI sign-in behavior, volatile-path inventory, supported-version policy) with machine-readable stamps enforced against the CI workflow by a new `check-grok-pin` verify guard, which fails closed if the pin doc ever disappears out from under the door job.
|
||||
- **A `grok-install` DX scenario** (`scripts/dx-explore.ts`) drives the REAL interactive Grok TUI through the brain-only install under a PTY, with a sign-in-wall early-stop so keyless runs record the friction in seconds instead of pasting into a login screen for the full wall clock.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Agent runners share their detection/env plumbing.** The byte-identical binary-resolution and env-filtering bodies moved out of the per-agent runners into `agent-runner.ts` (`detectBinary`/`filterAllowlistEnv`), shrinking every future runner; binary resolution never passes through a shell anymore. The live-lane Grok runner forwards only Grok's own credentials — the operator's Anthropic/OpenAI keys never reach a third-party binary.
|
||||
- **PTY transcripts are structurally redacted at every write site.** Provider-key values are replaced in every artifact — including the live screen mirror that outlives interrupted runs — with a hard-failing independent check behind the redaction; a secret split across output bursts can no longer be reassembled from the frame log. `--keyless` now genuinely drops provider keys in all install scenarios, the leak check never touches files that predate the run, and the PTY hot loops strip bounded windows instead of the whole buffer (repaint-heavy TUIs were making every poll quadratic).
|
||||
- **The hermes-door CI job got the same hardening sweep:** checkout token persistence off, and the door's full test output is preserved as evidence even for the failure class that previously left no trace.
|
||||
|
||||
To take advantage of v0.45.20.0: nothing changes for existing installs — this release adds a client, it doesn't modify brain behavior. Grok Build users: follow [docs/mcp/GROK.md](docs/mcp/GROK.md) (two commands: register, then `grok mcp doctor gbrain` to verify the seven-verb handshake). Maintainers enabling the paid CI lane: create the `XAI_API_KEY` repo secret, then land the follow-up that adds the schedule and canary legs.
|
||||
## [0.45.19.0] - 2026-08-15
|
||||
|
||||
**The interactive `gbrain init` pickers are now tested on a real terminal — and the repo carries one PTY layer instead of two.** The first thing every new user touches (the embedding provider picker and the search-mode picker) previously had no true-TTY coverage; the comments that claimed otherwise pointed at a harness nothing ever called.
|
||||
|
||||
@@ -227,6 +227,19 @@ Keep `--args` last (everything after it becomes server argv) and verify with
|
||||
`hermes mcp test gbrain` — the add exits 0 even on failure. Full reference:
|
||||
[docs/mcp/HERMES.md](docs/mcp/HERMES.md).
|
||||
|
||||
**If you are Grok Build** (xAI's `grok` CLI): register gbrain as your MCP server:
|
||||
|
||||
```bash
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
The add is lazy (exit 0 without connecting) — verify with
|
||||
`grok mcp doctor gbrain`, which spawns the server and must report
|
||||
`7 tools discovered`. This is the brain-only install; the `gbrain bootstrap`
|
||||
personal-agent path does not support Grok yet (Claude Code/Codex only).
|
||||
Verified against Grok Build v1.0.4. Full reference:
|
||||
[docs/mcp/GROK.md](docs/mcp/GROK.md).
|
||||
|
||||
Whether you scaffolded or not, read `skills/RESOLVER.md` (in your workspace, or the
|
||||
bundled copy at `~/gbrain/skills/RESOLVER.md` when running from the cloned repo). It's
|
||||
the skill dispatcher — tells you which skill to read for any task. Save this to your
|
||||
|
||||
@@ -173,6 +173,7 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
|
||||
- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config.
|
||||
- **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config.
|
||||
- **[Hermes](docs/mcp/HERMES.md)** — `printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve`. Keep `--args` last, and verify with `hermes mcp test gbrain` (the add exits 0 even on failure).
|
||||
- **[Grok Build](docs/mcp/GROK.md)** — `grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs`. The add is lazy (exit 0 without connecting) — verify with `grok mcp doctor gbrain`, which spawns the server and reports `7 tools discovered`. Verified against Grok Build v1.0.4.
|
||||
- **[OpenClaw](docs/mcp/OPENCLAW.md)** — the ClawHub bundle plugin registers gbrain automatically (`openclaw.plugin.json` ships in this repo), or add `{"command": "gbrain", "args": ["serve"]}` to `~/.openclaw/config.json`'s `mcpServers`.
|
||||
- **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers.
|
||||
- **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors.
|
||||
@@ -234,6 +235,21 @@ curl -X POST https://your-brain/ingest \
|
||||
For mobile capture, the inbox folder source picks up anything dropped into
|
||||
`~/.gbrain/inbox/` from iOS Shortcuts / AirDrop / Drafts / Finder.
|
||||
|
||||
Your other agents' histories import in one command. `gbrain transcripts ingest`
|
||||
parses agent session logs (Claude Code, Codex, OpenClaw, Hermes) and extracted
|
||||
consumer chat exports (ChatGPT / Claude.ai `conversations.json`) into readable
|
||||
conversation pages with provenance back to the exact session file. Secrets are
|
||||
scrubbed from message bodies, titles, speakers, and session metadata before
|
||||
anything is written, embedding is off by default for bulk backfills, and
|
||||
re-runs are free — unchanged sessions skip on content hash:
|
||||
|
||||
```bash
|
||||
gbrain transcripts ingest # discover importable session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
|
||||
gbrain transcripts status # found vs imported, per harness
|
||||
```
|
||||
|
||||
Third-party skillpacks can ship custom ingestion sources (Granola, Linear,
|
||||
voice, OCR) against the versioned `IngestionSource` contract at
|
||||
`gbrain/ingestion`. See [`docs/skillpack-anatomy.md`](docs/skillpack-anatomy.md).
|
||||
@@ -295,7 +311,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec
|
||||
|
||||
**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. **Obsidian-style vaults:** bare `[[note-name]]` wikilinks that point across folders — you wrote `[[struktura]]` but the page lives at `projects/struktura.md` — resolve by basename once you opt in with `gbrain config set link_resolution.global_basename true`. Off by default; `gbrain doctor` tells you how many edges you'd gain before you flip it. See [migrating an Obsidian vault](INSTALL_FOR_AGENTS.md#step-45-wire-the-knowledge-graph).
|
||||
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Opt-in per-job process isolation (`gbrain jobs work --job-isolation process`) runs each claimed job in its own SIGKILL-able child process, so a stuck handler dies for real and a crash takes one job instead of the whole worker; when the worker's DB health probe fails, it names the failing layer (`pool_starved` vs `server_unreachable`) instead of a blanket "DB unreachable". Sizing and rollout guidance in [`docs/guides/minions-deployment.md`](docs/guides/minions-deployment.md); probe-verdict triage in [`docs/guides/queue-operations-runbook.md`](docs/guides/queue-operations-runbook.md). Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
|
||||
**Non-English brains (FTS language config).** The Postgres full-text search tokenizer is configurable via `GBRAIN_FTS_LANGUAGE`. Defaults to `english`. Set it to any text-search configuration that exists in your Postgres instance:
|
||||
|
||||
|
||||
@@ -1,5 +1,87 @@
|
||||
# TODOS
|
||||
|
||||
## Issues #5+#6 follow-ups (pool starvation + process isolation; plan: ~/.claude/plans/system-instruction-you-are-working-witty-moore.md)
|
||||
|
||||
- [ ] **P1-companion — nested-checkout audit + dev-mode detection.** **What:**
|
||||
`transaction()` callers that invoke parent-engine methods (or module helpers
|
||||
taking `engine` not `tx`) take a SECOND read-pool slot while holding the tx
|
||||
slot — e.g. the `operations.ts` advisory-lock loop around `tx.addLink`. Under
|
||||
a saturated pool this is a client-side self-deadlock class. Audit call sites;
|
||||
add a dev-mode warning (e.g. a tx-depth counter consulted by `runUnsafe`).
|
||||
**Why:** the #6 incident's exact 240s-idle sessions were never reproduced
|
||||
under a debugger; this is the strongest remaining candidate — the shipped
|
||||
wave mitigates the starvation class but does not close this path. **Effort:**
|
||||
M. **Priority:** P1-companion.
|
||||
- [ ] **P2 — per-handler isolation policy.** **What:** a per-handler-name set
|
||||
(e.g. long-running LLM-bound handlers isolate, sub-second `lint`/`backlinks`
|
||||
stay inline) instead of the all-or-nothing `--job-isolation process`.
|
||||
**Why:** spawn cost (~0.3–1s) is noise for 644s subagent jobs, meaningful
|
||||
for sub-second handlers; one worker should be able to mix. **Context:**
|
||||
`worker.ts` executeJob's `isolated` gate is the seam. **Effort:** M.
|
||||
**Priority:** P2.
|
||||
- [ ] **P2 — per-child --max-rss caps.** **What:** RSS watchdog for isolation
|
||||
children (the worker-level watchdog covers the worker only in process mode;
|
||||
a startup note ships today). **Context:** child-job-runner.ts owns the child
|
||||
lifecycle; a poll of the child's RSS + group-kill on breach mirrors the
|
||||
worker watchdog. **Effort:** M. **Priority:** P2.
|
||||
- [ ] **P2 — jobs-side connection-budget clamp for isolated workers.** **What:**
|
||||
warn/clamp concurrency when `concurrency × (child pool + 1) + parent pools`
|
||||
exceeds a configured budget (GBRAIN_MAX_CONNECTIONS-style; precedent
|
||||
`sync-concurrency.ts:clampWorkersForConnectionBudget`). **Why:** isolation
|
||||
multiplies pooler CLIENT connections (~73 at concurrency 15); today the
|
||||
budget lives only in docs math. **Effort:** S. **Priority:** P2.
|
||||
- [ ] **P3 — --job-isolation pass-through for the autopilot's embedded
|
||||
supervisor.** **What:** `autopilot.ts` builds its own worker args; add the
|
||||
conditional flag there (jobs supervisor already passes through). **Effort:**
|
||||
S. **Priority:** P3.
|
||||
- [ ] **P3 — runLockRenewalTick adoption in the cycle drain.** **What:**
|
||||
`synthesize.ts` now uses the minimal `runDrainRenewalTick` (per-call signal +
|
||||
guard); adopting the full tick would add the audit channel + bounded
|
||||
reconnect. **Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — streaming child progress.** **What:** isolation children report
|
||||
progress via their own token-fenced DB writes today (identical to inline);
|
||||
an IPC stream would only add parent-side visibility (e.g. lifecycle events
|
||||
in `jobs watch`). **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — connection-audit release events + plain-idle visibility.**
|
||||
**What:** `logConnectionEvent` never emits `release`, so the JSONL cannot
|
||||
answer "who holds a slot"; and `getIdleBlockers` filters
|
||||
`state='idle in transaction'` only — the #6 incident's plain-`idle` sessions
|
||||
were invisible to it. **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — doctor connection_routing check.** **What:** wire
|
||||
`ConnectionManager.describeMode()` + `healthCheck()` (both currently
|
||||
zero-caller outside tests) into a doctor check naming the routing mode,
|
||||
kill-switch state, and per-pool probe latency. Comments in four files
|
||||
already reference this check as if it existed. **Effort:** S.
|
||||
**Priority:** P3.
|
||||
- [ ] **P3 — isolation test-gap follow-ups (pre-landing review).** **What:**
|
||||
(a) spawned-CLI negative tests for `jobs run-child` bootstrap guards (PGLite
|
||||
→ exit 13; missing job-id/env → exit 13) and for `jobs work` with
|
||||
isolation on + an unresolvable child CLI (fail-fast exit 1) — both need a
|
||||
real engine bootstrap so they live in the e2e lane; (b) a behavioral (not
|
||||
structural) test driving `withRefreshingLock` with a hung injected
|
||||
`handle.refresh` (signal aborted at timeout, no overlapping ticks); (c) a
|
||||
force-evict-skip test for isolation mode (needs the 30s evict window made
|
||||
injectable); (d) operator-flow message tests (verdict-tailored FATAL text,
|
||||
single-pool startup banner). **Why:** the ship coverage audit scored the
|
||||
wave 82% — these are the surviving gaps. **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — raceWithAbortTimeout shared helper.** **What:** the
|
||||
"Promise.race a query vs a setTimeout that aborts an AbortController,
|
||||
clearTimeout in finally" pattern now exists at five sites (db-probe
|
||||
withDeadline, synthesize runDrainRenewalTick, lock-renewal-tick callAbort,
|
||||
db-lock tickAbort, supervisor probeAbort), each re-deriving the same
|
||||
invariants. Extract one helper and adopt it. **Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — lazy handler resolution in run-child.** **What:** every isolation
|
||||
child runs full registerBuiltinHandlers (incl. plugin discovery) to resolve
|
||||
ONE handler; the job name is known from the row — a resolve-by-name path
|
||||
would skip discovery for builtins. Matters only if isolation is ever used
|
||||
for short jobs (documented as not the target). **Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — full checkout instrumentation via a Sql proxy.** **What:** the
|
||||
CheckoutGauge covers raw/direct/reserved/tx seams only; tagged-template
|
||||
traffic (most engine load) is untracked. A proxy around the postgres.js Sql
|
||||
callable could count real checkouts — investigate cost/fragility before
|
||||
building. **Why:** would turn the probe's "tracked subset" caveat into full
|
||||
coverage. **Effort:** M. **Priority:** P3.
|
||||
|
||||
## Security-process follow-ups (filed with Wave −1 of the fix-wave campaign, 2026-08-14)
|
||||
|
||||
- [ ] **P2 — Vulnerability disclosure policy.** **What:** a written disclosure
|
||||
@@ -254,14 +336,6 @@ fix-now findings landed on the branch; these four are the review-deferred tail.
|
||||
`requestToolsPersistLimiter`; the surface_change audit rows already give
|
||||
a DB-side count to enforce against if needed. **Effort:** medium.
|
||||
**Priority:** P3.
|
||||
- [ ] **P3 — cancel (not just abandon) timed-out submit-time queue probes.**
|
||||
**What:** the WP5 wedge/pause probes time-bound via Promise.race, but the
|
||||
losing query keeps running on the pool after the race resolves. Wire
|
||||
AbortSignal / statement_timeout so a slow probe releases its slot. **Why:**
|
||||
under pool exhaustion (the exact regime the probes exist to detect) an
|
||||
abandoned probe query holds a pooler slot and makes the exhaustion worse.
|
||||
**Context:** `src/core/minion/supervisor.ts` queryWedgeSignals callers in
|
||||
`src/core/operations.ts` submit paths. **Effort:** small. **Priority:** P3.
|
||||
- [ ] **P3 — document the status --json snapshot union under schema_version.**
|
||||
**What:** a short protocol note (docs/progress-events.md sibling) pinning
|
||||
the `get_status_snapshot` v2 shape as a discriminated union on
|
||||
@@ -361,7 +435,7 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at
|
||||
|
||||
- [ ] **`--live` agent-in-the-loop know-to-ask.** Replay fixtures with a real model deciding whether to issue retrieval calls; grade the agent, not just the deterministic reflex. Pre-registered in `docs/eval/BRAINBENCH.md` (the v1 metric grades the injection decision, which IS the shipped mechanism). Needs: seeded N-repeat methodology for model stochasticity + budget rails. Priority: P2.
|
||||
- [ ] **Intrusion-budget gating calibration.** `avg_injected_tokens` is reported, non-gating (decision 18) — a wrong threshold is worse than none. After a few weeks of scoreboard data across PRs, pick calibrated per-seam thresholds and promote it to a gated metric. Priority: P2.
|
||||
- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. Same for codex fragments when that integration lands. Priority: P1 (the claude-code integration has landed; this is now standalone-actionable).
|
||||
- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. For the codex half: the cathedral-4 transcripts lane shipped a verified codex rollout PARSER (`src/core/transcripts/codex.ts`, structural turn selection pinned against a live sample) — a codex contract adapter can now consume it instead of waiting for a hook integration. Priority: P1 (the claude-code integration has landed; codex parsing has landed; this is now standalone-actionable).
|
||||
- [ ] **Cathedral 1 conformance-kit fixture import.** The memory-verbs conformance scenarios convert to BrainBench fixtures via the published `evals/brainbench/schema/fixture.schema.json` once `garrytan/cathedral-1` merges ("conformance tests double as BrainBench seed fixtures", decision log 2026-06-12). Free corpus growth from already-reviewed scenarios. Blocked by: cathedral-1 on master. Priority: P2.
|
||||
- [ ] **Live-embeddings fidelity mode (`--embeddings`).** Hermetic CI grades the keyword/alias arms only (disclosed); an opt-in mode seeding real embeddings would grade write-back/continuity retrieval through the vector path. Same budget rails as `--llm`. Priority: P3.
|
||||
- [ ] **Community fixture intake + competitor adapters.** The TD1 remainder after the generated corpus absorbed in-PR growth: an `external-authors/`-style intake path for contributed fixtures (validator + privacy guard already gate them) and adapters for non-gbrain memory systems against the published schemas, enabling true head-to-head rows in the gbrain-evals scorecard. Priority: P3.
|
||||
@@ -5852,10 +5926,14 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
- [ ] **P2 — `gbrain ingest feed`: native feed adapter.** blog-ingest ships the
|
||||
agent-procedure layer; the durable path is a deterministic RSS/Atom adapter
|
||||
(discovery, pagination, canonical-URL dedup, 429 backoff) behind one command.
|
||||
- [ ] **P2 — Native AI-chat export importer.** conversation-archive converts
|
||||
ChatGPT/Claude/Perplexity exports via agent procedure; a native importer
|
||||
(export JSON → conversations/ pages) makes it deterministic. Pairs with the
|
||||
existing conversation-parser surface.
|
||||
- [x] **P2 — Native AI-chat export importer.** **Completed:** v0.46.0.0 (2026-08-14).
|
||||
`gbrain transcripts ingest` imports extracted ChatGPT and Claude.ai
|
||||
`conversations.json` exports natively (adapters at
|
||||
`src/core/transcripts/{chatgpt-export,claude-export}.ts`, rendering on the
|
||||
conversation-parser surface). Perplexity has no adapter yet — a candidate
|
||||
leaf module on the same `TranscriptAdapter` seam (the pattern the
|
||||
cathedral-4 "More harness adapters" follow-up below documents); the
|
||||
conversation-archive skill keeps the manual procedure for it meanwhile.
|
||||
- [ ] **P2 — Entity-guard as a native op.** phonetic-name-guard's own changelog
|
||||
proves prose-only failed: ASR-variant entity collisions need a native check
|
||||
(registry + alias table consulted at put/import time). The wave shipped the
|
||||
@@ -5914,3 +5992,91 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
|
||||
subsystem that deserves its own eng + CEO review, not a rider. The currency work
|
||||
(`skillpack status`/`sync`, doctor `skill_currency`) already keeps the brain's skill
|
||||
set current on upgrade; this item is purely about semantic retrieval of skills.
|
||||
|
||||
## Transcripts-import follow-ups (filed from cathedral-4, `gbrain transcripts ingest`)
|
||||
|
||||
Scoped OUT of the cathedral-4 PR by the CEO review's cherry-pick ceremony and the
|
||||
eng review — each carries a named design, none is a bug. Context: the import lane
|
||||
(adapters at `src/core/transcripts/`, session-atomic pipeline, embed-OFF default)
|
||||
covers DEAD logs; go-forward capture beyond Claude Code is deliberately absent.
|
||||
|
||||
- [ ] **OpenClaw go-forward capture.** Blocked upstream: the OpenClaw PluginApi exposes only `registerContextEngine` — no end-of-turn/agent-end capability. When the host grows one, the plugin (`src/openclaw-context-engine.ts`) subscribes and emits the session into the corpus lane (`~/.gbrain/transcripts/corpus` sidecar protocol) the way `gbrain hook session-end` does for Claude Code; the openclaw session PARSER already ships. Consent must ride a capture line like the bootstrap harness `--no-capture` model. Priority: P2.
|
||||
- [ ] **Codex go-forward capture (notify sweeper).** `docs/designs/AGENT_BOOTSTRAP_PLAN.md` FF2 names the design (notify sweeper over `~/.codex/sessions`); the rollout parser now ships in `src/core/transcripts/codex.ts`, so the sweeper is pure wiring: on codex notify, run `gbrain transcripts ingest <rollout> --quiet`. Needs the same consent posture as capture. Priority: P2.
|
||||
- [ ] **Scheduled re-import cycle phase.** `transcripts ingest --since last --all` as an opt-in cycle phase so dead-log import self-refreshes. REQUIRES its own consent-line design first: reading harness dirs on a schedule is capture-adjacent (the "Autonomous transcript watchers" decision above rules the spirit); the clean-scan watermark + status gap table already make manual re-runs cheap. Priority: P3.
|
||||
- [ ] **PII auto-detection redaction pass for imports.** The native lane redacts secrets (secret-scan) + user patterns (`harvest-private-patterns.txt`, emails included) and counts imperatives; broad PII detection (names, phones, addresses) is its own subsystem — the conversation-archive skill keeps the human scrub step for sensitive corpora meanwhile. Priority: P2.
|
||||
- [ ] **More harness adapters: Cursor / Gemini CLI / Copilot CLI.** Leaf modules on the `TranscriptAdapter` seam (~1h each with an agent): dated SPEC_TARGET + scrubbed fixture + drift alarm, per the shipped six. Formats unverified locally — verify a real sample first (the hermes gate pattern). Priority: P3.
|
||||
- [ ] **ChatGPT/Claude.ai export zip unwrapping.** v1 requires the EXTRACTED `conversations.json` ("unzip first" is documented + error-hinted). Add zip handling without a heavy dependency (Bun has no built-in zip; evaluate a minimal vendored inflate or shelling to `unzip` with confinement). Priority: P3.
|
||||
- [ ] **BrainBench raw-format fixture schema (sibling repo).** The in-repo pin (`test/e2e/transcripts-writeback-fidelity.test.ts`) grades raw files through the adapters with the gold extractor, but the BrainBench corpus schema (gbrain-evals) still rejects unknown keys and its corpus hash doesn't cover raw sidecars. Needs: versioned raw-fixture sidecar type + loader + hash coverage + baseline re-cut in gbrain-evals, then a `write_back_fidelity_raw` suite row here. Priority: P2.
|
||||
- [ ] **Hermes SPEC_TARGET verification against a populated store.** The schema came from the installed hermes-agent v0.20.0 source (`SCHEMA_SQL`), but no populated `state.db` existed on the dev machine — the fixture is synthetic-by-declaration. Verify against a real store after some Hermes sessions accrue, then flip `status: 'provisional'` → `'verified'` and pin the `active`/`compacted` semantics the adapter currently ignores. Priority: P3.
|
||||
## Grok Build wave follow-ups (filed at build time)
|
||||
|
||||
- [ ] **P1 — Enable the grok-door paid lane once XAI_API_KEY exists.** Admin
|
||||
creates the `XAI_API_KEY` repo/environment secret (console.x.ai; prefer a
|
||||
protected GitHub Environment scoped to door jobs), then one commit re-adds
|
||||
to `grok-door` in heavy-tests.yml: the `schedule` leg, the `heavy-tests`
|
||||
label leg, default-on dispatch, and a latest-version CANARY matrix leg
|
||||
(`continue-on-error`, schedule-scoped, own timeout) so the pinned lane stays
|
||||
deterministic while the canary tracks what users run. Same session: run the
|
||||
pending-auth Phase-0 observations (paid one-shot smoke, authed model list +
|
||||
measured per-turn cost pins, credential-file inventory after login → door
|
||||
evidence exclusions + TTY secretPaths, authed first-run TUI copy) into
|
||||
`docs/mcp/GROK-CLI-PIN.md`, and pin `parseGrokJson` + the separate
|
||||
non-retried JSON toolCall door test (one extra paid turn) once the
|
||||
streaming-json event shape is observed. Effort: S (CC ~30min + admin).
|
||||
- [ ] **P2 — `gbrain connect --agent grok`.** One-command install UX:
|
||||
`AgentId`/`AGENT_SPECS`/`AGENT_IDS` in `src/commands/connect.ts`, a
|
||||
`buildGrokMcpAddArgv` in `src/core/mcp-registration.ts` (shape already
|
||||
pinned in GROK-CLI-PIN.md), connect tests ("all four agents" pin moves to
|
||||
five), KEY_FILES entry. Deferred from the grok wave to avoid a second
|
||||
observation pass; the pin doc now exists, so this is mechanical. Effort: S.
|
||||
- [ ] **P2 — HERMES.md surface refresh.** The hermes register command predates
|
||||
the truthful-surface wave and wires the full 100+-op catalog;
|
||||
CLAUDE_CODE.md + GROK.md now recommend `--surface verbs`. Update the
|
||||
register one-liner + Direct config block (+ INSTALL_FOR_AGENTS hermes
|
||||
block) and re-verify against the pinned hermes. Effort: S.
|
||||
- [ ] **P2 — Backport the GITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUT/GITHUB_STATE
|
||||
deletion from `grokChildEnv` to `hermesChildEnv`** (and consider narrowing
|
||||
the `GITHUB_` ALLOW_PREFIX to the read-only metadata names) — the prefix
|
||||
rule forwards writable CI step-metadata files to untrusted agent children.
|
||||
Unit truth-table exists for the grok side to clone. Effort: S.
|
||||
- [ ] **P3 — Grok bootstrap-harness target.** `gbrain bootstrap` personal-agent
|
||||
support for Grok Build: `HarnessSelector` + `parseHarnessArgs`, a dated
|
||||
`TARGETS` spec in `host-specs.ts`, a `wireGrok` branch + TOML writer (grok
|
||||
config schema pinned; `codex-toml.ts` is the precedent), receipt/rollback/
|
||||
status handling, and the INSTALL_FOR_AGENTS honest-classification flip.
|
||||
Docs currently state "bootstrap does not support Grok yet". Effort: M.
|
||||
- [ ] **P3 — Door-adapter extraction + CI-tail composite action.** Trigger: the
|
||||
NEXT door agent (4th). Extract the agent-harness door family shape
|
||||
(resolve/auth/seed/childEnv/pin/turn) and hoist the shared workflow tail
|
||||
(evidence prep / scrub triple / upload / zero-pass grep / cred cleanup)
|
||||
into a composite action; port grok-door as first consumer. Until then the
|
||||
hermes-door/grok-door scrub blocks carry cross-reference comments. Also
|
||||
adopt a door CADENCE policy: nightly for the newest/most-churning agent,
|
||||
label-only after 2 stable monthly cycles per agent. Effort: M.
|
||||
- [ ] **P3 — Promote grok-install to a PTY assertion test.** Criterion: 2
|
||||
consecutive stable runs ≥1 month apart of the dx scenario (pre-ship ritual
|
||||
on grok-touching waves) with unchanged boot/sign-in copy. Would be the
|
||||
repo's first gbrain-driving PTY assertion test — keep it an instrument
|
||||
until the copy proves stable. Effort: M.
|
||||
- [ ] **P3 — Nightly cross-agent friction-diff artifact.** After door runs,
|
||||
`gbrain friction diff --base <hermes-run> --compare <grok-run>` rendered
|
||||
into a CI artifact so guide-following friction regressions surface without
|
||||
a dev-box session. Effort: S/M.
|
||||
- [ ] **P3 — `xai:` provider block in model-pricing.ts.** Grok models
|
||||
(grok-4.6/4.5 observed) for cost views once xAI pricing is sourced;
|
||||
separate concern from the harness wave (CANONICAL_PRICING discipline).
|
||||
Effort: S.
|
||||
- [ ] **P3 — BrainBench grok adapter.** `src/eval/brainbench/adapters/` +
|
||||
`ALL_HARNESSES` entry — same seam as the already-filed hermes adapter
|
||||
(TODOS "BrainBench hermes adapter"); build both together. Effort: M.
|
||||
- [ ] **P3 — Client-registry unification (Approach C).** The repo carries 7
|
||||
hardcoded client lists (connect AGENT_SPECS, bootstrap Harness,
|
||||
HarnessSelector, host-specs TARGETS, claw-test registry, brainbench
|
||||
ALL_HARNESSES, volunteer HARNESS_CHANNELS); grok proved the claw-test
|
||||
registry shape generalizes. Unify into one data-driven table AFTER the
|
||||
door-adapter extraction lands (earn it — don't freeze hermes-isms in).
|
||||
Effort: L.
|
||||
- [ ] **P3 — PIN-doc privacy-guard candidate.** GROK-CLI-PIN/HERMES-CLI-PIN
|
||||
carry verbatim observation transcripts; consider extending check-privacy.sh
|
||||
(or a dedicated check) to assert pin docs use `<tmp>`/placeholder paths and
|
||||
never carry key material or account ids. Effort: S.
|
||||
|
||||
@@ -99,6 +99,10 @@ Per-client setup guides live in [`docs/mcp/`](mcp/):
|
||||
- [`docs/mcp/CLAUDE_DESKTOP.md`](mcp/CLAUDE_DESKTOP.md)
|
||||
- [`docs/mcp/CHATGPT.md`](mcp/CHATGPT.md)
|
||||
- [`docs/mcp/PERPLEXITY.md`](mcp/PERPLEXITY.md)
|
||||
- [`docs/mcp/HERMES.md`](mcp/HERMES.md) — Hermes (Nous Research CLI)
|
||||
- [`docs/mcp/GROK.md`](mcp/GROK.md) — Grok Build (xAI CLI)
|
||||
- [`docs/mcp/OPENCLAW.md`](mcp/OPENCLAW.md) — OpenClaw (bundle plugin or stdio)
|
||||
- [`docs/mcp/CLAUDE_COWORK.md`](mcp/CLAUDE_COWORK.md) — Claude Cowork (team plan)
|
||||
- [`docs/mcp/DEPLOY.md`](mcp/DEPLOY.md) — production deploy patterns
|
||||
|
||||
The HTTP server ships with an admin SPA at `/admin`, an SSE activity feed at `/admin/events`, DCR-style client registration, scope-gated `read`/`write`/`admin` access, and rate limiting.
|
||||
|
||||
@@ -334,6 +334,18 @@ Unit tests and what they cover:
|
||||
- `test/enrichment-service.test.ts` — entity slugification, extraction, tier escalation.
|
||||
- `test/data-research.test.ts` — recipe validation, MRR/ARR extraction, dedup, tracker parsing, HTML stripping.
|
||||
- `test/minions.test.ts` — Minions job queue: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency, `child_done` inbox, attachments, removeOnComplete/Fail, `max_stalled` clamp/default/plumbing coverage.
|
||||
- `test/minion-queue-renewlock-signal.test.ts` — `renewLock` forwards its optional AbortSignal to `executeRawDirect` (stub-engine capture); legacy 3-arg calls unchanged; token-fence miss returns false.
|
||||
- `test/cycle-drain-renewal.test.ts` — `runDrainRenewalTick` (cycle drain): per-call signal aborted on timeout (slot released), onLost once on a lost fence, throws swallowed, hung renewal resolves at the deadline.
|
||||
- `test/queue-probe-cancellation.test.ts` — `probeQueueState`/`queryWedgeSignals` signal threading: the 1500ms budget CANCELS the losing probe query; fast-path signals never abort; throw still collapses to `{probe_failed: true}`.
|
||||
- `test/db-pool-max-lifetime.test.ts` — `resolveMaxLifetimeSeconds`: env forms, 0-disables, 30–60min jitter bounds, warn-once on invalid, per-call jitter variance.
|
||||
- `test/pool-gauge.test.ts` — `CheckoutGauge` pure semantics + the PostgresEngine seams with fake pools: counted while in flight, released on resolve, on REJECTED queries, and on the SYNCHRONOUS pre-aborted-signal throw (leak guards); `getPoolDiagnostics` fail-open.
|
||||
- `test/db-probe.test.ts` — `runDbProbe` verdict matrix (pool_starved / server_unreachable / unknown), honest-disjunction + no-waiter-arithmetic wording pins, hung probes cancelled via their signals, diagnostics absent/throwing fail open.
|
||||
- `test/postgres-engine-reserved-routing.test.ts` — `withReservedConnection` routing: direct pool when dual-pool active, read pool when kill-switched/in-tx, semaphore cap (directPoolSize−1) with read-pool overflow, permit released on fn throw and reserve failure.
|
||||
- `test/job-isolation-protocol.test.ts` — outcome-file codec round-trip + every decode failure path (missing/malformed/oversize→UnrecoverableError; byte counts, never content), handler-error instanceof reconstruction, child-CLI invocation resolution, and REAL detached-process `killProcessGroup` tests incl. the grandchild-death guarantee (exercises the Bun negative-pid `/bin/kill` fallback for real under `bun test`).
|
||||
- `test/run-child-entry.test.ts` — `runChildJobEntry` on real in-memory PGLite with a REAL claim-minted token: success (fenced updateProgress lands), handler-failure outcome (exit 0), token-mismatch never runs the handler (exit 14), missing job/handler, parent-death watchdog aborts a live handler.
|
||||
- `test/child-job-runner.test.ts` — `runJobInChild` against real .mjs children: success + full env contract (incl. `GBRAIN_DIRECT_POOL_SIZE=1`), error/lease outcome reconstruction, crash, SIGTERM-ignorer → group SIGKILL at the injected grace, pre-aborted signal, spawn ENOENT → `ChildSpawnInfraError`, worker-shutdown drain (report-during-drain completes; non-reporting kill → `ChildWorkerShutdownError`).
|
||||
- `test/worker-job-isolation.test.ts` — full parent path on PGLite with the `fake-run-child.mjs` fixture: claim → child → fenced completeJob (real token over env), error outcome → failJob, crash burns the attempt, spawn failure RELEASES with zero attempts burned, and the codex-2 #8 serialization-parity pin (unreportable results fail in BOTH modes, never falsely complete).
|
||||
- `test/jobs-isolation-flag.test.ts` — `parseJobIsolationFlag`: space/= forms, env fallback + flag-wins, empty-env default, other flags untouched.
|
||||
- `test/extract.test.ts` — link extraction, timeline extraction, frontmatter parsing, directory type inference.
|
||||
- `test/extract-db.test.ts` — `gbrain extract --source db`: typed link inference, idempotency, `--type` filter, `--dry-run` JSON output.
|
||||
- `test/extract-fs.test.ts` — `gbrain extract --source fs`: first-run inserts + second-run reports zero, dry-run dedups candidates across files, second-run perf regression guard for the N+1 dedup bug.
|
||||
@@ -400,12 +412,15 @@ E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `D
|
||||
- `test/e2e/sync.test.ts` — `--skip-failed` failure-loop test alongside happy-path tests: broken file → `performSync` returns `blocked_by_failures` with grouped breakdown → `performSync({skipFailed: true})` advances bookmark and returns `AcknowledgeResult` with code summary → second broken file → second cycle. Saves and restores the user's real `~/.gbrain/sync-failures.jsonl` so the test is hermetic. Asserts bookmark gating, JSONL state, dedup across paths, summary aggregation, and the literal doctor-rendering string format.
|
||||
- `test/e2e/upgrade.test.ts` — check-update against real GitHub API (network required).
|
||||
- `test/e2e/minions-shell-pglite.test.ts` — PGLite `--follow` inline shell-job path (in-memory, no `DATABASE_URL` required) — the path the minion-orchestrator skill documents for dev use.
|
||||
- `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/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 `#2084` describes pin every swept 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 (#2084 / the #1972→#2015→#2084 class). 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 — pre-#2084 it printed on 100% of query-shaped ops). Gated by `GBRAIN_PGBOUNCER_URL` + `GBRAIN_PGBOUNCER_DIRECT_URL` (NOT `DATABASE_URL`) — set automatically by `bun run ci:local`'s `pgbouncer` compose service; skips gracefully elsewhere. Uses a DEDICATED `gbrain_pgbouncer` database so it never races the `gbrain_test` TRUNCATE fixtures.
|
||||
- `test/e2e/volunteer-context-postgres.test.ts` — `volunteer_context` on REAL Postgres (#2095; 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.
|
||||
- `test/e2e/install-real-hermes.serial.test.ts` — the hermes "door": real `hermes` binary + real `hermes mcp add` handshake (full-catalog tool discovery; the count tracks the op catalog, so the test asserts discovery happened, not a number) + a paid `hermes -z` recall turn against a seeded brain. Triple-gated: `GBRAIN_REAL_HERMES_E2E=1` (explicit opt-in — run-e2e.sh scrubs GBRAIN_*, so it can never fire under `bun run test:e2e`) + resolvable binary + non-empty ANTHROPIC key (anthropic-pinned on purpose: a second provider key flips hermes provider-auto into a mis-routed 401). Hermetic HOME + HERMES_HOME with a tripwire on the operator's real config; evidence copies to `GBRAIN_E2E_EVIDENCE_DIR` for CI upload. Venue: heavy-tests.yml (`real-agent-e2e` + `hermes-door` jobs).
|
||||
- `test/e2e/install-real-grok.serial.test.ts` — the grok "door" (xAI Grok Build; every asserted shape observed against the pin in `docs/mcp/GROK-CLI-PIN.md`). SPLIT-GATED, a deliberate divergence from the hermes door: grok's `mcp add/list/doctor` run keyless, so the compat tier (version-shape pin, documented-shape `grok mcp add gbrain -- gbrain serve --surface verbs` via a PATH-staged bin dir, saved-TOML asserts via `Bun.TOML.parse`, `mcp doctor` handshake proving the seven-verb surface, vendor-fallback provenance guard, direct-TOML surface) needs only `GBRAIN_REAL_GROK_E2E=1` + a resolvable binary; the paid SMOKE additionally needs a non-empty `XAI_API_KEY` and asserts a PER-RUN NONCE fact (grok has fs/shell tools — the committed fact is greppable, so recall of it proves nothing) with web search disabled. `mcp add` is lazy (exit 0 always) — `mcp doctor <name> --json` is the honest discriminator (exit 0/1 observed). Hermetic HOME + GROK_HOME + tmp cwd on every spawn (grok reads vendor MCP configs for trusted folders and loads `.envrc` from cwd); bounded tripwire over the operator's real `~/.grok` config/credential files (volatile paths excluded — grok rewrites logs/sessions/bin/docs every run) + a checkout guard that no `.grok/`/`.mcp.json` appeared in the repo root. Venue: heavy-tests.yml (`real-agent-e2e` + `grok-door` jobs); run directly via `GBRAIN_REAL_GROK_E2E=1 bun test test/e2e/install-real-grok.serial.test.ts`.
|
||||
- `test/helpers/tty-harness.ts` + `test/tty-harness.test.ts` — the DX real-PTY harness (`Bun.spawn({terminal:})`): pure text/timing helpers unit-tested with zero subprocesses, plus three live PTY smokes against `sh` guarded by `describe.skipIf(!ptySupported())`. The harness itself is a dev instrument surface — its consumer `scripts/dx-explore.ts` never runs in CI (transcripts land in gitignored `.context/dx-runs/`); see `docs/guides/bootstrap.md` for the scenario runbook.
|
||||
- `test/e2e/search-swamp.test.ts` — reproduces the source-swamp case. Seeds a curated `originals/talks/article-outline-fat-code` page against two `<fork>/chat/` pages stuffed with the same multi-word phrase. Asserts the article wins keyword AND vector ranking, that `detail=high` lets the chat swamp re-surface, and that `source_id` passes through the two-stage CTE intact. PGLite in-memory.
|
||||
- `test/e2e/search-exclude.test.ts` — `test/` + `archive/` pages hidden by default, `include_slug_prefixes` opts back in, caller-supplied `exclude_slug_prefixes` adds to defaults. Both keyword and vector search paths.
|
||||
- `test/e2e/engine-parity.test.ts` — Postgres ↔ PGLite top-result and result-set parity for `searchKeyword` + `searchVector` (Postgres ranks pages then picks best chunk while PGLite returns chunks directly, so the source-boost behavior needs parity coverage). Skips without `DATABASE_URL`.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -294,7 +294,7 @@ bun test test/e2e/bootstrap-real-codex.serial.test.ts
|
||||
## DX exploration harness (developer instrument, not a test)
|
||||
|
||||
The door tests prove the install WORKS; they say nothing about how it FEELS.
|
||||
`test/helpers/tty-harness.ts` spawns any CLI (gbrain, `claude`, `codex`) under a
|
||||
`test/helpers/tty-harness.ts` spawns any CLI (gbrain, `claude`, `codex`, `grok`) under a
|
||||
real pseudo-terminal (Bun's `terminal:` spawn option) and records every output
|
||||
burst with a millisecond timestamp, so unnecessary pauses become a measurable
|
||||
artifact (`computeStalls` → `stalls.md`) instead of a vibe. Same hermetic env as
|
||||
@@ -313,6 +313,7 @@ bun run scripts/dx-explore.ts help # comprehension surfaces (no key
|
||||
bun run scripts/dx-explore.ts init [--keyless] # interactive init, naive-user autopilot
|
||||
bun run scripts/dx-explore.ts claude-install # REAL claude running the paste-in bootstrap
|
||||
bun run scripts/dx-explore.ts codex-install # REAL codex, same
|
||||
bun run scripts/dx-explore.ts grok-install # REAL grok, brain-only GROK.md install (no bootstrap path)
|
||||
bun run scripts/dx-explore.ts drive -- gbrain init # manual: steer a live TUI via a file channel
|
||||
```
|
||||
|
||||
|
||||
@@ -87,6 +87,69 @@ check warns if what you asked for isn't what's actually running (e.g. a
|
||||
negative value denied without privilege, or an OS `RLIMIT_NICE` clamp). This
|
||||
is distinct from the concurrency / inflight cap and composes with it.
|
||||
|
||||
### Per-job process isolation (`--job-isolation process`)
|
||||
|
||||
By default all concurrency slots execute inside one worker process. A
|
||||
handler that ignores its abort signal can only be force-evicted — the
|
||||
promise is abandoned, still running, still holding connections and memory —
|
||||
and any worker exit destroys every in-flight job at once. With isolation on,
|
||||
each claimed job runs in its own child process: a stuck handler is
|
||||
group-SIGKILLed for real (group signaling under Bun falls back to POSIX
|
||||
`/bin/kill`; if that's unavailable the worker logs that isolation is
|
||||
degraded), a crash or OOM in a child takes that one job instead of all N,
|
||||
and the OS reclaims every leaked resource when the child dies:
|
||||
|
||||
```bash
|
||||
# Recommended for long-running LLM-bound handlers (subagent):
|
||||
gbrain jobs supervisor --concurrency 4 --job-isolation process
|
||||
|
||||
# Bare worker, or durably via env:
|
||||
GBRAIN_JOB_ISOLATION=process gbrain jobs work --concurrency 4
|
||||
```
|
||||
|
||||
How it works: the worker keeps claim, lock renewal, and all result
|
||||
recording; the child (an internal `run-child` entrypoint of the same gbrain
|
||||
binary) re-validates the claim, runs the handler with its own small engine
|
||||
pool, and reports one atomic outcome file. Handler-error semantics are
|
||||
preserved across the boundary (unrecoverable → dead, rate-lease → no attempt
|
||||
burned, everything else → normal backoff). On worker shutdown children get
|
||||
the drain window to finish and report; a child killed before reporting is
|
||||
released with no attempt burned. If the worker dies hard, the orphaned child
|
||||
self-terminates via a parent-liveness watchdog and the stall sweeper
|
||||
requeues the job after lock expiry — the lock token fences the orphan's
|
||||
queue writes (result recording, progress, state transitions) into no-ops.
|
||||
The handler's own side effects (page writes through its engine) can still
|
||||
land until the watchdog stops the child; that window is the watchdog's
|
||||
poll + grace, not unbounded.
|
||||
|
||||
Sizing notes:
|
||||
|
||||
- **Connections:** each child opens its own small pools (read 3 by default,
|
||||
override via `GBRAIN_JOB_CHILD_POOL_SIZE`; direct 1). Worked example at
|
||||
concurrency 15: 15×(3+1) + the worker's 10+3 ≈ **73 client connections**
|
||||
total — 55 ride the transaction-pooler lane (multiplexed, no extra server
|
||||
backends) and 18 are lazy direct session-lane connections, each holding a
|
||||
real server backend while open. Budget the pooler-lane count against your
|
||||
pooler's client limit and the session-lane count against
|
||||
`max_connections`.
|
||||
- **Memory:** `--max-rss` covers the WORKER process only in this mode
|
||||
(handler memory lives in the children; the worker prints a note when both
|
||||
are set). There is no per-child RSS cap yet — a runaway child is contained
|
||||
only by host/container limits. Size host memory for concurrency × handler
|
||||
footprint.
|
||||
- **Spawn cost:** ~0.3–1s per job (engine connect included) — noise for
|
||||
long-running handlers, meaningful for sub-second ones (`lint`,
|
||||
`backlinks`). Keep those inline or on a separate inline worker.
|
||||
- **Security note:** the child receives the job's lock token via env. It is
|
||||
a *fencing* token (split-brain protection), not a secret — same-user env
|
||||
already contains the database URL.
|
||||
- **Child CLI resolution:** the worker fail-fast validates the child CLI at
|
||||
startup (compiled `gbrain` binary, bun-dev fallback, or the
|
||||
`GBRAIN_JOB_CHILD_CLI` env override — the ops/test escape hatch). Three
|
||||
consecutive child spawn/bootstrap failures self-exit the worker as
|
||||
unhealthy (a deterministically broken child CLI) for process-manager
|
||||
restart instead of burning attempts across the queue.
|
||||
|
||||
### Which supervisor when?
|
||||
|
||||
The supervisor solves in-process crash recovery. Platform-level
|
||||
|
||||
@@ -71,7 +71,10 @@ gbrain jobs get <id>
|
||||
## Rescue actions (in order of escalation)
|
||||
|
||||
```bash
|
||||
# Force-kill a single stuck job:
|
||||
# Cancel a single stuck job (inline mode: cooperative — the handler must
|
||||
# observe its abort signal, and after 30s it is force-evicted from tracking
|
||||
# but the promise keeps running; with --job-isolation process the child is
|
||||
# actually SIGTERM→SIGKILLed once cancellation is detected):
|
||||
gbrain jobs cancel <id>
|
||||
|
||||
# Clear a specific job entirely (last resort):
|
||||
@@ -119,6 +122,29 @@ claiming. Start one:
|
||||
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work --concurrency 4
|
||||
```
|
||||
|
||||
## Reading the DB-probe verdicts (pool starved vs server unreachable)
|
||||
|
||||
When the worker's health probe fails repeatedly, the terminal
|
||||
`[health] DB probe failed N consecutive times (verdict: ...)` line — and the
|
||||
`unhealthy` payload the supervisor sees — carries a verdict that names the
|
||||
failing LAYER (the intermediate `(N/3)` lines log only the failure detail).
|
||||
Read it before touching anything — the historical failure mode here was
|
||||
hours spent evaluating a database instance upgrade while the server sat at
|
||||
10% of max_connections.
|
||||
|
||||
| Verdict | What it means | What to do |
|
||||
|---|---|---|
|
||||
| `pool_starved` | The read-pool probe failed but the DIRECT-lane probe succeeded — the database server is reachable; the fault is in the transaction-pooler path (client pool exhaustion or a pooler-layer fault; the probe deliberately does not distinguish the two). | Look at client-side load: long-running handler queries holding slots, `GBRAIN_POOL_SIZE` too small for the workload, or a pooler-layer incident. Do NOT resize the database. The worker exit is correct recovery — it frees every client-held slot. |
|
||||
| `server_unreachable` | Both the pooler lane and the direct lane failed. | Check connectivity/capacity first: network, DNS, the database itself. Both-lanes-failed is the evidence — credential/config errors or a saturated direct lane can also land here, so glance at the probe detail text before concluding the server is down. |
|
||||
| `unknown` | The read probe failed and no direct lane exists to disambiguate (single-pool mode: non-Supabase, kill switch active, or no derivable direct URL). | Check the startup log for the single-pool warning; consider `GBRAIN_DIRECT_DATABASE_URL` so future incidents self-diagnose. |
|
||||
|
||||
The `gbrain-tracked in flight` counts in the message are a tracked SUBSET
|
||||
(raw/direct/reserved/transaction seams only) — most template-path queries are
|
||||
untracked, so `0 in flight` next to a `pool_starved` verdict means the
|
||||
saturation lives in that untracked traffic or at the pooler layer itself,
|
||||
not that the pool is idle. The verdict, not the counts, is the
|
||||
authoritative signal.
|
||||
|
||||
## Related
|
||||
|
||||
- [Minions worker deployment](minions-deployment.md) — supervisor lifecycle,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
# Grok Build CLI pin — observed behavior notes (v1.0.4)
|
||||
|
||||
Dev-facing companion to [GROK.md](GROK.md): every fact below was OBSERVED against a
|
||||
real install (2026-08-14), not researched from docs. The claw-test GrokRunner, the
|
||||
install door e2e, and the heavy-tests grok-door CI job assert exactly these shapes —
|
||||
when Grok Build releases change them, update this file, the workflow pins, and the
|
||||
affected assertions together (`scripts/check-grok-pin.sh` in `bun run verify` enforces
|
||||
the workflow-side match).
|
||||
|
||||
Naming note: **grok** (xAI Grok Build CLI, `XAI_API_KEY`) is not **groq** (Groq Inc.
|
||||
inference, `src/core/ai/recipes/groq.ts`, `GROQ_API_KEY`) and not **ngrok** (tunnels).
|
||||
|
||||
<!-- grok-pin: distribution_kind=npm -->
|
||||
<!-- grok-pin: npm_package=@xai-official/grok -->
|
||||
<!-- grok-pin: npm_version=1.0.4 -->
|
||||
<!-- grok-pin: npm_integrity=sha512-Nu3SFXTqwvCQr/LQFwrQYgngJhUQwX2h9ZSgzW4HowidjbPBWtMVO0xI88d2z6/zlDSNaT5YP/uk+2DthKQMsg== -->
|
||||
<!-- grok-pin: npm_linux_x64_integrity=sha512-Dan2LfKcFBiabuDGHaGgMT8Ndzibo2ljvSjh4MlpV5117JL+S/0KMbdyYpk+13d7t+4znniW1cm+rRwUGSAvtw== -->
|
||||
<!-- grok-pin: npm_linux_arm64_integrity=sha512-zGK42Eq3ZmIa7cSVnl6CiJ4cxTCMsNLQCmCoLJhy5eZXfAvZ1DA3K3HXmKCj4OScX8SalYlp7mx8HWl9Y6gytw== -->
|
||||
<!-- grok-pin: grok_version=1.0.4 -->
|
||||
<!-- grok-pin: installer_sha256=43d0943123edade1383a476a4f778674877acee7c1f98a00f094c4a0f7349321 -->
|
||||
<!-- grok-pin: observed_date=2026-08-14 -->
|
||||
|
||||
## Pin
|
||||
- **Grok Build v1.0.4**, `grok --version` output shape: `grok 1.0.4 (d846eb93d94d)`
|
||||
(version + build hash; the door's shape assert is `/^grok \d+\.\d+\.\d+ \([0-9a-f]+\)$/`).
|
||||
- **Provisioning (CI + local): pinned npm install** — `@xai-official/grok@1.0.4`,
|
||||
registry integrity `sha512-Nu3SFX…`. The package fans out to
|
||||
`@xai-official/grok-{darwin,linux,win32}-{arm64,x64}` optional deps at the same
|
||||
version; the CI job pins the LINUX payload integrities too (stamps above) because
|
||||
the wrapper's integrity covers only the wrapper tarball — the platform sub-package
|
||||
is the binary that executes. Load-bearing assumption, stated explicitly: npm
|
||||
version-immutability (a published version cannot be replaced on npmjs; only a new
|
||||
version or an unpublish, both of which fail the pinned install loudly).
|
||||
- Installer path (fallback only): `https://x.ai/cli/install.sh`, sha256
|
||||
`43d0943123edade1383a476a4f778674877acee7c1f98a00f094c4a0f7349321` (17,686 bytes).
|
||||
It SUPPORTS version pinning (`bash -s <X.Y.Z>`) and downloads versioned artifacts
|
||||
`grok-<version>-<os>-<arch>` from `https://x.ai/cli` (fallback GCS bucket
|
||||
`grok-build-public-artifacts`), self-checks `--version` post-download. Platform
|
||||
string from `uname -s`/`uname -m` with a Rosetta correction on Apple Silicon.
|
||||
- Verified against macOS arm64; npm `os`/`cpu` matrix covers linux x64/arm64 for CI.
|
||||
|
||||
## GROK_HOME — HONORED (verified)
|
||||
`GROK_HOME=<tmp> HOME=<tmp> grok mcp list|add|doctor` read+write `<tmp>/config.toml`
|
||||
and do NOT touch `~/.grok`. Belt-and-suspenders (HOME + GROK_HOME both to tmp) stays
|
||||
in the door anyway. NOTE what grok writes into `$GROK_HOME` on EVERY run (tripwire
|
||||
exclusions — these are VOLATILE): `active_sessions.lock`, `active_sessions.json`,
|
||||
`bin/grok-<version>` (it copies its own binary in), `logs/unified.jsonl`,
|
||||
`docs/user-guide/*.md` (it ships its user guide into the home), `leader.sock` (a
|
||||
leader daemon socket; `--leader-socket <PATH>` overrides). The tripwire hashes ONLY
|
||||
`config.toml` + credential-class files, never the volatile set.
|
||||
|
||||
## One-shot (`-p`)
|
||||
- `grok -p "<prompt>"` (`-p, --single`) prints the response to stdout and exits.
|
||||
- `--output-format plain|json|streaming-json|streaming-messages-json` (default plain;
|
||||
`streaming-json` = NDJSON of native ACP session updates; `streaming-messages-json` =
|
||||
Anthropic Messages wire format; `--include-partial-messages` adds deltas).
|
||||
- **Keyless one-shot: exit 1**, message (verbatim, both stdout and stderr):
|
||||
`Not signed in. To authenticate without a browser, run:\n grok login --device-code\n\nAlternatively, set the XAI_API_KEY environment variable or run `grok login` on a machine with a browser.`
|
||||
→ `hasGrokAuth()` = non-empty `XAI_API_KEY`; the TTY scenario's keyless early-stop
|
||||
matcher is `Not signed in`.
|
||||
- Cost/toolset flags that EXIST (observed in --help): `--always-approve`,
|
||||
`--permission-mode default|acceptEdits|auto|dontAsk|bypassPermissions|plan`,
|
||||
`--tools <LIST>`, `--disallowed-tools <LIST>`, `--allow/--deny <RULE>`,
|
||||
`--disable-web-search` (dedicated kill for web search + fetch — the door SMOKE uses
|
||||
THIS, not a tools list), `--max-turns <N>`, `-m/--model`, `--reasoning-effort`
|
||||
(alias `--effort`), `--cwd <PATH>`, `--rules`, `--prompt-file`, `--prompt-json`,
|
||||
`--json-schema` (implies json output), `--verbatim`, `--sandbox <PROFILE>`
|
||||
(env `GROK_SANDBOX`), `--no-memory`, `--no-plan`, `--no-subagents`.
|
||||
- There is NO auto-update CLI flag. Auto-update is config: `[cli] auto_update = true`
|
||||
is the DEFAULT — hermetic homes MUST seed `[cli] auto_update = false`. Manual
|
||||
updater: `grok update [--check --json --version <V> --force-reinstall --alpha]`.
|
||||
- `.envrc` gotcha: `load_envrc = true` by default — grok loads `.envrc` from the
|
||||
working directory. Door/live spawns pin `cwd` to tmp workspaces partly for this.
|
||||
|
||||
## Auth + model pin (non-interactive)
|
||||
- Keyless error pinned above; `grok login --device-code` exists for headless
|
||||
interactive auth; `XAI_API_KEY` env is the documented headless path (its end-to-end
|
||||
smoke is **pending auth** — no key was available at observation time; the door's
|
||||
paid tier stays skip-gated until then, per plan D0).
|
||||
- `grok models` works KEYLESS (exit 0): prints `You are not authenticated.`, then
|
||||
`Default model: grok-4.6` and the visible list (`grok-4.6 (default)`, `grok-4.5`).
|
||||
Authenticated list may be larger; per-turn cost pins are **pending auth**.
|
||||
- Model pin mechanism: per-call `-m <model>` (authoritative in tests — immune to
|
||||
config rewrites) and `[models] default = "<model>"` in config.toml.
|
||||
|
||||
## `grok mcp add` — THE big observed facts
|
||||
- Shape: `grok mcp add <name> [-e KEY=value]... [-s user|project] [-t stdio|http|sse] -- <command> [args...]`
|
||||
— everything after `--` is the server argv. **`-e/--env` is REPEATABLE, one
|
||||
KEY=value per flag** (their docs pin this as a breaking change from earlier
|
||||
releases: `use -e A=1 -e B=2, not --env A=1 B=2` — the hermes replace-bug class is
|
||||
fixed upstream). Server names: letters, numbers, hyphens, underscores only.
|
||||
- **Add is LAZY: exit 0 always, NO handshake at add time, no interactive prompt**
|
||||
(`Added stdio MCP server 'gbrain' … to user config` / `File modified:
|
||||
$GROK_HOME/config.toml`). Adding a NONEXISTENT command also exits 0. Never assert
|
||||
add's exit code; never treat `enabled = true` in the saved TOML as a handshake
|
||||
proof (it is written unconditionally).
|
||||
- Scope: `-s user` (default) → `~/.grok/config.toml`; `-s project` →
|
||||
`./.grok/config.toml` (committable; reference secrets as `${VAR}`).
|
||||
- **Bare command names resolve via the CALLER'S PATH** (verified): registering
|
||||
`-- gbrain serve --surface verbs` with a PATH-prefixed bin dir works — doctor
|
||||
resolved bare `gbrain` to the staged wrapper and completed the handshake. The
|
||||
bun-run wrapper shim (`#!/bin/sh\nexec bun run <abs>/src/cli.ts "$@"`) works as the
|
||||
staged binary (the fallback lane when a compiled binary is unavailable).
|
||||
- Startup timeout: per-server `startup_timeout_sec` (default 30) or global env
|
||||
`GROK_MCP_STARTUP_TIMEOUT_SECS` (seconds) / `MCP_TIMEOUT` (ms, Claude-compatible).
|
||||
The bun-run wrapper cold-transpiles slowly — the door sets 60+.
|
||||
|
||||
## Saved config schema (verbatim, from a real add)
|
||||
```toml
|
||||
[mcp_servers.gbrain]
|
||||
command = "/tmp/<staged-bin>/gbrain"
|
||||
args = [
|
||||
"serve",
|
||||
"--surface",
|
||||
"verbs",
|
||||
]
|
||||
enabled = true
|
||||
|
||||
[mcp_servers.gbrain.env]
|
||||
GBRAIN_SOURCE = "workspace"
|
||||
GBRAIN_HOME = "/tmp/<brain-home>"
|
||||
```
|
||||
Full schema keys (from grok's own shipped user guide, `$GROK_HOME/docs/user-guide/`):
|
||||
`command`, `args`, `env`, `enabled` (default true), `startup_timeout_sec` (default
|
||||
30), `tool_timeout_sec` (default 6000), `tool_timeouts`.
|
||||
|
||||
## Probes — the HONEST discriminator exists
|
||||
- **`grok mcp doctor <name> --json`**: SPAWNS the server for real. Good server →
|
||||
**exit 0** with checks `command found` / `server started` / `handshake OK`
|
||||
(`"detail": "protocol 2025-11-25"`) / **`7 tools discovered`** (the verbs surface's
|
||||
seven verbs, proven keyless end-to-end). Broken server (nonexistent command) →
|
||||
**exit 1**, check `command not found`, `passed: false`, plus a `hint`. THE door's
|
||||
hard discriminator; the T4 doctor pre-flight gates the paid loop (plan M6 resolves
|
||||
to the honest branch).
|
||||
- Doctor `--json` also enumerates config **sources** with per-source status —
|
||||
`~/.grok/config.toml`, `~/.claude.json`, `.mcp.json` — and each server carries a
|
||||
`"source"` field (`"config"`, `".mcp.json"`, …): the T2b provenance assertion reads
|
||||
this directly.
|
||||
- `grok mcp list --json` → exit 0, array of `{command, args, env, enabled, name,
|
||||
scope}`.
|
||||
- `grok inspect` (keyless, exit 0) shows version, CWD, `Project trusted: yes/no`,
|
||||
instructions, permissions, skills, agents — the config-discovery audit surface.
|
||||
|
||||
## Vendor-config fallback — TRUST-GATED (verified)
|
||||
A project `.mcp.json` in the cwd is SEEN by doctor (source `found`, server listed
|
||||
with `source: ".mcp.json"`) but the server check reports **`folder untrusted`** and
|
||||
`mcp list` shows nothing until the folder is trusted (first-run trust flow). So:
|
||||
fresh tmp HOME + fresh cwd ⇒ vendor entries structurally cannot activate (door
|
||||
provenance guarantee), and on an operator's machine the fallback only engages for
|
||||
folders they already trusted — the live-lane warning (operator `~/.claude.json`
|
||||
carrying `mcpServers.gbrain`) still applies for trusted folders.
|
||||
|
||||
## When the door goes red (triage)
|
||||
| Failure class | Signature | Remediation |
|
||||
|---|---|---|
|
||||
| npm pin drift | install step: version/integrity mismatch | Re-pin deliberately: bump `npm_version`+`npm_integrity` stamps here, re-run the re-observation checklist below, update workflow env pins (check-grok-pin.sh enforces the pair) |
|
||||
| installer digest drift (fallback path) | `sha256sum -c` fails on install.sh | Diff the new installer, re-pin `installer_sha256` after review |
|
||||
| version drift mid-run | `grok --version` re-check ≠ pinned | Auto-update engaged — verify `[cli] auto_update = false` seeding; re-pin if a deliberate bump |
|
||||
| blank XAI_API_KEY secret | named precondition/paid-sentinel failure | Admin adds/rotates the repo Actions secret (console.x.ai origin); keyless tier still ran |
|
||||
| invalid/expired key | bad-key preflight fails (pin its message after first authed run) | Rotate the secret; no code change |
|
||||
| tripwire fired | manifest mismatch on config/credential files only | True isolation breach — stop, inspect which file changed; volatile-path drift alone must NOT fire (bug in exclusions if it does) |
|
||||
| real door regression | doctor checks or recall assert fail with pins intact | Bisect against the pinned version; file upstream if grok-side |
|
||||
|
||||
Re-observation checklist on a version bump: re-run the npm/installer pin captures
|
||||
(§Pin), the help-surface diff (`--help`, `mcp --help`, `mcp add --help`), and the
|
||||
mcp add → saved-TOML → doctor sequence (§add/§probes). The one-shot/auth/model
|
||||
sections only need re-observation if their assertions start failing.
|
||||
|
||||
## Keyless TUI behavior (observed via the dx-explore PTY instrument)
|
||||
Under a real PTY with no credentials, interactive `grok` plays a Braille-
|
||||
pattern intro animation (U+2800-range glyphs) for a few seconds, then settles
|
||||
(~6s) onto a SIGN-IN screen: "Approve in your browser to finish signing in"
|
||||
plus a device code (and a ctrl+c hint). There is no unattended path past it.
|
||||
Two hazards for PTY automation, both observed: the animation frames carry
|
||||
zero word-like text (3+-letter runs) — a text-presence heuristic must count
|
||||
letter runs, not enumerate glyphs; and pasting into the sign-in screen leaves
|
||||
a persistent full-screen spinner redrawing at ~5 frames/sec, which starves
|
||||
quiet-based settling and makes full-buffer ANSI stripping the hot loop
|
||||
(strip bounded raw tails instead). Headless keyless is the clean
|
||||
`Not signed in` error above. The `grok-install` dx scenario early-stops at
|
||||
the sign-in copy (or a persistently textless screen) with the friction
|
||||
recorded — that IS the keyless measurement.
|
||||
|
||||
## Supported-version policy
|
||||
gbrain's grok integration is verified against **Grok Build v1.0.4** (this pin). The
|
||||
canary CI leg (enabled with the secret) tracks latest and is continue-on-error; the
|
||||
pinned lane is the deterministic gate. **Pending auth** (requires `XAI_API_KEY`):
|
||||
paid one-shot smoke, authed model list + per-turn cost pins, credential-file
|
||||
inventory after login (feeds evidence exclusions + TTY secretPaths), AUTHED
|
||||
first-run TUI dialog copy (the keyless TUI + headless copies are pinned above).
|
||||
@@ -0,0 +1,155 @@
|
||||
# Connect GBrain to Grok Build
|
||||
|
||||
> This page is the MCP-registration reference for **Grok Build** — xAI's
|
||||
> official `grok` CLI (early beta, subscriber-gated; not the community
|
||||
> `superagent-ai/grok-cli`, which ships a colliding `grok` binary — see
|
||||
> Troubleshooting). For the full brain install — CLI, engine, skills, dream
|
||||
> cycle — follow [INSTALL_FOR_AGENTS.md](../../INSTALL_FOR_AGENTS.md) first;
|
||||
> this page wires the finished brain into Grok Build over stdio MCP.
|
||||
> The `gbrain bootstrap` persistent-personal-agent path is **not yet
|
||||
> supported for Grok** (Claude Code and Codex only today) — brain-only
|
||||
> install is what this page delivers.
|
||||
|
||||
Grok Build spawns `gbrain serve` as a local stdio subprocess. No server, no
|
||||
tunnel, no token needed. Works with both PGLite and Supabase engines.
|
||||
|
||||
## Register (recommended)
|
||||
|
||||
```bash
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
`--surface verbs` exposes the seven-verb memory protocol (`recall`,
|
||||
`remember`, `entity`, `synthesize`, `forget`, `context_pack`, `delta` —
|
||||
[MEMORY_VERBS v1](../protocol/MEMORY_VERBS_v1.md)) instead of the full
|
||||
100+-op catalog — the recommended starting surface for coding agents.
|
||||
Three facts about `grok mcp add`, all observed:
|
||||
|
||||
- **The env flag is repeatable, one `KEY=value` per flag** (`-e A=1 -e B=2`).
|
||||
Server argv goes after `--`.
|
||||
- **Registration is lazy.** The add writes config and exits 0 without
|
||||
connecting — even for a nonexistent command. Verify with `grok mcp doctor`
|
||||
(below), never with the add's exit code.
|
||||
- **Scope:** the default writes to `~/.grok/config.toml`; add `-s project`
|
||||
to write a committable `./.grok/config.toml` instead (reference secrets as
|
||||
`${VAR}` in project scope — values are stored verbatim).
|
||||
|
||||
## Direct config (equally supported)
|
||||
|
||||
The add command writes an `[mcp_servers.gbrain]` block into
|
||||
`~/.grok/config.toml` (or `./.grok/config.toml` with project scope; the
|
||||
`GROK_HOME` env var relocates the user config dir). You can write it
|
||||
yourself instead:
|
||||
|
||||
```toml
|
||||
[mcp_servers.gbrain]
|
||||
command = "gbrain"
|
||||
args = ["serve", "--surface", "verbs"]
|
||||
startup_timeout_sec = 60
|
||||
enabled = true
|
||||
|
||||
[mcp_servers.gbrain.env]
|
||||
GBRAIN_HOME = "/home/alice-example"
|
||||
```
|
||||
|
||||
`startup_timeout_sec` defaults to 30; raise it (or export
|
||||
`GROK_MCP_STARTUP_TIMEOUT_SECS`) if gbrain runs from source via `bun run`,
|
||||
which cold-transpiles on first spawn. To remove gbrain, delete the block (or
|
||||
set `enabled = false` to disable without losing the config).
|
||||
|
||||
## Zero-config vendor fallback
|
||||
|
||||
Grok Build also reads MCP registrations from `~/.claude.json`, `.cursor/mcp.json`,
|
||||
and a project `.mcp.json` — at lower priority than its own config, and **only
|
||||
for folders you have trusted** in Grok (fresh folders report
|
||||
`folder untrusted` until you accept the trust prompt). If you already
|
||||
registered gbrain for Claude Code, Grok may pick it up with zero
|
||||
configuration. `grok mcp doctor --json` reports every source it consulted
|
||||
and which one each server came from — check the `source` field to see which
|
||||
config won before assuming the native one did.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
grok mcp list --json # entry: {"name":"gbrain","enabled":true,...}
|
||||
grok mcp doctor gbrain # THE real probe: spawns the server
|
||||
```
|
||||
|
||||
`grok mcp doctor gbrain` performs the actual handshake — expect the checks
|
||||
`command found`, `server started`, `handshake OK`, and `7 tools discovered`
|
||||
(the seven verbs), exit 0. A broken registration exits 1 with a failing
|
||||
check and a hint. Then one real round-trip:
|
||||
|
||||
```bash
|
||||
grok -p "use the gbrain recall tool to answer: what did I import most recently?"
|
||||
```
|
||||
|
||||
`grok -p` (single-turn headless) prints the final answer on stdout.
|
||||
|
||||
## Headless auth + model pin
|
||||
|
||||
For cron jobs, CI, or any non-TTY run:
|
||||
|
||||
- **Auth:** export `XAI_API_KEY` (from console.x.ai). Keyless headless runs
|
||||
exit 1 with `Not signed in`; `grok login --device-code` is the
|
||||
interactive-terminal alternative, `grok login` the browser one.
|
||||
- **Model pin:** pass `-m <model>` per call, or set it in config:
|
||||
|
||||
```toml
|
||||
[models]
|
||||
default = "grok-4.5"
|
||||
```
|
||||
|
||||
- **Updates:** Grok self-updates by default. For pinned/reproducible
|
||||
environments, seed:
|
||||
|
||||
```toml
|
||||
[cli]
|
||||
auto_update = false
|
||||
```
|
||||
|
||||
## Pair with cron
|
||||
|
||||
Grok Build has no built-in cron; schedule headless one-shots with your
|
||||
system scheduler:
|
||||
|
||||
```bash
|
||||
# crontab: brain maintenance every 4 hours
|
||||
0 */4 * * * XAI_API_KEY=... grok -p "Run gbrain sync and report anything unusual" --output-format plain
|
||||
```
|
||||
|
||||
See [docs/guides/cron-schedule.md](../guides/cron-schedule.md) for the full
|
||||
brain maintenance protocol (sync, embed, dream cycle).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Wrong `grok` on PATH** — the community `superagent-ai/grok-cli` also
|
||||
installs a `grok` binary. The official CLI answers `grok --version` with
|
||||
`grok X.Y.Z (buildhash)`; anything else is the other tool. Install the
|
||||
official one via `npm install -g @xai-official/grok` or
|
||||
`curl -fsSL https://x.ai/cli/install.sh | bash`.
|
||||
- **grok ≠ groq ≠ ngrok** — Grok Build (xAI, `XAI_API_KEY`) is not Groq
|
||||
(the inference provider, `GROQ_API_KEY`) and not ngrok (tunnels). A
|
||||
mis-set key produces auth errors against the wrong service.
|
||||
- **`Not signed in` (exit 1)** — no auth in a headless run. Export
|
||||
`XAI_API_KEY` or run `grok login --device-code`.
|
||||
- **Doctor says `folder untrusted`** — the registration came from a vendor
|
||||
config (`.mcp.json` / `~/.claude.json`) in a folder Grok hasn't been told
|
||||
to trust. Trust the folder in an interactive session, or register
|
||||
natively with `grok mcp add`.
|
||||
- **Doctor times out on `server started`** — raise `startup_timeout_sec`
|
||||
(or `GROK_MCP_STARTUP_TIMEOUT_SECS=90`) if gbrain runs via `bun run`.
|
||||
- **Skills note:** `gbrain skillpack scaffold` writes `skills/<name>/SKILL.md`
|
||||
into your workspace, which Grok does **not** auto-discover as Grok skills
|
||||
(it reads `.grok/skills`, `~/.grok/skills`, `~/.agents/skills`, plugins).
|
||||
gbrain's skills still work as reference documents the agent reads;
|
||||
`grok inspect` shows what Grok actually discovered.
|
||||
- **`grok inspect`** — the config-discovery audit: version, cwd trust,
|
||||
instructions, permissions, skills, agents, MCP sources.
|
||||
|
||||
---
|
||||
|
||||
Verified against **Grok Build v1.0.4** (early beta — expect churn; the pin
|
||||
is enforced in CI). Dev-facing observed-behavior notes (exact flag
|
||||
semantics, exit-code caveats, config schema, CI pin values) live in
|
||||
[GROK-CLI-PIN.md](GROK-CLI-PIN.md).
|
||||
@@ -28,6 +28,7 @@ Any of these commands stream events when `--progress-json` is set:
|
||||
- `gbrain eval`
|
||||
- `gbrain eval brainbench`
|
||||
- `gbrain apply-migrations` (the orchestrator + every child command)
|
||||
- `gbrain transcripts ingest` (per-file ticks + a per-session heartbeat over the import set)
|
||||
|
||||
Non-bulk commands (`stats`, `graph-query`, `get`, `put`, etc.) don't emit
|
||||
events — they return in under a second.
|
||||
@@ -158,6 +159,9 @@ Stable phase names shipped in v0.15.2:
|
||||
fixture count and a percentage would lie
|
||||
- `export.pages`
|
||||
- `files.sync`
|
||||
- `transcripts.ingest` (one tick per session-log file; sessions inside a
|
||||
multi-session file — the hermes store, consumer exports — don't get their
|
||||
own ticks, so total = file count; each session emits a heartbeat instead)
|
||||
|
||||
Sub-phases exposed via `child()`:
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@ If `claude` is not found: install Claude Code first, or use a block below.
|
||||
codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**Grok Build** (verify with `grok mcp doctor gbrain` — the add is lazy)
|
||||
```bash
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**OpenClaw / any stdio MCP host** — register the server command
|
||||
`gbrain serve --surface verbs`. Remote brains: `gbrain serve --http` on the
|
||||
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
|
||||
|
||||
+35
-1
@@ -1247,6 +1247,19 @@ Keep `--args` last (everything after it becomes server argv) and verify with
|
||||
`hermes mcp test gbrain` — the add exits 0 even on failure. Full reference:
|
||||
[docs/mcp/HERMES.md](docs/mcp/HERMES.md).
|
||||
|
||||
**If you are Grok Build** (xAI's `grok` CLI): register gbrain as your MCP server:
|
||||
|
||||
```bash
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
The add is lazy (exit 0 without connecting) — verify with
|
||||
`grok mcp doctor gbrain`, which spawns the server and must report
|
||||
`7 tools discovered`. This is the brain-only install; the `gbrain bootstrap`
|
||||
personal-agent path does not support Grok yet (Claude Code/Codex only).
|
||||
Verified against Grok Build v1.0.4. Full reference:
|
||||
[docs/mcp/GROK.md](docs/mcp/GROK.md).
|
||||
|
||||
Whether you scaffolded or not, read `skills/RESOLVER.md` (in your workspace, or the
|
||||
bundled copy at `~/gbrain/skills/RESOLVER.md` when running from the cloned repo). It's
|
||||
the skill dispatcher — tells you which skill to read for any task. Save this to your
|
||||
@@ -1770,6 +1783,7 @@ GBrain exposes nearly all of its 100+ operations as MCP tools (stdio and HTTP; a
|
||||
- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config.
|
||||
- **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config.
|
||||
- **[Hermes](docs/mcp/HERMES.md)** — `printf 'Y\n' | hermes mcp add gbrain --env GBRAIN_HOME=$HOME --connect-timeout 60 --command $(which gbrain) --args serve`. Keep `--args` last, and verify with `hermes mcp test gbrain` (the add exits 0 even on failure).
|
||||
- **[Grok Build](docs/mcp/GROK.md)** — `grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs`. The add is lazy (exit 0 without connecting) — verify with `grok mcp doctor gbrain`, which spawns the server and reports `7 tools discovered`. Verified against Grok Build v1.0.4.
|
||||
- **[OpenClaw](docs/mcp/OPENCLAW.md)** — the ClawHub bundle plugin registers gbrain automatically (`openclaw.plugin.json` ships in this repo), or add `{"command": "gbrain", "args": ["serve"]}` to `~/.openclaw/config.json`'s `mcpServers`.
|
||||
- **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers.
|
||||
- **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors.
|
||||
@@ -1831,6 +1845,21 @@ curl -X POST https://your-brain/ingest \
|
||||
For mobile capture, the inbox folder source picks up anything dropped into
|
||||
`~/.gbrain/inbox/` from iOS Shortcuts / AirDrop / Drafts / Finder.
|
||||
|
||||
Your other agents' histories import in one command. `gbrain transcripts ingest`
|
||||
parses agent session logs (Claude Code, Codex, OpenClaw, Hermes) and extracted
|
||||
consumer chat exports (ChatGPT / Claude.ai `conversations.json`) into readable
|
||||
conversation pages with provenance back to the exact session file. Secrets are
|
||||
scrubbed from message bodies, titles, speakers, and session metadata before
|
||||
anything is written, embedding is off by default for bulk backfills, and
|
||||
re-runs are free — unchanged sessions skip on content hash:
|
||||
|
||||
```bash
|
||||
gbrain transcripts ingest # discover importable session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
|
||||
gbrain transcripts status # found vs imported, per harness
|
||||
```
|
||||
|
||||
Third-party skillpacks can ship custom ingestion sources (Granola, Linear,
|
||||
voice, OCR) against the versioned `IngestionSource` contract at
|
||||
`gbrain/ingestion`. See [`docs/skillpack-anatomy.md`](docs/skillpack-anatomy.md).
|
||||
@@ -1892,7 +1921,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec
|
||||
|
||||
**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. **Obsidian-style vaults:** bare `[[note-name]]` wikilinks that point across folders — you wrote `[[struktura]]` but the page lives at `projects/struktura.md` — resolve by basename once you opt in with `gbrain config set link_resolution.global_basename true`. Off by default; `gbrain doctor` tells you how many edges you'd gain before you flip it. See [migrating an Obsidian vault](INSTALL_FOR_AGENTS.md#step-45-wire-the-knowledge-graph).
|
||||
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
**Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Opt-in per-job process isolation (`gbrain jobs work --job-isolation process`) runs each claimed job in its own SIGKILL-able child process, so a stuck handler dies for real and a crash takes one job instead of the whole worker; when the worker's DB health probe fails, it names the failing layer (`pool_starved` vs `server_unreachable`) instead of a blanket "DB unreachable". Sizing and rollout guidance in [`docs/guides/minions-deployment.md`](docs/guides/minions-deployment.md); probe-verdict triage in [`docs/guides/queue-operations-runbook.md`](docs/guides/queue-operations-runbook.md). Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything.
|
||||
|
||||
**Non-English brains (FTS language config).** The Postgres full-text search tokenizer is configurable via `GBRAIN_FTS_LANGUAGE`. Defaults to `english`. Set it to any text-search configuration that exists in your Postgres instance:
|
||||
|
||||
@@ -4420,6 +4449,11 @@ If `claude` is not found: install Claude Code first, or use a block below.
|
||||
codex mcp add gbrain -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**Grok Build** (verify with `grok mcp doctor gbrain` — the add is lazy)
|
||||
```bash
|
||||
grok mcp add gbrain -e "GBRAIN_HOME=$HOME" -- gbrain serve --surface verbs
|
||||
```
|
||||
|
||||
**OpenClaw / any stdio MCP host** — register the server command
|
||||
`gbrain serve --surface verbs`. Remote brains: `gbrain serve --http` on the
|
||||
host, then `gbrain connect https://host/mcp --token gbrain_xxx --install` on
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.45.19.0",
|
||||
"version": "0.46.1.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
"family": "bundle-plugin",
|
||||
"configSchema": {
|
||||
|
||||
+2
-1
@@ -50,6 +50,7 @@
|
||||
"check:admin-scope-drift": "bash scripts/check-admin-scope-drift.sh",
|
||||
"check:cli-exec": "bash scripts/check-cli-executable.sh",
|
||||
"check:engine-dynamic-import": "bash scripts/check-engine-dynamic-import.sh",
|
||||
"check:grok-pin": "bash scripts/check-grok-pin.sh",
|
||||
"check:gateway-routed": "bash scripts/check-gateway-routed-no-direct-anthropic.sh",
|
||||
"check:worker-pool-atomicity": "bash scripts/check-worker-pool-atomicity.sh",
|
||||
"check:doc-history": "bash scripts/check-key-files-current-state.sh",
|
||||
@@ -156,7 +157,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.19.0",
|
||||
"version": "0.46.1.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -21,10 +21,16 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FIXTURE_DIR="test/fixtures/conversation-formats"
|
||||
# cathedral-4: the transcripts-import fixtures (raw harness/export shapes)
|
||||
# carry the same placeholder-names-only contract as conversation-formats.
|
||||
FIXTURE_DIRS=("test/fixtures/conversation-formats" "test/fixtures/transcripts")
|
||||
|
||||
if [ ! -d "$FIXTURE_DIR" ]; then
|
||||
echo "[check-fixture-privacy] $FIXTURE_DIR does not exist; nothing to check"
|
||||
EXISTING_DIRS=()
|
||||
for d in "${FIXTURE_DIRS[@]}"; do
|
||||
[ -d "$d" ] && EXISTING_DIRS+=("$d")
|
||||
done
|
||||
if [ ${#EXISTING_DIRS[@]} -eq 0 ]; then
|
||||
echo "[check-fixture-privacy] no fixture dirs exist; nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -45,7 +51,7 @@ BANNED_TOKENS=(
|
||||
|
||||
errors=0
|
||||
for token in "${BANNED_TOKENS[@]}"; do
|
||||
matches=$(grep -ril "$token" "$FIXTURE_DIR" 2>/dev/null || true)
|
||||
matches=$(grep -ril "$token" "${EXISTING_DIRS[@]}" 2>/dev/null || true)
|
||||
if [ -n "$matches" ]; then
|
||||
echo "[check-fixture-privacy] BANNED token '$token' found in:"
|
||||
echo "$matches" | sed 's/^/ - /'
|
||||
@@ -61,4 +67,4 @@ if [ "$errors" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[check-fixture-privacy] OK: no banned tokens found in $FIXTURE_DIR"
|
||||
echo "[check-fixture-privacy] OK: no banned tokens found in ${EXISTING_DIRS[*]}"
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/check-grok-pin.sh — grok pin consistency guard.
|
||||
#
|
||||
# GROK-CLI-PIN.md is the single observed-behavior source for the grok
|
||||
# integration; its pins fan out to the heavy-tests grok-door job env, the
|
||||
# GrokRunner argv, and the door e2e assertions. The prose rule is "update
|
||||
# together" — this guard turns the workflow half of that rule into CI:
|
||||
#
|
||||
# 1. docs/mcp/GROK-CLI-PIN.md carries a machine-stable stamp block
|
||||
# (`<!-- grok-pin: key=value -->`, one per line) including
|
||||
# distribution_kind (npm | installer).
|
||||
# 2. The grok-door job env in .github/workflows/heavy-tests.yml must carry
|
||||
# EXACTLY the pin set for the chosen distribution_kind:
|
||||
# npm: GROK_VERSION==grok_version, GROK_NPM_PACKAGE==npm_package,
|
||||
# GROK_NPM_INTEGRITY==npm_integrity; no GROK_INSTALL_SHA256.
|
||||
# installer: GROK_VERSION==grok_version,
|
||||
# GROK_INSTALL_SHA256==installer_sha256; no GROK_NPM_INTEGRITY.
|
||||
# (The pin DOC may document both — the fallback path stays written down;
|
||||
# exclusivity is about which pins the WORKFLOW actually enforces.)
|
||||
#
|
||||
# Greps are anchored to the grok-door job block so a future canary matrix leg
|
||||
# (or a second door job) cannot satisfy the check by accident.
|
||||
#
|
||||
# SKIP-GRACEFUL: missing pin doc, missing workflow, or no grok-door job yet →
|
||||
# SKIP (exit 0), matching scripts/check-bootstrap-tag.sh. Test override:
|
||||
# GBRAIN_GROK_PIN_GUARD_ROOT points file resolution at a fixture tree.
|
||||
# BSD/GNU portable (no \t escapes, no GNU-only flags).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${GBRAIN_GROK_PIN_GUARD_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
|
||||
PIN_FILE="$ROOT/docs/mcp/GROK-CLI-PIN.md"
|
||||
WORKFLOW="$ROOT/.github/workflows/heavy-tests.yml"
|
||||
|
||||
if [ ! -f "$WORKFLOW" ]; then
|
||||
echo "check-grok-pin: SKIP (no $WORKFLOW)"
|
||||
exit 0
|
||||
fi
|
||||
if ! grep -q '^ grok-door:' "$WORKFLOW"; then
|
||||
echo "check-grok-pin: SKIP (no grok-door job in heavy-tests.yml yet)"
|
||||
exit 0
|
||||
fi
|
||||
# Once the grok-door job EXISTS, a missing pin doc is a FAILURE, not a skip —
|
||||
# deleting/renaming the doc must not silently disable the supply-chain gate.
|
||||
if [ ! -f "$PIN_FILE" ]; then
|
||||
echo "check-grok-pin: FAIL — grok-door job exists but $PIN_FILE is missing (the pin doc is the gate's source of truth)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail() {
|
||||
echo "check-grok-pin: FAIL — $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- 1. Parse the stamp block ------------------------------------------------
|
||||
stamp() {
|
||||
# First occurrence wins; a missing stamp yields the empty string (callers
|
||||
# decide whether that is a failure) — the `|| true` keeps set -e/pipefail
|
||||
# from treating grep's no-match exit as a script error.
|
||||
{ grep -E "^<!-- grok-pin: $1=" "$PIN_FILE" || true; } | head -1 \
|
||||
| sed -e 's/^<!-- grok-pin: [a-z0-9_]*=//' -e 's/ -->$//'
|
||||
}
|
||||
|
||||
# Duplicate stamps are drift bait (two values, which one is real?).
|
||||
dupes=$({ grep -E '^<!-- grok-pin: ' "$PIN_FILE" || true; } | sed -e 's/^<!-- grok-pin: //' -e 's/=.*$//' | sort | uniq -d)
|
||||
[ -n "$dupes" ] && fail "duplicate grok-pin stamp(s) in GROK-CLI-PIN.md: $dupes"
|
||||
|
||||
DIST_KIND=$(stamp distribution_kind)
|
||||
GROK_VERSION_PIN=$(stamp grok_version)
|
||||
[ -n "$DIST_KIND" ] || fail "GROK-CLI-PIN.md is missing the distribution_kind stamp"
|
||||
[ -n "$GROK_VERSION_PIN" ] || fail "GROK-CLI-PIN.md is missing the grok_version stamp"
|
||||
case "$DIST_KIND" in
|
||||
npm|installer) ;;
|
||||
*) fail "distribution_kind stamp must be npm or installer; got '$DIST_KIND'" ;;
|
||||
esac
|
||||
|
||||
# --- 2. Extract the grok-door job block --------------------------------------
|
||||
# Jobs sit at 2-space indent; the block ends at the next 2-space-indented key.
|
||||
job_block=$(awk '
|
||||
/^ grok-door:/ { f = 1; print; next }
|
||||
f && /^ [A-Za-z0-9_-]+:/ { exit }
|
||||
f { print }
|
||||
' "$WORKFLOW")
|
||||
[ -n "$job_block" ] || fail "could not extract the grok-door job block"
|
||||
|
||||
wf_env() {
|
||||
# Strip either quote style: a YAML-formatter pass flipping double to single
|
||||
# quotes must not read as pin drift.
|
||||
{ printf '%s\n' "$job_block" | grep -E "^ $1:" || true; } | head -1 \
|
||||
| sed -e "s/^ $1:[[:space:]]*//" -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'\$//"
|
||||
}
|
||||
|
||||
WF_VERSION=$(wf_env GROK_VERSION)
|
||||
WF_NPM_PACKAGE=$(wf_env GROK_NPM_PACKAGE)
|
||||
WF_NPM_INTEGRITY=$(wf_env GROK_NPM_INTEGRITY)
|
||||
WF_INSTALL_SHA=$(wf_env GROK_INSTALL_SHA256)
|
||||
|
||||
[ -n "$WF_VERSION" ] || fail "grok-door job env is missing GROK_VERSION"
|
||||
[ "$WF_VERSION" = "$GROK_VERSION_PIN" ] || fail "GROK_VERSION drift — workflow '$WF_VERSION' vs pin-doc stamp '$GROK_VERSION_PIN' (update together; see the pin doc's re-observation checklist)"
|
||||
|
||||
if [ "$DIST_KIND" = "npm" ]; then
|
||||
NPM_PACKAGE_PIN=$(stamp npm_package)
|
||||
NPM_INTEGRITY_PIN=$(stamp npm_integrity)
|
||||
[ -n "$NPM_PACKAGE_PIN" ] || fail "distribution_kind=npm but GROK-CLI-PIN.md is missing the npm_package stamp"
|
||||
[ -n "$NPM_INTEGRITY_PIN" ] || fail "distribution_kind=npm but GROK-CLI-PIN.md is missing the npm_integrity stamp"
|
||||
[ -n "$WF_NPM_PACKAGE" ] || fail "distribution_kind=npm but the grok-door job env is missing GROK_NPM_PACKAGE"
|
||||
[ -n "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=npm but the grok-door job env is missing GROK_NPM_INTEGRITY"
|
||||
[ "$WF_NPM_PACKAGE" = "$NPM_PACKAGE_PIN" ] || fail "GROK_NPM_PACKAGE drift — workflow '$WF_NPM_PACKAGE' vs stamp '$NPM_PACKAGE_PIN'"
|
||||
[ "$WF_NPM_INTEGRITY" = "$NPM_INTEGRITY_PIN" ] || fail "GROK_NPM_INTEGRITY drift — workflow vs stamp mismatch"
|
||||
# npm_version is a documented near-duplicate of grok_version — assert they
|
||||
# agree so bumping one alone can never pass green.
|
||||
NPM_VERSION_PIN=$(stamp npm_version)
|
||||
if [ -n "$NPM_VERSION_PIN" ] && [ "$NPM_VERSION_PIN" != "$GROK_VERSION_PIN" ]; then
|
||||
fail "npm_version stamp ($NPM_VERSION_PIN) disagrees with grok_version stamp ($GROK_VERSION_PIN) — update together"
|
||||
fi
|
||||
[ -z "$WF_INSTALL_SHA" ] || fail "distribution_kind=npm but the grok-door job also pins GROK_INSTALL_SHA256 — one provisioning mode only (mode exclusivity)"
|
||||
else
|
||||
INSTALL_SHA_PIN=$(stamp installer_sha256)
|
||||
[ -n "$INSTALL_SHA_PIN" ] || fail "distribution_kind=installer but GROK-CLI-PIN.md is missing the installer_sha256 stamp"
|
||||
[ -n "$WF_INSTALL_SHA" ] || fail "distribution_kind=installer but the grok-door job env is missing GROK_INSTALL_SHA256"
|
||||
[ "$WF_INSTALL_SHA" = "$INSTALL_SHA_PIN" ] || fail "GROK_INSTALL_SHA256 drift — workflow vs stamp mismatch"
|
||||
[ -z "$WF_NPM_INTEGRITY" ] || fail "distribution_kind=installer but the grok-door job also pins GROK_NPM_INTEGRITY — one provisioning mode only (mode exclusivity)"
|
||||
fi
|
||||
|
||||
echo "check-grok-pin: ok ($DIST_KIND mode, grok $GROK_VERSION_PIN)"
|
||||
+320
-98
@@ -69,6 +69,10 @@ import {
|
||||
saveTranscript,
|
||||
seedClaudeTuiConfig,
|
||||
parseDriveCommand,
|
||||
ptySupported,
|
||||
redactSecrets,
|
||||
stripAnsi,
|
||||
MIN_REDACT_SECRET_LEN,
|
||||
type TtySession,
|
||||
} from '../test/helpers/tty-harness.ts';
|
||||
|
||||
@@ -118,11 +122,14 @@ interface CliArgs {
|
||||
driveArgv: string[];
|
||||
}
|
||||
|
||||
/** Provider keys the hermetic base allows through; --keyless drops them. */
|
||||
/** Provider keys the hermetic base allows through; --keyless drops them.
|
||||
* Also the redaction-map source: every non-empty value here is scrubbed
|
||||
* from every written artifact. */
|
||||
const PROVIDER_KEY_NAMES = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'XAI_API_KEY',
|
||||
'GSTACK_ANTHROPIC_API_KEY',
|
||||
'GSTACK_OPENAI_API_KEY',
|
||||
];
|
||||
@@ -202,10 +209,25 @@ interface ScenarioCtx {
|
||||
* suffixes). ALWAYS deleted at cleanup — --keep keeps transcripts and
|
||||
* hermetic dirs for forensics, never credentials. */
|
||||
secretPaths: string[];
|
||||
/** Secret VALUES (name → value) redacted from every written artifact —
|
||||
* transcripts, the live screen mirror, events.jsonl. Structural, not
|
||||
* checklist: writes go through redactSecrets at the write site. */
|
||||
redact: Record<string, string>;
|
||||
events: Array<{ tMs: number; kind: 'input' | 'note' | 'screen'; data: string }>;
|
||||
t0: number;
|
||||
}
|
||||
|
||||
/** Non-empty provider-key VALUES currently in the environment — the redaction
|
||||
* map for every artifact write. */
|
||||
function buildRedactMap(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const name of PROVIDER_KEY_NAMES) {
|
||||
const v = process.env[name];
|
||||
if (v && v.trim().length >= MIN_REDACT_SECRET_LEN) out[name] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function newCtx(args: CliArgs, needsGbrain: boolean): ScenarioCtx {
|
||||
const outDir = path.resolve(
|
||||
args.dir ?? path.join(REPO_ROOT, '.context', 'dx-runs', `${args.scenario}-${nowStamp()}`),
|
||||
@@ -217,6 +239,7 @@ function newCtx(args: CliArgs, needsGbrain: boolean): ScenarioCtx {
|
||||
keep: args.keep,
|
||||
cleanups: [],
|
||||
secretPaths: [],
|
||||
redact: buildRedactMap(),
|
||||
events: [],
|
||||
t0: Date.now(),
|
||||
};
|
||||
@@ -266,7 +289,10 @@ function finishCtx(ctx: ScenarioCtx): void {
|
||||
scrubSecrets(ctx);
|
||||
fs.writeFileSync(
|
||||
path.join(ctx.outDir, 'events.jsonl'),
|
||||
ctx.events.map((e) => JSON.stringify(e)).join('\n') + (ctx.events.length ? '\n' : ''),
|
||||
redactSecrets(
|
||||
ctx.events.map((e) => JSON.stringify(e)).join('\n') + (ctx.events.length ? '\n' : ''),
|
||||
ctx.redact,
|
||||
),
|
||||
);
|
||||
if (ctx.keep && ctx.secretPaths.length > 0) {
|
||||
log(`--keep: retained hermetic dirs, but scrubbed ${ctx.secretPaths.length} credential file(s)`);
|
||||
@@ -290,13 +316,20 @@ function finishCtx(ctx: ScenarioCtx): void {
|
||||
}
|
||||
|
||||
/** Live session mirror so a watcher (or a Conductor agent) can follow along:
|
||||
* session/screen.txt (latest visible tail) + session/status.json. */
|
||||
function mirrorSession(dir: string, session: TtySession): () => void {
|
||||
* session/screen.txt (latest visible tail) + session/status.json. Each tick
|
||||
* strips only a bounded RAW tail (a full-buffer stripAnsi every 500ms is
|
||||
* quadratic on a 25-minute session) and redacts before writing — the mirror
|
||||
* is a live artifact that outlives an interrupted run, so it must never
|
||||
* carry a raw key even transiently. */
|
||||
function mirrorSession(dir: string, session: TtySession, redact?: Record<string, string>): () => void {
|
||||
const sessDir = path.join(dir, 'session');
|
||||
fs.mkdirSync(sessDir, { recursive: true });
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
fs.writeFileSync(path.join(sessDir, 'screen.txt'), session.visible().slice(-8000));
|
||||
fs.writeFileSync(
|
||||
path.join(sessDir, 'screen.txt'),
|
||||
redactSecrets(stripAnsi(session.raw().slice(-131_072)).slice(-8000), redact),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(sessDir, 'status.json'),
|
||||
JSON.stringify(
|
||||
@@ -322,6 +355,7 @@ function saveSession(ctx: ScenarioCtx, name: string, session: TtySession, extraM
|
||||
saveTranscript(dir, {
|
||||
frames: session.frames(),
|
||||
raw: session.raw(),
|
||||
redact: ctx.redact,
|
||||
meta: {
|
||||
scenario: name || path.basename(ctx.outDir),
|
||||
argv: session.argv,
|
||||
@@ -331,6 +365,53 @@ function saveSession(ctx: ScenarioCtx, name: string, session: TtySession, extraM
|
||||
...extraMeta,
|
||||
},
|
||||
});
|
||||
assertNoSecrets(dir, ctx.redact, ctx.t0);
|
||||
}
|
||||
|
||||
/** Independent double-check of the structural redaction above: grep every
|
||||
* written artifact for each raw secret value and HARD-FAIL on a hit (delete
|
||||
* the leaking file, mark the run failed). A leak here means redactSecrets
|
||||
* missed a rendering (e.g. ANSI-interleaved) — that is a bug to fix, never
|
||||
* a warning to scroll past. */
|
||||
function assertNoSecrets(dir: string, redact: Record<string, string>, sinceMs: number): void {
|
||||
const values = Object.entries(redact).filter(([, v]) => v && v.length >= MIN_REDACT_SECRET_LEN);
|
||||
if (values.length === 0) return;
|
||||
const walk = (d: string): string[] =>
|
||||
fs.readdirSync(d, { withFileTypes: true }).flatMap((e) => {
|
||||
const p = path.join(d, e.name);
|
||||
return e.isDirectory() ? walk(p) : e.isFile() ? [p] : [];
|
||||
});
|
||||
let leaked = false;
|
||||
for (const file of walk(dir)) {
|
||||
// NEVER touch files that predate this run: --dir can point anywhere
|
||||
// (repo root, even $HOME) and deleting a pre-existing .env that happens
|
||||
// to contain the key would be data loss, not leak containment.
|
||||
try { if (fs.statSync(file).mtimeMs < sinceMs - 1000) continue; } catch { continue; }
|
||||
let body: string;
|
||||
try { body = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
||||
// Joined-data pass for frame records: a secret split across JSONL
|
||||
// records never appears contiguously in the file body.
|
||||
let joinedData = '';
|
||||
if (file.endsWith('.jsonl')) {
|
||||
for (const line of body.split('\n')) {
|
||||
try { joinedData += String(JSON.parse(line)?.data ?? ''); } catch { /* not a data record */ }
|
||||
}
|
||||
}
|
||||
for (const [name, value] of values) {
|
||||
const hit =
|
||||
body.includes(value) ||
|
||||
stripAnsi(body).includes(value) || // ANSI-interleaved rendering
|
||||
(joinedData !== '' && (joinedData.includes(value) || stripAnsi(joinedData).includes(value)));
|
||||
if (hit) {
|
||||
leaked = true;
|
||||
fs.rmSync(file, { force: true });
|
||||
log(`SECRET LEAK: ${file} contained ${name} despite structural redaction — file deleted; fix redactSecrets coverage before trusting transcripts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (leaked) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── scenario: help ───────────────────────────────────────────────────────────
|
||||
@@ -376,7 +457,7 @@ async function scenarioInit(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
|
||||
timeoutMs: 600_000,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session, ctx.redact);
|
||||
|
||||
const steps: string[] = [];
|
||||
let lastMarkPos = 0;
|
||||
@@ -429,7 +510,10 @@ async function settlePastBootDialogs(
|
||||
while (Date.now() < deadline) {
|
||||
await session.waitForQuiet({ quietMs: 2000, timeoutMs: 30_000 });
|
||||
if (session.exited()) return;
|
||||
const tail = session.visible().slice(-2500);
|
||||
// Bounded strip: a repaint-heavy TUI (observed: grok's splash animation)
|
||||
// grows the raw buffer by MBs — stripping the FULL buffer every
|
||||
// iteration is the quadratic hot loop; strip a raw tail instead.
|
||||
const tail = stripAnsi(session.raw().slice(-131_072)).slice(-2500);
|
||||
if (!handled.has('trust') && /trust this ?folder/i.test(tail.replace(/\s+/g, ' '))) {
|
||||
handled.add('trust');
|
||||
event(ctx, 'note', 'boot dialog: workspace trust — accepted (option 1)');
|
||||
@@ -456,10 +540,120 @@ async function settlePastBootDialogs(
|
||||
session.sendKey('Enter');
|
||||
continue;
|
||||
}
|
||||
// Grok Build sign-in screen (observed keyless copy: "Not signed in").
|
||||
// No unattended path exists past it — record the friction and stop
|
||||
// settling; the caller's wall clock must not burn on a login dialog.
|
||||
if (/not signed in/i.test(tail)) {
|
||||
event(ctx, 'note', 'boot dialog: grok sign-in required — no unattended path; stopping settle');
|
||||
return;
|
||||
}
|
||||
// Quiet with no KNOWN dialog: if the tail still LOOKS like a prompt
|
||||
// (numbered options / y-n / picker glyph), note it — a silently
|
||||
// mis-settled boot is otherwise invisible in the audit trail.
|
||||
if (/(?:^|\n)\s*(?:\d+\.\s|[❯›]\s)|\((?:y\/n|Y\/n)\)/m.test(tail.slice(-400))) {
|
||||
event(ctx, 'note', 'settle: quiet with an unmatched dialog-shaped tail — proceeding (note-only; check the transcript if the paste lands oddly)');
|
||||
}
|
||||
return; // quiet + no dialog = at the input prompt
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage the compiled gbrain into a fresh bin dir (PATH-prepend target) so
|
||||
* the agent's bare `gbrain` runs this checkout. */
|
||||
function stageBinDir(ctx: ScenarioCtx): string {
|
||||
const binDir = tmp(ctx, 'gb-dx-bin-');
|
||||
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
|
||||
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
interface InstallSessionOpts {
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
env: Record<string, string | undefined>;
|
||||
extraAllow?: string[];
|
||||
dropEnv?: string[];
|
||||
/** The pasted prompt. Per-agent: the bootstrap runbook block for
|
||||
* claude/codex, a brain-only GROK.md-driven block for grok. */
|
||||
prompt: string;
|
||||
/** Success copy to race against (default: the bootstrap verify patterns).
|
||||
* Brain-only scenarios pass their own — grok's is the doctor banner. */
|
||||
successPatterns?: Array<RegExp | string>;
|
||||
timeoutMs?: number;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The shared install-session tail the claude/codex/grok scenarios all run:
|
||||
* launch → mirror → settle boot dialogs → paste the prompt → race
|
||||
* verify-success copy vs exit → let trailing output land → save. Per-agent
|
||||
* PREPARATION (TUI seeds, auth copies, git init) deliberately stays in each
|
||||
* scenario — the loop is what was duplicated, the prep genuinely differs. */
|
||||
async function runInstallSession(ctx: ScenarioCtx, opts: InstallSessionOpts): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? 1_800_000;
|
||||
let earlyTerminal: string | undefined;
|
||||
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
|
||||
const session = launchTty(opts.argv, {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
extraAllow: opts.extraAllow,
|
||||
dropEnv: opts.dropEnv,
|
||||
timeoutMs,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session, ctx.redact);
|
||||
try {
|
||||
await settlePastBootDialogs(ctx, session);
|
||||
// Sign-in wall / textless splash: no unattended path exists past either.
|
||||
// Observed (grok v1.0.4 keyless): headless prints "Not signed in"; the
|
||||
// TUI loops a full-screen glyph animation with NO textual prompt for 8+
|
||||
// minutes. Try one Enter (the common skip-splash gesture), then if the
|
||||
// screen still carries no meaningful text — or shows the sign-in copy —
|
||||
// record the friction (that IS the keyless measurement) and end early
|
||||
// instead of pasting into a wall for the full wall clock.
|
||||
const tailText = () => stripAnsi(session.raw().slice(-131_072)).slice(-2500);
|
||||
// Sign-in copy, both surfaces observed (GROK-CLI-PIN.md): headless prints
|
||||
// "Not signed in"; the TUI settles (~6s, after an intro animation) onto
|
||||
// "Approve in your browser to finish signing in" + a device code.
|
||||
const signInWall = () =>
|
||||
/not signed in|approve in your browser|finish signing in|sign in with/i.test(tailText());
|
||||
// Word-like text only: splash/spinner screens render Braille-pattern
|
||||
// glyphs (U+2800 range, observed) with scattered digits/SGR residue but
|
||||
// ZERO 3+-letter runs, while any real prompt/sign-in screen carries
|
||||
// words. Counting letter runs beats enumerating glyph exceptions.
|
||||
const meaningfulLen = (s: string) => (s.match(/[A-Za-z]{3,}/g) ?? []).join('').length;
|
||||
if (!session.exited() && (signInWall() || meaningfulLen(tailText()) < 40)) {
|
||||
event(ctx, 'note', 'sign-in wall or textless splash — sending one Enter (skip-splash attempt)');
|
||||
session.sendKey('Enter');
|
||||
await Bun.sleep(3000);
|
||||
if (!session.exited() && (signInWall() || meaningfulLen(tailText()) < 40)) {
|
||||
event(ctx, 'note', 'sign-in wall / no textual prompt persists — ending session early (keyless friction recorded)');
|
||||
earlyTerminal = 'sign-in-wall-or-splash';
|
||||
return; // finally owns cleanup; save happens after it
|
||||
}
|
||||
}
|
||||
event(ctx, 'input', 'paste install prompt');
|
||||
// Scope the verify match to output AFTER the paste: the pasted prompt
|
||||
// itself contains verify-adjacent copy, and matching the full buffer
|
||||
// re-scans a growing transcript every poll.
|
||||
const pasteMark = session.mark();
|
||||
session.send(opts.prompt);
|
||||
await Bun.sleep(1500);
|
||||
session.sendKey('Enter');
|
||||
const raceBudget = Math.max(timeoutMs - 300_000, Math.floor(timeoutMs * 0.8));
|
||||
const done = await Promise.race([
|
||||
session
|
||||
.waitForAny(opts.successPatterns ?? VERIFY_SUCCESS_PATTERNS, { timeoutMs: raceBudget, since: pasteMark })
|
||||
.then(() => 'verify-signal')
|
||||
.catch(() => 'no-signal'),
|
||||
session.waitForExit(raceBudget).then(() => 'exited'),
|
||||
]);
|
||||
event(ctx, 'note', `terminal condition: ${done}`);
|
||||
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, earlyTerminal ? { ...opts.meta, terminal: earlyTerminal } : opts.meta);
|
||||
}
|
||||
|
||||
/** The README paste block, pointed at THIS repo's runbook, plus a persona
|
||||
* appendix so the interview completes unattended. The appendix is the ONLY
|
||||
* deviation from the shipped block — flagged in meta so the audit discounts it. */
|
||||
@@ -481,14 +675,12 @@ function installPrompt(): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function scenarioClaudeInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
async function scenarioClaudeInstall(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const cfg = tmp(ctx, 'gb-dx-ccfg-');
|
||||
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
const binDir = tmp(ctx, 'gb-dx-bin-');
|
||||
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
|
||||
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
|
||||
const binDir = stageBinDir(ctx);
|
||||
|
||||
seedClaudeTuiConfig(cfg, {
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? process.env.GSTACK_ANTHROPIC_API_KEY,
|
||||
@@ -501,61 +693,33 @@ async function scenarioClaudeInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
ctx.secretPaths.push(path.join(cfg, '.claude.json'));
|
||||
|
||||
log('REAL interactive claude running the paste-in bootstrap (10-25 min, real API cost)');
|
||||
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
|
||||
const session = launchTty(
|
||||
// --dangerously-skip-permissions: v1 measures flow + copy + stalls without
|
||||
// permission-dialog babysitting. Permission-prompt COUNT is a separate
|
||||
// drive-mode pass (the dialogs are Claude Code's chrome, not gbrain copy).
|
||||
['claude', '--dangerously-skip-permissions'],
|
||||
{
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CLAUDE_CONFIG_DIR: cfg,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
timeoutMs: 1_800_000,
|
||||
await runInstallSession(ctx, {
|
||||
// dangerously-skip-permissions (spelled dash-free here): v1 measures flow
|
||||
// + copy + stalls without permission-dialog babysitting. Permission-prompt
|
||||
// COUNT is a separate drive-mode pass (the dialogs are Claude Code's
|
||||
// chrome, not gbrain copy).
|
||||
argv: ['claude', '--dangerously-skip-permissions'],
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CLAUDE_CONFIG_DIR: cfg,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
|
||||
prompt: installPrompt(),
|
||||
meta: {
|
||||
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
|
||||
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
|
||||
},
|
||||
);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
try {
|
||||
// Get past first-run chrome (trust dialog, bypass warning), then paste.
|
||||
await settlePastBootDialogs(ctx, session);
|
||||
event(ctx, 'input', 'paste install prompt');
|
||||
session.send(installPrompt());
|
||||
await Bun.sleep(1500);
|
||||
session.sendKey('Enter');
|
||||
// Run until verify-success copy or exit or wall clock.
|
||||
const done = await Promise.race([
|
||||
session
|
||||
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
|
||||
timeoutMs: 1_500_000,
|
||||
})
|
||||
.then(() => 'verify-signal')
|
||||
.catch(() => 'no-signal'),
|
||||
session.waitForExit(1_500_000).then(() => 'exited'),
|
||||
]);
|
||||
event(ctx, 'note', `terminal condition: ${done}`);
|
||||
// Let trailing output land.
|
||||
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, {
|
||||
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
|
||||
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
|
||||
});
|
||||
}
|
||||
|
||||
async function scenarioCodexInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
async function scenarioCodexInstall(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
const binDir = tmp(ctx, 'gb-dx-bin-');
|
||||
fs.copyFileSync(ctx.gbrainBin, path.join(binDir, 'gbrain'));
|
||||
fs.chmodSync(path.join(binDir, 'gbrain'), 0o755);
|
||||
const binDir = stageBinDir(ctx);
|
||||
|
||||
// Hermetic ~/.codex with ONLY the operator's auth (same posture as the
|
||||
// codex door test). codex refuses untrusted cwds — a git repo satisfies it.
|
||||
@@ -573,46 +737,99 @@ async function scenarioCodexInstall(ctx: ScenarioCtx): Promise<void> {
|
||||
spawnSync('git', ['-C', ws, 'config', 'user.name', 'DX Explore']);
|
||||
|
||||
log('REAL interactive codex running the paste-in bootstrap (10-25 min, real API cost)');
|
||||
log(`watch live: cat ${path.join(ctx.outDir, 'session', 'screen.txt')}`);
|
||||
const session = launchTty(
|
||||
['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'never'],
|
||||
{
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CODEX_HOME: codexHome,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
extraAllow: ['OPENAI_API_KEY', 'CODEX_*'],
|
||||
timeoutMs: 1_800_000,
|
||||
await runInstallSession(ctx, {
|
||||
argv: ['codex', '--sandbox', 'workspace-write', '--ask-for-approval', 'never'],
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
CODEX_HOME: codexHome,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
},
|
||||
extraAllow: ['OPENAI_API_KEY', 'CODEX_*'],
|
||||
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
|
||||
prompt: installPrompt(),
|
||||
meta: {
|
||||
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
|
||||
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── scenario: grok-install ───────────────────────────────────────────────────
|
||||
|
||||
/** Brain-only success copy for the grok scenario: the doctor banner proves
|
||||
* the registration handshook (observed, docs/mcp/GROK-CLI-PIN.md) — grok has
|
||||
* NO bootstrap path, so the bootstrap verify patterns must not be its bar. */
|
||||
const GROK_INSTALL_SUCCESS_PATTERNS: Array<RegExp | string> = [
|
||||
/7 tools discovered/i,
|
||||
/handshake OK/i,
|
||||
];
|
||||
|
||||
/** GROK.md-driven brain-only prompt — deliberately NOT the bootstrap paste
|
||||
* block: the docs classify grok as brain-only install (no `gbrain bootstrap`
|
||||
* support), so the scenario must not test an unsupported flow. */
|
||||
function grokInstallPrompt(): string {
|
||||
const guide = path.join(REPO_ROOT, 'docs', 'mcp', 'GROK.md');
|
||||
return (
|
||||
`Read and follow: ${guide}\n` +
|
||||
`Goal: wire the gbrain memory brain into you (Grok Build) over stdio MCP — ` +
|
||||
`brain-only install, no bootstrap. Steps: ` +
|
||||
`1) run \`gbrain init --pglite --no-embedding --non-interactive\`; ` +
|
||||
`2) register gbrain exactly as the guide's Register section shows; ` +
|
||||
`3) verify with \`grok mcp doctor gbrain\` — you are not done until it reports ` +
|
||||
`the tools-discovered check passing; ` +
|
||||
`4) one recall round-trip: use the gbrain remember tool to store ` +
|
||||
`"${PERSONA.AGENT_NAME} prefers ${PERSONA.VOICE_REGISTER}" and then recall it.\n\n` +
|
||||
`[Unattended-run appendix — I am stepping away; do not wait for my input. ` +
|
||||
`gbrain is already installed and on PATH. If a step needs auth that is ` +
|
||||
`unavailable, note the exact error and stop.]`
|
||||
);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
try {
|
||||
await settlePastBootDialogs(ctx, session);
|
||||
event(ctx, 'input', 'paste install prompt');
|
||||
session.send(installPrompt());
|
||||
await Bun.sleep(1500);
|
||||
session.sendKey('Enter');
|
||||
const done = await Promise.race([
|
||||
session
|
||||
.waitForAny(VERIFY_SUCCESS_PATTERNS, {
|
||||
timeoutMs: 1_500_000,
|
||||
})
|
||||
.then(() => 'verify-signal')
|
||||
.catch(() => 'no-signal'),
|
||||
session.waitForExit(1_500_000).then(() => 'exited'),
|
||||
]);
|
||||
event(ctx, 'note', `terminal condition: ${done}`);
|
||||
await session.waitForQuiet({ quietMs: 5000, timeoutMs: 60_000 });
|
||||
} finally {
|
||||
stopMirror();
|
||||
await session.close();
|
||||
}
|
||||
saveSession(ctx, '', session, {
|
||||
promptDeviation: 'unattended persona appendix + local runbook path + preinstalled binary',
|
||||
runbook: 'BOOTSTRAP_FOR_AGENTS.md (local)',
|
||||
}
|
||||
|
||||
async function scenarioGrokInstall(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
const home = tmp(ctx, 'gb-dx-home-');
|
||||
const gbHome = tmp(ctx, 'gb-dx-gbhome-');
|
||||
const ws = tmp(ctx, 'gb-dx-ws-');
|
||||
const binDir = stageBinDir(ctx);
|
||||
|
||||
// Hermetic ~/.grok seeded with the auto-update kill-switch (config-only
|
||||
// mechanism, default ON — observed v1.0.4). Auth travels via XAI_API_KEY
|
||||
// env only; grok MAY persist derived credentials after an authed session,
|
||||
// so the known candidate is pre-registered for the scrub (rm of a file
|
||||
// that never appears is a no-op).
|
||||
const grokHome = path.join(home, '.grok');
|
||||
fs.mkdirSync(grokHome, { recursive: true });
|
||||
// Verbatim kill-switch homes (update together): seedGrokConfig in
|
||||
// test/helpers/agent-harness.ts and the grok-door auth-preflight printf
|
||||
// in .github/workflows/heavy-tests.yml.
|
||||
fs.writeFileSync(path.join(grokHome, 'config.toml'), '[cli]\nauto_update = false\n');
|
||||
ctx.secretPaths.push(path.join(grokHome, 'mcp_credentials.json'));
|
||||
|
||||
log('REAL interactive grok running the GROK.md brain-only install (real API cost when authed)');
|
||||
log('keyless runs stop at the observed sign-in screen — that friction IS the measurement');
|
||||
await runInstallSession(ctx, {
|
||||
argv: ['grok'],
|
||||
cwd: ws,
|
||||
env: {
|
||||
HOME: home,
|
||||
GROK_HOME: grokHome,
|
||||
GBRAIN_HOME: gbHome,
|
||||
PATH: `${binDir}:${process.env.PATH ?? ''}`,
|
||||
// Never let a keyless first-run bounce the OPERATOR's browser for
|
||||
// sign-in; the settle loop stops at the observed "Not signed in" copy.
|
||||
BROWSER: '/usr/bin/false',
|
||||
},
|
||||
extraAllow: ['XAI_API_KEY'],
|
||||
// --keyless drops provider keys AFTER extraAllow re-admission — a keyless
|
||||
// grok-install must measure the sign-in wall, not silently run authed.
|
||||
dropEnv: args.keyless ? PROVIDER_KEY_NAMES : undefined,
|
||||
prompt: grokInstallPrompt(),
|
||||
successPatterns: GROK_INSTALL_SUCCESS_PATTERNS,
|
||||
meta: {
|
||||
promptDeviation: 'unattended appendix + local GROK.md path + preinstalled binary',
|
||||
runbook: 'docs/mcp/GROK.md (local, brain-only — grok has no bootstrap path)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -650,7 +867,7 @@ async function scenarioDrive(ctx: ScenarioCtx, args: CliArgs): Promise<void> {
|
||||
env,
|
||||
timeoutMs: 3_600_000,
|
||||
});
|
||||
const stopMirror = mirrorSession(ctx.outDir, session);
|
||||
const stopMirror = mirrorSession(ctx.outDir, session, ctx.redact);
|
||||
|
||||
let offset = 0;
|
||||
let stopping = false;
|
||||
@@ -701,10 +918,15 @@ const SCENARIOS: Record<string, { needsGbrain: boolean; run: (ctx: ScenarioCtx,
|
||||
init: { needsGbrain: true, run: scenarioInit },
|
||||
'claude-install': { needsGbrain: true, run: scenarioClaudeInstall },
|
||||
'codex-install': { needsGbrain: true, run: scenarioCodexInstall },
|
||||
'grok-install': { needsGbrain: true, run: scenarioGrokInstall },
|
||||
drive: { needsGbrain: true, run: scenarioDrive },
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!ptySupported()) {
|
||||
log('this Bun lacks PTY (terminal:) support — upgrade Bun (engines.bun in package.json) before running dx scenarios');
|
||||
process.exit(2);
|
||||
}
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const scenario = SCENARIOS[args.scenario];
|
||||
if (!scenario) {
|
||||
|
||||
@@ -60,3 +60,4 @@ check-wasm-embedded.sh buildfresh exempt binary embed check
|
||||
check-bootstrap-tag.sh repostate exempt VERSION stamp drift check
|
||||
check-cli-executable.sh repostate exempt file-mode check
|
||||
check-no-tracked-symlinks.sh repostate exempt git index state check
|
||||
check-grok-pin.sh repostate exempt pin-stamp drift check (GROK-CLI-PIN.md stamps vs heavy-tests grok-door env); own bun guard tests in test/check-bootstrap-guards.test.ts
|
||||
|
||||
|
+6
-5
@@ -86,11 +86,12 @@ mkdir -p "$E2E_TMP_HOME/.gbrain"
|
||||
# (not an allowlist rebuild), so PATH, HOME, TMPDIR, CI, DATABASE_URL, and bun
|
||||
# internals survive untouched. We keep GBRAIN_HOME (just set above for HOME
|
||||
# isolation); everything else GBRAIN_* is an operator override the suite must
|
||||
# not inherit — which also scrubs GBRAIN_REAL_HERMES_E2E, so the paid hermes
|
||||
# door suite structurally cannot fire under this runner (its venue is
|
||||
# heavy-tests.yml's direct bun test). Adapts GStack's buildHermeticEnv()
|
||||
# allowlist to gbrain's shell E2E runner.
|
||||
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
|
||||
# not inherit — which also scrubs GBRAIN_REAL_HERMES_E2E and
|
||||
# GBRAIN_REAL_GROK_E2E, so the paid hermes/grok door suites structurally
|
||||
# cannot fire under this runner (their venue is heavy-tests.yml's direct bun
|
||||
# test). GROK_ also drops an operator's GROK_BIN/GROK_HOME. Adapts GStack's
|
||||
# buildHermeticEnv() allowlist to gbrain's shell E2E runner.
|
||||
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GROK_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
|
||||
case "$_e2e_var" in
|
||||
GBRAIN_HOME) ;; # required for HOME isolation (set above) — keep
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL) ;; # #3485 preload opt-in (set above) — keep
|
||||
|
||||
@@ -68,6 +68,7 @@ CHECKS=(
|
||||
"check:no-double-retry"
|
||||
"check:batch-audit-site"
|
||||
"check:engine-dynamic-import"
|
||||
"check:grok-pin"
|
||||
"check:worker-lock-renewal-shape"
|
||||
"check:bootstrap-tag"
|
||||
"check:bootstrap-templates"
|
||||
|
||||
@@ -49,9 +49,11 @@ upstream: conversation-history+transcript-save@fc834ee
|
||||
|
||||
Two halves of one loop:
|
||||
|
||||
1. **IMPORT** — raw export or session log → one dated markdown page per
|
||||
conversation under `conversations/` → `gbrain import`/`gbrain sync` →
|
||||
parser validation → fact extraction → gap check.
|
||||
1. **IMPORT** — raw export or session log → dated markdown pages under
|
||||
`conversations/` (the native importer writes them directly and splits
|
||||
long sessions into parts; the manual path converts one page per
|
||||
conversation, then `gbrain import`/`gbrain sync`) → parser validation →
|
||||
fact extraction → gap check.
|
||||
2. **RETRIEVE** — search the archive, pull threads, build timelines, and
|
||||
answer "when did I first discuss X".
|
||||
|
||||
@@ -59,11 +61,29 @@ Years of AI-assistant history is one of the largest personal corpora most
|
||||
users own. This skill makes it first-class brain content instead of a JSON
|
||||
blob in a downloads folder.
|
||||
|
||||
**No native raw-export importer exists.** `gbrain import <dir>` ingests
|
||||
markdown directories; nothing in the CLI parses a provider's raw
|
||||
`conversations.json` directly. The conversion step below is agent work.
|
||||
(A native `gbrain import --format chatgpt|claude` is a filed follow-up; until
|
||||
it lands, this procedure is the supported path.)
|
||||
**A native importer now exists: `gbrain transcripts ingest`.** It parses
|
||||
agent session logs (Claude Code, Codex, OpenClaw, Hermes) AND extracted
|
||||
consumer exports (ChatGPT `conversations.json`, Claude.ai export) directly:
|
||||
detection, secret redaction, imessage-slack rendering, long-session
|
||||
splitting, and idempotent re-runs are all native. Prefer it over the manual
|
||||
procedure whenever the source is one of those six formats:
|
||||
|
||||
```
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # unzip first
|
||||
gbrain transcripts ingest # discover harness logs
|
||||
gbrain transcripts status # found vs imported gaps
|
||||
```
|
||||
|
||||
Native-vs-manual delta to know: the native lane redacts SECRETS (key
|
||||
patterns) plus your `~/.gbrain/harvest-private-patterns.txt` regexes and
|
||||
counts agent-directed imperatives into frontmatter, but broad PII detection
|
||||
(names, phones, addresses) remains YOUR review pass — the manual procedure's
|
||||
human scrub step still applies to sensitive corpora. Two more deltas: the
|
||||
native lane caps each message at ~4K characters in the page body (readable
|
||||
archive, not verbatim — the session file named in `source_uri` stays the
|
||||
verbatim record), and tool/thinking traffic appears only as one-line
|
||||
placeholders. Providers without a native adapter (e.g. Perplexity) keep
|
||||
using the manual conversion below.
|
||||
|
||||
## Where Conversations Live
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"conventions/subagent-routing.md": "8b8830b815a9a8581a12b489f966c0b0a39eb9b5f66e905a691a03653eef348d",
|
||||
"conventions/test-before-bulk.md": "6b2c52cda9e2cd5f04c15152b3d92aeb7187ab193a15082be0f8a3991a6a5725",
|
||||
"conventions/untrusted-content.md": "259384d490892cd0e1e8e054decf752d7354f516c83aee57b332c1a96aac6a6e",
|
||||
"conversation-archive/SKILL.md": "867d3a202ce500027ed2ab85edd9d3359d677aa7a180105f2b7db12ad3492701",
|
||||
"conversation-archive/SKILL.md": "4e1dea00f5e1e16e749a42f295fdccf556199d4400a2ba1b891aa91839e37214",
|
||||
"conversation-archive/routing-eval.jsonl": "ae087a84b1fd5b108b7cdab8d035a09b3ccecd8aad53ba5f71e463059108cfca",
|
||||
"correction-pipeline/SKILL.md": "caf1264b7afec46569d30f6d92b07f37ae375e3f4e6aeddd58866aec327053de",
|
||||
"correction-pipeline/routing-eval.jsonl": "7f8d96606a8d7bed3d79fdcee6904764c8abb9fa0b506adb414b5c4805b69d0b",
|
||||
|
||||
+5
-1
@@ -154,6 +154,9 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
// would leave that help dead code behind the generic stub (the init.ts:117
|
||||
// trap ENG-2 names).
|
||||
'bootstrap', 'hook', 'sweep',
|
||||
// cathedral-4: transcripts ships its own HELP (the ingest import lane +
|
||||
// the v0.29 recent reader). Without this the generic stub hides both.
|
||||
'transcripts',
|
||||
// jobs ships JOBS_HELP + a per-subcommand record (JOBS_SUBCOMMAND_HELP) in
|
||||
// jobs.ts, guarded BEFORE the thin-client refusal and the subcommand switch
|
||||
// so `jobs work --help` prints help instead of starting a worker daemon.
|
||||
@@ -177,6 +180,7 @@ const SELF_HELP_WITHOUT_ENGINE: Record<string, () => Promise<(engine: never, arg
|
||||
maintain: async () => (await import('./commands/maintain.ts')).runMaintain as never,
|
||||
'extract-conversation-facts': async () =>
|
||||
(await import('./commands/extract-conversation-facts.ts')).runExtractConversationFacts as never,
|
||||
transcripts: async () => (await import('./commands/transcripts.ts')).runTranscripts as never,
|
||||
// runJobs accepts BrainEngine | null and its help guard returns before any
|
||||
// engine (or subcommand body) is touched.
|
||||
jobs: async () => (await import('./commands/jobs.ts')).runJobs as never,
|
||||
@@ -3195,7 +3199,7 @@ TOOLS
|
||||
orphans [--json] [--count] Find pages with no inbound wikilinks
|
||||
salience [--days N] [--kind P] v0.29: pages ranked by emotional + activity salience
|
||||
anomalies [--since D] [--sigma N] v0.29: cohort-based statistical anomalies (tag, type)
|
||||
transcripts recent [--days N] v0.29: recent raw .txt transcripts (local-only)
|
||||
transcripts <ingest|status|recent> v0.46: import agent session logs + chat exports (local-only)
|
||||
dream [--dry-run] [--json] Run the overnight maintenance cycle once (cron-friendly).
|
||||
See also: autopilot --install (continuous daemon).
|
||||
check-resolvable [--json] [--fix] Validate skill tree (reachability/MECE/DRY)
|
||||
|
||||
@@ -28,11 +28,13 @@ import { parseProgressEvents, verifyExpectedPhases } from '../core/claw-test/pro
|
||||
import { resolveAgentRunner, listRegisteredAgents, registerAgentRunner, validateBinPathEnv } from '../core/claw-test/agent-runner.ts';
|
||||
import { OpenClawRunner } from '../core/claw-test/runners/openclaw.ts';
|
||||
import { HermesRunner } from '../core/claw-test/runners/hermes.ts';
|
||||
import { GrokRunner } from '../core/claw-test/runners/grok.ts';
|
||||
import { createTranscriptSink } from '../core/claw-test/transcript-capture.ts';
|
||||
|
||||
// Ensure built-in runners are registered.
|
||||
registerAgentRunner('openclaw', () => new OpenClawRunner());
|
||||
registerAgentRunner('hermes', () => new HermesRunner());
|
||||
registerAgentRunner('grok', () => new GrokRunner());
|
||||
|
||||
interface HarnessOpts {
|
||||
scenario: string;
|
||||
@@ -412,6 +414,9 @@ async function runScripted(
|
||||
const AGENT_INSTALL_HINTS: Record<string, string> = {
|
||||
openclaw: 'install openclaw or set OPENCLAW_BIN',
|
||||
hermes: 'install hermes (https://hermes-agent.nousresearch.com) or set HERMES_BIN',
|
||||
// Official xAI CLI only — the community superagent-ai grok-cli ships a
|
||||
// colliding `grok` binary (docs/mcp/GROK-CLI-PIN.md).
|
||||
grok: 'install grok (npm: @xai-official/grok, or https://x.ai/cli/install.sh) or set GROK_BIN',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -998,5 +1003,6 @@ Examples:
|
||||
gbrain claw-test --scenario fresh-install
|
||||
gbrain claw-test --scenario upgrade-from-v0.18 --keep-tempdir
|
||||
gbrain claw-test --live --agent openclaw
|
||||
gbrain claw-test --live --agent hermes`);
|
||||
gbrain claw-test --live --agent hermes
|
||||
gbrain claw-test --live --agent grok`);
|
||||
}
|
||||
|
||||
@@ -249,9 +249,17 @@ export interface ExtractConversationFactsCoreOpts {
|
||||
types?: AllowedType[];
|
||||
/** Process a single page; otherwise iterate all matching pages in the source. */
|
||||
slug?: string;
|
||||
/**
|
||||
* cathedral-4 batch selector: process exactly these pages (serial, with
|
||||
* the same per-page advisory lock + durable-outcome gates as enumeration).
|
||||
* ONE core invocation per caller run — per-slug invocations multiply
|
||||
* config resolution, checkpoint IO, and receipt writes by page count.
|
||||
* Takes precedence over `slug`.
|
||||
*/
|
||||
slugs?: string[];
|
||||
/** Show would-do counts without writing facts or advancing checkpoint. */
|
||||
dryRun?: boolean;
|
||||
/** Cap pages processed in this invocation. */
|
||||
/** Cap pages processed in this invocation (enumeration path only; ignored when `slugs` is set). */
|
||||
limit?: number;
|
||||
/** ISO watermark; messages older than this are filtered out. */
|
||||
sinceIso?: string;
|
||||
@@ -1336,7 +1344,24 @@ export async function runExtractConversationFactsCore(
|
||||
// types are not silently skipped (see ALLOWED_TYPE_ALIASES).
|
||||
const concreteTypes = pageTypesForAllowed(types);
|
||||
|
||||
if (opts.slug) {
|
||||
if (opts.slugs !== undefined) {
|
||||
// Batch mode is selected by the PRESENCE of the selector: an empty
|
||||
// list means "process exactly these zero pages" (a no-op), never a
|
||||
// fall-through to full-corpus enumeration and its LLM spend.
|
||||
for (const slug of opts.slugs) {
|
||||
if (signal?.aborted) throw new Error('aborted');
|
||||
const page = await engine.getPage(slug, { sourceId });
|
||||
if (!page) {
|
||||
result.pages_skipped_disappeared++;
|
||||
continue;
|
||||
}
|
||||
if (!concreteTypes.includes(page.type)) {
|
||||
result.pages_skipped++;
|
||||
continue;
|
||||
}
|
||||
await processPageWithLock(page);
|
||||
}
|
||||
} else if (opts.slug) {
|
||||
const page = await engine.getPage(opts.slug, { sourceId });
|
||||
if (!page) {
|
||||
result.pages_skipped_disappeared++;
|
||||
|
||||
+200
-4
@@ -6,7 +6,12 @@
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { MinionQueue } from '../core/minions/queue.ts';
|
||||
import { MinionWorker } from '../core/minions/worker.ts';
|
||||
import { WORKER_EXIT_RSS_WATCHDOG } from '../core/minions/worker-exit-codes.ts';
|
||||
import {
|
||||
WORKER_EXIT_RSS_WATCHDOG,
|
||||
JOB_CHILD_EXIT_USAGE,
|
||||
} from '../core/minions/worker-exit-codes.ts';
|
||||
import { CHILD_ENV, resolveChildCliInvocation } from '../core/minions/job-isolation.ts';
|
||||
import { runChildJobEntry } from '../core/minions/run-child.ts';
|
||||
import type { MinionHandler, MinionJob, MinionJobStatus } from '../core/minions/types.ts';
|
||||
import type { PaceKeyOverrides } from '../core/pace-mode.ts';
|
||||
import { loadConfig, isThinClient } from '../core/config.ts';
|
||||
@@ -154,6 +159,32 @@ export function resolveWorkerConcurrency(args: string[], env: NodeJS.ProcessEnv
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export type JobIsolationMode = 'inline' | 'process';
|
||||
|
||||
/**
|
||||
* issue #5: `--job-isolation <inline|process>` (space or `=` form), env
|
||||
* fallback GBRAIN_JOB_ISOLATION, default inline. `process` runs each claimed
|
||||
* job in a SIGKILL-able child process — blast radius 1 job instead of N.
|
||||
* Env injected as a param so tests never mutate process.env (rule R1).
|
||||
* Invalid values fail fast (parseMaxRssFlag convention).
|
||||
*/
|
||||
export function parseJobIsolationFlag(
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): JobIsolationMode {
|
||||
let raw: string | undefined;
|
||||
const eqForm = args.find((a) => a.startsWith('--job-isolation='));
|
||||
if (eqForm !== undefined) raw = eqForm.slice('--job-isolation='.length);
|
||||
if (raw === undefined) raw = parseFlag(args, '--job-isolation');
|
||||
if (raw === undefined || raw === '') raw = env.GBRAIN_JOB_ISOLATION;
|
||||
if (raw === undefined || raw === '') return 'inline';
|
||||
if (raw === 'inline' || raw === 'process') return raw;
|
||||
console.error(
|
||||
`Error: invalid job isolation mode ${JSON.stringify(raw)}. Valid: inline, process.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* #3026: the thin-client `list`/`get` branches receive jobs as parsed JSON
|
||||
* off the MCP wire, where every timestamp is an ISO string — but formatJob /
|
||||
@@ -269,11 +300,13 @@ USAGE
|
||||
gbrain jobs watch [--json] [--follow] [--refresh-ms=N]
|
||||
gbrain jobs work [--queue Q] [--concurrency N] [--max-rss MB]
|
||||
[--health-interval MS] [--nice N]
|
||||
[--job-isolation inline|process]
|
||||
gbrain jobs supervisor [start] [--detach] [--json]
|
||||
[--concurrency N] [--queue Q] [--pid-file PATH]
|
||||
[--max-crashes N] [--health-interval N]
|
||||
[--allow-shell-jobs] [--cli-path PATH]
|
||||
[--max-rss MB] [--nice N]
|
||||
[--job-isolation inline|process]
|
||||
|
||||
--nice N OS scheduling priority, -20 (highest) to 19 (nicest). Lowers CPU
|
||||
priority without cutting concurrency — full throughput when the
|
||||
@@ -344,9 +377,18 @@ const JOBS_SUBCOMMAND_HELP: Record<string, string> = {
|
||||
USAGE
|
||||
gbrain jobs work [--queue Q] [--concurrency N] [--max-rss MB]
|
||||
[--health-interval MS] [--nice N]
|
||||
[--job-isolation inline|process]
|
||||
|
||||
OPTIONS
|
||||
--queue Q Queue to claim from (default: default)
|
||||
--job-isolation M inline (default): handlers run in the worker process.
|
||||
process: each claimed job runs in its own child
|
||||
process — a stuck handler is group-SIGKILLed instead
|
||||
of abandoned, and a crash takes one job, not all N.
|
||||
Env fallback: GBRAIN_JOB_ISOLATION. Recommended for
|
||||
long-running LLM-bound handlers (subagent). Note:
|
||||
--max-rss then covers the worker only, and each child
|
||||
adds ~4 pooler client connections.
|
||||
--concurrency N Max jobs in flight. Resolution: flag, then
|
||||
GBRAIN_WORKER_CONCURRENCY env, then 1. Values < 1
|
||||
are clamped to 1 with a loud stderr note.
|
||||
@@ -376,6 +418,7 @@ USAGE
|
||||
[--max-crashes N] [--health-interval N]
|
||||
[--allow-shell-jobs] [--cli-path PATH]
|
||||
[--max-rss MB] [--nice N]
|
||||
[--job-isolation inline|process]
|
||||
gbrain jobs supervisor status [--json] [--pid-file PATH]
|
||||
gbrain jobs supervisor stop [--json] [--pid-file PATH]
|
||||
|
||||
@@ -397,6 +440,7 @@ OPTIONS (start)
|
||||
--cli-path PATH Explicit gbrain binary for the worker child
|
||||
--max-rss MB RSS watchdog for the worker (same rules as jobs work)
|
||||
--nice N OS priority for supervisor + worker children
|
||||
--job-isolation M Passed through to the worker (see jobs work --help)
|
||||
|
||||
EXIT CODES (start)
|
||||
0 clean shutdown 1 max crashes exceeded
|
||||
@@ -1185,6 +1229,59 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case 'run-child': {
|
||||
// INTERNAL (issue #5 process isolation): spawned by `jobs work` with
|
||||
// process isolation enabled. One job, one process: validate the claim,
|
||||
// run the handler with the child's own engine, write ONE outcome file,
|
||||
// exit. Deliberately absent from user-facing help. The CLI layer owns
|
||||
// engine.disconnect() + process.exit() (engine-ownership invariant).
|
||||
{
|
||||
const config = loadConfig();
|
||||
if (config?.engine === 'pglite') {
|
||||
console.error('[run-child] process isolation requires the Postgres engine.');
|
||||
await engine.disconnect();
|
||||
process.exit(JOB_CHILD_EXIT_USAGE);
|
||||
}
|
||||
const jobIdRaw = parseFlag(args, '--job-id');
|
||||
const jobId = jobIdRaw != null ? parseInt(jobIdRaw, 10) : NaN;
|
||||
const lockToken = process.env[CHILD_ENV.lockToken];
|
||||
const resultPath = process.env[CHILD_ENV.resultPath];
|
||||
const parentPidRaw = parseInt(process.env[CHILD_ENV.parentPid] ?? '0', 10);
|
||||
if (!Number.isInteger(jobId) || jobId <= 0 || !lockToken || !resultPath) {
|
||||
console.error(
|
||||
'[run-child] internal command spawned by the jobs worker; requires ' +
|
||||
`a numeric job id plus ${CHILD_ENV.lockToken} and ${CHILD_ENV.resultPath} in env.`,
|
||||
);
|
||||
await engine.disconnect();
|
||||
process.exit(JOB_CHILD_EXIT_USAGE);
|
||||
}
|
||||
|
||||
// Same handler surface as the worker: registerBuiltinHandlers also
|
||||
// performs plugin discovery, so plugin subagent jobs isolate too.
|
||||
const throwaway = new MinionWorker(engine, { queue: 'default', concurrency: 1 });
|
||||
await registerBuiltinHandlers(throwaway, engine, { quiet: true });
|
||||
|
||||
let code: number;
|
||||
try {
|
||||
code = await runChildJobEntry(
|
||||
engine,
|
||||
{
|
||||
jobId,
|
||||
lockToken,
|
||||
resultPath,
|
||||
parentPid: Number.isInteger(parentPidRaw) && parentPidRaw > 0 ? parentPidRaw : 0,
|
||||
},
|
||||
{ resolveHandler: (name) => throwaway.getHandler(name) },
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`[run-child] fatal: ${e instanceof Error ? e.message : String(e)}`);
|
||||
code = 1;
|
||||
}
|
||||
await engine.disconnect();
|
||||
process.exit(code);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-fallthrough -- unreachable: the case above always exits
|
||||
case 'work': {
|
||||
// Check if PGLite
|
||||
const config = (await import('../core/config.ts')).loadConfig();
|
||||
@@ -1245,11 +1342,78 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
}
|
||||
}
|
||||
|
||||
// issue #5: per-job process isolation. Resolve + validate the child CLI
|
||||
// invocation ONCE at startup and refuse to start on failure — a bad
|
||||
// path discovered per-job would release every claim as infra failures
|
||||
// (never dead-lettering, but never progressing either).
|
||||
const jobIsolation = parseJobIsolationFlag(args);
|
||||
let childCliInvocation: { cmd: string; argsPrefix: string[] } | null = null;
|
||||
let childTiniPath = '';
|
||||
if (jobIsolation === 'process') {
|
||||
const { resolveGbrainCliPath } = await import('./autopilot.ts');
|
||||
const inv = resolveChildCliInvocation(
|
||||
process.env,
|
||||
process.execPath,
|
||||
process.argv[1],
|
||||
() => resolveGbrainCliPath(),
|
||||
);
|
||||
if (!inv) {
|
||||
console.error(
|
||||
'Error: process isolation needs a resolvable gbrain CLI for job children ' +
|
||||
'(compiled binary on PATH, or GBRAIN_JOB_CHILD_CLI override).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// Canonicalize BEFORE validating: existsSync on a relative name checks
|
||||
// cwd while spawn() resolves via PATH — the validated file and the
|
||||
// executed binary could differ (security review). Resolving to an
|
||||
// absolute path makes the fail-fast check and the spawn agree.
|
||||
const { existsSync: childCliExists } = await import('node:fs');
|
||||
const { resolve: resolveCliPath } = await import('node:path');
|
||||
inv.cmd = resolveCliPath(inv.cmd);
|
||||
if (!childCliExists(inv.cmd)) {
|
||||
console.error(
|
||||
`Error: resolved child CLI does not exist: ${inv.cmd} ` +
|
||||
'(set GBRAIN_JOB_CHILD_CLI to a valid gbrain binary).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
childCliInvocation = inv;
|
||||
const { detectTini } = await import('../core/minions/spawn-helpers.ts');
|
||||
childTiniPath = detectTini();
|
||||
if (maxRssMb > 0) {
|
||||
console.error(
|
||||
'[gbrain jobs] note: with process isolation on, the --max-rss watchdog covers the ' +
|
||||
'WORKER process only — handler memory now lives in job children. Per-child caps are ' +
|
||||
'a filed follow-up; size host memory for concurrency x handler footprint.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try { await queue.ensureSchema(); }
|
||||
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
|
||||
|
||||
// issue #6: the direct-pool kill switch collapses lock renewal, health
|
||||
// probes, and handler workload onto ONE shared pool — silently. Make
|
||||
// the collapse loud at startup so a later 'pool_starved' incident has
|
||||
// an obvious prior warning instead of a mystery.
|
||||
{
|
||||
const { getConnectionRouting } = await import('../core/minions/db-probe.ts');
|
||||
const cm = getConnectionRouting(engine);
|
||||
if (cm?.isDualPoolActive && !cm.isDualPoolActive()) {
|
||||
const killSwitched = cm.describeMode?.().kill_switch_active === true;
|
||||
console.error(
|
||||
`[gbrain jobs] single-pool mode: lock renewal, health probes and handler workload share ` +
|
||||
`one connection pool${killSwitched ? ' (direct-lane kill switch is active)' : ''}. ` +
|
||||
`Under heavy handler load this pool can starve the lock heartbeat. For Supabase brains, ` +
|
||||
`ensure the direct (5432) host is reachable or set GBRAIN_DIRECT_DATABASE_URL.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const worker = new MinionWorker(engine, {
|
||||
queue: queueName, concurrency, maxRssMb, healthCheckInterval,
|
||||
jobIsolation, childCliInvocation, childTiniPath,
|
||||
});
|
||||
await registerBuiltinHandlers(worker, engine);
|
||||
|
||||
@@ -1259,9 +1423,37 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
// the external PM (systemd, Docker, cron watchdog) restart cleanly.
|
||||
worker.on('unhealthy', (info) => {
|
||||
if (info.reason === 'db_dead') {
|
||||
// issue #6: name the failing LAYER, not just "DB unreachable" —
|
||||
// that message sent operators chasing database capacity while the
|
||||
// real fault was client-side pool exhaustion. Exiting is still
|
||||
// correct recovery either way (it frees every client-held slot).
|
||||
if (info.verdict === 'pool_starved') {
|
||||
console.error(
|
||||
`[health] FATAL: connection-pool path saturated after ${info.consecutiveFailures} probes — ` +
|
||||
`the database server itself is reachable. (${info.message}) ` +
|
||||
`Likely causes: long-running handler queries holding pool slots, or too-small GBRAIN_POOL_SIZE ` +
|
||||
`for this workload. Consider --job-isolation process for long-running handlers ` +
|
||||
`(handler connections then die with each job's child process). ` +
|
||||
`Exiting for process-manager restart (frees all client-held slots).`,
|
||||
);
|
||||
} else if (info.verdict === 'server_unreachable') {
|
||||
console.error(
|
||||
`[health] FATAL: database server unreachable after ${info.consecutiveFailures} probes ` +
|
||||
`(both pooler and direct lanes failed). (${info.message}) ` +
|
||||
`Exiting for process-manager restart.`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`[health] FATAL: DB probe failed ${info.consecutiveFailures} consecutive times (${info.message}). ` +
|
||||
`Exiting for process-manager restart.`,
|
||||
);
|
||||
}
|
||||
} else if (info.reason === 'child_spawn_failing') {
|
||||
console.error(
|
||||
`[health] FATAL: DB unreachable after ${info.consecutiveFailures} probes (${info.message}). ` +
|
||||
`Exiting for process-manager restart.`,
|
||||
`[health] FATAL: ${info.consecutiveFailures} consecutive job-child spawn/bootstrap ` +
|
||||
`failures (${info.message}). The child CLI is deterministically broken — fix the ` +
|
||||
`worker's child CLI configuration (or GBRAIN_JOB_CHILD_CLI). Exiting for ` +
|
||||
`process-manager restart.`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
@@ -1290,7 +1482,10 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
: `, health-check: ${Math.round(healthCheckInterval / 1000)}s`)
|
||||
: '';
|
||||
const niceNote = niceResult ? `, nice: ${formatNice(niceResult.effective ?? niceVal!)}` : '';
|
||||
console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency}${watchdogNote}${healthNote}${niceNote})`);
|
||||
const isolationNote = jobIsolation === 'process'
|
||||
? `, isolation: process (child cli: ${childCliInvocation?.cmd}${childTiniPath ? ', tini' : ''})`
|
||||
: '';
|
||||
console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency}${watchdogNote}${healthNote}${niceNote}${isolationNote})`);
|
||||
console.log(`Registered handlers: ${worker.registeredNames.join(', ')}`);
|
||||
|
||||
// Register in the live worker registry (issue #1815) so jobs stats / doctor
|
||||
@@ -1609,6 +1804,7 @@ export async function runJobs(engineOrNull: BrainEngine | null, args: string[]):
|
||||
allowShellJobs,
|
||||
json: jsonMode,
|
||||
maxRssMb,
|
||||
jobIsolation: parseJobIsolationFlag(args),
|
||||
...(supNice !== undefined ? { nice_requested: supNice } : {}),
|
||||
...(supNiceResult?.effective != null ? { nice_effective: supNiceResult.effective } : {}),
|
||||
...(supNiceResult?.error ? { nice_error: supNiceResult.error } : {}),
|
||||
|
||||
+449
-25
@@ -1,30 +1,35 @@
|
||||
/**
|
||||
* gbrain transcripts — Recent raw conversation transcripts.
|
||||
* gbrain transcripts — session transcripts: recent corpus reads and the
|
||||
* cathedral-4 import lane.
|
||||
*
|
||||
* Local-only: this command reads `.txt` files from the dream-cycle corpus
|
||||
* directories. It exists as a CLI surface so humans can trigger the same
|
||||
* read path the v0.29 `get_recent_transcripts` MCP op uses (which is itself
|
||||
* gated on remote=false; subagents and MCP/HTTP callers cannot reach it).
|
||||
* gbrain transcripts recent — dream-corpus .txt reader (v0.29 surface).
|
||||
* gbrain transcripts ingest — import dead session logs (Claude Code,
|
||||
* Codex, OpenClaw, Hermes) and consumer chat
|
||||
* exports (ChatGPT, Claude.ai) into
|
||||
* conversation pages. Local-only, explicit
|
||||
* paths are trusted CLI input; embedding is
|
||||
* OFF by default (bulk imports defer to the
|
||||
* embed backfill lane).
|
||||
*
|
||||
* Usage:
|
||||
* gbrain transcripts recent # last 7 days, summaries
|
||||
* gbrain transcripts recent --days 14
|
||||
* gbrain transcripts recent --full # full content (capped at 100KB/file)
|
||||
* gbrain transcripts recent --json
|
||||
* PGLite note: like every engine-opening command, ingest cannot run while
|
||||
* `gbrain serve` holds the single-writer lock — the lock error names the PID.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
|
||||
import type { TranscriptFormat } from '../core/transcripts/types.ts';
|
||||
import { runTranscriptsIngest, type TranscriptsIngestResult } from '../core/transcripts/ingest.ts';
|
||||
import { isOpenclawCheckpointFile } from '../core/transcripts/openclaw.ts';
|
||||
|
||||
interface RunOpts {
|
||||
interface RecentOpts {
|
||||
days?: number;
|
||||
full?: boolean;
|
||||
limit?: number;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): RunOpts | { help: true } {
|
||||
const opts: RunOpts = {};
|
||||
function parseRecentArgs(args: string[]): RecentOpts | { help: true } {
|
||||
const opts: RecentOpts = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--help' || a === '-h') return { help: true };
|
||||
@@ -44,31 +49,450 @@ function parseArgs(args: string[]): RunOpts | { help: true } {
|
||||
return opts;
|
||||
}
|
||||
|
||||
const HELP = `Usage: gbrain transcripts recent [options]
|
||||
const FORMATS: readonly TranscriptFormat[] = [
|
||||
'claude-code',
|
||||
'codex',
|
||||
'openclaw',
|
||||
'hermes',
|
||||
'chatgpt',
|
||||
'claude-export',
|
||||
];
|
||||
|
||||
Recent raw conversation transcripts (NOT polished reflections). Reads from
|
||||
the dream-cycle corpus dirs (dream.synthesize.session_corpus_dir and
|
||||
dream.synthesize.meeting_transcripts_dir).
|
||||
interface IngestCliOpts {
|
||||
paths: string[];
|
||||
format?: TranscriptFormat;
|
||||
dryRun?: boolean;
|
||||
limit?: number;
|
||||
since?: string;
|
||||
source?: string;
|
||||
facts?: boolean;
|
||||
maxCostUsd?: number;
|
||||
embed?: boolean;
|
||||
all?: boolean;
|
||||
json?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
Options:
|
||||
--days N Window in days (default 7)
|
||||
--limit N Max transcripts (default 50)
|
||||
--full Return full content (default: ~300-char summary). Capped 100KB/file.
|
||||
--json JSON output for agents
|
||||
--help, -h Show this help
|
||||
function parseIngestArgs(args: string[]): IngestCliOpts | { help: true } | { error: string } {
|
||||
const opts: IngestCliOpts = { paths: [] };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--help' || a === '-h') return { help: true };
|
||||
if (a === '--json') { opts.json = true; continue; }
|
||||
if (a === '--quiet') { opts.quiet = true; continue; }
|
||||
if (a === '--dry-run') { opts.dryRun = true; continue; }
|
||||
if (a === '--embed') { opts.embed = true; continue; }
|
||||
if (a === '--facts') { opts.facts = true; continue; }
|
||||
if (a === '--all') { opts.all = true; continue; }
|
||||
if (a === '--format') {
|
||||
const v = args[++i] as TranscriptFormat | undefined;
|
||||
if (!v || !FORMATS.includes(v)) {
|
||||
return { error: `unknown format '${v ?? ''}' (expected one of: ${FORMATS.join(', ')})` };
|
||||
}
|
||||
opts.format = v;
|
||||
continue;
|
||||
}
|
||||
if (a === '--limit') {
|
||||
const n = parseInt(args[++i] ?? '', 10);
|
||||
if (!Number.isFinite(n) || n <= 0) return { error: 'limit must be a positive integer' };
|
||||
opts.limit = n;
|
||||
continue;
|
||||
}
|
||||
if (a === '--since') {
|
||||
const v = args[++i];
|
||||
if (!v) return { error: 'since needs an ISO timestamp or the word last' };
|
||||
if (v !== 'last') {
|
||||
// Validate + Z-normalize: the filter compares lexicographically
|
||||
// against Z-form ISO, so an offset-form or garbage value would
|
||||
// silently mis-filter (and a filtered-everything run would still
|
||||
// look clean).
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return { error: `since needs a parseable ISO timestamp or the word last (got '${v}')` };
|
||||
}
|
||||
opts.since = d.toISOString();
|
||||
continue;
|
||||
}
|
||||
opts.since = v;
|
||||
continue;
|
||||
}
|
||||
if (a === '--source-id' || a === '--source') {
|
||||
const v = args[++i];
|
||||
if (!v) return { error: 'source-id needs a value' };
|
||||
opts.source = v;
|
||||
continue;
|
||||
}
|
||||
if (a === '--max-cost-usd') {
|
||||
const n = parseFloat(args[++i] ?? '');
|
||||
if (!Number.isFinite(n) || n <= 0) return { error: 'max-cost-usd must be a positive number' };
|
||||
opts.maxCostUsd = n;
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith('-')) return { error: `unknown flag ${a}` };
|
||||
opts.paths.push(a);
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
Note: dream-generated outputs (frontmatter dream_generated: true) are skipped.
|
||||
const HELP = `Usage:
|
||||
gbrain transcripts ingest <path-or-glob>... [options]
|
||||
gbrain transcripts ingest # discovery: show found session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts status # found vs imported gap table
|
||||
gbrain transcripts recent [options]
|
||||
|
||||
ingest — import dead session logs and chat exports as conversation pages
|
||||
(readable text-turn archive: user/assistant text only, secrets redacted,
|
||||
long sessions split into searchable parts). Re-runs are free (content-hash
|
||||
skip). Embedding is OFF by default; run the embed backfill later or opt in.
|
||||
|
||||
--all Import every session log discovered under the harness
|
||||
roots (claude/codex/openclaw projects + the hermes store)
|
||||
--format F claude-code | codex | openclaw | hermes | chatgpt |
|
||||
claude-export (auto-detected when omitted)
|
||||
--dry-run Parse + redact + report; writes nothing
|
||||
--limit N Max sessions this run
|
||||
--since T Only sessions newer than ISO time T; the word "last"
|
||||
resumes from the previous clean run
|
||||
--source-id S Target source (default: the canonical 6-tier resolution)
|
||||
--embed Embed pages at import (default: defer to embed backfill)
|
||||
--facts Extract facts from imported pages (budget-capped)
|
||||
--max-cost-usd F Facts budget cap (default 5)
|
||||
--json Machine-readable result
|
||||
--quiet Suppress the human summary
|
||||
|
||||
recent — read recent raw dream-corpus transcripts (.txt), newest first:
|
||||
--days N Window in days (default 7)
|
||||
--limit N Max transcripts (default 50)
|
||||
--full Full content, capped 100KB/file (default: short summary)
|
||||
--json JSON output for agents
|
||||
Dream-generated outputs (frontmatter dream_generated: true) are skipped.
|
||||
|
||||
Notes: consumer exports must be unzipped first (pass conversations.json).
|
||||
On PGLite, stop gbrain serve first (single-writer lock).
|
||||
`;
|
||||
|
||||
/** Extensions the importer understands; directory expansion filters to these. */
|
||||
const IMPORTABLE_EXTENSIONS = ['.jsonl', '.db', '.json'];
|
||||
|
||||
/**
|
||||
* Expand path-or-glob args. Directory specs filter to importable extensions —
|
||||
* without the filter, every stray file in a real directory (macOS Finder
|
||||
* metadata, editor backups, READMEs) becomes a permanent per-file error that
|
||||
* breaks cleanScan on every run, silently killing the since-last resume for
|
||||
* directory scopes. Checkpoint snapshots are never imported.
|
||||
*/
|
||||
async function expandPaths(specs: string[]): Promise<string[]> {
|
||||
const { statSync } = await import('node:fs');
|
||||
const out: string[] = [];
|
||||
for (const spec of specs) {
|
||||
let matched = false;
|
||||
try {
|
||||
if (statSync(spec).isFile()) {
|
||||
out.push(spec);
|
||||
continue;
|
||||
}
|
||||
if (statSync(spec).isDirectory()) {
|
||||
const glob = new Bun.Glob('**/*');
|
||||
for (const p of glob.scanSync({ cwd: spec, absolute: true, onlyFiles: true })) {
|
||||
if (IMPORTABLE_EXTENSIONS.some((ext) => p.endsWith(ext))) out.push(p);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Not a literal path — try as a glob below.
|
||||
}
|
||||
const glob = new Bun.Glob(spec);
|
||||
for (const p of glob.scanSync({ cwd: process.cwd(), absolute: true, onlyFiles: true })) {
|
||||
out.push(p);
|
||||
matched = true;
|
||||
}
|
||||
if (!matched && !out.includes(spec)) {
|
||||
// Keep the unmatched spec so the per-file error names it.
|
||||
out.push(spec);
|
||||
}
|
||||
}
|
||||
return [...new Set(out)].filter((p) => !isOpenclawCheckpointFile(p));
|
||||
}
|
||||
|
||||
function fmtSummary(r: TranscriptsIngestResult): string {
|
||||
const byHarness = new Map<string, number>();
|
||||
for (const f of r.files) {
|
||||
for (const s of f.sessions) {
|
||||
if (!s.error) byHarness.set(s.harness, (byHarness.get(s.harness) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const lines: string[] = [];
|
||||
const counts = [...byHarness.entries()].map(([h, n]) => `${h}: ${n}`).join(', ');
|
||||
lines.push(
|
||||
`sessions: ${r.sessionsImported} imported (${counts || 'none'}), ` +
|
||||
`${r.sessionsFiltered} filtered, ${r.sessionsErrored} errored, ${r.sessionsSeen} seen`,
|
||||
);
|
||||
lines.push(
|
||||
`pages: ${r.pages.imported} imported, ${r.pages.skipped} unchanged` +
|
||||
(r.pages.errored ? `, ${r.pages.errored} ERRORED` : '') +
|
||||
(r.pages.planned ? `, ${r.pages.planned} planned (dry run)` : '') +
|
||||
(r.partsDeleted ? `, ${r.partsDeleted} stale parts deleted` : ''),
|
||||
);
|
||||
if (r.redactions > 0) lines.push(`redactions: ${r.redactions} secrets/patterns redacted before write`);
|
||||
if (r.imperatives > 0) lines.push(`flagged: ${r.imperatives} agent-directed imperative(s) noted in frontmatter`);
|
||||
if (r.driftFiles > 0) {
|
||||
lines.push(
|
||||
`DRIFT WARNING: ${r.driftFiles} file(s) parsed to zero sessions — the host ` +
|
||||
`format may have changed; see the adapter SPEC_TARGET runbook`,
|
||||
);
|
||||
}
|
||||
for (const f of r.files) {
|
||||
if (f.error) lines.push(`error: ${f.path}: ${f.error}`);
|
||||
for (const s of f.sessions) {
|
||||
if (s.error) lines.push(`error: ${f.path} session ${s.sessionId}: ${s.error}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function runIngest(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const parsed = parseIngestArgs(args);
|
||||
if ('help' in parsed) {
|
||||
console.log(HELP);
|
||||
return;
|
||||
}
|
||||
if ('error' in parsed) {
|
||||
console.error(`gbrain transcripts ingest: ${parsed.error}`);
|
||||
setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
// The watermark fingerprint binds the USER-STATED spec, captured BEFORE
|
||||
// discovery expands it — binding expanded file lists would mint a new
|
||||
// fingerprint every time a harness writes a new session, so the all-lane
|
||||
// since-last would never resume. Specs are RESOLVED first: the same
|
||||
// relative spec from two different cwds names different scopes (must not
|
||||
// share a watermark), and equivalent spellings of one dir must not
|
||||
// fragment into separate watermarks.
|
||||
const { resolve } = await import('node:path');
|
||||
const { hostname } = await import('node:os');
|
||||
// The all-lane scope is THIS machine's harness roots, so the fingerprint
|
||||
// carries host + roots: checkpoints are DB-backed and shared across every
|
||||
// machine on the brain — a bare literal would let machine B inherit
|
||||
// machine A's watermark and silently skip local sessions it never scanned.
|
||||
const { harnessRoots } = await import('../core/transcripts/detect.ts');
|
||||
const checkpointSpec =
|
||||
parsed.paths.length === 0
|
||||
? ['--all-discovery', hostname(), ...harnessRoots().map((r) => r.root).sort()]
|
||||
: [...parsed.paths].map((p) => resolve(p)).sort();
|
||||
|
||||
// No paths: discovery. Without the all flag, show what WOULD be imported
|
||||
// and stop (a safe default for a command that can touch four harness
|
||||
// histories); with it, import the discovered set.
|
||||
if (parsed.paths.length === 0) {
|
||||
const { discoverTranscriptFiles } = await import('../core/transcripts/discover.ts');
|
||||
const discovered = discoverTranscriptFiles();
|
||||
if (discovered.length === 0) {
|
||||
console.log('discovery: no session logs found under the harness roots');
|
||||
return;
|
||||
}
|
||||
if (!parsed.all) {
|
||||
const byFormat = new Map<string, { n: number; bytes: number }>();
|
||||
for (const d of discovered) {
|
||||
const cur = byFormat.get(d.format) ?? { n: 0, bytes: 0 };
|
||||
cur.n++;
|
||||
cur.bytes += d.bytes;
|
||||
byFormat.set(d.format, cur);
|
||||
}
|
||||
console.log('discovery (nothing imported yet — add the all flag to import):');
|
||||
for (const [format, { n, bytes }] of byFormat) {
|
||||
console.log(` ${format.padEnd(12)} ${String(n).padStart(5)} file(s) ${(bytes / 1024 / 1024).toFixed(1)} MB`);
|
||||
}
|
||||
console.log(' tip: `gbrain transcripts status` shows found vs imported per harness');
|
||||
return;
|
||||
}
|
||||
parsed.paths = discovered.map((d) => d.path);
|
||||
}
|
||||
|
||||
// Source: the canonical 6-tier chain (capture.ts pattern) — one resolved
|
||||
// id threads import + raw-data + reconciliation + checkpoint fingerprint.
|
||||
let sourceId = 'default';
|
||||
try {
|
||||
const { resolveSourceWithTier } = await import('../core/source-resolver.ts');
|
||||
const r = await resolveSourceWithTier(engine, parsed.source ?? null);
|
||||
sourceId = r.source_id;
|
||||
} catch (e) {
|
||||
console.error(`gbrain transcripts ingest: ${e instanceof Error ? e.message : String(e)}`);
|
||||
setCliExitVerdict(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Active pack ONCE per command (never per file).
|
||||
let activePack: { page_types: ReadonlyArray<{ name: string; path_prefixes: ReadonlyArray<string> }> } | undefined;
|
||||
try {
|
||||
const { loadActivePack } = await import('../core/schema-pack/load-active.ts');
|
||||
const { loadConfig } = await import('../core/config.ts');
|
||||
const resolved = await loadActivePack({ cfg: loadConfig(), remote: false, sourceId });
|
||||
activePack = { page_types: resolved.manifest.page_types };
|
||||
} catch {
|
||||
activePack = undefined;
|
||||
}
|
||||
|
||||
const paths = await expandPaths(parsed.paths);
|
||||
if (paths.length === 0) {
|
||||
console.error('gbrain transcripts ingest: 0 files matched');
|
||||
return;
|
||||
}
|
||||
|
||||
// --since last → op-checkpoint watermark (speed convenience only; the
|
||||
// status gap table is the correctness surface). Fingerprint binds
|
||||
// source + pathspec + format + adapter version so a second source or a
|
||||
// different root never inherits this watermark.
|
||||
const { fingerprint, loadOpCheckpoint, recordCompleted } = await import('../core/op-checkpoint.ts');
|
||||
const { TRANSCRIPT_IMPORT_VERSION } = await import('../core/transcripts/render.ts');
|
||||
const checkpointKey = {
|
||||
op: 'transcripts-ingest',
|
||||
fingerprint: fingerprint({
|
||||
sourceId,
|
||||
pathspec: checkpointSpec,
|
||||
format: parsed.format ?? 'auto',
|
||||
version: TRANSCRIPT_IMPORT_VERSION,
|
||||
}),
|
||||
};
|
||||
let sinceIso = parsed.since;
|
||||
if (parsed.since === 'last') {
|
||||
sinceIso = undefined;
|
||||
const keys = await loadOpCheckpoint(engine, checkpointKey);
|
||||
for (const k of keys) {
|
||||
if (k.startsWith('since:')) {
|
||||
const v = k.slice('since:'.length);
|
||||
if (!sinceIso || v > sinceIso) sinceIso = v;
|
||||
}
|
||||
}
|
||||
if (!sinceIso && !parsed.quiet) {
|
||||
console.error('transcripts ingest: no previous clean run for this scope — full scan');
|
||||
}
|
||||
}
|
||||
|
||||
const { createProgress } = await import('../core/progress.ts');
|
||||
const { cliOptsToProgressOptions, getCliOptions } = await import('../core/cli-options.ts');
|
||||
const reporter = createProgress(cliOptsToProgressOptions(getCliOptions()));
|
||||
reporter.start('transcripts.ingest', paths.length);
|
||||
|
||||
let result: TranscriptsIngestResult;
|
||||
try {
|
||||
result = await runTranscriptsIngest(engine, {
|
||||
paths,
|
||||
format: parsed.format,
|
||||
dryRun: parsed.dryRun,
|
||||
limit: parsed.limit,
|
||||
sinceIso,
|
||||
sourceId,
|
||||
embed: parsed.embed,
|
||||
activePack,
|
||||
onFileDone: () => reporter.tick(),
|
||||
// Multi-session stores (one hermes state.db = thousands of sessions)
|
||||
// need liveness BETWEEN file ticks.
|
||||
onSession: (sessionId) => reporter.heartbeat(`session ${sessionId.slice(0, 12)}`),
|
||||
});
|
||||
} finally {
|
||||
reporter.finish();
|
||||
}
|
||||
|
||||
if (!parsed.embed && !parsed.dryRun && result.pages.imported > 0 && !parsed.quiet) {
|
||||
console.error(
|
||||
'note: pages imported without embeddings (default) — run the embed backfill ' +
|
||||
'or re-run with the embed flag to make them vector-searchable now',
|
||||
);
|
||||
}
|
||||
|
||||
// Watermark: advance ONLY on a clean, untruncated, non-dry scan — and only
|
||||
// when the run ATTESTED full coverage (no since bound, or since=last). An
|
||||
// explicit since run never scanned below its cutoff and must not vouch for
|
||||
// sessions there.
|
||||
const attestsCoverage = parsed.since === undefined || parsed.since === 'last';
|
||||
if (result.cleanScan && result.maxSessionTs && attestsCoverage) {
|
||||
await recordCompleted(engine, checkpointKey, [`since:${result.maxSessionTs}`]);
|
||||
}
|
||||
|
||||
// --facts: ONE extractor invocation over every touched slug (including
|
||||
// hash-skipped pages — the extractor's version-token gate dedupes work).
|
||||
let factsSummary: { pages: number; spentUsd?: number } | undefined;
|
||||
if (parsed.facts && !parsed.dryRun && result.slugsTouched.length > 0) {
|
||||
const { runIngestFacts } = await import('../core/transcripts/ingest-facts.ts');
|
||||
factsSummary = await runIngestFacts(engine, {
|
||||
sourceId,
|
||||
slugs: [...new Set(result.slugsTouched)],
|
||||
maxCostUsd: parsed.maxCostUsd,
|
||||
quiet: parsed.quiet,
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.json) {
|
||||
console.log(JSON.stringify({ ...result, facts: factsSummary ?? null, source_id: sourceId }, null, 2));
|
||||
} else if (!parsed.quiet) {
|
||||
console.log(fmtSummary(result));
|
||||
if (factsSummary) {
|
||||
console.log(
|
||||
`facts: extracted over ${factsSummary.pages} page(s)` +
|
||||
(factsSummary.spentUsd !== undefined ? `, ~$${factsSummary.spentUsd.toFixed(2)} spent` : ''),
|
||||
);
|
||||
}
|
||||
const firstImported = result.files.flatMap((f) => f.sessions).find((s) => !s.error && s.baseSlug);
|
||||
if (firstImported && !parsed.dryRun) {
|
||||
console.log(`try it: gbrain query "${firstImported.baseSlug.split('/').pop()}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const allFailed =
|
||||
result.files.length > 0 &&
|
||||
result.files.every((f) => f.error !== undefined || (f.drift && f.sessions.length === 0));
|
||||
if (allFailed) setCliExitVerdict(1);
|
||||
}
|
||||
|
||||
async function runStatus(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const json = args.includes('--json');
|
||||
let sourceId = 'default';
|
||||
try {
|
||||
const { resolveSourceWithTier } = await import('../core/source-resolver.ts');
|
||||
sourceId = (await resolveSourceWithTier(engine, null)).source_id;
|
||||
} catch {
|
||||
// Fall through with default — status is read-only.
|
||||
}
|
||||
const { buildStatusRows, discoverTranscriptFiles, indexImportedSessions } = await import(
|
||||
'../core/transcripts/discover.ts'
|
||||
);
|
||||
const rows = buildStatusRows(discoverTranscriptFiles(), await indexImportedSessions(engine, sourceId));
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ source_id: sourceId, rows }, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`transcripts status (source: ${sourceId})`);
|
||||
console.log(' harness found imported-sessions not-yet-imported');
|
||||
for (const r of rows) {
|
||||
const gap = r.gapFiles === null ? '(store-level; run ingest to see)' : String(r.gapFiles);
|
||||
console.log(
|
||||
` ${r.format.padEnd(12)} ${String(r.found).padStart(6)} ${String(r.importedSessions).padStart(12)} ${gap}`,
|
||||
);
|
||||
}
|
||||
const totalGap = rows.reduce((n, r) => n + (r.gapFiles ?? 0), 0);
|
||||
if (totalGap > 0) {
|
||||
console.log(` backfill: gbrain transcripts ingest --all (${totalGap} file(s) waiting)`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTranscripts(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (sub === 'ingest') {
|
||||
await runIngest(engine, args.slice(1));
|
||||
return;
|
||||
}
|
||||
if (sub === 'status') {
|
||||
await runStatus(engine, args.slice(1));
|
||||
return;
|
||||
}
|
||||
if (sub !== 'recent') {
|
||||
console.log(HELP);
|
||||
if (sub && sub !== '--help' && sub !== '-h') setCliExitVerdict(2);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseArgs(args.slice(1));
|
||||
const parsed = parseRecentArgs(args.slice(1));
|
||||
if ('help' in parsed) {
|
||||
console.log(HELP);
|
||||
return;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* AgentRunner — pluggable contract for invoking external agents (openclaw,
|
||||
* hermes, codex, …) inside the claw-test harness. Two implementations ship
|
||||
* (openclaw, hermes); the interface stays narrow and concrete so adding
|
||||
* another runner is a ~100-line file.
|
||||
* hermes, grok, …) inside the claw-test harness. Three implementations ship
|
||||
* (openclaw, hermes, grok); the interface stays narrow and concrete — with
|
||||
* the shared detect/env helpers below, adding another runner is a ~50-line
|
||||
* file plus one registerAgentRunner line.
|
||||
*
|
||||
* The harness wraps spawn/timeout/transcript-capture; runners only have to
|
||||
* answer "where's your binary?" and "how do I invoke it with this prompt?".
|
||||
@@ -15,6 +16,9 @@
|
||||
* └────────────────────┘
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
|
||||
export interface AgentRunner {
|
||||
/** Stable agent name used by --agent flag and friction `agent` field. */
|
||||
readonly name: string;
|
||||
@@ -126,6 +130,67 @@ export function validateBinPathEnv(envName: string, p: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared binary resolution for runners: `$<envName>` (validated) > `which
|
||||
* <binName>` > unavailable, then a regular-file + executable-bit stat. The
|
||||
* three runners previously carried byte-identical copies of this body; the
|
||||
* extraction is behavior-preserving (same reason strings, same ordering).
|
||||
*/
|
||||
export function detectBinary(envName: string, binName: string): DetectResult {
|
||||
const fromEnv = process.env[envName]?.trim();
|
||||
let binPath: string | undefined;
|
||||
|
||||
if (fromEnv) {
|
||||
const validation = validateBinPathEnv(envName, fromEnv);
|
||||
if (validation) return { available: false, reason: validation };
|
||||
binPath = fromEnv;
|
||||
} else {
|
||||
try {
|
||||
// execFileSync, not a shell string: binName comes from callers today
|
||||
// (constants), but this helper is exported — interpolating it into a
|
||||
// shell would make a future metachar-bearing name become code.
|
||||
const out = execFileSync('which', [binName], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const found = out.trim();
|
||||
if (!found || !found.startsWith('/')) {
|
||||
return { available: false, reason: `${binName} not on PATH` };
|
||||
}
|
||||
binPath = found;
|
||||
} catch {
|
||||
return { available: false, reason: `${binName} not on PATH` };
|
||||
}
|
||||
}
|
||||
|
||||
if (!binPath) return { available: false, reason: 'no binary resolved' };
|
||||
|
||||
try {
|
||||
const s = statSync(binPath);
|
||||
if (!s.isFile()) return { available: false, reason: `not a regular file: ${binPath}` };
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (!(s.mode & 0o111)) return { available: false, reason: `not executable: ${binPath}` };
|
||||
} catch (e) {
|
||||
return { available: false, reason: `stat failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
|
||||
return { available: true, binPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the child env for a runner spawn: process.env filtered to the
|
||||
* runner's allowlist, then caller overrides merged on top (overrides win —
|
||||
* the live lane's PATH-shim prepend depends on this precedence).
|
||||
*/
|
||||
export function filterAllowlistEnv(
|
||||
allowlist: readonly string[],
|
||||
overrides: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const baseEnv: Record<string, string> = {};
|
||||
for (const key of allowlist) {
|
||||
const v = process.env[key];
|
||||
if (typeof v === 'string') baseEnv[key] = v;
|
||||
}
|
||||
return { ...baseEnv, ...overrides };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Grok runner — invokes the real `grok` binary (xAI Grok Build CLI) in a
|
||||
* tempdir with a BRIEF.md prompt. Live mode only.
|
||||
*
|
||||
* Invocation pattern (verified against a pinned local install, v1.0.4 —
|
||||
* see docs/mcp/GROK-CLI-PIN.md):
|
||||
* grok -p "<brief>" --output-format plain
|
||||
*
|
||||
* The p flag ("single") is Grok Build's headless one-shot: prompt in, final
|
||||
* response text on stdout, exit. We deliberately do NOT pass grok's cwd flag
|
||||
* — `spawnWithCapture` already sets `cwd`. `opts.agentName` is unused: the
|
||||
* one-shot mode targets the default agent. The permission posture
|
||||
* ("always-approve" / "permission-mode", both spelled dash-free here on
|
||||
* purpose) is deliberately NOT passed until the authed observation proves it
|
||||
* required for headless MCP tool calls — GROK-CLI-PIN.md marks that item
|
||||
* pending auth; revisit after the first authed live run.
|
||||
*
|
||||
* Naming: grok (xAI, XAI_API_KEY) is not groq (Groq Inc. inference recipe,
|
||||
* GROQ_API_KEY) and not ngrok (tunnels).
|
||||
*
|
||||
* Hermeticity posture (deliberate): live mode runs the OPERATOR's configured
|
||||
* Grok — the real ~/.grok (auth, model settings, trusted folders) is
|
||||
* inherited unless GROK_HOME points elsewhere — against a hermetic BRAIN.
|
||||
* Grok-specific contamination channel (observed): grok reads VENDOR MCP
|
||||
* configs — ~/.claude.json and project .mcp.json — for folders the operator
|
||||
* has trusted. If the operator's ~/.claude.json registers gbrain, a live-lane
|
||||
* grok turn in a trusted folder can bind the operator's REAL brain while the
|
||||
* oracle probes the hermetic one. `invoke()` logs a loud warning when it
|
||||
* detects that case; the fully hermetic lane is the door e2e
|
||||
* (install-real-grok.serial.test.ts), where fresh HOME + cwd make vendor
|
||||
* fallback structurally impossible (entries show "folder untrusted").
|
||||
* Also observed: grok loads .envrc from the cwd by default — live-mode
|
||||
* workspaces are harness-created tempdirs, so no operator .envrc is in reach.
|
||||
*
|
||||
* Binary resolution: $GROK_BIN > `which grok` > unavailable.
|
||||
* Path validation: must be absolute, must be executable, no '..' segments.
|
||||
* Collision note: the community superagent-ai grok-cli ships a colliding
|
||||
* `grok` binary; the official one is identified by its version output shape
|
||||
* `grok X.Y.Z (buildhash)` — the live lane writes a version preamble into
|
||||
* the transcript so a mis-bound binary is diagnosable from the transcript.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
BASE_ENV_ALLOWLIST,
|
||||
detectBinary,
|
||||
filterAllowlistEnv,
|
||||
type AgentRunner,
|
||||
type DetectResult,
|
||||
type InvokeOpts,
|
||||
type InvokeResult,
|
||||
} from '../agent-runner.ts';
|
||||
import { spawnWithCapture } from '../transcript-capture.ts';
|
||||
|
||||
/**
|
||||
* Allow-list for env propagation when spawning grok. Delta from the shared
|
||||
* base: GROK_HOME, so callers (door e2e, dx scenarios) can point Grok at an
|
||||
* isolated home instead of the operator's real ~/.grok; and XAI_API_KEY,
|
||||
* grok's documented headless auth path (keyless one-shot exits 1 with
|
||||
* "Not signed in" — see docs/mcp/GROK-CLI-PIN.md) — an operator who auths
|
||||
* via that env var would otherwise be blamed as an agent failure.
|
||||
* FOREIGN provider keys are removed from the base: grok is single-provider
|
||||
* (xAI) — forwarding the operator's Anthropic/OpenAI credentials to a
|
||||
* third-party binary buys nothing and is the exact cross-provider exposure
|
||||
* the door lane's grokChildEnv scrub exists to prevent.
|
||||
*/
|
||||
const FOREIGN_PROVIDER_KEYS = new Set(['ANTHROPIC_API_KEY', 'OPENAI_API_KEY']);
|
||||
const ENV_ALLOWLIST = [
|
||||
...BASE_ENV_ALLOWLIST.filter((k) => !FOREIGN_PROVIDER_KEYS.has(k)),
|
||||
'GROK_HOME',
|
||||
'XAI_API_KEY',
|
||||
];
|
||||
|
||||
export class GrokRunner implements AgentRunner {
|
||||
readonly name = 'grok';
|
||||
|
||||
async detect(): Promise<DetectResult> {
|
||||
return detectBinary('GROK_BIN', 'grok');
|
||||
}
|
||||
|
||||
async invoke(opts: InvokeOpts): Promise<InvokeResult> {
|
||||
const detected = await this.detect();
|
||||
if (!detected.available || !detected.binPath) {
|
||||
throw new Error(`grok runner unavailable: ${detected.reason ?? 'unknown'}`);
|
||||
}
|
||||
const args = ['-p', opts.brief, '--output-format', 'plain'];
|
||||
const env = filterAllowlistEnv(ENV_ALLOWLIST, opts.env);
|
||||
|
||||
this.warnOnVendorGbrainEntry(env);
|
||||
|
||||
// Version preamble: recorded as a plain stdout transcript event (the
|
||||
// transcript schema is stdio-bytes-only, so this is a captured preamble,
|
||||
// not a new event type). Makes a mis-bound community binary or an
|
||||
// auto-updated version diagnosable from the transcript alone.
|
||||
// execFileSync (no shell — the which-resolved path is not metachar-
|
||||
// validated) with the SAME filtered env as the turn itself: the resolved
|
||||
// binary must never see ambient secrets the allowlist excludes.
|
||||
try {
|
||||
const version = execFileSync(detected.binPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 15_000,
|
||||
env: env as NodeJS.ProcessEnv,
|
||||
cwd: opts.cwd,
|
||||
}).trim();
|
||||
opts.transcriptSink.write({
|
||||
ts: Date.now(),
|
||||
channel: 'stdout',
|
||||
bytes: Buffer.from(`[grok-runner preamble] version: ${version}\n`, 'utf-8'),
|
||||
});
|
||||
} catch {
|
||||
// Preamble is diagnostic only — never fail the run for it.
|
||||
}
|
||||
|
||||
const result = await spawnWithCapture(detected.binPath, args, {
|
||||
cwd: opts.cwd,
|
||||
env,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
transcriptSink: opts.transcriptSink,
|
||||
});
|
||||
|
||||
return { exitCode: result.exitCode, durationMs: result.durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loud tripwire for the vendor-config contamination channel: when the HOME
|
||||
* grok will see carries a ~/.claude.json with an mcpServers.gbrain entry,
|
||||
* a trusted-folder grok turn may route gbrain tool calls at the operator's
|
||||
* REAL brain. Warning only (live mode deliberately runs the operator's
|
||||
* agent); the hermetic lane is the door e2e.
|
||||
*/
|
||||
private warnOnVendorGbrainEntry(env: Record<string, string>): void {
|
||||
try {
|
||||
const home = env.HOME ?? process.env.HOME;
|
||||
if (!home) return;
|
||||
const claudeJson = join(home, '.claude.json');
|
||||
if (!existsSync(claudeJson)) return;
|
||||
const parsed = JSON.parse(readFileSync(claudeJson, 'utf-8')) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
if (parsed?.mcpServers && Object.prototype.hasOwnProperty.call(parsed.mcpServers, 'gbrain')) {
|
||||
console.warn(
|
||||
'[grok-runner] WARNING: ~/.claude.json registers an mcpServers.gbrain entry. ' +
|
||||
'Grok Build reads vendor MCP configs for trusted folders, so this live run ' +
|
||||
'may bind the OPERATOR\'S REAL brain instead of the hermetic one. ' +
|
||||
'Use a scratch HOME/GROK_HOME for clean measurements (docs/mcp/GROK-CLI-PIN.md).',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort tripwire — unreadable/invalid vendor config is not an error here.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,10 @@
|
||||
* Path validation: must be absolute, must be executable, no '..' segments.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
import {
|
||||
BASE_ENV_ALLOWLIST,
|
||||
validateBinPathEnv,
|
||||
detectBinary,
|
||||
filterAllowlistEnv,
|
||||
type AgentRunner,
|
||||
type DetectResult,
|
||||
type InvokeOpts,
|
||||
@@ -54,38 +53,7 @@ export class HermesRunner implements AgentRunner {
|
||||
readonly name = 'hermes';
|
||||
|
||||
async detect(): Promise<DetectResult> {
|
||||
const fromEnv = process.env.HERMES_BIN?.trim();
|
||||
let binPath: string | undefined;
|
||||
|
||||
if (fromEnv) {
|
||||
const validation = validateBinPathEnv('HERMES_BIN', fromEnv);
|
||||
if (validation) return { available: false, reason: validation };
|
||||
binPath = fromEnv;
|
||||
} else {
|
||||
try {
|
||||
const out = execSync('which hermes', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const found = out.trim();
|
||||
if (!found || !found.startsWith('/')) {
|
||||
return { available: false, reason: 'hermes not on PATH' };
|
||||
}
|
||||
binPath = found;
|
||||
} catch {
|
||||
return { available: false, reason: 'hermes not on PATH' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!binPath) return { available: false, reason: 'no binary resolved' };
|
||||
|
||||
try {
|
||||
const s = statSync(binPath);
|
||||
if (!s.isFile()) return { available: false, reason: `not a regular file: ${binPath}` };
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (!(s.mode & 0o111)) return { available: false, reason: `not executable: ${binPath}` };
|
||||
} catch (e) {
|
||||
return { available: false, reason: `stat failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
|
||||
return { available: true, binPath };
|
||||
return detectBinary('HERMES_BIN', 'hermes');
|
||||
}
|
||||
|
||||
async invoke(opts: InvokeOpts): Promise<InvokeResult> {
|
||||
@@ -94,14 +62,7 @@ export class HermesRunner implements AgentRunner {
|
||||
throw new Error(`hermes runner unavailable: ${detected.reason ?? 'unknown'}`);
|
||||
}
|
||||
const args = ['-z', opts.brief];
|
||||
|
||||
// Filter env to allow-list, then merge caller overrides.
|
||||
const baseEnv: Record<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
const v = process.env[key];
|
||||
if (typeof v === 'string') baseEnv[key] = v;
|
||||
}
|
||||
const env: Record<string, string> = { ...baseEnv, ...opts.env };
|
||||
const env = filterAllowlistEnv(ENV_ALLOWLIST, opts.env);
|
||||
|
||||
const result = await spawnWithCapture(detected.binPath, args, {
|
||||
cwd: opts.cwd,
|
||||
|
||||
@@ -6,18 +6,18 @@
|
||||
* test/e2e/bench-vs-openclaw/harness.ts):
|
||||
* openclaw agent --local --agent <agent-name> --message "<brief>"
|
||||
*
|
||||
* NOT `openclaw run --prompt-file BRIEF.md` (that flag does not exist —
|
||||
* Codex pass 2 of the eng review caught the speculative shape).
|
||||
* NOT `openclaw run` with a prompt-file flag (spelled "prompt-file" — that
|
||||
* flag does not exist; Codex pass 2 of the eng review caught the speculative
|
||||
* shape).
|
||||
*
|
||||
* Binary resolution: $OPENCLAW_BIN > `which openclaw` > unavailable.
|
||||
* Path validation: must be absolute, must be executable, no '..' segments.
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
import {
|
||||
BASE_ENV_ALLOWLIST,
|
||||
validateBinPathEnv,
|
||||
detectBinary,
|
||||
filterAllowlistEnv,
|
||||
type AgentRunner,
|
||||
type DetectResult,
|
||||
type InvokeOpts,
|
||||
@@ -33,38 +33,7 @@ export class OpenClawRunner implements AgentRunner {
|
||||
readonly name = 'openclaw';
|
||||
|
||||
async detect(): Promise<DetectResult> {
|
||||
const fromEnv = process.env.OPENCLAW_BIN?.trim();
|
||||
let binPath: string | undefined;
|
||||
|
||||
if (fromEnv) {
|
||||
const validation = validateBinPathEnv('OPENCLAW_BIN', fromEnv);
|
||||
if (validation) return { available: false, reason: validation };
|
||||
binPath = fromEnv;
|
||||
} else {
|
||||
try {
|
||||
const out = execSync('which openclaw', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
const found = out.trim();
|
||||
if (!found || !found.startsWith('/')) {
|
||||
return { available: false, reason: 'openclaw not on PATH' };
|
||||
}
|
||||
binPath = found;
|
||||
} catch {
|
||||
return { available: false, reason: 'openclaw not on PATH' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!binPath) return { available: false, reason: 'no binary resolved' };
|
||||
|
||||
try {
|
||||
const s = statSync(binPath);
|
||||
if (!s.isFile()) return { available: false, reason: `not a regular file: ${binPath}` };
|
||||
// eslint-disable-next-line no-bitwise
|
||||
if (!(s.mode & 0o111)) return { available: false, reason: `not executable: ${binPath}` };
|
||||
} catch (e) {
|
||||
return { available: false, reason: `stat failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
|
||||
return { available: true, binPath };
|
||||
return detectBinary('OPENCLAW_BIN', 'openclaw');
|
||||
}
|
||||
|
||||
async invoke(opts: InvokeOpts): Promise<InvokeResult> {
|
||||
@@ -74,14 +43,7 @@ export class OpenClawRunner implements AgentRunner {
|
||||
}
|
||||
const agentName = opts.agentName ?? DEFAULT_AGENT_NAME;
|
||||
const args = ['agent', '--local', '--agent', agentName, '--message', opts.brief];
|
||||
|
||||
// Filter env to allow-list, then merge caller overrides.
|
||||
const baseEnv: Record<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
const v = process.env[key];
|
||||
if (typeof v === 'string') baseEnv[key] = v;
|
||||
}
|
||||
const env: Record<string, string> = { ...baseEnv, ...opts.env };
|
||||
const env = filterAllowlistEnv(ENV_ALLOWLIST, opts.env);
|
||||
|
||||
const result = await spawnWithCapture(detected.binPath, args, {
|
||||
cwd: opts.cwd,
|
||||
@@ -93,4 +55,3 @@ export class OpenClawRunner implements AgentRunner {
|
||||
return { exitCode: result.exitCode, durationMs: result.durationMs };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'check-backlinks': ['--background', '--brain', '--brain-wide-max-cost-usd', '--dir', '--dry-run', '--explain', '--follow', '--help', '--include-frontmatter', '--json', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--source', '--stale', '--timeout', '--type'],
|
||||
'check-resolvable': ['--brain', '--dry-run', '--fix', '--help', '--json', '--skills-dir', '--source', '--strict', '--verbose'],
|
||||
'check-update': ['--all', '--brain', '--check', '--dim', '--ff-only', '--help', '--json', '--markdown', '--migrate-only', '--non-interactive', '--refresh-cache', '--source', '--swap-only', '--to', '--version', '--yes'],
|
||||
'claw-test': ['--ab', '--agent', '--all', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dir', '--exclusive', '--force-retry', '--force-schema', '--from-meetings', '--help', '--history', '--http', '--json', '--keep-tempdir', '--lang', '--list-agents', '--live', '--local', '--locks', '--markdown', '--max-age', '--message', '--multimodal', '--no-embed', '--no-embedding', '--no-extract', '--path', '--pglite', '--phase', '--priority', '--progress-json', '--prompt-file', '--refresh-unqualified', '--remediate', '--rollback', '--run-id', '--scenario', '--skip-verify', '--source', '--stale', '--surface', '--transcripts', '--undo-wave', '--use-captured-snapshot', '--with-calibration', '--yes'],
|
||||
'claw-test': ['--ab', '--agent', '--all', '--auto-update', '--brain', '--break-lock', '--build-index', '--by-mention', '--compile', '--days', '--dir', '--exclusive', '--force-retry', '--force-schema', '--from-meetings', '--help', '--history', '--http', '--json', '--keep-tempdir', '--lang', '--list-agents', '--live', '--local', '--locks', '--markdown', '--max-age', '--message', '--multimodal', '--no-embed', '--no-embedding', '--no-extract', '--output-format', '--path', '--pglite', '--phase', '--priority', '--progress-json', '--refresh-unqualified', '--remediate', '--rollback', '--run-id', '--scenario', '--skip-verify', '--source', '--stale', '--surface', '--transcripts', '--undo-wave', '--use-captured-snapshot', '--version', '--with-calibration', '--yes'],
|
||||
'code-callees': ['--aliases', '--all', '--all-sources', '--brain', '--chunker-debug', '--clone-dir', '--confirm-destructive', '--federated', '--force', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--no-federated', '--no-json', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--url', '--url-managed', '--yes'],
|
||||
'code-callers': ['--aliases', '--all', '--all-sources', '--brain', '--chunker-debug', '--clone-dir', '--confirm-destructive', '--federated', '--force', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--no-federated', '--no-json', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--url', '--url-managed', '--yes'],
|
||||
'code-def': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--pretty', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'],
|
||||
@@ -40,7 +40,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--detail', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--no-federated', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--yes'],
|
||||
'connect': ['--agent', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scope', '--scopes', '--show-token', '--source', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'],
|
||||
'conversation-parser': ['--aliases', '--all', '--brain', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'],
|
||||
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--is-inside-work-tree', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--project', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--remove', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--token', '--token-ttl', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
|
||||
'doctor': ['--ab', '--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--allow-shell-jobs', '--allow-unverified-remote', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--build-index', '--by-mention', '--by-type', '--cached', '--check', '--column', '--compile', '--concurrency', '--confidence', '--confirm', '--content-audit', '--count', '--days', '--delete-brain', '--detach', '--detail', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclude-standard', '--exclusive', '--explain', '--fast', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--get', '--git-dir', '--git-path', '--grant-types', '--harness', '--health-interval', '--help', '--history', '--home', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--init', '--input', '--is-inside-work-tree', '--job-isolation', '--jq', '--json', '--lang', '--limit', '--local', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-cron', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-mutate', '--no-verify', '--oauth-client-secret', '--older-than', '--once', '--others', '--overwrite', '--parallel', '--params', '--pat-file', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--project', '--push-only', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--remove', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--set', '--short', '--show-current', '--show-toplevel', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--stats', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--token', '--token-ttl', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--unset-all', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--verify', '--version', '--window', '--with-calibration', '--workers', '--yes'],
|
||||
'dream': ['--against', '--aliases', '--all', '--allow-regression', '--anchor', '--asof', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--budget-usd', '--budget-usd-answer', '--budget-usd-retrieval', '--by-type', '--by-type-floor', '--code', '--committed-baseline', '--compare', '--compile', '--concurrent', '--ctx-size', '--cycles', '--date', '--detail', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--embeddings', '--expansion', '--explain', '--fast', '--federated', '--fix', '--fixtures', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--format', '--from', '--from-db', '--from-pages', '--gold', '--harness', '--help', '--http', '--include-holdout', '--include-null-signature', '--input', '--install', '--json', '--judge-model', '--justification', '--keyword-only', '--lang', '--limit', '--llm', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-runtime', '--max-tokens', '--max-usd', '--mcp-only', '--min-recall', '--mode', '--model', '--models', '--modes', '--multimodal', '--name', '--name-only', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-llm', '--no-mutate', '--no-trajectory', '--once', '--out', '--output', '--output-dir', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--phase', '--priority', '--progress-interval', '--progress-json', '--pull', '--quiet', '--receipt-dir', '--remediate', '--repo', '--reranking', '--reset', '--resolve', '--restore-only', '--resume-from', '--retrieval-only', '--rounds', '--rubric-version', '--save', '--seed', '--short', '--show-toplevel', '--since', '--skip-replay', '--slot-a-model', '--slot-b-model', '--slot-c-model', '--slug', '--slug-prefix', '--source', '--source-id', '--stale', '--suite', '--suites', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--take', '--task', '--thin', '--threshold', '--timeout', '--to', '--token-ttl', '--top-k', '--undo', '--unsafe-bypass-dream-guard', '--update-baseline', '--verify', '--version', '--window', '--yes'],
|
||||
'edges-backfill': ['--aliases', '--all', '--all-sources', '--brain', '--concurrency', '--federated', '--help', '--include-null-signature', '--json', '--max-age', '--max-chunks', '--max-cost-usd', '--no-extract', '--no-federated', '--older-than', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--timeout', '--workers'],
|
||||
'embed': ['--aliases', '--all', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--catch-up', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--lang', '--markdown', '--max-age', '--max-cost-usd', '--model', '--multimodal', '--name', '--near-symbol', '--no', '--no-embed', '--no-embedding', '--no-extract', '--pace', '--pace-max-concurrency', '--parallel', '--path', '--pattern', '--pending', '--pglite', '--prefix', '--priority', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--restore-only', '--serial', '--slugs', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--thin', '--timeout', '--token-ttl', '--version'],
|
||||
@@ -61,7 +61,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'init': ['--all', '--brain', '--chat-model', '--check', '--ctx-size', '--embedding-dimensions', '--embedding-model', '--entity', '--expansion-model', '--fast', '--flag', '--force', '--from-pages', '--grant-types', '--help', '--http', '--issuer-url', '--json', '--judge-model', '--key', '--mcp-only', '--mcp-url', '--migrate-only', '--model', '--multimodal', '--no', '--no-embed', '--no-embedding', '--non-interactive', '--oauth-client-id', '--oauth-client-secret', '--path', '--pglite', '--provenance', '--schema-pack', '--scopes', '--skip-embed-check', '--source', '--stale', '--supabase', '--surface', '--to', '--token-ttl', '--touchpoint', '--url', '--version'],
|
||||
'integrations': ['--auto', '--brain', '--dry-run', '--embeddings', '--fast', '--force', '--from-pages', '--help', '--http', '--json', '--no-embedding', '--overwrite', '--refresh', '--reranking', '--source', '--surface', '--target', '--token-ttl'],
|
||||
'integrity': ['--aliases', '--all', '--auto', '--backend', '--background', '--brain', '--brain-wide-max-cost-usd', '--check', '--confidence', '--cost', '--dry-run', '--explain', '--fast', '--follow', '--force', '--fresh', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--limit', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--review-lower', '--skip-bare-tweet', '--skip-urls', '--source', '--stale', '--supabase', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl', '--type', '--url'],
|
||||
'jobs': ['--abbrev-ref', '--aliases', '--all', '--allow-empty', '--allow-protected', '--allow-shell-jobs', '--apply', '--asof', '--auto', '--auto-fix', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--by-type', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dim', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--hard-deadline', '--health-interval', '--held-out', '--help', '--http', '--idempotency-key', '--image', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--infer-dates', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--limit', '--lock', '--markdown', '--max-age', '--max-attempts', '--max-cost-usd', '--max-crashes', '--max-rss', '--max-runtime-min', '--max-sources', '--max-stalled', '--max-usd', '--max-waiting', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--non-interactive', '--now', '--offset', '--older-than', '--once', '--order', '--orphan', '--others', '--output', '--override-disabled', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--phase', '--pid-file', '--priority', '--progress-interval', '--progress-json', '--queue', '--quiet', '--redact-secrets', '--reenrich-after', '--refresh-cache', '--refresh-ms', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--sigkill-rescue', '--since', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--sleep', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--strategy', '--supersessions', '--surface', '--swap-only', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--to', '--token-ttl', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--verify', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
|
||||
'jobs': ['--abbrev-ref', '--aliases', '--all', '--allow-empty', '--allow-protected', '--allow-shell-jobs', '--apply', '--asof', '--auto', '--auto-fix', '--auto-with-prompt', '--background', '--backoff-delay', '--backoff-jitter', '--backoff-type', '--batch', '--batch-size', '--bound-max-concurrent', '--bound-slug-prefixes', '--bound-source', '--bound-tools', '--brain', '--break-lock', '--budget-usd', '--budget-usd-per-day', '--by-mention', '--by-type', '--cached', '--catch-up', '--check', '--cli-path', '--cluster', '--cluster-errors', '--code', '--concurrency', '--confidence', '--confirm-destructive', '--content', '--date', '--days', '--delay', '--detach', '--diff-filter', '--dim', '--dimensions', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--entities', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--federated-read', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--hard-deadline', '--health-interval', '--held-out', '--help', '--http', '--idempotency-key', '--image', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--infer-dates', '--inject-bootstrap', '--inline', '--input', '--install', '--interval', '--is-ancestor', '--job-id', '--job-isolation', '--json', '--kind', '--lang', '--limit', '--lock', '--markdown', '--max-age', '--max-attempts', '--max-cost-usd', '--max-crashes', '--max-rss', '--max-runtime-min', '--max-sources', '--max-stalled', '--max-usd', '--max-waiting', '--mcp-only', '--migrate-only', '--min-context', '--missing-path', '--mode', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--ner', '--nice', '--no', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federate', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-inject', '--no-mutate', '--no-pull', '--no-renames', '--no-schema-pack', '--no-verify', '--no-worker', '--non-interactive', '--now', '--offset', '--older-than', '--once', '--order', '--orphan', '--others', '--output', '--override-disabled', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--phase', '--pid-file', '--priority', '--progress-interval', '--progress-json', '--queue', '--quiet', '--redact-secrets', '--reenrich-after', '--refresh-cache', '--refresh-ms', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--review-lower', '--run-id', '--save', '--segment-limit', '--serial', '--session', '--session-id', '--short', '--show-toplevel', '--sigkill-rescue', '--since', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--sleep', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--stats', '--status', '--strategy', '--supersessions', '--surface', '--swap-only', '--symbol-kind', '--target', '--target-score', '--thin', '--thin-threshold', '--timeout', '--timeout-ms', '--to', '--token-ttl', '--trusted-extraction', '--type', '--types', '--uninstall', '--unsafe-bypass-dream-guard', '--url', '--user', '--verbose', '--verify', '--version', '--watch', '--wedge-rescue', '--with-db', '--workers', '--yes'],
|
||||
'lint': ['--aliases', '--all', '--background', '--brain', '--brain-wide-max-cost-usd', '--dry-run', '--exclude', '--explain', '--fast', '--fix', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--no-embedding', '--no-extract', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
|
||||
'lsd': ['--brain', '--force-resume', '--help', '--json', '--judge-model', '--limit', '--list-runs', '--max-cost', '--max-far-set', '--max-ideas-per-judge-call', '--no-save', '--resume', '--retry-judge', '--save', '--source', '--strict-budget', '--yes'],
|
||||
'maintain': ['--aliases', '--all', '--background', '--brain', '--break-lock', '--by-mention', '--catch-up', '--column', '--concurrency', '--content-audit', '--count', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--explain', '--fast', '--fix', '--force', '--force-retry', '--force-schema', '--from-meetings', '--full', '--help', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--index-audit', '--infer-dates', '--input', '--json', '--kind', '--lang', '--locks', '--markdown', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-usd', '--migrate-only', '--multimodal', '--near-symbol', '--ner', '--nice', '--no-extract', '--no-mutate', '--older-than', '--once', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--probe-pglite', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--regenerate', '--remediate', '--remediation-plan', '--reset', '--resolve', '--restore-only', '--resume', '--run-id', '--safe', '--scope', '--since', '--skills-dir', '--skip-failed', '--slugs', '--source', '--source-id', '--stale', '--status', '--supabase', '--supersessions', '--symbol-kind', '--target', '--target-score', '--thin', '--to', '--top-k', '--type', '--unsafe-bypass-dream-guard', '--url', '--verbose', '--window', '--workers', '--yes'],
|
||||
@@ -108,7 +108,7 @@ export const CLI_FLAG_REGISTRY: Record<string, readonly string[]> = {
|
||||
'sync': ['--abbrev-ref', '--abi', '--abort', '--aliases', '--all', '--all-sources', '--allow-empty', '--asof', '--auto', '--background', '--batch-size', '--brain', '--brain-wide-max-cost-usd', '--branch', '--break-lock', '--by-mention', '--cached', '--catch-up', '--clone-dir', '--code', '--column', '--compile', '--concurrency', '--confirm-destructive', '--content-audit', '--count', '--depth', '--detach', '--diff-filter', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--empty', '--exclude', '--exclude-standard', '--explain', '--fast', '--federated', '--ff-only', '--file', '--fix', '--follow', '--force', '--force-break-lock', '--force-rechunk', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--git-path', '--hard-deadline', '--help', '--http', '--include-flagged', '--include-frontmatter', '--include-gitignored', '--include-null-signature', '--index-audit', '--infer-dates', '--interval', '--is-ancestor', '--json', '--kind', '--lang', '--lock', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-jobs', '--max-rss', '--max-sources', '--max-usd', '--migrate-only', '--missing-path', '--model', '--multimodal', '--name', '--name-only', '--name-status', '--ner', '--nice', '--no-auto-embed', '--no-embed', '--no-embedding', '--no-extract', '--no-federated', '--no-gpg-sign', '--no-hard-deadline', '--no-pull', '--no-recurse-submodules', '--no-renames', '--no-schema-pack', '--no-verify', '--object-format', '--older-than', '--orphan', '--others', '--overwrite', '--pace', '--pace-max-concurrency', '--pack', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--prefix', '--priority', '--probe-pglite', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebase', '--rebuild-rollup', '--refresh', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--respect-gitignore', '--restore-only', '--resume', '--retry-failed', '--run-id', '--save', '--scope', '--serial', '--short', '--show-toplevel', '--since', '--skills-dir', '--skip-failed', '--slug', '--slugs', '--source', '--source-id', '--src-subpath', '--stale', '--status', '--stdin', '--strategy', '--supabase', '--supersessions', '--surface', '--target', '--target-score', '--thin', '--timeout', '--to', '--token-ttl', '--top-k', '--type', '--url', '--url-managed', '--verbose', '--verify', '--watch', '--window', '--workers', '--yes'],
|
||||
'takes': ['--aliases', '--all', '--brain', '--bucket-size', '--by', '--claim', '--dir', '--domain', '--dry-run', '--evidence', '--expired', '--fast', '--federated', '--force', '--from-pages', '--help', '--holder', '--http', '--include-covered', '--include-null-signature', '--json', '--kind', '--limit', '--max-pages', '--no-embedding', '--no-extract', '--no-federated', '--outcome', '--path', '--pattern', '--pending', '--quality', '--refresh', '--repo', '--reset', '--resolve', '--restore-only', '--row', '--since', '--slugs', '--sort', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--token-ttl', '--unit', '--until', '--value', '--weight', '--who', '--yes'],
|
||||
'think': ['--aliases', '--all', '--anchor', '--brain', '--calibration-holder', '--explain', '--fast', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-usd', '--mcp-only', '--model', '--no-embedding', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--rounds', '--save', '--since', '--source', '--stale', '--supersessions', '--surface', '--take', '--thin', '--timeout', '--token-ttl', '--until', '--with-calibration'],
|
||||
'transcripts': ['--aliases', '--all', '--brain', '--days', '--full', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--timeout'],
|
||||
'transcripts': ['--aliases', '--all', '--all-discovery', '--background', '--brain', '--brain-wide-max-cost-usd', '--by-mention', '--code', '--compile', '--days', '--dry-run', '--embed', '--explain', '--facts', '--fast', '--federated', '--follow', '--force', '--format', '--from-pages', '--full', '--help', '--http', '--include-null-signature', '--json', '--limit', '--markdown', '--max-cost-usd', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--progress-interval', '--progress-json', '--quiet', '--remediate', '--repo', '--reset', '--resolve', '--restore-only', '--since', '--slug', '--source', '--source-id', '--stale', '--supersessions', '--surface', '--thin', '--timeout', '--token-ttl'],
|
||||
'upgrade': ['--all', '--apply-clean-hunks', '--brain', '--check', '--code', '--compile', '--concurrency', '--detail', '--dim', '--embedding-dimensions', '--embedding-model', '--fast', '--ff-only', '--flag', '--force', '--force-all', '--force-orchestrator', '--force-retry', '--force-schema', '--format', '--from-pages', '--help', '--host-dir', '--http', '--inject-bootstrap', '--inline', '--install', '--interval', '--json', '--limit', '--list', '--markdown', '--max-rss', '--migrate-only', '--migration', '--mode', '--model', '--multimodal', '--name-only', '--no', '--no-autopilot-install', '--no-embed', '--no-embedding', '--no-inject', '--no-worker', '--non-interactive', '--now', '--path', '--pglite', '--quiet', '--repo', '--reset', '--since', '--skills-dir', '--skip-verify', '--source', '--stale', '--status', '--supabase', '--surface', '--swap-only', '--target', '--to', '--token-ttl', '--uninstall', '--user', '--verbose', '--verify', '--version', '--workers', '--yes'],
|
||||
'watch': ['--aliases', '--all', '--brain', '--fast', '--federated', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--max-pages', '--min-confidence', '--no-embedding', '--no-extract', '--no-federated', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--stats', '--supersessions', '--surface', '--thin', '--token-ttl', '--window-turns'],
|
||||
'ze-switch': ['--aliases', '--all', '--brain', '--confirm-reembed', '--dry-run', '--force', '--help', '--ignore-env-override', '--ignore-missing-key', '--include-null-signature', '--json', '--markdown', '--no-extract', '--non-interactive', '--pattern', '--pending', '--reset', '--resolve', '--resume', '--source', '--stale', '--supersessions', '--thin', '--undo', '--yes'],
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
*/
|
||||
|
||||
import postgres from 'postgres';
|
||||
import { resolvePrepare, resolveSessionTimeouts, resolvePoolSize, endPoolBounded } from './db.ts';
|
||||
import { resolvePrepare, resolveSessionTimeouts, resolvePoolSize, resolveMaxLifetimeSeconds, endPoolBounded } from './db.ts';
|
||||
import { redactPgUrl } from './url-redact.ts';
|
||||
import { logConnectionEvent } from './connection-audit.ts';
|
||||
|
||||
@@ -303,6 +303,8 @@ export class ConnectionManager {
|
||||
max: resolvePoolSize(this.opts.readPoolSize),
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
// Explicit (matches the postgres.js implicit default; GBRAIN_POOL_MAX_LIFETIME_S overrides).
|
||||
max_lifetime: resolveMaxLifetimeSeconds(),
|
||||
types: { bigint: postgres.BigInt },
|
||||
};
|
||||
const timeouts = resolveSessionTimeouts();
|
||||
@@ -406,6 +408,8 @@ export class ConnectionManager {
|
||||
max: size,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
// Explicit (matches the postgres.js implicit default; GBRAIN_POOL_MAX_LIFETIME_S overrides).
|
||||
max_lifetime: resolveMaxLifetimeSeconds(),
|
||||
types: { bigint: postgres.BigInt },
|
||||
// Always use prepared statements on the direct pool — no PgBouncer
|
||||
// here, so the prepare-cache invalidation issue doesn't apply.
|
||||
|
||||
@@ -241,6 +241,55 @@ export function rewriteChunkedSlug(slug: string, hash6: string, idx: number): st
|
||||
|
||||
// ── Public entry ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One drain-loop lock-renewal tick, extracted for hermetic tests (worker.ts
|
||||
* parity is `runLockRenewalTick`; this is the deliberately simpler best-effort
|
||||
* variant — no audit channel, no reconnect, no time-based give-up).
|
||||
*
|
||||
* Fixes the issue #6 abandoned-racer class in the cycle drain: the previous
|
||||
* inline tick had no per-call timeout, so a hung renewLock stacked one
|
||||
* checked-out pool slot per interval firing forever. Now each call carries an
|
||||
* AbortSignal that is aborted when the timeout wins the race (the query is
|
||||
* cancelled and its slot released), and callers guard re-entrancy so at most
|
||||
* one renewal is in flight.
|
||||
*
|
||||
* Returns after the renewal settles or times out; a `false` renewal invokes
|
||||
* `onLost` (token fence lost — caller aborts the handler). Errors and
|
||||
* timeouts are swallowed: best-effort, the next tick retries.
|
||||
*/
|
||||
export async function runDrainRenewalTick(
|
||||
renewLock: (
|
||||
id: number,
|
||||
lockToken: string,
|
||||
lockMs: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
) => Promise<boolean>,
|
||||
jobId: number,
|
||||
lockToken: string,
|
||||
lockMs: number,
|
||||
onLost: () => void,
|
||||
callTimeoutMs: number,
|
||||
): Promise<void> {
|
||||
const callAbort = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
const ok = await Promise.race([
|
||||
renewLock(jobId, lockToken, lockMs, { signal: callAbort.signal }),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
callAbort.abort();
|
||||
reject(new Error(`renewLock timed out after ${callTimeoutMs}ms`));
|
||||
}, callTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (!ok) onLost();
|
||||
} catch {
|
||||
/* best-effort; next tick retries */
|
||||
} finally {
|
||||
if (timer != null) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SynthesizePhaseOpts {
|
||||
brainDir: string;
|
||||
dryRun: boolean;
|
||||
@@ -433,12 +482,25 @@ export async function runSubagentsInline(
|
||||
// just its own) can't requeue a live child. A false return means the row
|
||||
// was cancelled or reclaimed — abort the handler. Errors are swallowed
|
||||
// (best-effort; the next tick retries), never an unhandledRejection.
|
||||
// Re-entrancy guard + per-call cancellation via runDrainRenewalTick: a
|
||||
// hung renewLock no longer stacks a fresh checked-out pool slot per
|
||||
// interval firing (issue #6 abandoned-racer class).
|
||||
let drainTickInFlight = false;
|
||||
const renewTimer = setInterval(() => {
|
||||
queue.renewLock(job.id, lockToken, lockMs)
|
||||
.then((ok) => {
|
||||
if (!ok && !abort.signal.aborted) abort.abort(new Error('lock-renewal-failed'));
|
||||
})
|
||||
.catch(() => { /* best-effort; next tick retries */ });
|
||||
if (drainTickInFlight) return;
|
||||
drainTickInFlight = true;
|
||||
void runDrainRenewalTick(
|
||||
(id, tok, ms, opts) => queue.renewLock(id, tok, ms, opts),
|
||||
job.id,
|
||||
lockToken,
|
||||
lockMs,
|
||||
() => {
|
||||
if (!abort.signal.aborted) abort.abort(new Error('lock-renewal-failed'));
|
||||
},
|
||||
Math.max(1000, Math.floor(lockMs / 3)),
|
||||
).finally(() => {
|
||||
drainTickInFlight = false;
|
||||
});
|
||||
}, Math.max(50, Math.floor(lockMs / 3)));
|
||||
// Run, then record — separated so a completeJob connection error can't
|
||||
// masquerade as a handler failure, and a failJob connection error can't
|
||||
|
||||
+32
-6
@@ -43,8 +43,13 @@ export interface DbLockHandle {
|
||||
* false means the lock was stolen or released — the caller must stop
|
||||
* relying on mutual exclusion. Transient DB errors still THROW (they are
|
||||
* not evidence of a steal; the TTL is the backstop).
|
||||
*
|
||||
* `opts.signal` cancels the in-flight UPDATE when the caller's heartbeat
|
||||
* timeout gives up on it (issue #6 — an abandoned refresh otherwise holds
|
||||
* a checked-out pool slot for its full server-side duration). PGLite
|
||||
* ignores the signal (single embedded connection, no pool to starve).
|
||||
*/
|
||||
refresh: () => Promise<boolean>;
|
||||
refresh: (opts?: { signal?: AbortSignal }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,7 +273,7 @@ export async function tryAcquireDbLock(
|
||||
return {
|
||||
id: lockId,
|
||||
acquiredAt: fence,
|
||||
refresh: async () => {
|
||||
refresh: async (refreshOpts?: { signal?: AbortSignal }) => {
|
||||
// v0.41.13.0: bump BOTH ttl_expires_at AND last_refreshed_at.
|
||||
// v0.42.x (#1794): route through the DIRECT session pool, not the
|
||||
// transaction pool, so a Supavisor pooler exhaustion (EMAXCONNSESSION)
|
||||
@@ -280,6 +285,7 @@ export async function tryAcquireDbLock(
|
||||
WHERE id = $2 AND holder_pid = $3 AND extract(epoch from acquired_at)::text = $4
|
||||
RETURNING id`,
|
||||
[ttl, lockId, pid, fence],
|
||||
refreshOpts,
|
||||
);
|
||||
return updated.length > 0;
|
||||
},
|
||||
@@ -882,8 +888,13 @@ export async function withRefreshingLock<T>(
|
||||
if (!handle) throw new LockUnavailableError(lockId);
|
||||
|
||||
let healthOk = true;
|
||||
// Re-entrancy guard: with a 15s minimum cadence and a 30s default timeout,
|
||||
// two ticks can overlap on a slow pool — one refresh in flight at a time.
|
||||
let refreshTickInFlight = false;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (refreshTickInFlight) return;
|
||||
refreshTickInFlight = true;
|
||||
void (async () => {
|
||||
try {
|
||||
// v0.42.x (#1794, V1): the refresh IS the heartbeat. handle.refresh()
|
||||
@@ -896,10 +907,23 @@ export async function withRefreshingLock<T>(
|
||||
// health, and we do NOT clearInterval on a transient failure: a blip
|
||||
// self-heals on the next tick; the TTL is the backstop if the pool stays
|
||||
// genuinely dead (at which point a steal is correct).
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('refresh_timeout')), heartbeatTimeoutMs)
|
||||
);
|
||||
const stillOwned = await Promise.race([handle.refresh(), timeout]);
|
||||
// issue #6: abort the per-tick signal when the timeout wins so the
|
||||
// losing UPDATE is cancelled (slot released), not orphaned on the
|
||||
// direct pool for its full server-side duration.
|
||||
const tickAbort = new AbortController();
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutTimer = setTimeout(() => {
|
||||
tickAbort.abort();
|
||||
reject(new Error('refresh_timeout'));
|
||||
}, heartbeatTimeoutMs);
|
||||
});
|
||||
let stillOwned: boolean;
|
||||
try {
|
||||
stillOwned = await Promise.race([handle.refresh({ signal: tickAbort.signal }), timeout]);
|
||||
} finally {
|
||||
if (timeoutTimer != null) clearTimeout(timeoutTimer);
|
||||
}
|
||||
if (stillOwned === false) {
|
||||
// W0 (D5.10): the fenced refresh matched 0 rows — the lock was
|
||||
// stolen or force-cleared. Further refreshes are pointless (and a
|
||||
@@ -918,6 +942,8 @@ export async function withRefreshingLock<T>(
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[lock-refresh] ${lockId}: ${msg}; will retry next tick\n`);
|
||||
healthOk = false;
|
||||
} finally {
|
||||
refreshTickInFlight = false;
|
||||
}
|
||||
})();
|
||||
}, refreshIntervalMs);
|
||||
|
||||
@@ -115,6 +115,55 @@ export function resolvePoolSize(explicit?: number): number {
|
||||
return DEFAULT_POOL_SIZE_FALLBACK;
|
||||
}
|
||||
|
||||
let warnedBadMaxLifetime = false;
|
||||
/** Test-only: reset the warn-once latch. */
|
||||
export function _resetMaxLifetimeWarningForTests(): void {
|
||||
warnedBadMaxLifetime = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-pool connection max lifetime for every postgres() call site
|
||||
* (module singleton, engine instance pool, ConnectionManager read + direct
|
||||
* pools).
|
||||
*
|
||||
* postgres.js already defaults to `60 * (30 + Math.random() * 30)` — and
|
||||
* critically that built-in default is a FUNCTION, re-evaluated PER
|
||||
* CONNECTION (connection.js: `typeof seconds === 'function' ? seconds() :
|
||||
* seconds`), so each connection gets its own 30–60min deadline. A
|
||||
* pre-evaluated number would make every connection in a pool share ONE
|
||||
* recycle deadline — a warm-up burst then reconnects simultaneously
|
||||
* (data-migration specialist finding). The default here is therefore the
|
||||
* same per-connection jitter function; only the env override returns a
|
||||
* fixed number (the explicit escape hatch):
|
||||
*
|
||||
* GBRAIN_POOL_MAX_LIFETIME_S=900 # recycle after 15 min
|
||||
* GBRAIN_POOL_MAX_LIFETIME_S=0 # disable recycling entirely
|
||||
*
|
||||
* max_lifetime only recycles connections as they are RETURNED to the pool;
|
||||
* it cannot reclaim a leaked checkout — this is explicitness + a knob, not
|
||||
* a starvation fix. Invalid values warn once on stderr and fall back to the
|
||||
* default. The env param is injectable so tests never mutate process.env.
|
||||
*/
|
||||
export function resolveMaxLifetimeSeconds(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): number | null | (() => number) {
|
||||
const raw = env.GBRAIN_POOL_MAX_LIFETIME_S;
|
||||
if (raw !== undefined && raw !== '') {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && Number.isInteger(parsed) && parsed >= 0) {
|
||||
return parsed === 0 ? null : parsed;
|
||||
}
|
||||
if (!warnedBadMaxLifetime) {
|
||||
warnedBadMaxLifetime = true;
|
||||
process.stderr.write(
|
||||
`[gbrain] Ignoring invalid GBRAIN_POOL_MAX_LIFETIME_S=${JSON.stringify(raw)} (want a non-negative integer of seconds; 0 disables); using the jittered 30-60min default\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Per-connection jitter, matching the postgres.js built-in default shape.
|
||||
return () => Math.floor(60 * (30 + Math.random() * 30));
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-level GUCs applied to every new backend connection. Prevents
|
||||
* orphan pgbouncer sessions from holding locks or running queries
|
||||
@@ -240,6 +289,8 @@ export async function connect(config: EngineConfig): Promise<boolean> {
|
||||
max: resolvePoolSize(),
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
// Explicit (matches the postgres.js implicit default; GBRAIN_POOL_MAX_LIFETIME_S overrides).
|
||||
max_lifetime: resolveMaxLifetimeSeconds(),
|
||||
types: {
|
||||
// Register pgvector type
|
||||
bigint: postgres.BigInt,
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Parent-side child-process runner for per-job isolation (issue #5).
|
||||
*
|
||||
* `runJobInChild` is the one-line seam executeJob swaps in for
|
||||
* `handler(context)` when isolation is on. The parent keeps claim, lock
|
||||
* renewal and ALL result recording; this module owns spawn → signal → reap →
|
||||
* decode:
|
||||
*
|
||||
* spawn — detached (own process group; group signals reach handler
|
||||
* grandchildren even under tini), tini-wrapped when available,
|
||||
* stdio ['ignore','inherit','inherit'] so handler logs stream
|
||||
* to the operator; results travel by outcome file, never stdout.
|
||||
* signal — per-job abort (timeout / cancel / lock-lost /
|
||||
* lock-renewal-failed) → group SIGTERM now, group SIGKILL at
|
||||
* +CHILD_KILL_GRACE_MS (25s — inside the worker's 30s
|
||||
* force-evict window, which stays as an untouched backstop).
|
||||
* Worker shutdown → same SIGTERM (the child's own handler fires
|
||||
* ctx.shutdownSignal, giving handlers the drain window to
|
||||
* finish AND write their outcome) with the SIGKILL backstop.
|
||||
* classify — outcome file presence rules (job-isolation.ts). No file:
|
||||
* per-job abort → generic throw (executeJob's catch reads
|
||||
* abort.signal.reason, so infra aborts still burn no attempt);
|
||||
* worker shutdown → ChildWorkerShutdownError (released, NO
|
||||
* attempt burned — a routine deploy must not burn attempts;
|
||||
* codex-2 #7); otherwise a crash (attempt burned, correct).
|
||||
* Pre-exec spawn failure → ChildSpawnInfraError (released, no
|
||||
* attempt burned: one bad CLI path must not dead-letter a
|
||||
* queue; the CLI layer also fail-fast validates at startup).
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { buildSpawnInvocation } from './spawn-helpers.ts';
|
||||
import {
|
||||
UnrecoverableError,
|
||||
ABORT_REASON_TIMEOUT,
|
||||
ABORT_REASON_LOCK_LOST,
|
||||
ABORT_REASON_LOCK_RENEWAL_FAILED,
|
||||
} from './types.ts';
|
||||
import {
|
||||
JOB_CHILD_EXIT_USAGE,
|
||||
JOB_CHILD_EXIT_NOT_CLAIMED,
|
||||
} from './worker-exit-codes.ts';
|
||||
import {
|
||||
CHILD_ENV,
|
||||
CHILD_KILL_GRACE_MS,
|
||||
CHILD_READ_POOL_MAX,
|
||||
buildChildArgs,
|
||||
decodeChildOutcomeFileAsync,
|
||||
killProcessGroup,
|
||||
reconstructHandlerError,
|
||||
unrefTimer,
|
||||
type ChildCliInvocation,
|
||||
} from './job-isolation.ts';
|
||||
|
||||
/** Pre-exec spawn failure — infrastructure, not a job defect. executeJob
|
||||
* releases the job with no attempt burned (stall sweeper requeues). */
|
||||
export class ChildSpawnInfraError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ChildSpawnInfraError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Child terminated by worker shutdown before it could report. Released with
|
||||
* no attempt burned — routine deploys must not burn attempts (codex-2 #7). */
|
||||
export class ChildWorkerShutdownError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ChildWorkerShutdownError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Child found the job reclaimed/cancelled (exit 14) — provably owned
|
||||
* elsewhere. The worker releases without failJob (the fenced failJob would
|
||||
* no-op anyway); definitely not an attempt against THIS claim. */
|
||||
export class ChildNotClaimedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ChildNotClaimedError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-job abort reasons that mean THE JOB was targeted (timeout / lock
|
||||
* loss) rather than the worker winding down. gracefulShutdown('watchdog')
|
||||
* aborts BOTH the shutdown signal and every per-job signal — the shutdown
|
||||
* classification must win for those (adversarial-review P3: the watchdog
|
||||
* drain otherwise burns an attempt on innocent isolated jobs). Built from
|
||||
* the shared literals in types.ts so a rename at an abort site cannot
|
||||
* silently flip child classification (maintainability review — the
|
||||
* never-produced 'cancel'/'cancelled' entries were dropped: cancellation
|
||||
* surfaces as lock-lost via the fenced renewLock). */
|
||||
const PER_JOB_ABORT_REASONS = new Set<string>([
|
||||
ABORT_REASON_TIMEOUT,
|
||||
ABORT_REASON_LOCK_LOST,
|
||||
ABORT_REASON_LOCK_RENEWAL_FAILED,
|
||||
]);
|
||||
|
||||
export interface RunJobInChildOpts {
|
||||
jobId: number;
|
||||
jobName: string;
|
||||
lockToken: string;
|
||||
/** Per-job abort (timeout / cancel / lock-lost / lock-renewal-failed). */
|
||||
abortSignal: AbortSignal;
|
||||
/** Worker-process SIGTERM/SIGINT. */
|
||||
shutdownSignal: AbortSignal;
|
||||
/** Resolved once at worker startup (fail-fast); how to invoke the CLI. */
|
||||
invocation: ChildCliInvocation;
|
||||
/** tini path ('' when absent — direct spawn, same degradation as the supervisor). */
|
||||
tiniPath: string;
|
||||
/** Injectable for tests. Default CHILD_KILL_GRACE_MS. */
|
||||
killGraceMs?: number;
|
||||
/** Injectable base env for tests. Default process.env. */
|
||||
env?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
interface ChildExit {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
spawnErr?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one claimed job in a child process. Resolves with the handler result
|
||||
* (parent then runs the normal completeJob path); throws reconstructed
|
||||
* handler errors / classification errors (parent's existing catch handles
|
||||
* them verbatim).
|
||||
*/
|
||||
export async function runJobInChild(opts: RunJobInChildOpts): Promise<unknown> {
|
||||
const dir = mkdtempSync(join(tmpdir(), `gbrain-job-${opts.jobId}-`));
|
||||
const resultPath = join(dir, 'outcome.json');
|
||||
const graceMs = opts.killGraceMs ?? CHILD_KILL_GRACE_MS;
|
||||
const base = opts.env ?? process.env;
|
||||
|
||||
// Bound the child's pools: sockets die with the process (the isolation
|
||||
// win), but per-child footprint must stay small — read pool <= 3, direct
|
||||
// pool 1 (a child runs no claim/renewal heartbeats; codex-2 #6). An
|
||||
// operator's own GBRAIN_POOL_SIZE is respected when STRICTER than the
|
||||
// default (their pooler MaxClients tuning must not be silently raised);
|
||||
// GBRAIN_JOB_CHILD_POOL_SIZE, when valid, is the explicit per-child knob
|
||||
// and wins outright. Invalid values fall through to the default.
|
||||
const parsePoolSize = (v: string | undefined): number | null => {
|
||||
if (v === undefined || v === '') return null;
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
const childOverride = parsePoolSize(base[CHILD_ENV.childPoolSize]);
|
||||
const userPool = parsePoolSize(base.GBRAIN_POOL_SIZE);
|
||||
const childPoolSize = childOverride ?? Math.min(userPool ?? CHILD_READ_POOL_MAX, CHILD_READ_POOL_MAX);
|
||||
|
||||
const childEnv: Record<string, string | undefined> = {
|
||||
...base,
|
||||
[CHILD_ENV.lockToken]: opts.lockToken,
|
||||
[CHILD_ENV.resultPath]: resultPath,
|
||||
[CHILD_ENV.isChild]: '1',
|
||||
[CHILD_ENV.parentPid]: String(process.pid),
|
||||
GBRAIN_POOL_SIZE: String(childPoolSize),
|
||||
GBRAIN_DIRECT_POOL_SIZE: '1',
|
||||
};
|
||||
|
||||
const inv = buildSpawnInvocation(opts.tiniPath, opts.invocation.cmd, [
|
||||
...opts.invocation.argsPrefix,
|
||||
...buildChildArgs(opts.jobId),
|
||||
]);
|
||||
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(inv.cmd, inv.args, {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
env: childEnv as NodeJS.ProcessEnv,
|
||||
detached: true,
|
||||
});
|
||||
} catch (e) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new ChildSpawnInfraError(`job child spawn failed (${inv.cmd}): ${msg}`);
|
||||
}
|
||||
|
||||
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let termed = false;
|
||||
const terminate = (): void => {
|
||||
if (termed) return;
|
||||
termed = true;
|
||||
if (child.pid != null) {
|
||||
killProcessGroup(child.pid, 'SIGTERM');
|
||||
killTimer = setTimeout(() => {
|
||||
// Loud on failure (red-team finding): the /bin/kill fallback is the
|
||||
// NORMAL delivery path in Bun-compiled binaries, and a container
|
||||
// without /bin/kill (distroless) would otherwise silently void the
|
||||
// SIGKILL guarantee while the child runs to completion and the job
|
||||
// gets requeued elsewhere (duplicate side effects).
|
||||
if (child.pid != null && child.exitCode == null && child.signalCode == null) {
|
||||
const delivered = killProcessGroup(child.pid, 'SIGKILL');
|
||||
if (!delivered) {
|
||||
console.error(
|
||||
`[isolation] job ${opts.jobId} (${opts.jobName}): group SIGKILL was NOT delivered ` +
|
||||
`to pid ${child.pid} (platform=${process.platform}; is /bin/kill present?). ` +
|
||||
`The child may still be running — the SIGKILL guarantee is degraded on this host.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, graceMs);
|
||||
unrefTimer(killTimer);
|
||||
}
|
||||
};
|
||||
const onAbort = (): void => terminate();
|
||||
const onShutdown = (): void => terminate();
|
||||
if (opts.abortSignal.aborted) onAbort();
|
||||
else opts.abortSignal.addEventListener('abort', onAbort, { once: true });
|
||||
if (opts.shutdownSignal.aborted) onShutdown();
|
||||
else opts.shutdownSignal.addEventListener('abort', onShutdown, { once: true });
|
||||
|
||||
console.log(
|
||||
`[isolation] job ${opts.jobId} (${opts.jobName}) child pid ${child.pid ?? '?'} spawned`,
|
||||
);
|
||||
|
||||
try {
|
||||
const exit = await new Promise<ChildExit>((resolve) => {
|
||||
child.once('error', (e) => resolve({ code: null, signal: null, spawnErr: e }));
|
||||
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
|
||||
if (exit.spawnErr && child.pid == null) {
|
||||
throw new ChildSpawnInfraError(
|
||||
`job child spawn failed (${inv.cmd}): ${exit.spawnErr.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[isolation] job ${opts.jobId} (${opts.jobName}) child pid ${child.pid ?? '?'} ` +
|
||||
`exited code=${exit.code ?? 'null'} signal=${exit.signal ?? 'null'}`,
|
||||
);
|
||||
|
||||
const abortReason = opts.abortSignal.aborted
|
||||
? (opts.abortSignal.reason instanceof Error
|
||||
? opts.abortSignal.reason.message
|
||||
: String(opts.abortSignal.reason ?? 'aborted'))
|
||||
: null;
|
||||
// Shutdown classification wins UNLESS the per-job abort names a
|
||||
// job-targeted reason. gracefulShutdown('watchdog') aborts BOTH signals —
|
||||
// checking abortSignal first would shadow the no-burn shutdown release
|
||||
// and dead-letter innocent isolated jobs (adversarial-review P3).
|
||||
const isShutdownClass =
|
||||
opts.shutdownSignal.aborted &&
|
||||
(abortReason === null || !PER_JOB_ABORT_REASONS.has(abortReason));
|
||||
|
||||
let outcome: Awaited<ReturnType<typeof decodeChildOutcomeFileAsync>>;
|
||||
try {
|
||||
// Async decode: a large-but-allowed outcome must not block the worker
|
||||
// event loop that runs lock-renewal ticks (performance review).
|
||||
outcome = await decodeChildOutcomeFileAsync(resultPath);
|
||||
} catch (decodeErr) {
|
||||
// No usable outcome. Classify by WHY the child died.
|
||||
if (decodeErr instanceof UnrecoverableError) throw decodeErr; // oversize cap — dead on attempt 1
|
||||
if (isShutdownClass) {
|
||||
throw new ChildWorkerShutdownError(
|
||||
`job child terminated by worker shutdown before reporting (exit code=${exit.code} signal=${exit.signal})`,
|
||||
);
|
||||
}
|
||||
if (opts.abortSignal.aborted) {
|
||||
// executeJob's catch reads abort.signal.reason first, so infra
|
||||
// reasons (lock-renewal-failed / lock-lost) still burn no attempt
|
||||
// and timeout/cancel keep their existing semantics.
|
||||
throw new Error(
|
||||
`job child terminated after abort without an outcome (exit code=${exit.code} signal=${exit.signal})`,
|
||||
);
|
||||
}
|
||||
// Bootstrap failures carry reserved exit codes and are NOT handler
|
||||
// defects: 13 = usage/config (ops misconfiguration — release like a
|
||||
// spawn failure), 14 = job reclaimed before the handler ran (owned
|
||||
// elsewhere — release; the fenced failJob would no-op regardless).
|
||||
if (exit.code === JOB_CHILD_EXIT_USAGE) {
|
||||
throw new ChildSpawnInfraError(
|
||||
`job child bootstrap failed (exit ${exit.code}) — check the worker's child CLI/engine configuration`,
|
||||
);
|
||||
}
|
||||
if (exit.code === JOB_CHILD_EXIT_NOT_CLAIMED) {
|
||||
throw new ChildNotClaimedError(
|
||||
`job child found the claim gone (exit ${exit.code}) — reclaimed or cancelled before the handler ran`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`${decodeErr instanceof Error ? decodeErr.message : String(decodeErr)} ` +
|
||||
`(exit code=${exit.code} signal=${exit.signal})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (outcome.outcome === 'success') return outcome.result;
|
||||
// A handler-error outcome DURING worker shutdown is presumed
|
||||
// shutdown-induced (cooperative handlers that honor shutdownSignal bail
|
||||
// and report an error): release with no attempt burned rather than
|
||||
// punishing exactly the well-behaved handlers on every deploy
|
||||
// (adversarial-review P2). Worst case a genuinely-failing job that
|
||||
// coincided with a deploy gets one free retry — bounded and benign.
|
||||
if (isShutdownClass) {
|
||||
throw new ChildWorkerShutdownError(
|
||||
`job child reported an error during worker shutdown (${outcome.message}) — released, not burned`,
|
||||
);
|
||||
}
|
||||
throw reconstructHandlerError(outcome);
|
||||
} finally {
|
||||
if (killTimer != null) clearTimeout(killTimer);
|
||||
opts.abortSignal.removeEventListener('abort', onAbort);
|
||||
opts.shutdownSignal.removeEventListener('abort', onShutdown);
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* DB liveness probe with pool-starvation disambiguation (issue #6).
|
||||
*
|
||||
* The incident this exists for: a worker's read pool was exhausted by
|
||||
* checked-out-and-abandoned queries, the `SELECT 1` probe couldn't get a slot
|
||||
* within its budget, and the worker exited with "DB unreachable" — while the
|
||||
* server sat at ~10% of max_connections. Operators spent hours on the wrong
|
||||
* layer (evaluating an instance upgrade that would have changed nothing).
|
||||
*
|
||||
* Mechanism: probe the read pool; on failure, probe the DIRECT session lane
|
||||
* (when dual-pool is active). Direct success proves the server is reachable
|
||||
* and narrows the fault to the transaction-pooler path — client pool
|
||||
* exhaustion or a pooler-layer fault; the probe deliberately does NOT claim
|
||||
* to distinguish those two (codex-2 #2). Either way the operator is pointed
|
||||
* away from "the database is down / too small".
|
||||
*
|
||||
* Verdicts:
|
||||
* pool_starved — read probe failed, direct probe succeeded.
|
||||
* server_unreachable — read AND direct probes failed.
|
||||
* unknown — read probe failed, no direct lane to disambiguate.
|
||||
*
|
||||
* Both probes carry an AbortSignal that fires when their deadline wins, so a
|
||||
* hung probe is cancelled (slot released), never abandoned — same contract
|
||||
* as the lock-renewal tick.
|
||||
*
|
||||
* Pure/hermetic: every effect is injected via `DbProbeDeps`; the worker's
|
||||
* adapter is a thin closure. (lock-renewal-tick.ts is the pattern.)
|
||||
*/
|
||||
|
||||
import type { PoolGaugeSnapshot } from '../pool-gauge.ts';
|
||||
|
||||
export type ProbeVerdict = 'pool_starved' | 'server_unreachable' | 'unknown';
|
||||
|
||||
/** Default budget for the direct-lane disambiguation probe. */
|
||||
export const DIRECT_PROBE_TIMEOUT_MS = 3_000;
|
||||
|
||||
export interface PoolDiagnostics {
|
||||
/** Gauge counts — a tracked SUBSET (see pool-gauge.ts honesty contract). */
|
||||
tracked: PoolGaugeSnapshot;
|
||||
/** Read-pool max, when the engine can report it; null otherwise. */
|
||||
poolMax: number | null;
|
||||
}
|
||||
|
||||
export interface DbProbeDeps {
|
||||
/** SELECT 1 on the read pool; MUST honor the signal (cancellation). */
|
||||
probeRead: (signal: AbortSignal) => Promise<void>;
|
||||
/**
|
||||
* SELECT 1 on the direct session lane. Present ONLY when dual-pool is
|
||||
* genuinely active — an executeRawDirect that would silently fall back to
|
||||
* the read pool (kill-switch collapse) must NOT be passed here, or the
|
||||
* "disambiguation" would probe the same starved pool twice.
|
||||
*/
|
||||
probeDirect?: (signal: AbortSignal) => Promise<void>;
|
||||
/** Optional gauge snapshot for supporting detail. Fail-open: may be absent or throw. */
|
||||
getDiagnostics?: () => PoolDiagnostics | null;
|
||||
/** Read-probe budget (worker default 10s). */
|
||||
timeoutMs: number;
|
||||
/** Direct-probe budget (default 3s). */
|
||||
directTimeoutMs: number;
|
||||
}
|
||||
|
||||
export type DbProbeResult =
|
||||
| { ok: true }
|
||||
| { ok: false; verdict: ProbeVerdict; detail: string };
|
||||
|
||||
/**
|
||||
* Narrow, shared view of the engine's ConnectionManager for routing-aware
|
||||
* callers (the worker's probe adapter, jobs.ts's single-pool startup
|
||||
* warning). One typed accessor instead of hand-rolled structural casts that
|
||||
* drift independently from the real class (maintainability review).
|
||||
*/
|
||||
export interface EngineConnectionRouting {
|
||||
isDualPoolActive?: () => boolean;
|
||||
describeMode?: () => { kill_switch_active?: boolean; direct_pool_size?: number };
|
||||
}
|
||||
|
||||
export function getConnectionRouting(engine: unknown): EngineConnectionRouting | null {
|
||||
const cm = (engine as { connectionManager?: EngineConnectionRouting }).connectionManager;
|
||||
return cm ?? null;
|
||||
}
|
||||
|
||||
async function withDeadline(
|
||||
run: (signal: AbortSignal) => Promise<void>,
|
||||
ms: number,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const ac = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
run(ac.signal),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
ac.abort();
|
||||
reject(new Error(`${label} timeout after ${ms}ms`));
|
||||
}, ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer != null) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render gauge detail. Never throws; empty string when unavailable. */
|
||||
function renderDiagnostics(getDiagnostics?: () => PoolDiagnostics | null): string {
|
||||
try {
|
||||
const diag = getDiagnostics?.();
|
||||
if (!diag) return '';
|
||||
const { tracked, poolMax } = diag;
|
||||
const maxNote = poolMax != null ? ` (read pool max ${poolMax})` : '';
|
||||
const base =
|
||||
` gbrain-tracked in flight (subset — template-path queries untracked):` +
|
||||
` raw=${tracked.raw}, direct=${tracked.direct}, reserved=${tracked.reserved}, tx=${tracked.tx}${maxNote}.`;
|
||||
const total = tracked.raw + tracked.direct + tracked.reserved + tracked.tx;
|
||||
if (total === 0) {
|
||||
return (
|
||||
base +
|
||||
' Tracked subset shows 0 in flight — the saturation is in untracked' +
|
||||
' template-query traffic; see docs/guides/queue-operations-runbook.md.'
|
||||
);
|
||||
}
|
||||
return base;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDbProbe(deps: DbProbeDeps): Promise<DbProbeResult> {
|
||||
let readErrMsg: string;
|
||||
try {
|
||||
await withDeadline(deps.probeRead, deps.timeoutMs, 'probe');
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
readErrMsg = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
if (!deps.probeDirect) {
|
||||
return {
|
||||
ok: false,
|
||||
verdict: 'unknown',
|
||||
detail: `${readErrMsg}; no direct lane available to disambiguate pool starvation from a dead server`,
|
||||
};
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
await withDeadline(deps.probeDirect, deps.directTimeoutMs, 'direct probe');
|
||||
return {
|
||||
ok: false,
|
||||
verdict: 'pool_starved',
|
||||
detail:
|
||||
`read-pool probe failed (${readErrMsg}) but the direct-lane probe succeeded in ${Date.now() - t0}ms — ` +
|
||||
`the server IS reachable; the fault is in the transaction-pooler path ` +
|
||||
`(client pool exhaustion or a pooler-layer fault).` +
|
||||
renderDiagnostics(deps.getDiagnostics),
|
||||
};
|
||||
} catch (e) {
|
||||
const directErrMsg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
ok: false,
|
||||
verdict: 'server_unreachable',
|
||||
detail: `read probe: ${readErrMsg}; direct probe: ${directErrMsg} — server/network unreachable`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Shared MinionJobContext builder (issue #5 — per-job process isolation).
|
||||
*
|
||||
* Extracted verbatim from MinionWorker.executeJob so the same DB-backed
|
||||
* context wiring serves BOTH execution modes:
|
||||
*
|
||||
* inline — the worker builds it against its own engine/queue;
|
||||
* process — `gbrain jobs run-child` builds it against the CHILD's engine
|
||||
* (child-owns-engine design: every write below is token-fenced,
|
||||
* so a reclaimed job's orphan child degrades to a no-op writer).
|
||||
*
|
||||
* Behavioral no-op for the inline path.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { MinionQueue } from './queue.ts';
|
||||
import type { MinionJob, MinionJobContext, TokenUpdate } from './types.ts';
|
||||
|
||||
export function buildJobContext(
|
||||
engine: BrainEngine,
|
||||
queue: MinionQueue,
|
||||
job: MinionJob,
|
||||
lockToken: string,
|
||||
signal: AbortSignal,
|
||||
shutdownSignal: AbortSignal,
|
||||
): MinionJobContext {
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
data: job.data,
|
||||
attempts_made: job.attempts_made,
|
||||
signal,
|
||||
deadlineAtMs: job.timeout_at != null ? job.timeout_at.getTime() : null,
|
||||
shutdownSignal,
|
||||
updateProgress: async (progress: unknown) => {
|
||||
await queue.updateProgress(job.id, lockToken, progress);
|
||||
},
|
||||
updateTokens: async (tokens: TokenUpdate) => {
|
||||
await queue.updateTokens(job.id, lockToken, tokens);
|
||||
},
|
||||
log: async (message: string | Record<string, unknown>) => {
|
||||
const value = typeof message === 'string' ? message : JSON.stringify(message);
|
||||
await engine.executeRaw(
|
||||
`UPDATE minion_jobs SET stacktrace = COALESCE(stacktrace, '[]'::jsonb) || to_jsonb($1::text),
|
||||
updated_at = now()
|
||||
WHERE id = $2 AND status = 'active' AND lock_token = $3`,
|
||||
[value, job.id, lockToken]
|
||||
);
|
||||
},
|
||||
isActive: async () => {
|
||||
const rows = await engine.executeRaw<{ id: number }>(
|
||||
`SELECT id FROM minion_jobs WHERE id = $1 AND status = 'active' AND lock_token = $2`,
|
||||
[job.id, lockToken]
|
||||
);
|
||||
return rows.length > 0;
|
||||
},
|
||||
readInbox: async () => {
|
||||
return queue.readInbox(job.id, lockToken);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Per-job process isolation protocol (issue #5).
|
||||
*
|
||||
* The worker (parent) keeps claim / lock-renewal / completeJob / failJob;
|
||||
* `gbrain jobs run-child` (child) owns handler execution with its own small
|
||||
* engine pool. This module is the protocol between them:
|
||||
*
|
||||
* payload in — job id via argv (`jobs run-child --job-id N`, ps-visible
|
||||
* for ops); lock token via GBRAIN_JOB_LOCK_TOKEN env (off
|
||||
* argv — not a secret, it's a fencing token, but no reason
|
||||
* to put it in `ps` output); result path via
|
||||
* GBRAIN_JOB_RESULT_PATH.
|
||||
* result out — ONE JSON file, written atomically (tmp + rename), decoded
|
||||
* by the parent. stdout/stderr stay inherited for handler
|
||||
* logs (handlers print freely — no sentinel parsing), and
|
||||
* node-IPC is deliberately avoided (zero precedent in this
|
||||
* codebase; fd inheritance through a tini wrapper is
|
||||
* unproven here).
|
||||
* termination — killProcessGroup(): children are spawned detached (own
|
||||
* process group) because SIGKILL on the tini pid alone kills
|
||||
* tini, NOT the handler grandchild (tini cannot forward
|
||||
* SIGKILL). Bun rejects negative pids in process.kill()
|
||||
* (oven-sh/bun#15791) and gbrain ships as a Bun-compiled
|
||||
* binary, so the group signal falls back to POSIX
|
||||
* /bin/kill when needed.
|
||||
*
|
||||
* Handler-error semantics survive the boundary: the child encodes the two
|
||||
* error classes executeJob branches on (UnrecoverableError → 'dead',
|
||||
* RateLeaseUnavailableError → lease release, no attempt burned) and
|
||||
* `reconstructHandlerError` rebuilds real instances parent-side so the
|
||||
* existing `instanceof` branches work verbatim. Everything else degrades to
|
||||
* a generic Error → the normal delayed/dead backoff path, same as inline.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { UnrecoverableError } from './types.ts';
|
||||
import { RateLeaseUnavailableError } from './handlers/subagent.ts';
|
||||
|
||||
/** Grace between group-SIGTERM and group-SIGKILL on abort. Deliberately
|
||||
* inside the worker's 30s force-evict window so the evict path stays a
|
||||
* nearly-unreachable backstop. */
|
||||
export const CHILD_KILL_GRACE_MS = 25_000;
|
||||
|
||||
/** Decode cap for the child's outcome file. Results already round-trip
|
||||
* through the completeJob JSONB column in inline mode, so anything near
|
||||
* this cap is pathological; oversize throws UnrecoverableError (loud dead
|
||||
* on attempt 1 — deterministic failure, retries would fail identically). */
|
||||
export const CHILD_OUTCOME_MAX_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** Default read-pool cap for isolation children. Referenced by the
|
||||
* --job-isolation help copy ("~4 pooler client connections" = this + the
|
||||
* direct pool of 1) and the minions-deployment.md budget math. */
|
||||
export const CHILD_READ_POOL_MAX = 3;
|
||||
|
||||
/** Bun-compat timer unref (plain cast copy-pasted thrice before this helper). */
|
||||
export function unrefTimer(t: unknown): void {
|
||||
(t as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
/** Env vars of the parent↔child contract. Spelled once here. */
|
||||
export const CHILD_ENV = {
|
||||
lockToken: 'GBRAIN_JOB_LOCK_TOKEN',
|
||||
resultPath: 'GBRAIN_JOB_RESULT_PATH',
|
||||
isChild: 'GBRAIN_JOB_CHILD',
|
||||
parentPid: 'GBRAIN_JOB_PARENT_PID',
|
||||
childCliOverride: 'GBRAIN_JOB_CHILD_CLI',
|
||||
childPoolSize: 'GBRAIN_JOB_CHILD_POOL_SIZE',
|
||||
} as const;
|
||||
|
||||
export type ChildErrorKind = 'unrecoverable' | 'rate_lease' | 'generic';
|
||||
|
||||
export type ChildOutcome =
|
||||
| { outcome: 'success'; result: unknown }
|
||||
| {
|
||||
outcome: 'error';
|
||||
errorKind: ChildErrorKind;
|
||||
message: string;
|
||||
stack?: string;
|
||||
lease?: { key: string; active: number; max: number };
|
||||
};
|
||||
|
||||
/** Child-side: classify a handler throw into the wire shape. */
|
||||
export function encodeHandlerError(err: unknown): ChildOutcome {
|
||||
if (err instanceof RateLeaseUnavailableError) {
|
||||
return {
|
||||
outcome: 'error',
|
||||
errorKind: 'rate_lease',
|
||||
message: err.message,
|
||||
lease: { key: err.key, active: err.active, max: err.max },
|
||||
};
|
||||
}
|
||||
if (err instanceof UnrecoverableError) {
|
||||
return {
|
||||
outcome: 'error',
|
||||
errorKind: 'unrecoverable',
|
||||
message: err.message,
|
||||
...(err.stack ? { stack: err.stack } : {}),
|
||||
};
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stack = err instanceof Error ? err.stack : undefined;
|
||||
return { outcome: 'error', errorKind: 'generic', message, ...(stack ? { stack } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parent-side: rebuild a real error instance so executeJob's existing
|
||||
* `instanceof` branches (dead / lease-release / delayed+backoff) work
|
||||
* verbatim. Unknown errorKind values degrade to generic (whitelist — the
|
||||
* file is same-user-written but a malformed kind must not crash the worker).
|
||||
*/
|
||||
export function reconstructHandlerError(o: Extract<ChildOutcome, { outcome: 'error' }>): Error {
|
||||
if (o.errorKind === 'rate_lease' && o.lease) {
|
||||
return new RateLeaseUnavailableError(o.lease.key, o.lease.active, o.lease.max);
|
||||
}
|
||||
if (o.errorKind === 'unrecoverable') {
|
||||
return new UnrecoverableError(o.message);
|
||||
}
|
||||
const err = new Error(o.message);
|
||||
if (o.stack) {
|
||||
(err as Error & { childStack?: string }).childStack = o.stack;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
/** Child-side: atomic outcome write (tmp + rename on the same filesystem). */
|
||||
export function writeChildOutcomeFile(path: string, outcome: ChildOutcome): void {
|
||||
const tmp = `${path}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(outcome), 'utf8');
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure outcome parser (shared by the sync and async decode paths). Throws:
|
||||
* - UnrecoverableError when the file exceeds `maxBytes` (deterministic —
|
||||
* dead on attempt 1, no silent truncation);
|
||||
* - generic Error for malformed/unrecognized content (byte count only in
|
||||
* the message, NEVER file content — handler output may carry secrets).
|
||||
* The `lease` payload is shape-validated (security review): a corrupt file
|
||||
* must degrade to 'generic', not inject undefined fields into the parent's
|
||||
* lease-release accounting.
|
||||
*/
|
||||
export function parseChildOutcome(raw: string, size: number, maxBytes = CHILD_OUTCOME_MAX_BYTES): ChildOutcome {
|
||||
if (size > maxBytes) {
|
||||
throw new UnrecoverableError(
|
||||
`job child result exceeds the ${Math.floor(maxBytes / (1024 * 1024))}MiB outcome cap (${size} bytes); ` +
|
||||
`retries would fail identically — return a smaller result or persist large artifacts elsewhere`,
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`job child outcome file is not valid JSON (${size} bytes)`);
|
||||
}
|
||||
const o = parsed as Partial<ChildOutcome> | null;
|
||||
if (o && o.outcome === 'success') return { outcome: 'success', result: (o as { result?: unknown }).result };
|
||||
if (o && o.outcome === 'error' && typeof (o as { message?: unknown }).message === 'string') {
|
||||
const kind = (o as { errorKind?: unknown }).errorKind;
|
||||
const rawLease = (o as { lease?: unknown }).lease as
|
||||
| { key?: unknown; active?: unknown; max?: unknown }
|
||||
| undefined;
|
||||
const leaseValid =
|
||||
rawLease != null &&
|
||||
typeof rawLease.key === 'string' &&
|
||||
Number.isFinite(rawLease.active as number) &&
|
||||
Number.isFinite(rawLease.max as number);
|
||||
// rate_lease without a valid lease payload degrades to generic — same
|
||||
// policy as the errorKind whitelist.
|
||||
const errorKind =
|
||||
kind === 'unrecoverable' ? 'unrecoverable'
|
||||
: kind === 'rate_lease' && leaseValid ? 'rate_lease'
|
||||
: 'generic';
|
||||
return {
|
||||
outcome: 'error',
|
||||
errorKind,
|
||||
message: (o as { message: string }).message,
|
||||
...((o as { stack?: unknown }).stack && typeof (o as { stack?: unknown }).stack === 'string'
|
||||
? { stack: (o as { stack: string }).stack }
|
||||
: {}),
|
||||
...(errorKind === 'rate_lease' && leaseValid
|
||||
? { lease: { key: rawLease.key as string, active: rawLease.active as number, max: rawLease.max as number } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
throw new Error(`job child outcome file has an unrecognized shape (${size} bytes)`);
|
||||
}
|
||||
|
||||
const MISSING_OUTCOME_MESSAGE =
|
||||
'job child exited without writing its outcome file (crash, OOM, or kill before completion)';
|
||||
|
||||
/** Sync decode (tests + non-hot-path callers). */
|
||||
export function decodeChildOutcomeFile(path: string, maxBytes = CHILD_OUTCOME_MAX_BYTES): ChildOutcome {
|
||||
let size: number;
|
||||
try {
|
||||
size = statSync(path).size;
|
||||
} catch {
|
||||
throw new Error(MISSING_OUTCOME_MESSAGE);
|
||||
}
|
||||
if (size > maxBytes) return parseChildOutcome('', size, maxBytes); // throws the cap error
|
||||
return parseChildOutcome(readFileSync(path, 'utf8'), size, maxBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Async decode for the WORKER's per-job path: a large-but-allowed outcome
|
||||
* (up to 32MiB) must not block the event loop that runs lock-renewal ticks
|
||||
* and the health-probe chain (performance review).
|
||||
*/
|
||||
export async function decodeChildOutcomeFileAsync(
|
||||
path: string,
|
||||
maxBytes = CHILD_OUTCOME_MAX_BYTES,
|
||||
): Promise<ChildOutcome> {
|
||||
let size: number;
|
||||
try {
|
||||
size = (await stat(path)).size;
|
||||
} catch {
|
||||
throw new Error(MISSING_OUTCOME_MESSAGE);
|
||||
}
|
||||
if (size > maxBytes) return parseChildOutcome('', size, maxBytes); // throws the cap error
|
||||
return parseChildOutcome(await readFile(path, 'utf8'), size, maxBytes);
|
||||
}
|
||||
|
||||
/** argv for the child invocation (appended after the resolved CLI). */
|
||||
export function buildChildArgs(jobId: number): string[] {
|
||||
return ['jobs', 'run-child', '--job-id', String(jobId)];
|
||||
}
|
||||
|
||||
export interface ChildCliInvocation {
|
||||
cmd: string;
|
||||
/** Args that come BEFORE buildChildArgs() output (e.g. the cli.ts path in bun-dev). */
|
||||
argsPrefix: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve how to invoke the gbrain CLI for a child process. Pure — all
|
||||
* inputs injected:
|
||||
*
|
||||
* 1. GBRAIN_JOB_CHILD_CLI env override (ops/test escape hatch)
|
||||
* 2. resolveBinary() — the compiled-binary resolver
|
||||
* (resolveGbrainCliPath; never returns a .ts path)
|
||||
* 3. bun-dev fallback: running from `bun src/cli.ts` → invoke
|
||||
* `<execPath> <argv1>` so dev and tests work without a compiled binary
|
||||
*
|
||||
* Returns null when nothing resolves — the caller must fail fast at worker
|
||||
* startup (one bad path must not dead-letter a queue job-by-job).
|
||||
*/
|
||||
export function resolveChildCliInvocation(
|
||||
env: Record<string, string | undefined>,
|
||||
execPath: string,
|
||||
argv1: string | undefined,
|
||||
resolveBinary: () => string | null,
|
||||
): ChildCliInvocation | null {
|
||||
const override = env[CHILD_ENV.childCliOverride];
|
||||
if (override && override.trim() !== '') {
|
||||
return { cmd: override, argsPrefix: [] };
|
||||
}
|
||||
try {
|
||||
const bin = resolveBinary();
|
||||
if (bin) return { cmd: bin, argsPrefix: [] };
|
||||
} catch {
|
||||
// fall through to the dev fallback
|
||||
}
|
||||
if (argv1 && (argv1.endsWith('/cli.ts') || argv1.endsWith('\\cli.ts') || argv1 === 'cli.ts')) {
|
||||
return { cmd: execPath, argsPrefix: [argv1] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal an entire process GROUP.
|
||||
*
|
||||
* Children are spawned `detached: true` (own group) so this reaches the
|
||||
* handler grandchildren even under a tini wrapper — SIGKILL on the tini pid
|
||||
* alone kills tini and ORPHANS the still-running handler (tini cannot
|
||||
* forward SIGKILL; that failure mode would silently void issue #5's
|
||||
* headline guarantee exactly in container deployments).
|
||||
*
|
||||
* Bun's process.kill() rejects negative pids (oven-sh/bun#15791), so on any
|
||||
* throw other than ESRCH we fall back to POSIX /bin/kill, which
|
||||
* group-signals fine on darwin + linux. Returns true when the signal was
|
||||
* delivered to a live group; false when the group is already gone (ESRCH —
|
||||
* success for our purposes) or delivery failed.
|
||||
*/
|
||||
export function killProcessGroup(pid: number, signal: 'SIGTERM' | 'SIGKILL'): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 1) return false;
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (e) {
|
||||
const code = (e as NodeJS.ErrnoException).code;
|
||||
if (code === 'ESRCH') return false; // group already gone
|
||||
// RangeError on Bun (negative pid unsupported) or EPERM etc. — fall back
|
||||
// to /bin/kill by ABSOLUTE path (a PATH-resolved binary in a kill path is
|
||||
// an unnecessary indirection; security review). This is the NORMAL path
|
||||
// in Bun-compiled production binaries; the sync exec is ~1-3ms and only
|
||||
// runs on abort/shutdown, never in the claim/renewal hot loop.
|
||||
try {
|
||||
const sigName = signal.replace(/^SIG/, '');
|
||||
const res = spawnSync('/bin/kill', ['-s', sigName, '--', `-${pid}`], { stdio: 'ignore' });
|
||||
return res.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,19 @@ function warnAndFallback(name: string, raw: string, fallback: number): number {
|
||||
* `runLockRenewalTick` is pure and trivially testable.
|
||||
*/
|
||||
export interface LockRenewalDeps {
|
||||
renewLock: (jobId: number, lockToken: string, lockDurationMs: number) => Promise<boolean>;
|
||||
/**
|
||||
* The optional `opts.signal` is aborted when this call loses the tick's
|
||||
* timeout race, so the underlying UPDATE is CANCELLED (postgres.js
|
||||
* `.cancel()` via executeRawDirect) instead of orphaned on a checked-out
|
||||
* pool slot for its full server-side duration — the #6 starvation class.
|
||||
* Optional-param widening keeps the legacy 3-arg test mocks compiling.
|
||||
*/
|
||||
renewLock: (
|
||||
jobId: number,
|
||||
lockToken: string,
|
||||
lockDurationMs: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
) => Promise<boolean>;
|
||||
audit: LockRenewalAuditSinkLike;
|
||||
/** Injectable for hermetic time-based tests. Production: `Date.now`. */
|
||||
now: () => number;
|
||||
@@ -155,8 +167,9 @@ export interface LockRenewalDeps {
|
||||
* Injectable for hermetic Promise.race tests. Production:
|
||||
* `globalThis.setTimeout`. The function must return a value that
|
||||
* `clearTimeout` accepts, but this seam doesn't expose clearTimeout
|
||||
* because the timeout race fires-and-forgets (the lost race is
|
||||
* harmless — at worst we have a dangling reject that no one awaits).
|
||||
* because the timeout race fires-and-forgets. The losing renewLock is no
|
||||
* longer merely abandoned: the timeout callback also aborts the per-call
|
||||
* signal so the query releases its pool slot.
|
||||
*/
|
||||
setTimeout: (cb: () => void, ms: number) => unknown;
|
||||
/**
|
||||
@@ -228,11 +241,19 @@ export async function runLockRenewalTick(
|
||||
if (state.cancelled()) return { kind: 'cancelled' };
|
||||
|
||||
let renewed: boolean;
|
||||
// Per-call cancellation: when the timeout wins the race, abort the signal
|
||||
// so the losing UPDATE releases its pool slot instead of holding it until
|
||||
// the server finishes (issue #6 — an abandoned racer under a saturated
|
||||
// pooler pinned a checked-out connection for minutes). On the win path the
|
||||
// late-firing timer aborts an already-settled query, which runUnsafe
|
||||
// ignores (abort listener removed in its .finally).
|
||||
const callAbort = new AbortController();
|
||||
try {
|
||||
renewed = await Promise.race([
|
||||
deps.renewLock(state.jobId, state.lockToken, state.lockDurationMs),
|
||||
deps.renewLock(state.jobId, state.lockToken, state.lockDurationMs, { signal: callAbort.signal }),
|
||||
new Promise<never>((_, reject) => {
|
||||
deps.setTimeout(() => {
|
||||
callAbort.abort();
|
||||
reject(new Error(`renewLock timed out after ${state.knobs.callTimeoutMs}ms`));
|
||||
}, state.knobs.callTimeoutMs);
|
||||
}),
|
||||
|
||||
@@ -1291,8 +1291,18 @@ export class MinionQueue {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Renew lock (token-fenced). Returns false if token mismatch (job was reclaimed). */
|
||||
async renewLock(id: number, lockToken: string, lockDurationMs: number): Promise<boolean> {
|
||||
/**
|
||||
* Renew lock (token-fenced). Returns false if token mismatch (job was reclaimed).
|
||||
* `opts.signal` cancels the in-flight UPDATE (postgres.js `.cancel()`) when the
|
||||
* caller's timeout race gives up on it — otherwise the abandoned query holds a
|
||||
* checked-out pool slot for its full server-side duration (issue #6).
|
||||
*/
|
||||
async renewLock(
|
||||
id: number,
|
||||
lockToken: string,
|
||||
lockDurationMs: number,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<boolean> {
|
||||
// Direct (session-mode) pool — see claim(). The heartbeat that keeps a job
|
||||
// alive for minutes cannot run on the transaction pooler without periodic
|
||||
// CONNECTION_ENDED drops that look like lock-expiry and orphan the job.
|
||||
@@ -1300,7 +1310,8 @@ export class MinionQueue {
|
||||
`UPDATE minion_jobs SET lock_until = now() + ($1::double precision * interval '1 millisecond'), updated_at = now()
|
||||
WHERE id = $2 AND lock_token = $3 AND status = 'active'
|
||||
RETURNING id`,
|
||||
[lockDurationMs, id, lockToken]
|
||||
[lockDurationMs, id, lockToken],
|
||||
opts
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* `gbrain jobs run-child` core (issue #5 — per-job process isolation).
|
||||
*
|
||||
* The child side of the isolation boundary. The PARENT worker owns claim,
|
||||
* lock renewal, completeJob/failJob and all attempt accounting; this process
|
||||
* only executes the handler and reports ONE outcome file (see
|
||||
* job-isolation.ts for the protocol). Child-owns-engine: every ctx callback
|
||||
* below is token-fenced, so if the job is reclaimed while we run, our writes
|
||||
* degrade to no-ops.
|
||||
*
|
||||
* Deliberately runs NONE of the worker machinery: no health probe, no stall
|
||||
* detection, no lock timer — the parent is the sole liveness owner. What it
|
||||
* does install:
|
||||
*
|
||||
* - SIGTERM handler → fires shutdownSignal ONLY (inline signal-separation
|
||||
* parity): cooperative handlers keep ctx.signal live, finish inside the
|
||||
* drain window, and write their outcome before the parent escalates to a
|
||||
* group SIGKILL. Handlers that watch shutdownSignal (shell) run their own
|
||||
* SIGTERM→grace→SIGKILL cleanup.
|
||||
* - Parent-liveness watchdog: polls `process.kill(parentPid, 0)` every 15s
|
||||
* (a ppid check is DEAD CODE under tini — the child's ppid is tini,
|
||||
* which outlives the worker). On parent death: abort both signals, and
|
||||
* hard-exit after a 30s grace so an orphaned LLM-bound handler doesn't
|
||||
* burn spend to completion. Lock expiry + the stall sweeper requeue the
|
||||
* job on the parent's side of the world.
|
||||
*
|
||||
* The CLI layer (jobs.ts case 'run-child') owns engine.disconnect() and
|
||||
* process.exit() — this module returns an exit code (engine-ownership
|
||||
* invariant, same as MinionWorker).
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { MinionQueue } from './queue.ts';
|
||||
import type { MinionHandler } from './types.ts';
|
||||
import { buildJobContext } from './job-context.ts';
|
||||
import {
|
||||
JOB_CHILD_EXIT_NOT_CLAIMED,
|
||||
JOB_CHILD_EXIT_RESULT_WRITE_FAILED,
|
||||
} from './worker-exit-codes.ts';
|
||||
import { encodeHandlerError, unrefTimer, writeChildOutcomeFile } from './job-isolation.ts';
|
||||
|
||||
export interface RunChildOpts {
|
||||
jobId: number;
|
||||
lockToken: string;
|
||||
resultPath: string;
|
||||
/** Worker pid for the liveness watchdog; 0/absent disables the watchdog. */
|
||||
parentPid: number;
|
||||
}
|
||||
|
||||
export interface RunChildInjectables {
|
||||
/** Hermetic tests inject a handler map instead of registerBuiltinHandlers. */
|
||||
resolveHandler: (name: string) => MinionHandler | undefined;
|
||||
/** Watchdog cadence override (default 15s). */
|
||||
parentPollMs?: number;
|
||||
/** Orphan hard-exit grace override (default 30s). */
|
||||
orphanGraceMs?: number;
|
||||
/** Exit hook for tests (default process.exit for the orphan path only). */
|
||||
hardExit?: (code: number) => void;
|
||||
}
|
||||
|
||||
export async function runChildJobEntry(
|
||||
engine: BrainEngine,
|
||||
opts: RunChildOpts,
|
||||
injectables: RunChildInjectables,
|
||||
): Promise<number> {
|
||||
const queue = new MinionQueue(engine);
|
||||
|
||||
// Ground truth is the DB row, re-read here (one SELECT) rather than a
|
||||
// serialized payload: the parent claimed it, but reclaim/cancel can race
|
||||
// our startup. A mismatch means we must not run the handler at all.
|
||||
const job = await queue.getJob(opts.jobId);
|
||||
if (!job || job.status !== 'active' || job.lock_token !== opts.lockToken) {
|
||||
process.stderr.write(
|
||||
`[run-child] job ${opts.jobId} is not claimed by this token ` +
|
||||
`(status=${job?.status ?? 'missing'}) — exiting without running the handler\n`,
|
||||
);
|
||||
return JOB_CHILD_EXIT_NOT_CLAIMED;
|
||||
}
|
||||
|
||||
const handler = injectables.resolveHandler(job.name);
|
||||
if (!handler) {
|
||||
// Parity with inline mode, which dead-letters a missing handler
|
||||
// immediately (failJob 'dead') — 'unrecoverable' reconstructs to
|
||||
// UnrecoverableError parent-side, so the parent dead-letters on attempt 1
|
||||
// instead of retrying a deterministic condition (adversarial-review P3).
|
||||
try {
|
||||
writeChildOutcomeFile(opts.resultPath, {
|
||||
outcome: 'error',
|
||||
errorKind: 'unrecoverable',
|
||||
message: `No handler for job type '${job.name}' in the isolation child`,
|
||||
});
|
||||
return 0;
|
||||
} catch {
|
||||
return JOB_CHILD_EXIT_RESULT_WRITE_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
const abort = new AbortController();
|
||||
const shutdown = new AbortController();
|
||||
|
||||
// SIGTERM = worker shutdown: fire ONLY shutdownSignal, preserving the
|
||||
// inline signal-separation contract (worker.ts aborts only shutdownAbort on
|
||||
// SIGTERM; per-job ctx.signal stays live so cooperative handlers finish +
|
||||
// report inside the drain window instead of aborting mid-deploy —
|
||||
// adversarial-review P2). The parent's group SIGKILL at drain end is the
|
||||
// backstop for handlers that keep running.
|
||||
const onSigterm = (): void => {
|
||||
if (!shutdown.signal.aborted) shutdown.abort(new Error('worker-shutdown'));
|
||||
};
|
||||
// Parent death (orphan) aborts BOTH: nobody will SIGKILL us, the lock will
|
||||
// expire and the job will be requeued — stop the handler outright.
|
||||
const onOrphaned = (): void => {
|
||||
onSigterm();
|
||||
if (!abort.signal.aborted) abort.abort(new Error('worker-shutdown'));
|
||||
};
|
||||
process.on('SIGTERM', onSigterm);
|
||||
|
||||
// Parent-liveness watchdog (unref'd — never keeps the child alive).
|
||||
const pollMs = injectables.parentPollMs ?? 15_000;
|
||||
const graceMs = injectables.orphanGraceMs ?? 30_000;
|
||||
const hardExit = injectables.hardExit ?? ((code: number) => process.exit(code));
|
||||
let watchdog: ReturnType<typeof setInterval> | null = null;
|
||||
if (opts.parentPid > 0) {
|
||||
watchdog = setInterval(() => {
|
||||
try {
|
||||
process.kill(opts.parentPid, 0);
|
||||
} catch {
|
||||
process.stderr.write(
|
||||
`[run-child] parent worker (pid ${opts.parentPid}) is gone — aborting handler; ` +
|
||||
`hard exit in ${Math.round(graceMs / 1000)}s\n`,
|
||||
);
|
||||
if (watchdog != null) clearInterval(watchdog);
|
||||
onOrphaned();
|
||||
const t = setTimeout(() => hardExit(1), graceMs);
|
||||
unrefTimer(t);
|
||||
}
|
||||
}, pollMs);
|
||||
unrefTimer(watchdog);
|
||||
}
|
||||
|
||||
try {
|
||||
const context = buildJobContext(
|
||||
engine,
|
||||
queue,
|
||||
job,
|
||||
opts.lockToken,
|
||||
abort.signal,
|
||||
shutdown.signal,
|
||||
);
|
||||
let outcome;
|
||||
try {
|
||||
const result = await handler(context);
|
||||
// completeJob's {value: x} wrap decision must run BEFORE JSON
|
||||
// serialization: a JSON round-trip changes typeof for Date /
|
||||
// toJSON-bearing results (object → string), which would flip the wrap
|
||||
// parent-side (adversarial-review P3, result-shape parity). Wrap here;
|
||||
// the parent's own wrap is then a no-op (object/undefined passthrough).
|
||||
const wrapped = result != null
|
||||
? (typeof result === 'object' ? (result as Record<string, unknown>) : { value: result })
|
||||
: undefined;
|
||||
outcome = { outcome: 'success' as const, result: wrapped };
|
||||
} catch (err) {
|
||||
outcome = encodeHandlerError(err);
|
||||
}
|
||||
try {
|
||||
writeChildOutcomeFile(opts.resultPath, outcome);
|
||||
} catch (writeErr) {
|
||||
const msg = writeErr instanceof Error ? writeErr.message : String(writeErr);
|
||||
process.stderr.write(`[run-child] failed to write outcome file: ${msg}\n`);
|
||||
return JOB_CHILD_EXIT_RESULT_WRITE_FAILED;
|
||||
}
|
||||
return 0;
|
||||
} finally {
|
||||
if (watchdog != null) clearInterval(watchdog);
|
||||
process.off('SIGTERM', onSigterm);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,10 @@ export interface SupervisorOpts {
|
||||
nice_requested?: number;
|
||||
/** Effective niceness of the supervisor process after its own renice attempt. */
|
||||
nice_effective?: number;
|
||||
/** issue #5: when 'process', the spawned worker runs each claimed job in a
|
||||
* SIGKILL-able child process (passed through as `--job-isolation process`).
|
||||
* Omitted/inline: today's shared-process execution. */
|
||||
jobIsolation?: 'inline' | 'process';
|
||||
/** Error string if the supervisor's own renice failed (e.g. EPERM). */
|
||||
nice_error?: string;
|
||||
/**
|
||||
@@ -174,7 +178,7 @@ const DEFAULTS: Omit<SupervisorOpts, 'cliPath'> = {
|
||||
* niceness also inherits to the worker's own children automatically.
|
||||
*/
|
||||
export function buildWorkerArgs(
|
||||
opts: Pick<SupervisorOpts, 'concurrency' | 'queue' | 'maxRssMb' | 'nice_requested'>,
|
||||
opts: Pick<SupervisorOpts, 'concurrency' | 'queue' | 'maxRssMb' | 'nice_requested' | 'jobIsolation'>,
|
||||
): string[] {
|
||||
const args = [
|
||||
'jobs', 'work',
|
||||
@@ -187,6 +191,11 @@ export function buildWorkerArgs(
|
||||
if (opts.nice_requested !== undefined) {
|
||||
args.push('--nice', String(opts.nice_requested));
|
||||
}
|
||||
// Conditional push (issue #5): omitted for inline so existing deployments'
|
||||
// argv is byte-identical (pinned by supervisor-build-worker-args.test.ts).
|
||||
if (opts.jobIsolation === 'process') {
|
||||
args.push('--job-isolation', 'process');
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -250,6 +259,7 @@ export async function queryWedgeSignals(
|
||||
engine: BrainEngine,
|
||||
queue: string,
|
||||
handlerNames: string[],
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<WedgeSignals> {
|
||||
const rows = await engine.executeRaw<{
|
||||
stalled: string;
|
||||
@@ -272,6 +282,7 @@ export async function queryWedgeSignals(
|
||||
FROM minion_jobs
|
||||
WHERE queue = $1`,
|
||||
[queue, handlerNames],
|
||||
opts,
|
||||
);
|
||||
const row = rows[0] ?? {
|
||||
stalled: '0', active_healthy: '0', waiting: '0',
|
||||
@@ -339,11 +350,23 @@ export async function probeQueueState(
|
||||
): Promise<QueueSubmitState> {
|
||||
const timeoutMs = opts.timeoutMs ?? QUEUE_PROBE_TIMEOUT_MS;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// issue #6 / TODOS "cancel timed-out submit-time queue probes": the losing
|
||||
// inner probe used to keep running on the pool after the race resolved —
|
||||
// under pool exhaustion the abandoned query held a slot and made the
|
||||
// exhaustion worse. The timeout now aborts a per-probe signal so the
|
||||
// in-flight SQL is cancelled (postgres.js .cancel()) and its slot released.
|
||||
const probeAbort = new AbortController();
|
||||
const timeout = new Promise<QueueSubmitState>((resolveTimeout) => {
|
||||
timer = setTimeout(() => resolveTimeout({ probe_failed: true }), timeoutMs);
|
||||
timer = setTimeout(() => {
|
||||
probeAbort.abort();
|
||||
resolveTimeout({ probe_failed: true });
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([probeQueueStateInner(engine, queue, handlerNames), timeout]);
|
||||
return await Promise.race([
|
||||
probeQueueStateInner(engine, queue, handlerNames, { signal: probeAbort.signal }),
|
||||
timeout,
|
||||
]);
|
||||
} catch {
|
||||
return { probe_failed: true };
|
||||
} finally {
|
||||
@@ -355,8 +378,9 @@ async function probeQueueStateInner(
|
||||
engine: BrainEngine,
|
||||
queue: string,
|
||||
handlerNames: string[],
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<QueueSubmitState> {
|
||||
const sig = await queryWedgeSignals(engine, queue, handlerNames);
|
||||
const sig = await queryWedgeSignals(engine, queue, handlerNames, opts);
|
||||
|
||||
// Oldest-waiting age, same filter shape as the wedge signals. Perf: the
|
||||
// wave's migration (landing in another lane) adds the btree
|
||||
@@ -368,6 +392,7 @@ async function probeQueueStateInner(
|
||||
FROM minion_jobs
|
||||
WHERE queue = $1 AND status = 'waiting'`,
|
||||
[queue],
|
||||
opts,
|
||||
);
|
||||
const rawAge = ageRows[0]?.age ?? null;
|
||||
const oldestAge = rawAge === null ? null : Number(rawAge);
|
||||
|
||||
@@ -218,6 +218,16 @@ export interface MinionWorkerOpts {
|
||||
* hung probe would wedge the recursive setTimeout chain forever and
|
||||
* silently disable the health monitor. Default: 10000 (10 seconds). */
|
||||
dbProbeTimeoutMs?: number;
|
||||
/** issue #5: 'process' runs each claimed job in a SIGKILL-able child
|
||||
* process (blast radius = 1 job). Default 'inline' (today's behavior).
|
||||
* Requires childCliInvocation; the CLI layer resolves + validates it. */
|
||||
jobIsolation?: 'inline' | 'process';
|
||||
/** How to invoke the gbrain CLI for job children (resolved fail-fast at
|
||||
* worker startup by the CLI layer; structurally ChildCliInvocation from
|
||||
* job-isolation.ts — kept inline here to avoid an import cycle). */
|
||||
childCliInvocation?: { cmd: string; argsPrefix: string[] } | null;
|
||||
/** tini path for wrapping job children ('' = absent, direct spawn). */
|
||||
childTiniPath?: string;
|
||||
}
|
||||
|
||||
// --- Job Context (passed to handlers) ---
|
||||
@@ -372,6 +382,20 @@ export type TranscriptEntry =
|
||||
| { type: 'llm_turn'; model: string; tokens_in: number; tokens_out: number; ts: string }
|
||||
| { type: 'error'; message: string; stack?: string; ts: string };
|
||||
|
||||
// --- Abort-reason literals (single source of truth) ---
|
||||
//
|
||||
// Per-job abort sites construct `new Error(REASON)`; classification sites
|
||||
// (worker.ts INFRASTRUCTURE_ABORT_REASONS, child-job-runner.ts
|
||||
// PER_JOB_ABORT_REASONS) match on the message. Deriving both sets from these
|
||||
// constants keeps a rename at an abort site from silently flipping child
|
||||
// classification (maintainability review).
|
||||
|
||||
/** Infrastructure faults: released, no attempt burned; stall sweeper requeues. */
|
||||
export const ABORT_REASON_LOCK_RENEWAL_FAILED = 'lock-renewal-failed';
|
||||
export const ABORT_REASON_LOCK_LOST = 'lock-lost';
|
||||
/** Job-targeted aborts: keep their existing attempt semantics. */
|
||||
export const ABORT_REASON_TIMEOUT = 'timeout';
|
||||
|
||||
// --- Errors ---
|
||||
|
||||
/** Throw this from a handler to skip all retry logic and go straight to 'dead'. */
|
||||
|
||||
@@ -22,3 +22,17 @@
|
||||
|
||||
/** Worker drained itself because RSS crossed the watchdog cap. */
|
||||
export const WORKER_EXIT_RSS_WATCHDOG = 12;
|
||||
|
||||
// --- `gbrain jobs run-child` exit codes (issue #5 process isolation) -------
|
||||
//
|
||||
// The parent (child-job-runner.ts) classifies a child by RESULT-FILE PRESENCE
|
||||
// first: a written outcome file + exit 0 is the normal path even for handler
|
||||
// FAILURE (a reported error outcome is a successful report). Non-zero codes
|
||||
// mean "could not run or could not report":
|
||||
|
||||
/** run-child misuse: bad/missing argv or env, or a PGLite engine (no isolation there). */
|
||||
export const JOB_CHILD_EXIT_USAGE = 13;
|
||||
/** Job row validation failed: not 'active' or lock-token mismatch (reclaimed). */
|
||||
export const JOB_CHILD_EXIT_NOT_CLAIMED = 14;
|
||||
/** Handler finished but the outcome file could not be written. */
|
||||
export const JOB_CHILD_EXIT_RESULT_WRITE_FAILED = 15;
|
||||
|
||||
+201
-77
@@ -18,7 +18,11 @@ import type {
|
||||
MinionJob, MinionJobContext, MinionHandler, MinionWorkerOpts,
|
||||
MinionQueueOpts, TokenUpdate,
|
||||
} from './types.ts';
|
||||
import { UnrecoverableError } from './types.ts';
|
||||
import {
|
||||
UnrecoverableError,
|
||||
ABORT_REASON_LOCK_RENEWAL_FAILED,
|
||||
ABORT_REASON_LOCK_LOST,
|
||||
} from './types.ts';
|
||||
import { MinionQueue } from './queue.ts';
|
||||
import { calculateBackoff } from './backoff.ts';
|
||||
import { RateLeaseUnavailableError } from './handlers/subagent.ts';
|
||||
@@ -29,6 +33,20 @@ import {
|
||||
type LockRenewalDeps,
|
||||
type LockRenewalState,
|
||||
} from './lock-renewal-tick.ts';
|
||||
import {
|
||||
runDbProbe,
|
||||
getConnectionRouting,
|
||||
DIRECT_PROBE_TIMEOUT_MS,
|
||||
type DbProbeResult,
|
||||
type PoolDiagnostics,
|
||||
} from './db-probe.ts';
|
||||
import { buildJobContext } from './job-context.ts';
|
||||
import {
|
||||
runJobInChild,
|
||||
ChildSpawnInfraError,
|
||||
ChildWorkerShutdownError,
|
||||
ChildNotClaimedError,
|
||||
} from './child-job-runner.ts';
|
||||
import { lockRenewalAudit } from '../audit/lock-renewal-audit.ts';
|
||||
import { isRetryableConnError } from '../retry-matcher.ts';
|
||||
import { reconnectAfterConnectionError as reconnectEngineAfterConnError } from './reconnect.ts';
|
||||
@@ -49,8 +67,8 @@ import { reconnectAfterConnectionError as reconnectEngineAfterConnError } from '
|
||||
* to this set is a deliberate two-line change, not a silent regression).
|
||||
*/
|
||||
export const INFRASTRUCTURE_ABORT_REASONS = new Set<string>([
|
||||
'lock-renewal-failed',
|
||||
'lock-lost',
|
||||
ABORT_REASON_LOCK_RENEWAL_FAILED,
|
||||
ABORT_REASON_LOCK_LOST,
|
||||
]);
|
||||
import { randomUUID } from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
@@ -120,10 +138,19 @@ export function getAccurateRss(
|
||||
}
|
||||
|
||||
/** Reason payload emitted with `'unhealthy'` when self-health-check trips.
|
||||
* CLI layer (jobs.ts:work) subscribes and decides whether to call process.exit. */
|
||||
* CLI layer (jobs.ts:work) subscribes and decides whether to call process.exit.
|
||||
* `verdict` (issue #6) distinguishes local pool starvation from a genuinely
|
||||
* unreachable server so operators stop debugging the wrong layer; absent on
|
||||
* engines without the probe's disambiguation lane. */
|
||||
export type UnhealthyReason =
|
||||
| { reason: 'db_dead'; consecutiveFailures: number; message: string }
|
||||
| { reason: 'stalled'; waitingCount: number; idleMinutes: number };
|
||||
| {
|
||||
reason: 'db_dead';
|
||||
consecutiveFailures: number;
|
||||
message: string;
|
||||
verdict?: 'pool_starved' | 'server_unreachable' | 'unknown';
|
||||
}
|
||||
| { reason: 'stalled'; waitingCount: number; idleMinutes: number }
|
||||
| { reason: 'child_spawn_failing'; consecutiveFailures: number; message: string };
|
||||
|
||||
/**
|
||||
* Read the quiet_hours JSONB column off a MinionJob, if present. The
|
||||
@@ -188,6 +215,18 @@ export class MinionWorker extends EventEmitter {
|
||||
private _peakRssMb = 0;
|
||||
/** Latch so the 80%-of-cap soft-warn fires once per crossing, not every check. */
|
||||
private _softWarnFired = false;
|
||||
/**
|
||||
* Circuit breaker for deterministic child-bootstrap failures (red-team
|
||||
* finding): a spawn failure releases the job with no attempt burned, the
|
||||
* stall sweeper requeues it, the same worker re-claims — an infinite
|
||||
* claim/release loop the stall detector cannot see (every settle refreshes
|
||||
* the progress clock). After CHILD_SPAWN_FAIL_EXIT_AFTER consecutive
|
||||
* spawn-class failures we emit 'unhealthy' so the process manager restarts
|
||||
* the worker (and the supervisor's crash budget takes over if the child
|
||||
* CLI stays broken).
|
||||
*/
|
||||
private _consecutiveChildSpawnFailures = 0;
|
||||
private static readonly CHILD_SPAWN_FAIL_EXIT_AFTER = 3;
|
||||
|
||||
private opts: Required<MinionWorkerOpts>;
|
||||
|
||||
@@ -215,7 +254,22 @@ export class MinionWorker extends EventEmitter {
|
||||
stallExitAfterMs: opts?.stallExitAfterMs ?? 10 * 60_000,
|
||||
dbFailExitAfter: opts?.dbFailExitAfter ?? 3,
|
||||
dbProbeTimeoutMs: opts?.dbProbeTimeoutMs ?? 10_000,
|
||||
jobIsolation: opts?.jobIsolation ?? 'inline',
|
||||
childCliInvocation: opts?.childCliInvocation ?? null,
|
||||
childTiniPath: opts?.childTiniPath ?? '',
|
||||
};
|
||||
// Process isolation contract: 'process' without a resolved child CLI
|
||||
// invocation would silently execute handlers INLINE while the evict path
|
||||
// believed it was isolated (predicate mismatch — red-team finding). The
|
||||
// CLI layer always resolves + validates the invocation; library callers
|
||||
// must too. Loud construction throw, same discipline as the stall
|
||||
// thresholds below.
|
||||
if (this.opts.jobIsolation === 'process' && this.opts.childCliInvocation == null) {
|
||||
throw new Error(
|
||||
"MinionWorkerOpts: jobIsolation 'process' requires childCliInvocation " +
|
||||
'(resolve it via resolveChildCliInvocation and validate it exists before constructing the worker).',
|
||||
);
|
||||
}
|
||||
// Stall thresholds contract: exit MUST be strictly greater than warn.
|
||||
// If exit <= warn, the warn-then-exit semantics break: a single tick at
|
||||
// idle > warn would set stallWarningSince and the subsequent tick at
|
||||
@@ -231,6 +285,15 @@ export class MinionWorker extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only handler lookup. `gbrain jobs run-child` registers the builtin
|
||||
* handlers against a throwaway worker (registerBuiltinHandlers's existing
|
||||
* contract) and resolves the one it needs through this accessor.
|
||||
*/
|
||||
getHandler(name: string): MinionHandler | undefined {
|
||||
return this.handlers.get(name);
|
||||
}
|
||||
|
||||
/** Register a handler for a job type. */
|
||||
register(name: string, handler: MinionHandler): void {
|
||||
this.handlers.set(name, handler);
|
||||
@@ -263,7 +326,9 @@ export class MinionWorker extends EventEmitter {
|
||||
if (this.listenerCount('unhealthy') === 0) {
|
||||
const detail = info.reason === 'db_dead'
|
||||
? `DB unreachable (${info.consecutiveFailures} probes): ${info.message}`
|
||||
: `worker stalled (${info.waitingCount} waiting, ${info.idleMinutes}m idle)`;
|
||||
: info.reason === 'child_spawn_failing'
|
||||
? `job-child spawn failing (${info.consecutiveFailures} consecutive): ${info.message}`
|
||||
: `worker stalled (${info.waitingCount} waiting, ${info.idleMinutes}m idle)`;
|
||||
console.error(
|
||||
`[health] FATAL: ${detail}. No 'unhealthy' listener registered; ` +
|
||||
`defaulting to process.exit(1) for process-manager restart.`,
|
||||
@@ -388,28 +453,36 @@ export class MinionWorker extends EventEmitter {
|
||||
let healthRunning = false;
|
||||
let healthExited = false;
|
||||
|
||||
// Race executeRaw against a wall-clock deadline. A hung connection
|
||||
// (network-partitioned PgBouncer, deadlocked backend) would otherwise
|
||||
// hold the await forever — the recursive setTimeout's next tick is only
|
||||
// scheduled in `finally`, so a hung probe would silently disable the
|
||||
// entire health monitor. The timeout treats hangs as failures and feeds
|
||||
// them into `dbFailExitAfter`.
|
||||
const probeWithTimeout = async (): Promise<void> => {
|
||||
const ac = new AbortController();
|
||||
const timeoutMs = this.opts.dbProbeTimeoutMs;
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
await Promise.race([
|
||||
this.engine.executeRaw('SELECT 1'),
|
||||
new Promise<never>((_, reject) => {
|
||||
ac.signal.addEventListener('abort', () => {
|
||||
reject(new Error(`probe timeout after ${timeoutMs}ms`));
|
||||
});
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
// DB liveness probe with pool-starvation disambiguation (issue #6).
|
||||
// The probe body lives in db-probe.ts (hermetically tested,
|
||||
// lock-renewal-tick pattern); this is the thin adapter. Both probes
|
||||
// carry an AbortSignal — a hung probe is CANCELLED (slot released),
|
||||
// never abandoned. The direct-lane probe runs ONLY when dual-pool is
|
||||
// genuinely active (a kill-switched executeRawDirect would probe the
|
||||
// same starved read pool twice and fake a verdict).
|
||||
const runProbe = async (): Promise<DbProbeResult> => {
|
||||
const cm = getConnectionRouting(this.engine);
|
||||
const dualPool = cm?.isDualPoolActive?.() === true;
|
||||
const getDiag = (this.engine as {
|
||||
getPoolDiagnostics?: () => PoolDiagnostics | null;
|
||||
}).getPoolDiagnostics;
|
||||
return runDbProbe({
|
||||
probeRead: async (signal) => {
|
||||
await this.engine.executeRaw('SELECT 1', undefined, { signal });
|
||||
},
|
||||
...(dualPool
|
||||
? {
|
||||
probeDirect: async (signal: AbortSignal) => {
|
||||
await this.engine.executeRawDirect('SELECT 1', undefined, { signal });
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(typeof getDiag === 'function'
|
||||
? { getDiagnostics: () => getDiag.call(this.engine) }
|
||||
: {}),
|
||||
timeoutMs: this.opts.dbProbeTimeoutMs,
|
||||
directTimeoutMs: DIRECT_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
};
|
||||
|
||||
const runHealthCheck = async (): Promise<void> => {
|
||||
@@ -417,25 +490,25 @@ export class MinionWorker extends EventEmitter {
|
||||
healthRunning = true;
|
||||
try {
|
||||
// --- 1. DB liveness probe ---
|
||||
try {
|
||||
await probeWithTimeout();
|
||||
const probe = await runProbe();
|
||||
if (probe.ok) {
|
||||
consecutiveDbFailures = 0;
|
||||
} catch (e) {
|
||||
} else {
|
||||
consecutiveDbFailures++;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(
|
||||
`[health] DB probe failed (${consecutiveDbFailures}/${this.opts.dbFailExitAfter}): ${msg}`,
|
||||
`[health] DB probe failed (${consecutiveDbFailures}/${this.opts.dbFailExitAfter}): ${probe.detail}`,
|
||||
);
|
||||
if (consecutiveDbFailures >= this.opts.dbFailExitAfter) {
|
||||
console.error(
|
||||
`[health] DB unreachable after ${this.opts.dbFailExitAfter} consecutive probes. ` +
|
||||
`Emitting 'unhealthy' for process-manager restart.`,
|
||||
`[health] DB probe failed ${this.opts.dbFailExitAfter} consecutive times ` +
|
||||
`(verdict: ${probe.verdict}). Emitting 'unhealthy' for process-manager restart.`,
|
||||
);
|
||||
healthExited = true;
|
||||
this.emitUnhealthy({
|
||||
reason: 'db_dead',
|
||||
consecutiveFailures: consecutiveDbFailures,
|
||||
message: msg,
|
||||
message: probe.detail,
|
||||
verdict: probe.verdict,
|
||||
});
|
||||
}
|
||||
return; // Skip stall check when DB is flaky
|
||||
@@ -876,7 +949,7 @@ export class MinionWorker extends EventEmitter {
|
||||
// and the tick keeps its legacy no-reconnect behavior.
|
||||
const engineReconnect = (this.engine as { reconnect?: (ctx?: { error?: unknown }) => Promise<void> }).reconnect;
|
||||
const renewalDeps: LockRenewalDeps = {
|
||||
renewLock: (id, tok, dur) => this.queue.renewLock(id, tok, dur),
|
||||
renewLock: (id, tok, dur, opts) => this.queue.renewLock(id, tok, dur, opts),
|
||||
audit: lockRenewalAudit,
|
||||
now: Date.now,
|
||||
setTimeout: (cb, ms) => globalThis.setTimeout(cb, ms),
|
||||
@@ -946,11 +1019,12 @@ export class MinionWorker extends EventEmitter {
|
||||
);
|
||||
clearInterval(lockTimer);
|
||||
this.inFlight.delete(job.id);
|
||||
// D8a: don't failJob if the abort was infrastructure. The
|
||||
// stall detector will reclaim the row cleanly because the
|
||||
// lock has expired (lock-renewal aborts only fire after
|
||||
// lockDuration - safetyMargin elapsed without renewal).
|
||||
if (!INFRASTRUCTURE_ABORT_REASONS.has(reason)) {
|
||||
// D8a: don't failJob on infrastructure aborts (stall detector
|
||||
// reclaims after lock expiry). Isolation mode: also skip — the
|
||||
// group SIGKILL already fired and executeJob's own recording
|
||||
// follows; a competing evict failJob('dead') could dead-letter a
|
||||
// job with attempts remaining (adversarial-review P3).
|
||||
if (!INFRASTRUCTURE_ABORT_REASONS.has(reason) && this.opts.jobIsolation !== 'process') {
|
||||
this.queue.failJob(
|
||||
job.id,
|
||||
lockToken,
|
||||
@@ -1024,48 +1098,48 @@ export class MinionWorker extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// issue #5: 'process' isolation runs the handler in a SIGKILL-able child;
|
||||
// the parent keeps claim/renewal and ALL result recording below — this
|
||||
// branch swaps ONLY the execution engine. When isolated, the parent-side
|
||||
// context is skipped entirely (the child builds its own against its own
|
||||
// engine; building it here would be dead work holding closures). The
|
||||
// constructor guarantees childCliInvocation != null whenever
|
||||
// jobIsolation === 'process', so this predicate matches the evict guard.
|
||||
const isolated = this.opts.jobIsolation === 'process';
|
||||
|
||||
// Build job context with per-job AbortSignal + shared shutdown signal.
|
||||
// Most handlers only care about `signal` (timeout / cancel / lock-loss).
|
||||
// `shutdownSignal` is separate: fires only on worker process SIGTERM/SIGINT.
|
||||
// Handlers that need to run cleanup before worker exit (shell handler's
|
||||
// SIGTERM→5s→SIGKILL on its child) subscribe to shutdownSignal too.
|
||||
const context: MinionJobContext = {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
data: job.data,
|
||||
attempts_made: job.attempts_made,
|
||||
signal: abort.signal,
|
||||
deadlineAtMs: job.timeout_at != null ? job.timeout_at.getTime() : null,
|
||||
shutdownSignal: this.shutdownAbort.signal,
|
||||
updateProgress: async (progress: unknown) => {
|
||||
await this.queue.updateProgress(job.id, lockToken, progress);
|
||||
},
|
||||
updateTokens: async (tokens: TokenUpdate) => {
|
||||
await this.queue.updateTokens(job.id, lockToken, tokens);
|
||||
},
|
||||
log: async (message: string | Record<string, unknown>) => {
|
||||
const value = typeof message === 'string' ? message : JSON.stringify(message);
|
||||
await this.engine.executeRaw(
|
||||
`UPDATE minion_jobs SET stacktrace = COALESCE(stacktrace, '[]'::jsonb) || to_jsonb($1::text),
|
||||
updated_at = now()
|
||||
WHERE id = $2 AND status = 'active' AND lock_token = $3`,
|
||||
[value, job.id, lockToken]
|
||||
// Builder shared with `gbrain jobs run-child` (job-context.ts) so the
|
||||
// process-isolation child wires the exact same DB-backed callbacks.
|
||||
const context: MinionJobContext | null = isolated
|
||||
? null
|
||||
: buildJobContext(
|
||||
this.engine,
|
||||
this.queue,
|
||||
job,
|
||||
lockToken,
|
||||
abort.signal,
|
||||
this.shutdownAbort.signal,
|
||||
);
|
||||
},
|
||||
isActive: async () => {
|
||||
const rows = await this.engine.executeRaw<{ id: number }>(
|
||||
`SELECT id FROM minion_jobs WHERE id = $1 AND status = 'active' AND lock_token = $2`,
|
||||
[job.id, lockToken]
|
||||
);
|
||||
return rows.length > 0;
|
||||
},
|
||||
readInbox: async () => {
|
||||
return this.queue.readInbox(job.id, lockToken);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handler(context);
|
||||
const result = isolated
|
||||
? await runJobInChild({
|
||||
jobId: job.id,
|
||||
jobName: job.name,
|
||||
lockToken,
|
||||
abortSignal: abort.signal,
|
||||
shutdownSignal: this.shutdownAbort.signal,
|
||||
invocation: this.opts.childCliInvocation as { cmd: string; argsPrefix: string[] },
|
||||
tiniPath: this.opts.childTiniPath,
|
||||
})
|
||||
: await handler(context as MinionJobContext);
|
||||
|
||||
// The child spawned and ran — the spawn path is healthy again.
|
||||
this._consecutiveChildSpawnFailures = 0;
|
||||
|
||||
clearInterval(lockTimer);
|
||||
|
||||
@@ -1121,6 +1195,56 @@ export class MinionWorker extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Any error that ISN'T a spawn failure proves the spawn path works —
|
||||
// keep the breaker's "consecutive" semantics honest.
|
||||
if (!(err instanceof ChildSpawnInfraError)) {
|
||||
this._consecutiveChildSpawnFailures = 0;
|
||||
}
|
||||
|
||||
// issue #5 process isolation — two more infrastructure classes, same
|
||||
// release semantics as the block above (lock expires once launchJob's
|
||||
// finally clears the renewal timer; the stall sweeper requeues):
|
||||
// - spawn failure: an ops misconfiguration (bad child CLI path) must
|
||||
// not burn attempts job-by-job until the queue dead-letters. The
|
||||
// CLI layer also fail-fast validates the invocation at startup.
|
||||
// - worker shutdown: a routine deploy killed the child before it
|
||||
// could report (codex-2 #7); burning an attempt per deploy would
|
||||
// dead-letter long jobs after a few releases.
|
||||
if (err instanceof ChildSpawnInfraError) {
|
||||
console.error(
|
||||
`Job ${job.id} (${job.name}) released after child spawn failure — ` +
|
||||
`check the worker's child CLI configuration: ${errorText} (no attempt burned)`,
|
||||
);
|
||||
this._consecutiveChildSpawnFailures += 1;
|
||||
if (this._consecutiveChildSpawnFailures >= MinionWorker.CHILD_SPAWN_FAIL_EXIT_AFTER) {
|
||||
console.error(
|
||||
`[isolation] ${this._consecutiveChildSpawnFailures} consecutive child spawn/bootstrap ` +
|
||||
`failures — the child CLI is deterministically broken. Emitting 'unhealthy' for ` +
|
||||
`process-manager restart instead of looping claim/release forever.`,
|
||||
);
|
||||
this.emitUnhealthy({
|
||||
reason: 'child_spawn_failing',
|
||||
consecutiveFailures: this._consecutiveChildSpawnFailures,
|
||||
message: errorText,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (err instanceof ChildWorkerShutdownError) {
|
||||
console.log(
|
||||
`Job ${job.id} (${job.name}) released after worker shutdown (${errorText}); ` +
|
||||
`stall detector will requeue (no attempt burned)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (err instanceof ChildNotClaimedError) {
|
||||
// The child proved the claim is gone (reclaimed/cancelled before the
|
||||
// handler ran). The token-fenced failJob would no-op anyway — return
|
||||
// without burning anything against a claim we no longer hold.
|
||||
console.log(`Job ${job.id} (${job.name}): ${errorText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// v0.41 Bug 2: lease-full bounces don't burn attempts.
|
||||
//
|
||||
// Pre-v0.41 every non-`UnrecoverableError` routed to `delayed` with
|
||||
|
||||
@@ -4502,27 +4502,38 @@ export class PGLiteEngine implements BrainEngine {
|
||||
// still trip Postgres 21000 on multi-source brains — caller's choice).
|
||||
// With opts.sourceId, the lookup is source-scoped so the right row
|
||||
// gets the raw_data attached.
|
||||
// cathedral-4 parity: RETURNING id + zero-row check, matching the
|
||||
// Postgres engine — a missing page must THROW, never silently no-op
|
||||
// (callers treat a raw-data miss as an integrity failure).
|
||||
if (opts?.sourceId) {
|
||||
await this.db.query(
|
||||
const r = await this.db.query(
|
||||
`INSERT INTO raw_data (page_id, source, data)
|
||||
SELECT id, $2, $3::jsonb
|
||||
FROM pages WHERE slug = $1 AND source_id = $4
|
||||
ON CONFLICT (page_id, source) DO UPDATE SET
|
||||
data = EXCLUDED.data,
|
||||
fetched_at = now()`,
|
||||
fetched_at = now()
|
||||
RETURNING id`,
|
||||
[slug, source, JSON.stringify(data), opts.sourceId]
|
||||
);
|
||||
if (r.rows.length === 0) {
|
||||
throw new Error(`putRawData failed: page "${slug}" (source=${opts.sourceId}) not found`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await this.db.query(
|
||||
const r = await this.db.query(
|
||||
`INSERT INTO raw_data (page_id, source, data)
|
||||
SELECT id, $2, $3::jsonb
|
||||
FROM pages WHERE slug = $1
|
||||
ON CONFLICT (page_id, source) DO UPDATE SET
|
||||
data = EXCLUDED.data,
|
||||
fetched_at = now()`,
|
||||
fetched_at = now()
|
||||
RETURNING id`,
|
||||
[slug, source, JSON.stringify(data)]
|
||||
);
|
||||
if (r.rows.length === 0) {
|
||||
throw new Error(`putRawData failed: page "${slug}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async getRawData(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* CheckoutGauge — approximate in-flight query counters for the health probe's
|
||||
* pool diagnostics (issue #6).
|
||||
*
|
||||
* HONESTY CONTRACT (read before extending): this gauge counts calls through
|
||||
* the engine's raw/direct/reserved/transaction seams ONLY. The majority of
|
||||
* engine traffic — tagged-template queries on `this.sql` (getConfig, CRUD,
|
||||
* search) — is NOT tracked; postgres.js exposes no public checkout counters
|
||||
* and proxying the Sql template function is too invasive for a diagnostic.
|
||||
* Every consumer must label these numbers as a tracked SUBSET and must never
|
||||
* derive "available" or "waiting" figures from them (that arithmetic is
|
||||
* invented telemetry — outside-voice review, codex-2 #3). The authoritative
|
||||
* starvation signal is the direct-lane disambiguation probe in
|
||||
* `src/core/minions/db-probe.ts`; these counts are supporting detail.
|
||||
*
|
||||
* Fail-open by construction: plain integer bumps, no I/O, release() clamps
|
||||
* at zero so a missed acquire can never underflow into negative counts.
|
||||
*/
|
||||
|
||||
/** Which engine seam the in-flight call went through. */
|
||||
export type GaugeKind = 'raw' | 'direct' | 'reserved' | 'tx';
|
||||
|
||||
export interface PoolGaugeSnapshot {
|
||||
/** executeRaw on the read pool. */
|
||||
raw: number;
|
||||
/** executeRawDirect (direct session lane when dual-pool, read pool otherwise). */
|
||||
direct: number;
|
||||
/** withReservedConnection holders. */
|
||||
reserved: number;
|
||||
/** transaction() bodies. */
|
||||
tx: number;
|
||||
}
|
||||
|
||||
export class CheckoutGauge {
|
||||
private counts: PoolGaugeSnapshot = { raw: 0, direct: 0, reserved: 0, tx: 0 };
|
||||
|
||||
acquire(kind: GaugeKind): void {
|
||||
this.counts[kind] += 1;
|
||||
}
|
||||
|
||||
release(kind: GaugeKind): void {
|
||||
if (this.counts[kind] > 0) this.counts[kind] -= 1;
|
||||
}
|
||||
|
||||
snapshot(): PoolGaugeSnapshot {
|
||||
return { ...this.counts };
|
||||
}
|
||||
}
|
||||
+129
-13
@@ -25,6 +25,7 @@ import {
|
||||
type BatchAuditSite,
|
||||
} from './retry.ts';
|
||||
import { isConnectionEndedError } from './retry-matcher.ts';
|
||||
import { CheckoutGauge, type PoolGaugeSnapshot } from './pool-gauge.ts';
|
||||
import {
|
||||
valueHash,
|
||||
normalizeDimension,
|
||||
@@ -82,7 +83,7 @@ import { GBrainError, PAGE_SORT_SQL, ENRICH_ORDER_SQL } from './types.ts';
|
||||
import { finalizeLastSeen } from './chronicle/last-seen.ts';
|
||||
import { computeAnomaliesFromBuckets } from './cycle/anomaly.ts';
|
||||
import * as db from './db.ts';
|
||||
import { ConnectionManager } from './connection-manager.ts';
|
||||
import { ConnectionManager, DEFAULT_DIRECT_POOL_SIZE } from './connection-manager.ts';
|
||||
import { logConnectionEvent } from './connection-audit.ts';
|
||||
import { validateSlug, contentHash, rowToPage, rowToStalePage, rowToChunk, rowToSearchResult, parseEmbedding, tryParseEmbedding, takeRowToTake, takeHitRowToHit, isUndefinedTableError, warnOncePerProcess } from './utils.ts';
|
||||
import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
|
||||
@@ -132,6 +133,13 @@ export class PostgresEngine implements BrainEngine {
|
||||
private _savedConfig: (EngineConfig & { poolSize?: number; parentConnectionManager?: ConnectionManager }) | null = null;
|
||||
/** Whether a reconnect is in progress (prevents concurrent reconnects). */
|
||||
private _reconnecting = false;
|
||||
/**
|
||||
* Approximate in-flight counters for the health probe's diagnostics
|
||||
* (issue #6). Tracks the raw/direct/reserved/tx seams ONLY — see the
|
||||
* honesty contract in pool-gauge.ts. Shared by tx-scoped engine clones
|
||||
* via the prototype chain (same process, same pools). Fail-open.
|
||||
*/
|
||||
private checkoutGauge = new CheckoutGauge();
|
||||
/**
|
||||
* #1471: module-singleton OWNERSHIP token. `true` only for the engine whose
|
||||
* connect() actually created the shared db.ts `sql` singleton (returned
|
||||
@@ -297,6 +305,8 @@ export class PostgresEngine implements BrainEngine {
|
||||
max: size,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
// Explicit (matches the postgres.js implicit default; GBRAIN_POOL_MAX_LIFETIME_S overrides).
|
||||
max_lifetime: db.resolveMaxLifetimeSeconds(),
|
||||
types: { bigint: postgres.BigInt },
|
||||
// Silence postgres NOTICE-level messages by default. See db.ts for
|
||||
// rationale (stdout-parsing callers like jobs-submit --json break when
|
||||
@@ -1059,18 +1069,83 @@ export class PostgresEngine implements BrainEngine {
|
||||
|
||||
async transaction<T>(fn: (engine: BrainEngine) => Promise<T>): Promise<T> {
|
||||
const conn = this.sql;
|
||||
return conn.begin(async (tx) => {
|
||||
// Create a scoped engine with tx as its connection, no shared state mutation
|
||||
const txEngine = Object.create(this) as PostgresEngine;
|
||||
Object.defineProperty(txEngine, 'sql', { get: () => tx });
|
||||
Object.defineProperty(txEngine, '_sql', { value: tx as unknown as ReturnType<typeof postgres>, writable: false });
|
||||
return fn(txEngine);
|
||||
}) as Promise<T>;
|
||||
// try/finally, not .finally on the chained promise: begin() can throw
|
||||
// SYNCHRONOUSLY (e.g. nested transaction on a tx clone whose conn has no
|
||||
// .begin), which would skip a chained .finally and leak the counter.
|
||||
this.checkoutGauge.acquire('tx');
|
||||
try {
|
||||
return await (conn.begin(async (tx) => {
|
||||
// Create a scoped engine with tx as its connection, no shared state mutation
|
||||
const txEngine = Object.create(this) as PostgresEngine;
|
||||
Object.defineProperty(txEngine, 'sql', { get: () => tx });
|
||||
Object.defineProperty(txEngine, '_sql', { value: tx as unknown as ReturnType<typeof postgres>, writable: false });
|
||||
return fn(txEngine);
|
||||
}) as Promise<T>);
|
||||
} finally {
|
||||
this.checkoutGauge.release('tx');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* issue #6 (reserved-connection routing): concurrent DIRECT-pool reserves
|
||||
* are capped at directPoolSize - 1 so the claim/renewLock heartbeats always
|
||||
* keep >= 1 direct slot; overflow falls back to the READ pool — exactly the
|
||||
* pre-routing behavior, so this change is strictly never-worse than the
|
||||
* status quo (deliberate rejection of queue-for-a-permit: that would block
|
||||
* migrations behind multi-minute CREATE INDEX holds). Per-process by
|
||||
* design: each process owns its own direct pool, so a CLI migration's
|
||||
* reserves cannot starve a worker's heartbeats.
|
||||
*/
|
||||
private _reservedDirectInFlight = 0;
|
||||
|
||||
async withReservedConnection<T>(fn: (conn: ReservedConnection) => Promise<T>): Promise<T> {
|
||||
const pool = this.sql;
|
||||
const reserved = await pool.reserve();
|
||||
// Long-hold reserved work (CREATE INDEX CONCURRENTLY, transaction:false
|
||||
// migration DDL, backfill BEGIN..COMMIT batches) belongs on the DIRECT
|
||||
// session lane: 30-min statement_timeout + maintenance_work_mem GUCs and
|
||||
// it stops pinning the worker's shared read pool (the observed 353s
|
||||
// COMMIT in issue #6 was a reserved read-pool slot). Never reroute inside
|
||||
// an open transaction (same guard shape as executeRawDirect).
|
||||
const inTransaction = this._sql !== null && this.connectionManager?.peekReadPool() !== this._sql;
|
||||
let pool = this.sql;
|
||||
let fromDirect = false;
|
||||
if (!inTransaction && this.connectionManager?.isDualPoolActive()) {
|
||||
const size = this.connectionManager.describeMode().direct_pool_size ?? DEFAULT_DIRECT_POOL_SIZE;
|
||||
// NO floor on the cap (red-team finding): at direct_pool_size=1 a
|
||||
// Math.max(1, ...) floor would let a multi-minute reserve consume the
|
||||
// ONLY direct session and starve claim/renewLock heartbeats — the
|
||||
// exact #6 class, reintroduced on the direct pool. cap <= 0 means the
|
||||
// direct lane has no spare capacity for reserves: use the read pool
|
||||
// (the true status quo).
|
||||
const cap = size - 1;
|
||||
if (cap >= 1 && this._reservedDirectInFlight < cap) {
|
||||
// Take the permit in the SAME synchronous frame as the check — a
|
||||
// check-then-increment spanning `await ddl()` is a TOCTOU that lets
|
||||
// same-tick concurrent reserves overshoot the cap and starve the
|
||||
// heartbeat slot the cap exists to protect (adversarial-review P2).
|
||||
this._reservedDirectInFlight += 1;
|
||||
fromDirect = true;
|
||||
try {
|
||||
pool = await this.connectionManager.ddl();
|
||||
} catch {
|
||||
// ddl() failure flips its own kill switch; fall back to the read
|
||||
// pool (status quo) rather than failing the caller.
|
||||
this._reservedDirectInFlight -= 1;
|
||||
fromDirect = false;
|
||||
pool = this.sql;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Gauge BEFORE reserve(): a reserve() stuck waiting for a free slot is
|
||||
// exactly the in-flight pressure the probe diagnostics should surface.
|
||||
this.checkoutGauge.acquire('reserved');
|
||||
let reserved: Awaited<ReturnType<typeof pool.reserve>>;
|
||||
try {
|
||||
reserved = await pool.reserve();
|
||||
} catch (e) {
|
||||
this.checkoutGauge.release('reserved');
|
||||
if (fromDirect) this._reservedDirectInFlight -= 1;
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
const conn: ReservedConnection = {
|
||||
async executeRaw<R = Record<string, unknown>>(
|
||||
@@ -1091,7 +1166,34 @@ export class PostgresEngine implements BrainEngine {
|
||||
};
|
||||
return await fn(conn);
|
||||
} finally {
|
||||
reserved.release();
|
||||
// Counter/gauge decrements run regardless of release() throwing
|
||||
// (double-release or socket error must not permanently leak a permit
|
||||
// of the small direct-reserve budget — data-migration review).
|
||||
try {
|
||||
reserved.release();
|
||||
} catch {
|
||||
// best-effort; the pool's own lifecycle handles a broken reservation
|
||||
}
|
||||
this.checkoutGauge.release('reserved');
|
||||
if (fromDirect) this._reservedDirectInFlight -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health-probe diagnostics (issue #6). Duck-typed — deliberately NOT on the
|
||||
* BrainEngine interface (PGLite has no pool to diagnose; the worker reads
|
||||
* it optionally, same pattern as `engine.reconnect`). Fail-open: returns
|
||||
* null instead of throwing.
|
||||
*/
|
||||
getPoolDiagnostics(): { tracked: PoolGaugeSnapshot; poolMax: number | null } | null {
|
||||
try {
|
||||
const max = (this.sql as unknown as { options?: { max?: number } }).options?.max;
|
||||
return {
|
||||
tracked: this.checkoutGauge.snapshot(),
|
||||
poolMax: typeof max === 'number' ? max : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6150,7 +6252,15 @@ export class PostgresEngine implements BrainEngine {
|
||||
params?: unknown[],
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<T[]> {
|
||||
return this.runUnsafe<T>(this.sql, sql, params, opts);
|
||||
// try/finally (not .finally on the promise): runUnsafe throws
|
||||
// SYNCHRONOUSLY on a pre-aborted signal, which would skip a chained
|
||||
// .finally and leak the counter.
|
||||
this.checkoutGauge.acquire('raw');
|
||||
try {
|
||||
return await this.runUnsafe<T>(this.sql, sql, params, opts);
|
||||
} finally {
|
||||
this.checkoutGauge.release('raw');
|
||||
}
|
||||
// Pre-#406 behavior: throw on any error including connection death.
|
||||
// Per-call auto-retry is not safe here because executeRaw is also used
|
||||
// for non-transactional mutations (DELETE/UPDATE/INSERT in sources.ts,
|
||||
@@ -6186,7 +6296,13 @@ export class PostgresEngine implements BrainEngine {
|
||||
const conn = (!inTransaction && this.connectionManager?.isDualPoolActive())
|
||||
? await this.connectionManager.ddl()
|
||||
: this.sql;
|
||||
return this.runUnsafe<T>(conn, sql, params, opts);
|
||||
// try/finally, not .finally — see executeRaw (sync throw on pre-aborted signal).
|
||||
this.checkoutGauge.acquire('direct');
|
||||
try {
|
||||
return await this.runUnsafe<T>(conn, sql, params, opts);
|
||||
} finally {
|
||||
this.checkoutGauge.release('direct');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* chatgpt-export.ts — ChatGPT data-export adapter (cathedral-4, CP1).
|
||||
*
|
||||
* v1 consumes the EXTRACTED conversations.json (the export zip is not
|
||||
* unwrapped here — "unzip first" is documented; a zip wrapper is a filed
|
||||
* TODO so this module stays dependency-free). One file = MANY conversations.
|
||||
*
|
||||
* The mapping is a TREE, not a list: regenerated answers create sibling
|
||||
* branches. The canonical transcript is the `current_node` parent-pointer
|
||||
* walk (root-ward, then reversed) — off-path branches are dropped BY DESIGN
|
||||
* (they were regenerated away). When `current_node` is missing, the fallback
|
||||
* is the leaf with the latest message create_time. Orphaned parents (pointer
|
||||
* to a missing node) terminate the walk without error. This walk is the
|
||||
* intricate part of the whole adapter set — the edge fixture pins branched,
|
||||
* orphaned, and fallback cases.
|
||||
*
|
||||
* PROVISIONAL: shape assembled from the widely-documented export format, not
|
||||
* verified against a fresh export on this machine; the drift alarm
|
||||
* (bytesRead > 0, sessions == 0) is the runtime backstop.
|
||||
*/
|
||||
|
||||
import type { HostSpecTarget } from '../bootstrap/host-specs.ts';
|
||||
import type {
|
||||
FileDiagnostics,
|
||||
ParsedSession,
|
||||
ParseSessionsOpts,
|
||||
TranscriptAdapter,
|
||||
TranscriptMessage,
|
||||
} from './types.ts';
|
||||
import { loadExportConversations } from './export-json.ts';
|
||||
|
||||
export const CHATGPT_SPEC_TARGET: HostSpecTarget = {
|
||||
id: 'chatgpt-export-2026-08',
|
||||
status: 'provisional',
|
||||
verifiedAt: '2026-08-14',
|
||||
references: [
|
||||
'ChatGPT settings data-export archive: conversations.json',
|
||||
'test/fixtures/transcripts/chatgpt-conversations.json',
|
||||
],
|
||||
note:
|
||||
'Top level: ARRAY of conversations {title, create_time epoch, ' +
|
||||
'conversation_id|id, current_node, mapping}. mapping: {node_id: {id, ' +
|
||||
'parent, children, message}}. message: {author:{role}, create_time, ' +
|
||||
"content:{content_type, parts:[...]}}. Kept: role user/assistant with " +
|
||||
'non-empty STRING parts (multimodal dict parts skipped). system/tool ' +
|
||||
'roles skipped. Canonical path = current_node parent walk; fallback = ' +
|
||||
'latest-create_time leaf. Monolithic JSON: over-cap files are REJECTED, ' +
|
||||
'never truncated (a partial parse is invalid JSON).',
|
||||
};
|
||||
|
||||
function epochToIso(v: unknown): string {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return '';
|
||||
return new Date(Math.round(v * 1000)).toISOString();
|
||||
}
|
||||
|
||||
interface MappingNode {
|
||||
id?: string;
|
||||
parent?: string | null;
|
||||
message?: {
|
||||
author?: { role?: string };
|
||||
create_time?: number | null;
|
||||
content?: { content_type?: string; parts?: unknown[] };
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Text of a node's message when it is a keepable user/assistant turn. */
|
||||
function nodeToMessage(node: MappingNode): TranscriptMessage | null {
|
||||
const msg = node.message;
|
||||
if (!msg || typeof msg !== 'object') return null;
|
||||
const role = msg.author?.role;
|
||||
if (role !== 'user' && role !== 'assistant') return null;
|
||||
const parts = msg.content?.parts;
|
||||
if (!Array.isArray(parts)) return null;
|
||||
const text = parts
|
||||
.filter((p): p is string => typeof p === 'string' && p.trim().length > 0)
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (!text) return null;
|
||||
return { role, timestamp: epochToIso(msg.create_time), text };
|
||||
}
|
||||
|
||||
/** Walk parent pointers from a leaf to the root; missing parents terminate. */
|
||||
function walkFrom(mapping: Record<string, MappingNode>, leafId: string): TranscriptMessage[] {
|
||||
const out: TranscriptMessage[] = [];
|
||||
const seen = new Set<string>();
|
||||
let cur: string | undefined = leafId;
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
const node: MappingNode | undefined = mapping[cur];
|
||||
if (!node) break; // orphaned pointer — stop quietly
|
||||
const m = nodeToMessage(node);
|
||||
if (m) out.push(m);
|
||||
cur = typeof node.parent === 'string' ? node.parent : undefined;
|
||||
}
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
/** Fallback when current_node is absent: leaf with the newest create_time. */
|
||||
function latestLeaf(mapping: Record<string, MappingNode>): string | undefined {
|
||||
const hasChild = new Set<string>();
|
||||
for (const node of Object.values(mapping)) {
|
||||
const parent = node?.parent;
|
||||
if (typeof parent === 'string') hasChild.add(parent);
|
||||
}
|
||||
let best: string | undefined;
|
||||
let bestTime = -Infinity;
|
||||
for (const [id, node] of Object.entries(mapping)) {
|
||||
if (hasChild.has(id)) continue;
|
||||
const t = typeof node?.message?.create_time === 'number' ? node.message.create_time : 0;
|
||||
if (t >= bestTime) {
|
||||
bestTime = t;
|
||||
best = id;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export const chatgptExportAdapter: TranscriptAdapter = {
|
||||
format: 'chatgpt',
|
||||
specTarget: CHATGPT_SPEC_TARGET,
|
||||
|
||||
detect(path: string, sample: Buffer): boolean {
|
||||
if (!path.endsWith('.json')) return false;
|
||||
const head = sample.toString('utf8');
|
||||
return head.includes('"mapping"') && !head.includes('"chat_messages"');
|
||||
},
|
||||
|
||||
async *parse(path: string, opts: ParseSessionsOpts = {}): AsyncGenerator<ParsedSession, FileDiagnostics> {
|
||||
const { data, bytes: size } = loadExportConversations(path, {
|
||||
maxBytes: opts.maxBytes,
|
||||
label: 'chatgpt',
|
||||
});
|
||||
|
||||
let sessions = 0;
|
||||
for (const conv of data) {
|
||||
if (typeof conv !== 'object' || conv === null) continue;
|
||||
const c = conv as Record<string, unknown>;
|
||||
const mapping = (typeof c.mapping === 'object' && c.mapping !== null ? c.mapping : null) as
|
||||
| Record<string, MappingNode>
|
||||
| null;
|
||||
if (!mapping) continue;
|
||||
const leaf =
|
||||
typeof c.current_node === 'string' && c.current_node in mapping
|
||||
? c.current_node
|
||||
: latestLeaf(mapping);
|
||||
if (!leaf) continue;
|
||||
const messages = walkFrom(mapping, leaf);
|
||||
if (!messages.length) continue;
|
||||
// Fallback ids are CONTENT-DERIVED, never a bare per-file ordinal: two
|
||||
// export files' first id-less conversations would otherwise both hash
|
||||
// from the same string and dedup-skip or abort each other.
|
||||
const sessionId =
|
||||
(typeof c.conversation_id === 'string' && c.conversation_id) ||
|
||||
(typeof c.id === 'string' && c.id) ||
|
||||
`chatgpt-fallback-${typeof c.title === 'string' ? c.title : ''}-${
|
||||
typeof c.create_time === 'number' ? c.create_time : ''
|
||||
}-${messages[0]?.timestamp ?? ''}-${sessions}`;
|
||||
sessions++;
|
||||
yield {
|
||||
meta: {
|
||||
harness: 'chatgpt',
|
||||
sessionId,
|
||||
title: typeof c.title === 'string' ? c.title : undefined,
|
||||
startedAt: epochToIso(c.create_time) || messages[0].timestamp || undefined,
|
||||
raw: {
|
||||
conversation_id: sessionId,
|
||||
title: typeof c.title === 'string' ? c.title : null,
|
||||
source_path: path,
|
||||
},
|
||||
},
|
||||
messages,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bytesRead: size,
|
||||
skippedLines: 0,
|
||||
truncated: false,
|
||||
sessions,
|
||||
zeroSessionsReason:
|
||||
sessions === 0 ? 'no conversations with user/assistant text on the canonical path' : undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -284,6 +284,80 @@ function entryToTurn(entry: unknown): WindowTurn | null {
|
||||
return { role, text };
|
||||
}
|
||||
|
||||
// ── Session parse for the import lane (cathedral-4, ADDITIVE) ───────────────
|
||||
|
||||
/**
|
||||
* A turn WITH its source timestamp, for the transcripts-import lane. The
|
||||
* hook lane keeps consuming `parseTranscript` (WindowTurn, no timestamps) —
|
||||
* this function is additive and MUST NOT change that behavior (pinned by the
|
||||
* regression test in test/transcript-adapters.test.ts).
|
||||
*/
|
||||
export interface TimedTurn {
|
||||
role: WindowTurn['role'];
|
||||
text: string;
|
||||
/** ISO 8601 from the line's `timestamp` field; '' when the line lacks one. */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ParsedClaudeSession {
|
||||
/** From the first line carrying one. */
|
||||
sessionId: string;
|
||||
cwd?: string;
|
||||
/** ISO of the first turn's timestamp ('' when absent). */
|
||||
startedAt: string;
|
||||
turns: TimedTurn[];
|
||||
bytesRead: number;
|
||||
skippedLines: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-file parse for imports: unlike `parseTranscript`, this NEVER
|
||||
* tail-reads (the slug date needs the session start) — a file over
|
||||
* `maxBytes` throws so the caller can reject it loudly. One .jsonl file is
|
||||
* one Claude Code session.
|
||||
*/
|
||||
export function parseClaudeSessionFile(
|
||||
path: string,
|
||||
opts: { maxBytes?: number } = {},
|
||||
): ParsedClaudeSession {
|
||||
const cap = Math.max(1, Math.floor(opts.maxBytes ?? TRANSCRIPT_HARD_CAP_BYTES));
|
||||
const size = statSync(path).size;
|
||||
if (size > cap) {
|
||||
throw new Error(`transcript too large for import: ${size} bytes (cap ${cap})`);
|
||||
}
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const turns: TimedTurn[] = [];
|
||||
let sessionId = '';
|
||||
let cwd: string | undefined;
|
||||
let skippedLines = 0;
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(t);
|
||||
} catch {
|
||||
skippedLines++;
|
||||
continue;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
if (!sessionId && typeof e.sessionId === 'string' && e.sessionId) sessionId = e.sessionId;
|
||||
if (!cwd && typeof e.cwd === 'string' && e.cwd) cwd = e.cwd;
|
||||
const turn = entryToTurn(entry);
|
||||
if (!turn) continue;
|
||||
const timestamp = typeof e.timestamp === 'string' ? e.timestamp : '';
|
||||
turns.push({ role: turn.role, text: turn.text, timestamp });
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
cwd,
|
||||
startedAt: turns.find((t) => t.timestamp)?.timestamp ?? '',
|
||||
turns,
|
||||
bytesRead: size,
|
||||
skippedLines,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Corpus rendering [S3#2 consumer] ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* claude-code.ts — TranscriptAdapter wrapper over the SHIPPED Claude Code
|
||||
* parser (claude-code-jsonl.ts). The wrapper adds nothing to the parsing —
|
||||
* the hardened parser, its SPEC_TARGET, and its fixture stay the single
|
||||
* source of truth; this file only adapts its output to the seam contract
|
||||
* (one .jsonl file = one session, timestamps preserved via
|
||||
* parseClaudeSessionFile).
|
||||
*/
|
||||
|
||||
import type {
|
||||
FileDiagnostics,
|
||||
ParsedSession,
|
||||
ParseSessionsOpts,
|
||||
TranscriptAdapter,
|
||||
} from './types.ts';
|
||||
import { TRANSCRIPT_JSONL_HARD_CAP } from './types.ts';
|
||||
import { parseClaudeSessionFile, SPEC_TARGET } from './claude-code-jsonl.ts';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
/** First-line keys that mark a Claude Code project transcript. */
|
||||
function looksLikeClaudeLine(obj: Record<string, unknown>): boolean {
|
||||
if (typeof obj.sessionId === 'string' && (obj.type === 'user' || obj.type === 'assistant')) {
|
||||
return true;
|
||||
}
|
||||
// Non-turn head lines (summary, attachment) still carry the shape family.
|
||||
return 'isSidechain' in obj || 'parentUuid' in obj;
|
||||
}
|
||||
|
||||
export const claudeCodeAdapter: TranscriptAdapter = {
|
||||
format: 'claude-code',
|
||||
specTarget: SPEC_TARGET,
|
||||
|
||||
detect(path: string, sample: Buffer): boolean {
|
||||
if (!path.endsWith('.jsonl')) return false;
|
||||
const firstLine = sample.toString('utf8').split('\n', 1)[0]?.trim();
|
||||
if (!firstLine) return false;
|
||||
try {
|
||||
const obj = JSON.parse(firstLine) as Record<string, unknown>;
|
||||
return typeof obj === 'object' && obj !== null && looksLikeClaudeLine(obj);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async *parse(path: string, opts: ParseSessionsOpts = {}): AsyncGenerator<ParsedSession, FileDiagnostics> {
|
||||
const r = parseClaudeSessionFile(path, {
|
||||
maxBytes: opts.maxBytes ?? TRANSCRIPT_JSONL_HARD_CAP,
|
||||
});
|
||||
const sessionId = r.sessionId || basename(path, '.jsonl');
|
||||
let sessions = 0;
|
||||
if (r.turns.length > 0) {
|
||||
sessions = 1;
|
||||
yield {
|
||||
meta: {
|
||||
harness: 'claude-code',
|
||||
sessionId,
|
||||
cwd: r.cwd,
|
||||
startedAt: r.startedAt || undefined,
|
||||
raw: { sessionId, cwd: r.cwd ?? null, source_path: path },
|
||||
},
|
||||
messages: r.turns.map((t) => ({ role: t.role, timestamp: t.timestamp, text: t.text })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
bytesRead: r.bytesRead,
|
||||
skippedLines: r.skippedLines,
|
||||
truncated: false,
|
||||
sessions,
|
||||
zeroSessionsReason: sessions === 0 ? 'no user or assistant turns in file' : undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* claude-export.ts — Claude.ai data-export adapter (cathedral-4, CP1).
|
||||
*
|
||||
* v1 consumes the EXTRACTED conversations.json from the account export
|
||||
* ("unzip first" documented; zip wrapper is a filed TODO). Flat shape — the
|
||||
* cheap sibling of the ChatGPT mapping-tree walk. One file = MANY
|
||||
* conversations.
|
||||
*
|
||||
* PROVISIONAL: shape assembled from the documented export format, not
|
||||
* verified against a fresh export on this machine; drift alarm is the
|
||||
* runtime backstop.
|
||||
*/
|
||||
|
||||
import type { HostSpecTarget } from '../bootstrap/host-specs.ts';
|
||||
import type {
|
||||
FileDiagnostics,
|
||||
ParsedSession,
|
||||
ParseSessionsOpts,
|
||||
TranscriptAdapter,
|
||||
TranscriptMessage,
|
||||
} from './types.ts';
|
||||
import { loadExportConversations } from './export-json.ts';
|
||||
|
||||
export const CLAUDE_EXPORT_SPEC_TARGET: HostSpecTarget = {
|
||||
id: 'claude-ai-export-2026-08',
|
||||
status: 'provisional',
|
||||
verifiedAt: '2026-08-14',
|
||||
references: [
|
||||
'Claude.ai account data export: conversations.json',
|
||||
'test/fixtures/transcripts/claude-export.json',
|
||||
],
|
||||
note:
|
||||
'Top level: ARRAY of conversations {uuid, name, created_at ISO, ' +
|
||||
'chat_messages:[{uuid, text, sender, created_at}]}. sender "human" maps ' +
|
||||
'to user; "assistant" stays. Empty-text messages are skipped. Monolithic ' +
|
||||
'JSON: over-cap files are REJECTED, never truncated.',
|
||||
};
|
||||
|
||||
export const claudeExportAdapter: TranscriptAdapter = {
|
||||
format: 'claude-export',
|
||||
specTarget: CLAUDE_EXPORT_SPEC_TARGET,
|
||||
|
||||
detect(path: string, sample: Buffer): boolean {
|
||||
if (!path.endsWith('.json')) return false;
|
||||
const head = sample.toString('utf8');
|
||||
// Symmetric guard with the chatgpt detector: a ChatGPT export whose
|
||||
// early message TEXT contains the literal key name must not misdetect.
|
||||
return head.includes('"chat_messages"') && !head.includes('"mapping"');
|
||||
},
|
||||
|
||||
async *parse(path: string, opts: ParseSessionsOpts = {}): AsyncGenerator<ParsedSession, FileDiagnostics> {
|
||||
const { data, bytes: size } = loadExportConversations(path, {
|
||||
maxBytes: opts.maxBytes,
|
||||
label: 'claude',
|
||||
});
|
||||
|
||||
let sessions = 0;
|
||||
for (const conv of data) {
|
||||
if (typeof conv !== 'object' || conv === null) continue;
|
||||
const c = conv as Record<string, unknown>;
|
||||
const rows = Array.isArray(c.chat_messages) ? c.chat_messages : null;
|
||||
if (!rows) continue;
|
||||
const messages: TranscriptMessage[] = [];
|
||||
for (const row of rows) {
|
||||
if (typeof row !== 'object' || row === null) continue;
|
||||
const r = row as Record<string, unknown>;
|
||||
const role = r.sender === 'human' ? 'user' : r.sender === 'assistant' ? 'assistant' : null;
|
||||
if (!role) continue;
|
||||
const text = typeof r.text === 'string' ? r.text.trim() : '';
|
||||
if (!text) continue;
|
||||
messages.push({
|
||||
role,
|
||||
timestamp: typeof r.created_at === 'string' ? r.created_at : '',
|
||||
text,
|
||||
});
|
||||
}
|
||||
if (!messages.length) continue;
|
||||
// Content-derived fallback (see chatgpt-export.ts): a bare per-file
|
||||
// ordinal collides across export files.
|
||||
const sessionId =
|
||||
(typeof c.uuid === 'string' && c.uuid) ||
|
||||
`claude-export-fallback-${typeof c.name === 'string' ? c.name : ''}-${
|
||||
typeof c.created_at === 'string' ? c.created_at : ''
|
||||
}-${messages[0]?.timestamp ?? ''}-${sessions}`;
|
||||
sessions++;
|
||||
yield {
|
||||
meta: {
|
||||
harness: 'claude-export',
|
||||
sessionId,
|
||||
title: typeof c.name === 'string' && c.name ? c.name : undefined,
|
||||
startedAt:
|
||||
(typeof c.created_at === 'string' && c.created_at) || messages[0].timestamp || undefined,
|
||||
raw: {
|
||||
conversation_uuid: sessionId,
|
||||
name: typeof c.name === 'string' ? c.name : null,
|
||||
source_path: path,
|
||||
},
|
||||
},
|
||||
messages,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bytesRead: size,
|
||||
skippedLines: 0,
|
||||
truncated: false,
|
||||
sessions,
|
||||
zeroSessionsReason:
|
||||
sessions === 0 ? 'no conversations with human/assistant text messages' : undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* codex.ts — Codex rollout (.jsonl) adapter (cathedral-4).
|
||||
*
|
||||
* One rollout file = one session. Line shape: {timestamp, type, payload}.
|
||||
* Verified against a live local rollout 2026-08-14 (see SPEC_TARGET).
|
||||
*
|
||||
* TURN SELECTION IS STRUCTURAL, not heuristic: the human's typed text is
|
||||
* recorded as `event_msg` payload.type='user_message' (payload.message);
|
||||
* `response_item` rows with role user/developer are INJECTED context
|
||||
* (app-context, plugin lists, instruction preambles) and are skipped
|
||||
* wholesale. Assistant text comes from `response_item` payload.type='message'
|
||||
* role='assistant' output_text blocks. reasoning / tool calls / token_count
|
||||
* and every other event kind are skipped — the archive records conversation
|
||||
* text only (lossy by design).
|
||||
*/
|
||||
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import type { HostSpecTarget } from '../bootstrap/host-specs.ts';
|
||||
import type {
|
||||
FileDiagnostics,
|
||||
ParsedSession,
|
||||
ParseSessionsOpts,
|
||||
TranscriptAdapter,
|
||||
TranscriptMessage,
|
||||
} from './types.ts';
|
||||
import { TRANSCRIPT_JSONL_HARD_CAP } from './types.ts';
|
||||
|
||||
export const CODEX_SPEC_TARGET: HostSpecTarget = {
|
||||
id: 'codex-rollout-2026-08',
|
||||
status: 'verified',
|
||||
verifiedAt: '2026-08-14',
|
||||
references: [
|
||||
'local ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl (codex CLI, live sample 2026-08-14)',
|
||||
'test/fixtures/transcripts/codex-rollout.jsonl',
|
||||
],
|
||||
note:
|
||||
'One JSON object per line: {timestamp: ISO, type, payload}. type ' +
|
||||
"'session_meta' header carries payload.{session_id, cwd, timestamp, " +
|
||||
"cli_version}. User turns: type 'event_msg' with payload.type " +
|
||||
"'user_message' (payload.message = typed text). Assistant turns: type " +
|
||||
"'response_item' with payload.{type:'message', role:'assistant', " +
|
||||
"content:[{type:'output_text', text}]}. response_item rows with role " +
|
||||
'user/developer are injected context and are skipped. reasoning, ' +
|
||||
'custom_tool_call*, function_call*, token_count, world_state, ' +
|
||||
'turn_context, compacted: all skipped. Unknown fields tolerated.',
|
||||
};
|
||||
|
||||
function textFromBlocks(content: unknown, blockType: string): string {
|
||||
if (!Array.isArray(content)) return '';
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (typeof block !== 'object' || block === null) continue;
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type === blockType && typeof b.text === 'string' && b.text.trim()) parts.push(b.text);
|
||||
}
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
|
||||
export const codexAdapter: TranscriptAdapter = {
|
||||
format: 'codex',
|
||||
specTarget: CODEX_SPEC_TARGET,
|
||||
|
||||
detect(path: string, sample: Buffer): boolean {
|
||||
if (!path.endsWith('.jsonl')) return false;
|
||||
const firstLine = sample.toString('utf8').split('\n', 1)[0]?.trim();
|
||||
if (!firstLine || !firstLine.startsWith('{')) return false;
|
||||
try {
|
||||
const obj = JSON.parse(firstLine) as Record<string, unknown>;
|
||||
// STRUCTURAL check — a substring sniff misdetects any transcript whose
|
||||
// first message merely QUOTES rollout text (realistic for this repo's
|
||||
// own users) and would strand it in the drift lane.
|
||||
return obj !== null && typeof obj === 'object' && obj.type === 'session_meta';
|
||||
} catch {
|
||||
// First line truncated by the sample window (oversized session_meta):
|
||||
// fall back to the key sniff for exactly that case.
|
||||
return firstLine.includes('"session_meta"') && firstLine.includes('"payload"');
|
||||
}
|
||||
},
|
||||
|
||||
async *parse(path: string, opts: ParseSessionsOpts = {}): AsyncGenerator<ParsedSession, FileDiagnostics> {
|
||||
const cap = opts.maxBytes ?? TRANSCRIPT_JSONL_HARD_CAP;
|
||||
const size = statSync(path).size;
|
||||
if (size > cap) {
|
||||
throw new Error(`codex rollout too large for import: ${size} bytes (cap ${cap})`);
|
||||
}
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
let skippedLines = 0;
|
||||
let sessionId = '';
|
||||
let cwd: string | undefined;
|
||||
let startedAt = '';
|
||||
const messages: TranscriptMessage[] = [];
|
||||
let rawMeta: Record<string, unknown> | undefined;
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(t);
|
||||
} catch {
|
||||
skippedLines++;
|
||||
continue;
|
||||
}
|
||||
if (typeof entry !== 'object' || entry === null) continue;
|
||||
const e = entry as Record<string, unknown>;
|
||||
const payload = (typeof e.payload === 'object' && e.payload !== null ? e.payload : {}) as Record<string, unknown>;
|
||||
const lineTs = typeof e.timestamp === 'string' ? e.timestamp : '';
|
||||
|
||||
if (e.type === 'session_meta') {
|
||||
if (typeof payload.session_id === 'string') sessionId = payload.session_id;
|
||||
if (typeof payload.cwd === 'string') cwd = payload.cwd;
|
||||
if (typeof payload.timestamp === 'string') startedAt = payload.timestamp;
|
||||
else if (lineTs) startedAt = lineTs;
|
||||
rawMeta = {
|
||||
session_id: sessionId,
|
||||
cwd: cwd ?? null,
|
||||
cli_version: typeof payload.cli_version === 'string' ? payload.cli_version : null,
|
||||
model_provider: typeof payload.model_provider === 'string' ? payload.model_provider : null,
|
||||
source_path: path,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (e.type === 'event_msg' && payload.type === 'user_message') {
|
||||
const text = typeof payload.message === 'string' ? payload.message.trim() : '';
|
||||
if (text) messages.push({ role: 'user', timestamp: lineTs, text });
|
||||
continue;
|
||||
}
|
||||
if (e.type === 'response_item' && payload.type === 'message' && payload.role === 'assistant') {
|
||||
const text = textFromBlocks(payload.content, 'output_text');
|
||||
if (text) messages.push({ role: 'assistant', timestamp: lineTs, text });
|
||||
continue;
|
||||
}
|
||||
// Everything else (reasoning, tool traffic, injected user/developer
|
||||
// response_items, telemetry events) is skipped by design.
|
||||
}
|
||||
|
||||
let sessions = 0;
|
||||
if (messages.length > 0) {
|
||||
sessions = 1;
|
||||
const sid = sessionId || basename(path, '.jsonl');
|
||||
yield {
|
||||
meta: {
|
||||
harness: 'codex',
|
||||
sessionId: sid,
|
||||
cwd,
|
||||
startedAt: startedAt || messages[0].timestamp || undefined,
|
||||
raw: rawMeta ?? { session_id: sid, source_path: path },
|
||||
},
|
||||
messages,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bytesRead: size,
|
||||
skippedLines,
|
||||
truncated: false,
|
||||
sessions,
|
||||
zeroSessionsReason:
|
||||
sessions === 0 ? 'no user_message events or assistant message items in rollout' : undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* detect.ts — format detection + harness discovery roots for the transcripts
|
||||
* import lane (cathedral-4).
|
||||
*
|
||||
* The ADAPTERS registry is the one place import formats are enumerated;
|
||||
* detection order matters (cheap magic bytes first, then first-line JSON
|
||||
* shapes, then monolithic-JSON key sniffs). An explicit format flag from the
|
||||
* CLI always wins over detection.
|
||||
*
|
||||
* Trust split: EXPLICIT paths are trusted local-CLI input (extension +
|
||||
* byte-cap + lstat checks only). DISCOVERY mode is confined to the static
|
||||
* harness roots below — consumer exports have no canonical root and are
|
||||
* explicit-path only. `roots` is an injectable parameter so tests never
|
||||
* touch the real home directory.
|
||||
*/
|
||||
|
||||
import { closeSync, lstatSync, openSync, readSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { TranscriptAdapter, TranscriptFormat } from './types.ts';
|
||||
import { claudeCodeAdapter } from './claude-code.ts';
|
||||
import { codexAdapter } from './codex.ts';
|
||||
import { openclawAdapter } from './openclaw.ts';
|
||||
import { hermesAdapter } from './hermes.ts';
|
||||
import { chatgptExportAdapter } from './chatgpt-export.ts';
|
||||
import { claudeExportAdapter } from './claude-export.ts';
|
||||
|
||||
// ── Harness discovery roots (discovery mode only) ───────────────────────────
|
||||
|
||||
export interface HarnessRoot {
|
||||
format: TranscriptFormat;
|
||||
/** Directory scanned recursively for session files (or the single store file). */
|
||||
root: string;
|
||||
/** Glob-ish suffix filter applied during discovery. */
|
||||
extension: '.jsonl' | '.db';
|
||||
}
|
||||
|
||||
/** The static discovery surface. Injectable (`overrides`) for tests. */
|
||||
export function harnessRoots(overrides?: HarnessRoot[]): HarnessRoot[] {
|
||||
if (overrides) return overrides;
|
||||
const home = homedir();
|
||||
return [
|
||||
{ format: 'claude-code', root: join(home, '.claude', 'projects'), extension: '.jsonl' },
|
||||
{ format: 'codex', root: join(home, '.codex', 'sessions'), extension: '.jsonl' },
|
||||
{ format: 'openclaw', root: join(home, '.openclaw', 'agents'), extension: '.jsonl' },
|
||||
// Hermes keeps every session in one SQLite store (hermes-agent
|
||||
// DEFAULT_DB_PATH = <hermes home>/state.db; HERMES_HOME honored).
|
||||
{
|
||||
format: 'hermes',
|
||||
root: process.env.HERMES_HOME ?? join(home, '.hermes'),
|
||||
extension: '.db',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Registry ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detection order: SQLite magic is unambiguous; JSONL first-line shapes are
|
||||
* mutually exclusive (session_meta / session-header / claude keys); the two
|
||||
* monolithic-JSON exports are sniffed by their distinguishing keys. Every
|
||||
* adapter registers here unconditionally; any format-level scoping belongs
|
||||
* to callers.
|
||||
*
|
||||
*/
|
||||
export function transcriptAdapters(): TranscriptAdapter[] {
|
||||
return [
|
||||
hermesAdapter,
|
||||
openclawAdapter,
|
||||
codexAdapter,
|
||||
claudeCodeAdapter,
|
||||
claudeExportAdapter,
|
||||
chatgptExportAdapter,
|
||||
];
|
||||
}
|
||||
|
||||
const SAMPLE_BYTES = 64 * 1024;
|
||||
|
||||
/** Read the file head for detection without loading the whole file. */
|
||||
export function readSample(path: string, bytes = SAMPLE_BYTES): Buffer {
|
||||
const fd = openSync(path, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(bytes);
|
||||
const n = readSync(fd, buf, 0, bytes, 0);
|
||||
return buf.subarray(0, n);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export type DetectResult =
|
||||
| { ok: true; adapter: TranscriptAdapter }
|
||||
| { ok: false; reason: 'unreadable' | 'symlink' | 'unknown_format'; tried: TranscriptFormat[] };
|
||||
|
||||
/**
|
||||
* Detect the adapter for a path. `explicitFormat` (from the CLI flag) wins
|
||||
* without sniffing; unknown formats report every detector tried so the error
|
||||
* is actionable.
|
||||
*/
|
||||
export function detectAdapter(
|
||||
path: string,
|
||||
opts: { explicitFormat?: TranscriptFormat; adapters?: TranscriptAdapter[] } = {},
|
||||
): DetectResult {
|
||||
const adapters = opts.adapters ?? transcriptAdapters();
|
||||
if (opts.explicitFormat) {
|
||||
const adapter = adapters.find((a) => a.format === opts.explicitFormat);
|
||||
if (adapter) return { ok: true, adapter };
|
||||
return { ok: false, reason: 'unknown_format', tried: adapters.map((a) => a.format) };
|
||||
}
|
||||
try {
|
||||
const st = lstatSync(path);
|
||||
if (st.isSymbolicLink()) return { ok: false, reason: 'symlink', tried: [] };
|
||||
} catch {
|
||||
return { ok: false, reason: 'unreadable', tried: [] };
|
||||
}
|
||||
let sample: Buffer;
|
||||
try {
|
||||
sample = readSample(path);
|
||||
} catch {
|
||||
return { ok: false, reason: 'unreadable', tried: [] };
|
||||
}
|
||||
for (const adapter of adapters) {
|
||||
if (adapter.detect(path, sample)) return { ok: true, adapter };
|
||||
}
|
||||
return { ok: false, reason: 'unknown_format', tried: adapters.map((a) => a.format) };
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* discover.ts — harness-root discovery + the status gap table (cathedral-4).
|
||||
*
|
||||
* Discovery is CONFINED to the static harness roots (detect.ts) — this is
|
||||
* the untrusted-enumeration side of the trust split, so symlinks are
|
||||
* lstat-rejected and only the expected extensions are picked up. Consumer
|
||||
* exports have no canonical root and never appear here.
|
||||
*
|
||||
* The status table derives its "imported" side from PAGES (one paginated
|
||||
* listPages walk, client-side transcript_import filtering, distinct
|
||||
* session ids) — durable truth that catches late-arriving sessions no
|
||||
* watermark can. File↔session matching for the gap column uses the
|
||||
* session-id-in-filename property of the three JSONL harnesses; the Hermes
|
||||
* store is one file holding many sessions, so its gap is reported at
|
||||
* session granularity only.
|
||||
*/
|
||||
|
||||
import { lstatSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { TranscriptFormat } from './types.ts';
|
||||
import { harnessRoots, type HarnessRoot } from './detect.ts';
|
||||
import { isOpenclawCheckpointFile } from './openclaw.ts';
|
||||
|
||||
export interface DiscoveredFile {
|
||||
format: TranscriptFormat;
|
||||
path: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** Recursively list regular files under root (lstat: symlinks are skipped). */
|
||||
function walk(dir: string, out: string[], depth = 0): void {
|
||||
if (depth > 6) return; // harness layouts are shallow; don't wander
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const name of entries) {
|
||||
const p = join(dir, name);
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(p);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.isSymbolicLink()) continue;
|
||||
if (st.isDirectory()) walk(p, out, depth + 1);
|
||||
else if (st.isFile()) out.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
export function discoverTranscriptFiles(roots?: HarnessRoot[]): DiscoveredFile[] {
|
||||
const out: DiscoveredFile[] = [];
|
||||
for (const { format, root, extension } of harnessRoots(roots)) {
|
||||
if (format === 'hermes') {
|
||||
const store = join(root, 'state.db');
|
||||
try {
|
||||
const st = lstatSync(store);
|
||||
if (st.isFile()) out.push({ format, path: store, bytes: st.size });
|
||||
} catch {
|
||||
// No store — hermes simply absent from discovery.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const files: string[] = [];
|
||||
walk(root, files);
|
||||
for (const p of files) {
|
||||
if (!p.endsWith(extension)) continue;
|
||||
if (isOpenclawCheckpointFile(p)) continue;
|
||||
let bytes = 0;
|
||||
try {
|
||||
bytes = lstatSync(p).size;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
out.push({ format, path: p, bytes });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface ImportedSessionIndex {
|
||||
/** harness → distinct imported session ids. */
|
||||
byHarness: Map<string, Set<string>>;
|
||||
pagesScanned: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ONE frontmatter-only query — never a query per harness, and never
|
||||
* `SELECT p.*`: conversation pages carry bodies up to the split target
|
||||
* (~300KB per part by design), so a full-page walk at backfill scale
|
||||
* (thousands of sessions) would stream hundreds of MB just to read two
|
||||
* frontmatter keys. Both engines serve executeRaw.
|
||||
*/
|
||||
export async function indexImportedSessions(
|
||||
engine: BrainEngine,
|
||||
sourceId: string,
|
||||
): Promise<ImportedSessionIndex> {
|
||||
const byHarness = new Map<string, Set<string>>();
|
||||
let pagesScanned = 0;
|
||||
const rows = await engine.executeRaw<{ frontmatter: unknown }>(
|
||||
`SELECT frontmatter FROM pages
|
||||
WHERE type = 'conversation' AND source_id = $1 AND deleted_at IS NULL`,
|
||||
[sourceId],
|
||||
);
|
||||
for (const row of rows) {
|
||||
pagesScanned++;
|
||||
const fm = (typeof row.frontmatter === 'string' ? JSON.parse(row.frontmatter) : row.frontmatter) as
|
||||
| Record<string, unknown>
|
||||
| null;
|
||||
const ti = fm?.transcript_import as { harness?: string; session_id?: string } | undefined;
|
||||
if (!ti || typeof ti.harness !== 'string' || typeof ti.session_id !== 'string') continue;
|
||||
let set = byHarness.get(ti.harness);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
byHarness.set(ti.harness, set);
|
||||
}
|
||||
set.add(ti.session_id);
|
||||
}
|
||||
return { byHarness, pagesScanned };
|
||||
}
|
||||
|
||||
export interface StatusRow {
|
||||
format: TranscriptFormat;
|
||||
/** Files (stores, for hermes) found under the harness root. */
|
||||
found: number;
|
||||
/** Distinct imported session ids for this harness. */
|
||||
importedSessions: number;
|
||||
/** Found files with no imported session id in their basename (JSONL harnesses; null for hermes). */
|
||||
gapFiles: number | null;
|
||||
}
|
||||
|
||||
export function buildStatusRows(
|
||||
discovered: DiscoveredFile[],
|
||||
imported: ImportedSessionIndex,
|
||||
roots?: HarnessRoot[],
|
||||
): StatusRow[] {
|
||||
// The harness list derives from the ONE registry (harnessRoots) — a new
|
||||
// adapter added there appears in status automatically instead of silently
|
||||
// vanishing from the gap table.
|
||||
const formats = [...new Set(harnessRoots(roots).map((r) => r.format))];
|
||||
return formats.map((format) => {
|
||||
const files = discovered.filter((d) => d.format === format);
|
||||
const sessionIds = imported.byHarness.get(format) ?? new Set<string>();
|
||||
let gapFiles: number | null = null;
|
||||
if (format !== 'hermes') {
|
||||
gapFiles = files.filter((f) => {
|
||||
const base = f.path.split('/').pop() ?? '';
|
||||
// Fast path: for claude-code/openclaw the basename stem IS the
|
||||
// session id — a Set hit avoids the O(ids) substring scan.
|
||||
const stem = base.replace(/\.jsonl$/, '');
|
||||
if (sessionIds.has(stem)) return false;
|
||||
for (const id of sessionIds) {
|
||||
if (id && base.includes(id)) return false;
|
||||
}
|
||||
return true;
|
||||
}).length;
|
||||
}
|
||||
return { format, found: files.length, importedSessions: sessionIds.size, gapFiles };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* export-json.ts — shared loader for monolithic consumer-export JSON
|
||||
* (cathedral-4). One home for the cap/parse/shape checks and their error
|
||||
* strings so the two export adapters cannot drift apart: monolithic JSON
|
||||
* cannot be partially parsed, so over-cap files are REJECTED (never
|
||||
* truncated), and a zip or wrong-shape file gets the unzip-first hint.
|
||||
*/
|
||||
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { TRANSCRIPT_EXPORT_JSON_HARD_CAP } from './types.ts';
|
||||
|
||||
/** Load an extracted conversations.json: returns the top-level array. */
|
||||
export function loadExportConversations(
|
||||
path: string,
|
||||
opts: { maxBytes?: number; label: string },
|
||||
): { data: unknown[]; bytes: number } {
|
||||
const cap = opts.maxBytes ?? TRANSCRIPT_EXPORT_JSON_HARD_CAP;
|
||||
const size = statSync(path).size;
|
||||
if (size > cap) {
|
||||
throw new Error(
|
||||
`${opts.label} export too large for import: ${size} bytes (cap ${cap}) — split the export`,
|
||||
);
|
||||
}
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`not an extracted conversations.json (unzip the export first): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(
|
||||
'not an extracted conversations.json (expected a top-level array) — unzip the export first',
|
||||
);
|
||||
}
|
||||
return { data, bytes: size };
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* hermes.ts — Hermes state.db (SQLite) adapter (cathedral-4).
|
||||
*
|
||||
* ONE store file holds MANY sessions (hermes-agent DEFAULT_DB_PATH =
|
||||
* <hermes home>/state.db). Reads are COPY-THEN-READ by default: readonly
|
||||
* opens of a WAL-mode SQLite database require write access to the -shm
|
||||
* sidecar and can intermittently lock against a live writer, so the adapter
|
||||
* copies the DB (+ -wal/-shm sidecars when present) to a temp dir and reads
|
||||
* the copy — deterministic, zero lock races, cleaned up in finally.
|
||||
*
|
||||
* Schema verified against the INSTALLED hermes-agent v0.20.0 source
|
||||
* (hermes_state_common.py SCHEMA_SQL) — sessions(id, source, display_name,
|
||||
* title, started_at REAL epoch-seconds, cwd, model) and messages(session_id,
|
||||
* role, content, timestamp REAL). No populated sample DB existed on the dev
|
||||
* machine, so the SPEC_TARGET stays PROVISIONAL and the fixture is built
|
||||
* from the same schema by test code; the bytes>0/sessions==0 drift signal is
|
||||
* the runtime backstop.
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import type { HostSpecTarget } from '../bootstrap/host-specs.ts';
|
||||
import type {
|
||||
FileDiagnostics,
|
||||
ParsedSession,
|
||||
ParseSessionsOpts,
|
||||
TranscriptAdapter,
|
||||
TranscriptMessage,
|
||||
} from './types.ts';
|
||||
|
||||
export const HERMES_SPEC_TARGET: HostSpecTarget = {
|
||||
id: 'hermes-state-db-2026-08',
|
||||
status: 'provisional',
|
||||
verifiedAt: '2026-08-14',
|
||||
references: [
|
||||
'installed hermes-agent v0.20.0 hermes_state_common.py SCHEMA_SQL (schema source of truth)',
|
||||
'hermes-agent hermes_state.py DEFAULT_DB_PATH = <hermes home>/state.db',
|
||||
'test/fixtures/transcripts/hermes-fixture-builder.ts (synthetic, schema-matched)',
|
||||
],
|
||||
note:
|
||||
'SQLite store, WAL mode. sessions: id TEXT PK, source, display_name, ' +
|
||||
'title, started_at REAL (epoch seconds), ended_at, cwd, model. messages: ' +
|
||||
'session_id, role, content TEXT, timestamp REAL. The import keeps role ' +
|
||||
"user/assistant rows with non-empty content; content that looks like a " +
|
||||
'JSON block array is unwrapped to its text blocks. active/compacted ' +
|
||||
'flags are IGNORED (the archive wants full history, not the live ' +
|
||||
'context window). PROVISIONAL: no populated production sample verified.',
|
||||
};
|
||||
|
||||
/** Hard cap for the store copy (FTS indexes make legitimate stores large). */
|
||||
export const HERMES_DB_HARD_CAP = 512 * 1024 * 1024;
|
||||
|
||||
const SQLITE_MAGIC = 'SQLite format 3\u0000';
|
||||
|
||||
function epochToIso(v: unknown): string {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v) || v <= 0) return '';
|
||||
return new Date(Math.round(v * 1000)).toISOString();
|
||||
}
|
||||
|
||||
/** Unwrap content that is a JSON block array; pass plain text through. */
|
||||
function contentToText(content: unknown): string {
|
||||
if (typeof content !== 'string') return '';
|
||||
const t = content.trim();
|
||||
if (!t) return '';
|
||||
if (t.startsWith('[')) {
|
||||
try {
|
||||
const blocks = JSON.parse(t) as unknown;
|
||||
if (Array.isArray(blocks)) {
|
||||
const parts: string[] = [];
|
||||
for (const block of blocks) {
|
||||
if (typeof block === 'string' && block.trim()) parts.push(block);
|
||||
else if (typeof block === 'object' && block !== null) {
|
||||
const b = block as Record<string, unknown>;
|
||||
if (typeof b.text === 'string' && b.text.trim()) parts.push(b.text);
|
||||
}
|
||||
}
|
||||
return parts.join('\n').trim();
|
||||
}
|
||||
} catch {
|
||||
// Not JSON after all — fall through to plain text.
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
title: string | null;
|
||||
display_name: string | null;
|
||||
started_at: number | null;
|
||||
cwd: string | null;
|
||||
model: string | null;
|
||||
source: string | null;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
role: string;
|
||||
content: string | null;
|
||||
timestamp: number | null;
|
||||
}
|
||||
|
||||
export const hermesAdapter: TranscriptAdapter = {
|
||||
format: 'hermes',
|
||||
specTarget: HERMES_SPEC_TARGET,
|
||||
|
||||
detect(path: string, sample: Buffer): boolean {
|
||||
if (!path.endsWith('.db')) return false;
|
||||
return sample.toString('latin1', 0, 16) === SQLITE_MAGIC;
|
||||
},
|
||||
|
||||
async *parse(path: string, opts: ParseSessionsOpts = {}): AsyncGenerator<ParsedSession, FileDiagnostics> {
|
||||
const cap = opts.maxBytes ?? HERMES_DB_HARD_CAP;
|
||||
const size = statSync(path).size;
|
||||
// The cap bounds the TOTAL copied (db + sidecars) — a runaway WAL can
|
||||
// dwarf the main file, and only capping the db would let the copy blow
|
||||
// through temp storage while advertising a 512MB bound.
|
||||
let totalBytes = size;
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
if (existsSync(path + suffix)) totalBytes += statSync(path + suffix).size;
|
||||
}
|
||||
if (totalBytes > cap) {
|
||||
throw new Error(
|
||||
`hermes store too large for import: ${totalBytes} bytes incl. sidecars (cap ${cap})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Copy-then-read: DB plus WAL/SHM sidecars so un-checkpointed writes are
|
||||
// visible in the copy. A live writer can checkpoint BETWEEN the copies —
|
||||
// the resulting torn snapshot surfaces as a schema/corruption error from
|
||||
// the sessions query below, lands in the drift lane, and (because drift
|
||||
// freezes the watermark) is safely retried by the next run.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'gbrain-hermes-'));
|
||||
const copyPath = join(tmp, basename(path));
|
||||
let sessions = 0;
|
||||
try {
|
||||
copyFileSync(path, copyPath);
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
if (existsSync(path + suffix)) copyFileSync(path + suffix, copyPath + suffix);
|
||||
}
|
||||
|
||||
const db = new Database(copyPath, { readonly: true });
|
||||
try {
|
||||
let sessionRows: SessionRow[];
|
||||
try {
|
||||
sessionRows = db
|
||||
.query<SessionRow, []>(
|
||||
'SELECT id, title, display_name, started_at, cwd, model, source ' +
|
||||
'FROM sessions ORDER BY started_at',
|
||||
)
|
||||
.all();
|
||||
} catch (err) {
|
||||
// Missing/renamed tables = host schema drift, not a crash.
|
||||
return {
|
||||
bytesRead: size,
|
||||
skippedLines: 0,
|
||||
truncated: false,
|
||||
sessions: 0,
|
||||
zeroSessionsReason: `schema mismatch reading sessions table: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const msgQuery = db.query<MessageRow, [string]>(
|
||||
"SELECT role, content, timestamp FROM messages WHERE session_id = ? " +
|
||||
"AND role IN ('user','assistant') ORDER BY timestamp, id",
|
||||
);
|
||||
for (const row of sessionRows) {
|
||||
if (typeof row.id !== 'string' || !row.id) continue;
|
||||
const messages: TranscriptMessage[] = [];
|
||||
for (const m of msgQuery.all(row.id)) {
|
||||
const role = m.role === 'user' || m.role === 'assistant' ? m.role : null;
|
||||
if (!role) continue;
|
||||
const text = contentToText(m.content);
|
||||
if (!text) continue;
|
||||
messages.push({ role, timestamp: epochToIso(m.timestamp), text });
|
||||
}
|
||||
if (!messages.length) continue;
|
||||
sessions++;
|
||||
yield {
|
||||
meta: {
|
||||
harness: 'hermes',
|
||||
sessionId: row.id,
|
||||
title: row.title ?? row.display_name ?? undefined,
|
||||
cwd: row.cwd ?? undefined,
|
||||
model: row.model ?? undefined,
|
||||
startedAt: epochToIso(row.started_at) || messages[0].timestamp || undefined,
|
||||
raw: {
|
||||
session_id: row.id,
|
||||
source: row.source ?? null,
|
||||
cwd: row.cwd ?? null,
|
||||
source_path: path,
|
||||
},
|
||||
},
|
||||
messages,
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
bytesRead: size,
|
||||
skippedLines: 0,
|
||||
truncated: false,
|
||||
sessions,
|
||||
zeroSessionsReason:
|
||||
sessions === 0 ? 'no sessions with user/assistant text messages in store' : undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* ingest-facts.ts — the `--facts` lane of transcripts ingest (cathedral-4).
|
||||
*
|
||||
* ONE `runExtractConversationFactsCore` invocation per run (the batch
|
||||
* `slugs` selector), wrapped in ONE `withBudgetTracker` — passing a tracker
|
||||
* via opts alone is not accounting (the gateway reads AsyncLocalStorage),
|
||||
* and per-slug core invocations multiply config resolution, checkpoint IO,
|
||||
* and receipt writes by page count.
|
||||
*
|
||||
* Targets EVERY slug the ingest touched, INCLUDING hash-skipped pages (an
|
||||
* earlier no-facts import then a re-run with the facts flag must still
|
||||
* extract); the extractor's durable-outcome/version-token gate dedupes the
|
||||
* already-extracted ones. Respects the brain-wide `facts.extraction_enabled`
|
||||
* kill-switch with a notice, never a throw (the core throws on disabled; the
|
||||
* pre-check is the sweep pattern).
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { isFactsExtractionEnabled } from '../facts/extract.ts';
|
||||
import { BudgetTracker } from '../budget/budget-tracker.ts';
|
||||
import { withBudgetTracker } from '../ai/gateway.ts';
|
||||
import {
|
||||
DEFAULT_MAX_COST_USD,
|
||||
runExtractConversationFactsCore,
|
||||
} from '../../commands/extract-conversation-facts.ts';
|
||||
|
||||
export interface IngestFactsResult {
|
||||
pages: number;
|
||||
spentUsd?: number;
|
||||
skippedDisabled?: boolean;
|
||||
}
|
||||
|
||||
export async function runIngestFacts(
|
||||
engine: BrainEngine,
|
||||
opts: { sourceId: string; slugs: string[]; maxCostUsd?: number; quiet?: boolean },
|
||||
): Promise<IngestFactsResult> {
|
||||
if (!(await isFactsExtractionEnabled(engine))) {
|
||||
if (!opts.quiet) {
|
||||
console.error(
|
||||
'transcripts ingest: facts extraction is disabled brain-wide ' +
|
||||
'(facts.extraction_enabled=false) — pages imported, facts skipped',
|
||||
);
|
||||
}
|
||||
return { pages: 0, skippedDisabled: true };
|
||||
}
|
||||
|
||||
const tracker = new BudgetTracker({
|
||||
maxCostUsd: opts.maxCostUsd ?? DEFAULT_MAX_COST_USD,
|
||||
label: 'transcripts-ingest-facts',
|
||||
});
|
||||
await withBudgetTracker(tracker, () =>
|
||||
runExtractConversationFactsCore(engine, {
|
||||
sourceId: opts.sourceId,
|
||||
slugs: opts.slugs,
|
||||
budgetTracker: tracker,
|
||||
}),
|
||||
);
|
||||
return { pages: opts.slugs.length, spentUsd: tracker.totalSpent };
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* ingest.ts — the transcripts-import core (cathedral-4).
|
||||
*
|
||||
* Engine-facing, CLI-free: `gbrain transcripts ingest` parses flags and
|
||||
* calls runTranscriptsIngest; e2e tests call it directly. Pipeline per
|
||||
* session (ATOMICITY = SESSION, never file — a multi-session file commits
|
||||
* the sessions that pass and skips the ones that fail; idempotent re-runs
|
||||
* complete the rest):
|
||||
*
|
||||
* detect → adapter.parse (AsyncGenerator, per-session) → since/limit
|
||||
* filters → redactSession (fail-closed) → renderSessionParts →
|
||||
* importFromContent per part (embed OFF unless opted in) →
|
||||
* putRawData(baseSlug) → stale-part reconciliation (delete part > of).
|
||||
*
|
||||
* Error taxonomy:
|
||||
* - per-FILE: unreadable / unknown format / symlink → counted, run continues.
|
||||
* - per-SESSION: scan failure, oversize part, adapter throw → counted,
|
||||
* file continues.
|
||||
* - RUN-LEVEL (fail-closed integrity): importFromContent duplicate-lookup
|
||||
* or read-back failures and putRawData misses rethrow and abort the run.
|
||||
* Heuristic seam: import errors matching /too large/ stay per-session.
|
||||
*
|
||||
* Watermark: the RESULT carries `cleanScan` (no errors anywhere, no limit
|
||||
* truncation) + `maxSessionTs`; the COMMAND advances the `--since last`
|
||||
* checkpoint only on a clean scan — a truncated or partially-failed run
|
||||
* must never skip work permanently.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import { importFromContent } from '../import-file.ts';
|
||||
import type { TranscriptAdapter, TranscriptFormat } from './types.ts';
|
||||
import { detectAdapter } from './detect.ts';
|
||||
import {
|
||||
loadImportRedactionPatterns,
|
||||
redactSession,
|
||||
renderSessionParts,
|
||||
} from './render.ts';
|
||||
|
||||
export interface IngestActivePack {
|
||||
page_types: ReadonlyArray<{ name: string; path_prefixes: ReadonlyArray<string> }>;
|
||||
}
|
||||
|
||||
export interface TranscriptsIngestOpts {
|
||||
/** Files to import (post-glob, pre-detection). */
|
||||
paths: string[];
|
||||
/** Explicit format wins over detection. */
|
||||
format?: TranscriptFormat;
|
||||
/** Parse + redact + render + report; ZERO engine writes. */
|
||||
dryRun?: boolean;
|
||||
/** Max sessions imported this run (session granularity; truncation ⇒ not a clean scan). */
|
||||
limit?: number;
|
||||
/** Only sessions whose LAST message is strictly newer than this ISO. */
|
||||
sinceIso?: string;
|
||||
/** Resolved source id — threads through import, raw-data, reconciliation. */
|
||||
sourceId: string;
|
||||
/** Embedding opt-in (default OFF: bulk imports defer to the embed backfill). */
|
||||
embed?: boolean;
|
||||
activePack?: IngestActivePack;
|
||||
/** Test seam for the redaction user-pattern file. */
|
||||
userPatternsPath?: string;
|
||||
/** Adapter registry override (tests). */
|
||||
adapters?: TranscriptAdapter[];
|
||||
/** Called once per processed file (progress ticks). */
|
||||
onFileDone?: (done: number, total: number, path: string) => void;
|
||||
/**
|
||||
* Called once per SESSION — the liveness signal for multi-session stores
|
||||
* (one hermes state.db can hold thousands of sessions between file ticks).
|
||||
*/
|
||||
onSession?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export interface IngestSessionOutcome {
|
||||
sessionId: string;
|
||||
harness: TranscriptFormat;
|
||||
baseSlug: string;
|
||||
parts: number;
|
||||
/** Per-part import statuses (dry-run: 'planned'). */
|
||||
statuses: Array<'imported' | 'skipped' | 'error' | 'planned'>;
|
||||
redactions: number;
|
||||
imperatives: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IngestFileOutcome {
|
||||
path: string;
|
||||
format?: TranscriptFormat;
|
||||
sessions: IngestSessionOutcome[];
|
||||
skippedLines: number;
|
||||
drift: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TranscriptsIngestResult {
|
||||
files: IngestFileOutcome[];
|
||||
pages: { imported: number; skipped: number; errored: number; planned: number };
|
||||
sessionsSeen: number;
|
||||
sessionsImported: number;
|
||||
sessionsFiltered: number;
|
||||
sessionsErrored: number;
|
||||
redactions: number;
|
||||
imperatives: number;
|
||||
partsDeleted: number;
|
||||
driftFiles: number;
|
||||
erroredFiles: number;
|
||||
/** EVERY slug the run touched — imported AND hash-skipped (--facts targets all). */
|
||||
slugsTouched: string[];
|
||||
/** True ⇔ no file/session errors and no limit truncation: watermark may advance. */
|
||||
cleanScan: boolean;
|
||||
/** Newest session last-message ISO seen (imported or filtered). */
|
||||
maxSessionTs: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session's last message timestamp, NORMALIZED to Z-form ISO ('' when none
|
||||
* carry one). Normalization matters because since/watermark comparisons are
|
||||
* lexicographic: an offset-form ISO (+07:00) string-sorts after a real-time
|
||||
* newer Z-form and would poison the watermark. UNPARSEABLE timestamps are
|
||||
* SKIPPED, never passed through — a single hostile/corrupt value like a
|
||||
* letter-leading string would otherwise become the watermark and since-filter
|
||||
* every real session forever.
|
||||
*/
|
||||
function lastMessageTs(messages: Array<{ timestamp: string }>): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const raw = messages[i].timestamp;
|
||||
if (!raw) continue;
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) continue;
|
||||
return d.toISOString();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const RUN_ABORT_MARKER = 'transcripts-ingest run abort';
|
||||
|
||||
function isPerSessionImportError(err: unknown): boolean {
|
||||
return err instanceof Error && /too large/i.test(err.message);
|
||||
}
|
||||
|
||||
export async function runTranscriptsIngest(
|
||||
engine: BrainEngine,
|
||||
opts: TranscriptsIngestOpts,
|
||||
): Promise<TranscriptsIngestResult> {
|
||||
const result: TranscriptsIngestResult = {
|
||||
files: [],
|
||||
pages: { imported: 0, skipped: 0, errored: 0, planned: 0 },
|
||||
sessionsSeen: 0,
|
||||
sessionsImported: 0,
|
||||
sessionsFiltered: 0,
|
||||
sessionsErrored: 0,
|
||||
redactions: 0,
|
||||
imperatives: 0,
|
||||
partsDeleted: 0,
|
||||
driftFiles: 0,
|
||||
erroredFiles: 0,
|
||||
slugsTouched: [],
|
||||
cleanScan: true,
|
||||
maxSessionTs: '',
|
||||
};
|
||||
let limitTruncated = false;
|
||||
|
||||
// Redaction patterns compile ONCE per run — loadPatterns re-reads and
|
||||
// recompiles the pattern file on every call, which a bulk import would
|
||||
// otherwise repeat thousands of times.
|
||||
const redactionPatterns = loadImportRedactionPatterns(opts.userPatternsPath);
|
||||
|
||||
const total = opts.paths.length;
|
||||
let done = 0;
|
||||
let newWorkSessions = 0;
|
||||
|
||||
for (const path of opts.paths) {
|
||||
if (limitTruncated) break;
|
||||
const fileOutcome: IngestFileOutcome = {
|
||||
path,
|
||||
sessions: [],
|
||||
skippedLines: 0,
|
||||
drift: false,
|
||||
};
|
||||
result.files.push(fileOutcome);
|
||||
|
||||
const detected = detectAdapter(path, {
|
||||
explicitFormat: opts.format,
|
||||
adapters: opts.adapters,
|
||||
});
|
||||
if (!detected.ok) {
|
||||
fileOutcome.error =
|
||||
detected.reason === 'unknown_format'
|
||||
? `unknown format (tried: ${detected.tried.join(', ')}); pass an explicit format flag`
|
||||
: detected.reason;
|
||||
result.erroredFiles++;
|
||||
result.cleanScan = false;
|
||||
done++;
|
||||
opts.onFileDone?.(done, total, path);
|
||||
continue;
|
||||
}
|
||||
fileOutcome.format = detected.adapter.format;
|
||||
|
||||
const gen = detected.adapter.parse(path);
|
||||
try {
|
||||
let step = await gen.next();
|
||||
while (!step.done) {
|
||||
if (limitTruncated) {
|
||||
// Stop consuming; the generator's finally blocks clean up.
|
||||
await gen.return?.(undefined as never);
|
||||
break;
|
||||
}
|
||||
const session = step.value;
|
||||
result.sessionsSeen++;
|
||||
opts.onSession?.(session.meta.sessionId);
|
||||
const lastTs = lastMessageTs(session.messages);
|
||||
if (lastTs && lastTs > result.maxSessionTs) result.maxSessionTs = lastTs;
|
||||
|
||||
if (opts.sinceIso && lastTs && lastTs <= opts.sinceIso) {
|
||||
result.sessionsFiltered++;
|
||||
step = await gen.next();
|
||||
continue;
|
||||
}
|
||||
// The limit counts NEW WORK only (sessions with a non-skipped part).
|
||||
// Counting hash-skipped re-scans would make batched backfill loop
|
||||
// over the same already-imported prefix forever: every run would
|
||||
// burn the limit on free re-scans and truncate before new sessions.
|
||||
if (opts.limit !== undefined && newWorkSessions >= opts.limit) {
|
||||
limitTruncated = true;
|
||||
result.cleanScan = false;
|
||||
await gen.return?.(undefined as never);
|
||||
break;
|
||||
}
|
||||
|
||||
const outcome: IngestSessionOutcome = {
|
||||
sessionId: session.meta.sessionId,
|
||||
harness: session.meta.harness,
|
||||
baseSlug: '',
|
||||
parts: 0,
|
||||
statuses: [],
|
||||
redactions: 0,
|
||||
imperatives: 0,
|
||||
};
|
||||
fileOutcome.sessions.push(outcome);
|
||||
|
||||
try {
|
||||
const redacted = redactSession(session, {
|
||||
userPatternsPath: opts.userPatternsPath,
|
||||
patterns: redactionPatterns,
|
||||
});
|
||||
outcome.redactions = redacted.redactionCount;
|
||||
outcome.imperatives = redacted.imperativesFlagged;
|
||||
const rendered = renderSessionParts(redacted, { sourcePath: path });
|
||||
outcome.baseSlug = rendered.baseSlug;
|
||||
outcome.parts = rendered.parts.length;
|
||||
|
||||
if (opts.dryRun) {
|
||||
outcome.statuses = rendered.parts.map(() => 'planned' as const);
|
||||
result.pages.planned += rendered.parts.length;
|
||||
} else {
|
||||
// The RESOLVED base slug: identity dedup can resolve part 1 to an
|
||||
// EXISTING page under a different slug (same session id, changed
|
||||
// title or corrected start date) — raw-data writes and stale-part
|
||||
// reconciliation must follow the page that actually exists, or
|
||||
// every re-run aborts on a nonexistent slug.
|
||||
let resolvedBaseSlug = rendered.baseSlug;
|
||||
for (const part of rendered.parts) {
|
||||
try {
|
||||
const r = await importFromContent(engine, part.slug, part.content, {
|
||||
noEmbed: !opts.embed,
|
||||
sourceId: opts.sourceId,
|
||||
activePack: opts.activePack,
|
||||
source_kind: `transcript:${session.meta.harness}`,
|
||||
source_uri: path,
|
||||
ingested_via: 'cli:transcripts-ingest',
|
||||
});
|
||||
outcome.statuses.push(r.status);
|
||||
if (r.status === 'imported') result.pages.imported++;
|
||||
else if (r.status === 'skipped') result.pages.skipped++;
|
||||
else result.pages.errored++;
|
||||
const actualSlug = r.slug || part.slug;
|
||||
if (part.part === 1 && actualSlug) resolvedBaseSlug = actualSlug;
|
||||
result.slugsTouched.push(actualSlug);
|
||||
} catch (err) {
|
||||
if (isPerSessionImportError(err)) throw err; // → per-session catch
|
||||
const e = new Error(
|
||||
`${RUN_ABORT_MARKER}: import integrity failure on ${part.slug}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
(e as { cause?: unknown }).cause = err;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// importFromContent RETURNS status 'error' (it does not throw)
|
||||
// for e.g. frontmatter-parse failures. A page that never landed
|
||||
// is a session error and must freeze the watermark — otherwise
|
||||
// a since-last run permanently skips content that never imported.
|
||||
if (outcome.statuses.includes('error')) {
|
||||
throw new Error(
|
||||
`page import returned error status for session ${session.meta.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allSkipped =
|
||||
outcome.statuses.length > 0 && outcome.statuses.every((s) => s === 'skipped');
|
||||
if (!allSkipped) newWorkSessions++;
|
||||
|
||||
// Session metadata rides the base page's raw_data — the REDACTED
|
||||
// copy, never the original (secrets in titles/cwd would otherwise
|
||||
// bypass the page-body redaction). On all-skipped re-runs the
|
||||
// write is HEALED, not assumed: a prior run can have committed
|
||||
// the pages and then died before putRawData, and hash-skips
|
||||
// would otherwise make that hole permanent.
|
||||
if (redacted.session.meta.raw) {
|
||||
try {
|
||||
const rawSource = `transcript:${session.meta.harness}`;
|
||||
// Skipped re-runs COMPARE, never assume: existence alone is
|
||||
// not freshness — a private pattern added AFTER the first
|
||||
// import must refresh the stored copy, and a prior run can
|
||||
// have died before this write. Content-equal rows skip the
|
||||
// write so healthy re-runs stay write-free.
|
||||
let needsRaw = true;
|
||||
if (allSkipped) {
|
||||
const existing = await engine.getRawData(resolvedBaseSlug, rawSource, {
|
||||
sourceId: opts.sourceId,
|
||||
});
|
||||
needsRaw =
|
||||
existing.length === 0 ||
|
||||
JSON.stringify(existing[0].data) !== JSON.stringify(redacted.session.meta.raw);
|
||||
}
|
||||
if (needsRaw) {
|
||||
await engine.putRawData(resolvedBaseSlug, rawSource, redacted.session.meta.raw, {
|
||||
sourceId: opts.sourceId,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const e = new Error(
|
||||
`${RUN_ABORT_MARKER}: putRawData failed for ${resolvedBaseSlug}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
(e as { cause?: unknown }).cause = err;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Stale-part reconciliation: a session that shrank or re-split
|
||||
// leaves higher-numbered part pages behind — delete them, or a
|
||||
// stale part stays searchable forever. ENUMERATED via one SQL
|
||||
// query (never a sequential probe: a crash mid-delete leaves
|
||||
// holes that a first-miss or bounded-miss probe walks past) and
|
||||
// run on EVERY pass including all-skipped re-runs, because a
|
||||
// prior run can have died between the page writes and this step.
|
||||
const partRows = await engine.executeRaw<{ slug: string }>(
|
||||
`SELECT slug FROM pages
|
||||
WHERE source_id = $1 AND deleted_at IS NULL AND slug LIKE $2`,
|
||||
[opts.sourceId, `${resolvedBaseSlug}-p%`],
|
||||
);
|
||||
for (const row of partRows) {
|
||||
const suffix = row.slug.slice(resolvedBaseSlug.length);
|
||||
const m = /^-p(\d+)$/.exec(suffix);
|
||||
const num = m ? Number(m[1]) : NaN;
|
||||
if (Number.isFinite(num) && num > rendered.parts.length) {
|
||||
await engine.deletePage(row.slug, { sourceId: opts.sourceId });
|
||||
result.partsDeleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.sessionsImported++;
|
||||
result.redactions += outcome.redactions;
|
||||
result.imperatives += outcome.imperatives;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith(RUN_ABORT_MARKER)) throw err;
|
||||
outcome.error = err instanceof Error ? err.message : String(err);
|
||||
result.sessionsErrored++;
|
||||
result.cleanScan = false;
|
||||
}
|
||||
|
||||
step = await gen.next();
|
||||
}
|
||||
if (step.done && step.value) {
|
||||
const diag = step.value;
|
||||
fileOutcome.skippedLines = diag.skippedLines;
|
||||
if (diag.bytesRead > 0 && diag.sessions === 0) {
|
||||
fileOutcome.drift = true;
|
||||
result.driftFiles++;
|
||||
// A drifting file may hold sessions a fixed parser will surface
|
||||
// later (torn hermes copy, transient format break) — the shared
|
||||
// watermark must not advance past it.
|
||||
result.cleanScan = false;
|
||||
}
|
||||
if (diag.skippedLines > 0) {
|
||||
// Malformed lines can be DROPPED RECORDS (an actively-appended
|
||||
// file read mid-write, corruption) — freeze the watermark so a
|
||||
// later repair with an older timestamp is still picked up.
|
||||
// Re-scans stay cheap via content-hash skip.
|
||||
result.cleanScan = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith(RUN_ABORT_MARKER)) throw err;
|
||||
fileOutcome.error = err instanceof Error ? err.message : String(err);
|
||||
result.erroredFiles++;
|
||||
result.cleanScan = false;
|
||||
}
|
||||
|
||||
done++;
|
||||
opts.onFileDone?.(done, total, path);
|
||||
}
|
||||
|
||||
if (opts.dryRun) result.cleanScan = false; // dry-runs never advance watermarks
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* openclaw.ts — OpenClaw session (.jsonl) adapter (cathedral-4).
|
||||
*
|
||||
* One session file = one session; `.checkpoint.<uuid>.jsonl` siblings are
|
||||
* point-in-time copies and are excluded at DISCOVERY time (detect.ts glob)
|
||||
* AND defensively here in detect(). Verified against a live local session
|
||||
* 2026-08-14 (see SPEC_TARGET).
|
||||
*/
|
||||
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import type { HostSpecTarget } from '../bootstrap/host-specs.ts';
|
||||
import type {
|
||||
FileDiagnostics,
|
||||
ParsedSession,
|
||||
ParseSessionsOpts,
|
||||
TranscriptAdapter,
|
||||
TranscriptMessage,
|
||||
} from './types.ts';
|
||||
import { TRANSCRIPT_JSONL_HARD_CAP } from './types.ts';
|
||||
|
||||
export const OPENCLAW_SPEC_TARGET: HostSpecTarget = {
|
||||
id: 'openclaw-session-2026-08',
|
||||
status: 'verified',
|
||||
verifiedAt: '2026-08-14',
|
||||
references: [
|
||||
'local ~/.openclaw/agents/<agent>/sessions/<uuid>.jsonl (live sample 2026-08-14)',
|
||||
'test/fixtures/transcripts/agent-session.jsonl',
|
||||
],
|
||||
note:
|
||||
"One JSON object per line. Header: {type:'session', id, cwd, timestamp, " +
|
||||
"version}. Turns: {type:'message', timestamp, message:{role, content, " +
|
||||
"timestamp}} where content is [{type:'text', text}] blocks (non-text " +
|
||||
'blocks skipped). model_change / thinking_level_change / custom / ' +
|
||||
"compaction lines are skipped. Sibling files named " +
|
||||
"'<id>.checkpoint.<uuid>.jsonl' are snapshots, never imported. Unknown " +
|
||||
'fields tolerated.',
|
||||
};
|
||||
|
||||
const CHECKPOINT_RE = /\.checkpoint\.[^./]+\.jsonl$/;
|
||||
|
||||
/** True for `<id>.checkpoint.<uuid>.jsonl` snapshot siblings. */
|
||||
export function isOpenclawCheckpointFile(path: string): boolean {
|
||||
return CHECKPOINT_RE.test(path);
|
||||
}
|
||||
|
||||
export const openclawAdapter: TranscriptAdapter = {
|
||||
format: 'openclaw',
|
||||
specTarget: OPENCLAW_SPEC_TARGET,
|
||||
|
||||
detect(path: string, sample: Buffer): boolean {
|
||||
if (!path.endsWith('.jsonl') || isOpenclawCheckpointFile(path)) return false;
|
||||
const firstLine = sample.toString('utf8').split('\n', 1)[0]?.trim();
|
||||
if (!firstLine) return false;
|
||||
try {
|
||||
const obj = JSON.parse(firstLine) as Record<string, unknown>;
|
||||
return obj !== null && typeof obj === 'object' && obj.type === 'session' && typeof obj.id === 'string';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async *parse(path: string, opts: ParseSessionsOpts = {}): AsyncGenerator<ParsedSession, FileDiagnostics> {
|
||||
const cap = opts.maxBytes ?? TRANSCRIPT_JSONL_HARD_CAP;
|
||||
const size = statSync(path).size;
|
||||
if (size > cap) {
|
||||
throw new Error(`openclaw session too large for import: ${size} bytes (cap ${cap})`);
|
||||
}
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
let skippedLines = 0;
|
||||
let sessionId = '';
|
||||
let cwd: string | undefined;
|
||||
let startedAt = '';
|
||||
const messages: TranscriptMessage[] = [];
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
let entry: unknown;
|
||||
try {
|
||||
entry = JSON.parse(t);
|
||||
} catch {
|
||||
skippedLines++;
|
||||
continue;
|
||||
}
|
||||
if (typeof entry !== 'object' || entry === null) continue;
|
||||
const e = entry as Record<string, unknown>;
|
||||
if (e.type === 'session') {
|
||||
if (typeof e.id === 'string') sessionId = e.id;
|
||||
if (typeof e.cwd === 'string') cwd = e.cwd;
|
||||
if (typeof e.timestamp === 'string') startedAt = e.timestamp;
|
||||
continue;
|
||||
}
|
||||
if (e.type !== 'message') continue; // model_change / custom / compaction
|
||||
const msg = e.message;
|
||||
if (typeof msg !== 'object' || msg === null) continue;
|
||||
const m = msg as Record<string, unknown>;
|
||||
const role = m.role === 'user' || m.role === 'assistant' ? m.role : null;
|
||||
if (!role) continue;
|
||||
const content = m.content;
|
||||
let text = '';
|
||||
if (typeof content === 'string') {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (typeof block !== 'object' || block === null) continue;
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) parts.push(b.text);
|
||||
}
|
||||
text = parts.join('\n');
|
||||
}
|
||||
text = text.trim();
|
||||
if (!text) continue;
|
||||
const timestamp =
|
||||
typeof m.timestamp === 'string' && m.timestamp
|
||||
? m.timestamp
|
||||
: typeof e.timestamp === 'string'
|
||||
? e.timestamp
|
||||
: '';
|
||||
messages.push({ role, timestamp, text });
|
||||
}
|
||||
|
||||
let sessions = 0;
|
||||
if (messages.length > 0) {
|
||||
sessions = 1;
|
||||
const sid = sessionId || basename(path, '.jsonl');
|
||||
yield {
|
||||
meta: {
|
||||
harness: 'openclaw',
|
||||
sessionId: sid,
|
||||
cwd,
|
||||
startedAt: startedAt || messages[0].timestamp || undefined,
|
||||
raw: { session_id: sid, cwd: cwd ?? null, source_path: path },
|
||||
},
|
||||
messages,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bytesRead: size,
|
||||
skippedLines,
|
||||
truncated: false,
|
||||
sessions,
|
||||
zeroSessionsReason: sessions === 0 ? 'no text-bearing message lines in session file' : undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* render.ts — session → conversation page(s) for the transcripts-import lane
|
||||
* (cathedral-4).
|
||||
*
|
||||
* Pipeline per session (all BEFORE any engine write; fail-closed — a throw
|
||||
* here means the caller aborts the SESSION, never writes a partial page):
|
||||
*
|
||||
* redact (secret-scan + user patterns + imperative count)
|
||||
* → render body lines (imessage-slack, REAL timestamps, anchor-escape)
|
||||
* → split at message boundaries into part pages under the embed-skip
|
||||
* threshold → frontmatter (YAML serializer, mandatory type+date).
|
||||
*
|
||||
* Body format is the conversation-parser `imessage-slack` builtin — the
|
||||
* REGEX IS SHARED (imported from builtins.ts), never re-declared: the line
|
||||
* we emit must match it (round-trip guarantee) and any BODY line that would
|
||||
* match it is escaped so hostile message content cannot forge speakers or
|
||||
* timestamps on re-parse.
|
||||
*
|
||||
* Split pages: bodies over PART_TARGET_BYTES split at message boundaries
|
||||
* with OVERLAP_MESSAGES carried into the next part (cross-boundary
|
||||
* decision/answer pairs can still ground facts; extraction dedup absorbs the
|
||||
* duplicates). Splitting exists because pages over the ~500KB embed_skip
|
||||
* threshold import as zero-chunk, unsearchable pages — the 5MB import cap is
|
||||
* NOT the binding limit, embed-skip is. Part slugs: part 1 keeps the base
|
||||
* slug (stable when a session later grows into more parts); parts 2..N get
|
||||
* `-pN`. frontmatter.id is UNIQUE PER PART (`<id8>-pN`) — a shared
|
||||
* per-session id would make parts 2..N skip as cross-slug duplicates.
|
||||
*/
|
||||
|
||||
import { safeDump } from 'js-yaml';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DEFAULT_BYTES_BLOCK } from '../content-sanity.ts';
|
||||
import { redactFindings } from '../secret-scan.ts';
|
||||
import { loadPatterns } from '../skillpack/harvest-lint.ts';
|
||||
import { ensureWellFormed, truncateUtf8 } from '../text-safe.ts';
|
||||
import { BUILTIN_PATTERNS } from '../conversation-parser/builtins.ts';
|
||||
import type { ParsedSession, TranscriptMessage } from './types.ts';
|
||||
import { buildTranscriptSlug, transcriptFullId } from './types.ts';
|
||||
|
||||
// ── Shared line format (imessage-slack builtin) ─────────────────────────────
|
||||
|
||||
const IMESSAGE_SLACK = BUILTIN_PATTERNS.find((p) => p.id === 'imessage-slack');
|
||||
if (!IMESSAGE_SLACK) {
|
||||
throw new Error('conversation-parser builtin imessage-slack is missing — render format broken');
|
||||
}
|
||||
/** The one anchor regex — imported from the parser, never re-declared. */
|
||||
export const MESSAGE_ANCHOR_RE: RegExp = IMESSAGE_SLACK.regex;
|
||||
|
||||
/** Date-heading shapes some builtins treat as day boundaries — escaped too. */
|
||||
const DATE_HEADING_RE = /^#{1,6}\s*\d{4}-\d{2}-\d{2}\b/;
|
||||
|
||||
/** ~4K chars per message keeps pages readable; full text stays in source_uri. */
|
||||
export const MESSAGE_CHAR_CAP = 4000;
|
||||
|
||||
/**
|
||||
* Part bodies target well under the embed-skip/block threshold — the tie is
|
||||
* CODE, not prose: a part page at or above the content-sanity block line
|
||||
* would import as a zero-chunk, unsearchable page, defeating the split.
|
||||
* (Operators can lower the threshold via config; the 0.6 factor leaves
|
||||
* headroom for frontmatter overhead and modest overrides.)
|
||||
*/
|
||||
export const PART_TARGET_BYTES = Math.min(300 * 1024, Math.floor(DEFAULT_BYTES_BLOCK * 0.6));
|
||||
/** Messages repeated at each part boundary for cross-part fact grounding. */
|
||||
export const OVERLAP_MESSAGES = 2;
|
||||
|
||||
/** Adapter-schema version stamped into transcript_import frontmatter. */
|
||||
export const TRANSCRIPT_IMPORT_VERSION = 1;
|
||||
|
||||
// ── Redaction ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Default user-pattern file — the same convention skillpack harvest uses. */
|
||||
export function defaultUserPatternsPath(): string {
|
||||
return join(homedir(), '.gbrain', 'harvest-private-patterns.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-directed imperative shapes. Detection only STAMPS A COUNT into the
|
||||
* page's transcript_import frontmatter (hash-covered, idempotent) so readers
|
||||
* and future triage can see the page carries instruction-shaped content —
|
||||
* it never hides or rewrites the text.
|
||||
*/
|
||||
const IMPERATIVE_RES: readonly RegExp[] = [
|
||||
/\b(ignore|disregard|forget)\s+(all\s+|any\s+)?(previous|prior|above|earlier)\s+(instructions|context|rules)\b/i,
|
||||
/\byou\s+(must|should)\s+now\s+(act|behave|respond)\b/i,
|
||||
/\bnew\s+system\s+prompt\b/i,
|
||||
];
|
||||
|
||||
export interface RedactedSession {
|
||||
session: ParsedSession;
|
||||
redactionCount: number;
|
||||
imperativesFlagged: number;
|
||||
}
|
||||
|
||||
export type ImportRedactionPattern = { regex: RegExp; source: string };
|
||||
|
||||
/**
|
||||
* Compile the import-lane redaction pattern set ONCE per run. The harvest
|
||||
* defaults include a slack-channel pattern that also matches issue/PR refs
|
||||
* (a token like a hash-prefixed number) — ubiquitous in coding transcripts
|
||||
* and NOT private — so it is excluded; the other defaults (private names,
|
||||
* emails) plus every user-file pattern stay.
|
||||
*/
|
||||
export function loadImportRedactionPatterns(userPatternsPath?: string): ImportRedactionPattern[] {
|
||||
return loadPatterns(userPatternsPath ?? defaultUserPatternsPath()).filter(
|
||||
(p) => !p.source.includes('(?:^|\\s)#'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret-scan + user-pattern redaction over every text surface that will be
|
||||
* persisted (message text, SPEAKER labels, title, raw-meta string fields).
|
||||
* Throws on scanner or pattern failure — page writes are FAIL-CLOSED (unlike
|
||||
* the hook corpus lane, these pages are searchable and synced).
|
||||
*/
|
||||
export function redactSession(
|
||||
session: ParsedSession,
|
||||
opts: { userPatternsPath?: string; patterns?: ImportRedactionPattern[] } = {},
|
||||
): RedactedSession {
|
||||
const patterns = opts.patterns ?? loadImportRedactionPatterns(opts.userPatternsPath);
|
||||
let redactionCount = 0;
|
||||
let imperativesFlagged = 0;
|
||||
|
||||
const clean = (text: string): string => {
|
||||
let out = ensureWellFormed(text);
|
||||
const r = redactFindings(out);
|
||||
redactionCount += r.redactions.length;
|
||||
out = r.text;
|
||||
for (const { regex } of patterns) {
|
||||
out = out.replace(regex, () => {
|
||||
redactionCount++;
|
||||
return '<REDACTED:user-pattern>';
|
||||
});
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const messages = session.messages.map((m) => {
|
||||
for (const re of IMPERATIVE_RES) {
|
||||
if (re.test(m.text)) {
|
||||
imperativesFlagged++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Speaker labels are persisted into the anchor line, so they get the
|
||||
// same redaction as bodies (a secret or private name in a display name
|
||||
// must not bypass the scan).
|
||||
return {
|
||||
...m,
|
||||
text: clean(m.text),
|
||||
...(m.speaker ? { speaker: clean(m.speaker) } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
const meta = { ...session.meta };
|
||||
if (meta.title) meta.title = clean(meta.title);
|
||||
if (meta.raw) {
|
||||
// Flatness is ENFORCED, not assumed: strings are cleaned; primitive
|
||||
// scalars pass; anything nested (arrays/objects an adapter let through
|
||||
// from hostile export data) is DROPPED — it would reach putRawData
|
||||
// unscanned otherwise.
|
||||
const raw: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(meta.raw)) {
|
||||
if (typeof v === 'string') raw[k] = clean(v);
|
||||
else if (v === null || typeof v === 'number' || typeof v === 'boolean') raw[k] = v;
|
||||
}
|
||||
meta.raw = raw;
|
||||
}
|
||||
|
||||
return { session: { meta, messages }, redactionCount, imperativesFlagged };
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** `2026-08-01T10:00:05.000Z` → `(2026-08-01 10:00 AM)` (UTC), matching the builtin. */
|
||||
function anchorTimestamp(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const day = iso.slice(0, 10);
|
||||
let h = d.getUTCHours();
|
||||
const ampm = h >= 12 ? 'PM' : 'AM';
|
||||
h = h % 12 || 12;
|
||||
const mm = String(d.getUTCMinutes()).padStart(2, '0');
|
||||
return `${day} ${h}:${mm} ${ampm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape any BODY line that would parse as a message anchor or a date
|
||||
* heading: a leading backslash breaks both `^\*\*` and `^#` while staying
|
||||
* readable in raw markdown. Without this, a pasted anchor-shaped line inside
|
||||
* a message forges speakers/timestamps on round-trip (P0).
|
||||
*/
|
||||
export function escapeAnchorLines(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => (MESSAGE_ANCHOR_RE.test(line) || DATE_HEADING_RE.test(line) ? `\\${line}` : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export interface RenderedPart {
|
||||
slug: string;
|
||||
/** Full page content: YAML frontmatter + body. */
|
||||
content: string;
|
||||
/** UNIQUE per part — the import-dedup identity. */
|
||||
frontmatterId: string;
|
||||
part: number;
|
||||
of: number;
|
||||
}
|
||||
|
||||
export interface RenderSessionResult {
|
||||
parts: RenderedPart[];
|
||||
/** Base slug (part 1's slug) — putRawData and reconciliation key off it. */
|
||||
baseSlug: string;
|
||||
dateIso: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speaker label for the anchor line. Anchor-forming characters are stripped
|
||||
* (never escaped — the label sits INSIDE the anchor, so a speaker containing
|
||||
* `**` or `(date):` shapes could otherwise forge message boundaries on
|
||||
* round-trip; hostile BODY lines are handled by escapeAnchorLines).
|
||||
*/
|
||||
function speakerLabel(m: TranscriptMessage): string {
|
||||
const raw = m.speaker?.trim();
|
||||
if (!raw) return m.role === 'user' ? 'User' : 'Assistant';
|
||||
const cleaned = ensureWellFormed(raw).replace(/\*/g, '').replace(/[()\n:]/g, ' ').trim();
|
||||
return cleaned || (m.role === 'user' ? 'User' : 'Assistant');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one redacted session into 1..N part pages. Timestamps: each message
|
||||
* uses its own REAL timestamp; a message missing one carries the previous
|
||||
* message's timestamp forward (carried, never fabricated — documented in the
|
||||
* page header note); a session with NO timestamps at all is unrenderable and
|
||||
* throws (the adapter contract requires real times).
|
||||
*/
|
||||
export function renderSessionParts(
|
||||
redacted: RedactedSession,
|
||||
opts: { sourcePath: string } = { sourcePath: '' },
|
||||
): RenderSessionResult {
|
||||
const { session, imperativesFlagged } = redacted;
|
||||
const { meta, messages } = session;
|
||||
if (!messages.length) throw new Error('renderSessionParts: session has no messages');
|
||||
|
||||
const firstTs = meta.startedAt || messages.find((m) => m.timestamp)?.timestamp;
|
||||
if (!firstTs) {
|
||||
throw new Error(
|
||||
`session ${meta.sessionId} carries no timestamps — refusing to fabricate provenance`,
|
||||
);
|
||||
}
|
||||
const dateIso = firstTs;
|
||||
const baseSlug = buildTranscriptSlug(meta.harness, dateIso, {
|
||||
sessionId: meta.sessionId,
|
||||
title: meta.title,
|
||||
});
|
||||
// Dedup identity: HARNESS-NAMESPACED 64-bit hash (importFromContent skips
|
||||
// any cross-slug frontmatter-id match as a duplicate, so this id must be
|
||||
// collision-proof across harnesses, days, and fallback session ids).
|
||||
const identityBase = `${meta.harness}-${transcriptFullId(meta.sessionId)}`;
|
||||
|
||||
// One rendered block per message (anchor line + escaped continuation).
|
||||
let lastTs = firstTs;
|
||||
const blocks: string[] = messages.map((m) => {
|
||||
const ts = m.timestamp || lastTs;
|
||||
lastTs = ts;
|
||||
const text = escapeAnchorLines(truncateUtf8(m.text, MESSAGE_CHAR_CAP));
|
||||
const [head, ...rest] = text.split('\n');
|
||||
const anchor = `**${speakerLabel(m)}** (${anchorTimestamp(ts)}): ${head}`;
|
||||
return rest.length ? `${anchor}\n${rest.join('\n')}` : anchor;
|
||||
});
|
||||
|
||||
// Split at message boundaries under the part target, with overlap.
|
||||
const groups: string[][] = [];
|
||||
let current: string[] = [];
|
||||
let currentBytes = 0;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const b = blocks[i];
|
||||
const bytes = Buffer.byteLength(b, 'utf8') + 2;
|
||||
if (current.length > 0 && currentBytes + bytes > PART_TARGET_BYTES) {
|
||||
groups.push(current);
|
||||
const overlap = current.slice(-OVERLAP_MESSAGES);
|
||||
current = [...overlap];
|
||||
currentBytes = overlap.reduce((n, s) => n + Buffer.byteLength(s, 'utf8') + 2, 0);
|
||||
}
|
||||
current.push(b);
|
||||
currentBytes += bytes;
|
||||
}
|
||||
if (current.length) groups.push(current);
|
||||
|
||||
const of = groups.length;
|
||||
const title = meta.title?.trim() || `${meta.harness} session ${meta.sessionId.slice(0, 12)}`;
|
||||
|
||||
const parts: RenderedPart[] = groups.map((group, idx) => {
|
||||
const part = idx + 1;
|
||||
const slug = part === 1 ? baseSlug : `${baseSlug}-p${part}`;
|
||||
const frontmatterId = `${identityBase}-p${part}`;
|
||||
const fm: Record<string, unknown> = {
|
||||
type: 'conversation',
|
||||
title: of > 1 ? `${title} (part ${part} of ${of})` : title,
|
||||
date: dateIso.slice(0, 10),
|
||||
id: frontmatterId,
|
||||
transcript_import: {
|
||||
harness: meta.harness,
|
||||
session_id: meta.sessionId,
|
||||
version: TRANSCRIPT_IMPORT_VERSION,
|
||||
part,
|
||||
of,
|
||||
...(imperativesFlagged > 0 ? { imperatives_flagged: imperativesFlagged } : {}),
|
||||
},
|
||||
};
|
||||
const body = group.join('\n\n');
|
||||
const content = `---\n${safeDump(fm, { lineWidth: 1000 })}---\n\n${body}\n`;
|
||||
return { slug, content, frontmatterId, part, of };
|
||||
});
|
||||
|
||||
return { parts, baseSlug, dateIso };
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* types.ts — the transcript-adapter seam (cathedral-4).
|
||||
*
|
||||
* One contract for every dead-log format gbrain can import: coding-harness
|
||||
* session logs (Claude Code, Codex, OpenClaw, Hermes) and consumer chat
|
||||
* exports (ChatGPT, Claude.ai). Each adapter is a leaf module in this
|
||||
* directory; the registry in detect.ts is the only place formats are
|
||||
* enumerated. Every adapter carries a DATED SPEC_TARGET (the
|
||||
* bootstrap/host-specs.ts discipline) because these are host formats gbrain
|
||||
* does not control.
|
||||
*
|
||||
* Cardinality: one FILE may contain MANY sessions (Hermes state.db, ChatGPT
|
||||
* conversations.json), so `parse` is an AsyncGenerator of sessions whose
|
||||
* RETURN value is the per-file diagnostics — a zero-yield file must still be
|
||||
* able to explain itself (drift signal: bytesRead > 0 with zero sessions).
|
||||
*
|
||||
* Timestamps are REAL source timestamps, always. Every supported format
|
||||
* carries per-message times; an adapter must surface them, never invent them
|
||||
* — forged times would corrupt provenance and the rendered page's
|
||||
* conversation format round-trip.
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import type { HostSpecTarget } from '../bootstrap/host-specs.ts';
|
||||
import { slugifySegment } from '../sync.ts';
|
||||
|
||||
export type TranscriptFormat =
|
||||
| 'claude-code'
|
||||
| 'codex'
|
||||
| 'openclaw'
|
||||
| 'hermes'
|
||||
| 'chatgpt'
|
||||
| 'claude-export';
|
||||
|
||||
export interface TranscriptMessage {
|
||||
role: 'user' | 'assistant';
|
||||
/** Display name when the source carries one (consumer exports); omitted → role label. */
|
||||
speaker?: string;
|
||||
/** ISO 8601 UTC, from the SOURCE. Adapters never invent timestamps. */
|
||||
timestamp: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TranscriptSessionMeta {
|
||||
harness: TranscriptFormat;
|
||||
/** Source-native session/conversation id (uniqueness suffix for the slug). */
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
/** ISO 8601 UTC session start; slug date derives from this (fallback: first message). */
|
||||
startedAt?: string;
|
||||
/**
|
||||
* Raw session metadata for engine.putRawData — a plain OBJECT, never a
|
||||
* pre-stringified JSON string (the postgres.js double-encode trap).
|
||||
*/
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ParsedSession {
|
||||
meta: TranscriptSessionMeta;
|
||||
/** Oldest → newest. Empty-message sessions are skipped by the caller. */
|
||||
messages: TranscriptMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file diagnostics: the AsyncGenerator RETURN value. `sessions` counts
|
||||
* yields; `zeroSessionsReason` makes an empty file explain itself (the
|
||||
* parser-drift signal is `bytesRead > 0 && sessions === 0`).
|
||||
*/
|
||||
export interface FileDiagnostics {
|
||||
bytesRead: number;
|
||||
skippedLines: number;
|
||||
truncated: boolean;
|
||||
sessions: number;
|
||||
zeroSessionsReason?: string;
|
||||
}
|
||||
|
||||
export interface ParseSessionsOpts {
|
||||
/** Per-format byte budget; adapters REJECT (not truncate) monolithic JSON over budget. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export interface TranscriptAdapter {
|
||||
format: TranscriptFormat;
|
||||
specTarget: HostSpecTarget;
|
||||
/** Cheap sniff over the file's head bytes; detect.ts owns ordering. */
|
||||
detect(path: string, sample: Buffer): boolean;
|
||||
parse(path: string, opts?: ParseSessionsOpts): AsyncGenerator<ParsedSession, FileDiagnostics>;
|
||||
}
|
||||
|
||||
// ── Byte caps (format-specific; see adapter headers) ────────────────────────
|
||||
|
||||
/** Hard cap for any single session-log file. */
|
||||
export const TRANSCRIPT_JSONL_HARD_CAP = 50 * 1024 * 1024;
|
||||
/**
|
||||
* Monolithic consumer-export JSON cannot be partially parsed — over this the
|
||||
* adapter rejects with a split-the-export error instead of truncating.
|
||||
*/
|
||||
export const TRANSCRIPT_EXPORT_JSON_HARD_CAP = 200 * 1024 * 1024;
|
||||
|
||||
// ── Slug construction (ONE helper — no per-adapter templates) ───────────────
|
||||
|
||||
/** Per-provider page directories, matching skills/conversation-archive layout. */
|
||||
const SLUG_DIRS: Record<TranscriptFormat, string> = {
|
||||
'claude-code': 'conversations/sessions',
|
||||
codex: 'conversations/sessions',
|
||||
openclaw: 'conversations/sessions',
|
||||
hermes: 'conversations/sessions',
|
||||
chatgpt: 'conversations/chatgpt',
|
||||
'claude-export': 'conversations/claude',
|
||||
};
|
||||
|
||||
const HARNESS_FORMATS: ReadonlySet<TranscriptFormat> = new Set([
|
||||
'claude-code',
|
||||
'codex',
|
||||
'openclaw',
|
||||
'hermes',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Stable HASHED id suffixes. Always a sha256 prefix, never a cleaned prefix
|
||||
* of the source id: prefix identity let same-prefix session ids silently
|
||||
* overwrite a same-day page (slug collision) or dedup-skip a different-day
|
||||
* one (frontmatter-id collision) — reproduced adversarially against PGLite.
|
||||
* 12 hex chars (48 bits) for the slug keeps collisions negligible at
|
||||
* backfill-everything scale; 16 hex chars (64 bits) for the dedup identity.
|
||||
*/
|
||||
export function transcriptSlugId(sourceId: string): string {
|
||||
return createHash('sha256').update(sourceId).digest('hex').slice(0, 12);
|
||||
}
|
||||
|
||||
export function transcriptFullId(sourceId: string): string {
|
||||
return createHash('sha256').update(sourceId).digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/** Max slugified-title length inside an export slug (keeps slugs readable). */
|
||||
const TITLE_SLUG_MAX = 48;
|
||||
|
||||
/**
|
||||
* The one slug builder for every imported conversation page.
|
||||
*
|
||||
* Harness sessions: conversations/sessions/YYYY-MM-DD-<harness>-<hash12>
|
||||
* ChatGPT threads: conversations/chatgpt/YYYY-MM-DD-<titleslug>-<hash12>
|
||||
* Claude.ai threads: conversations/claude/YYYY-MM-DD-<titleslug>-<hash12>
|
||||
*
|
||||
* `dateIso` is the session start (UTC); callers fall back to the first
|
||||
* message timestamp when the source lacks a start time. Part pages append
|
||||
* their own `-pN` suffix at render time — never here.
|
||||
*/
|
||||
export function buildTranscriptSlug(
|
||||
format: TranscriptFormat,
|
||||
dateIso: string,
|
||||
meta: { sessionId: string; title?: string },
|
||||
): string {
|
||||
const day = dateIso.slice(0, 10);
|
||||
const id = transcriptSlugId(meta.sessionId);
|
||||
if (HARNESS_FORMATS.has(format)) {
|
||||
return `${SLUG_DIRS[format]}/${day}-${format}-${id}`;
|
||||
}
|
||||
const title = slugifySegment(meta.title ?? '').slice(0, TITLE_SLUG_MAX).replace(/-$/, '');
|
||||
const label = title || 'untitled';
|
||||
return `${SLUG_DIRS[format]}/${day}-${label}-${id}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# gbrain agent workspace — template
|
||||
|
||||
<!-- gbrain-template-stamp: 0.45.19.0 -->
|
||||
<!-- gbrain-template-stamp: 0.46.1.0 -->
|
||||
|
||||
This repository is the **"Use this template"** distribution artifact for a
|
||||
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import {
|
||||
registerAgentRunner, resolveAgentRunner, listRegisteredAgents,
|
||||
_resetRegistryForTests,
|
||||
_resetRegistryForTests, detectBinary, filterAllowlistEnv,
|
||||
type AgentRunner, type DetectResult, type InvokeOpts, type InvokeResult, type TranscriptSink,
|
||||
} from '../src/core/claw-test/agent-runner.ts';
|
||||
|
||||
@@ -95,3 +99,44 @@ describe('agent-agnosticism guard', () => {
|
||||
expect(d.reason).toBe('not installed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared runner helpers (detectBinary / filterAllowlistEnv)', () => {
|
||||
test('filterAllowlistEnv: overrides win over process.env (PATH-shim precedence)', () => {
|
||||
const realPath = process.env.PATH;
|
||||
expect(realPath).toBeTruthy();
|
||||
const env = filterAllowlistEnv(['PATH', 'HOME'], { PATH: '/shim-bin:/usr/bin' });
|
||||
expect(env.PATH).toBe('/shim-bin:/usr/bin');
|
||||
expect(env.PATH).not.toBe(realPath);
|
||||
if (typeof process.env.HOME === 'string') expect(env.HOME).toBe(process.env.HOME);
|
||||
});
|
||||
|
||||
test('filterAllowlistEnv: unlisted process.env keys never reach the child', async () => {
|
||||
await withEnv({ AGENT_RUNNER_TEST_LEAK_CANARY: 'leaked' }, () => {
|
||||
const env = filterAllowlistEnv(['PATH'], {});
|
||||
expect(env.AGENT_RUNNER_TEST_LEAK_CANARY).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('detectBinary: non-executable regular file is unavailable with the stat reason', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'agent-runner-detect-'));
|
||||
const p = join(dir, 'not-exec');
|
||||
try {
|
||||
writeFileSync(p, '#!/bin/sh\n', { mode: 0o644 });
|
||||
await withEnv({ AGENT_RUNNER_TEST_BIN: p }, () => {
|
||||
const d = detectBinary('AGENT_RUNNER_TEST_BIN', 'definitely-not-a-real-binary-name');
|
||||
expect(d.available).toBe(false);
|
||||
expect(d.reason).toBe(`not executable: ${p}`);
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detectBinary: missing env + missing binary reports not-on-PATH', async () => {
|
||||
await withEnv({ AGENT_RUNNER_TEST_BIN: undefined }, () => {
|
||||
const d = detectBinary('AGENT_RUNNER_TEST_BIN', 'definitely-not-a-real-binary-name');
|
||||
expect(d.available).toBe(false);
|
||||
expect(d.reason).toBe('definitely-not-a-real-binary-name not on PATH');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -484,3 +484,204 @@ describe('verify + workflow wiring', () => {
|
||||
expect(wf).toContain('tests/docker/bootstrap-e2e.sh');
|
||||
});
|
||||
});
|
||||
|
||||
// ── check-grok-pin.sh ────────────────────────────────────────────────────────
|
||||
|
||||
const GROK_PIN_GUARD = join(ROOT, 'scripts/check-grok-pin.sh');
|
||||
|
||||
function runGrokPinGuard(fixtureRoot: string): { status: number | null; out: string } {
|
||||
const r = spawnSync('bash', [GROK_PIN_GUARD], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GBRAIN_GROK_PIN_GUARD_ROOT: fixtureRoot },
|
||||
});
|
||||
return { status: r.status, out: `${r.stdout}\n${r.stderr}` };
|
||||
}
|
||||
|
||||
const GROK_PIN_STAMPS_NPM = [
|
||||
'<!-- grok-pin: distribution_kind=npm -->',
|
||||
'<!-- grok-pin: npm_package=@example/grok -->',
|
||||
'<!-- grok-pin: npm_version=1.0.4 -->',
|
||||
'<!-- grok-pin: npm_integrity=sha512-AAA= -->',
|
||||
'<!-- grok-pin: grok_version=1.0.4 -->',
|
||||
'<!-- grok-pin: installer_sha256=abc123 -->',
|
||||
].join('\n');
|
||||
|
||||
function grokDoorWorkflow(envLines: string[]): string {
|
||||
return [
|
||||
'jobs:',
|
||||
' other-job:',
|
||||
' steps: []',
|
||||
' grok-door:',
|
||||
' env:',
|
||||
...envLines.map((l) => ` ${l}`),
|
||||
' steps: []',
|
||||
' trailing-job:',
|
||||
' env:',
|
||||
' GROK_VERSION: "9.9.9"', // outside the grok-door block — must be ignored
|
||||
' steps: []',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const GROK_NPM_ENV_OK = [
|
||||
'GROK_VERSION: "1.0.4"',
|
||||
'GROK_NPM_PACKAGE: "@example/grok"',
|
||||
'GROK_NPM_INTEGRITY: "sha512-AAA="',
|
||||
];
|
||||
|
||||
describe('check-grok-pin.sh', () => {
|
||||
test('ok: npm mode with matching workflow pins (anchored to the grok-door block)', () => {
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${GROK_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow(GROK_NPM_ENV_OK),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.out).toContain('check-grok-pin: ok');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('SKIP-graceful pre-landing; FAIL-closed once the door job exists without the pin doc', () => {
|
||||
// Door job present + pin doc MISSING = the gate's source of truth was
|
||||
// deleted — that must fail, not skip (post-landing fail-closed rule).
|
||||
withFixture({
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow(GROK_NPM_ENV_OK),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('pin doc is the gate');
|
||||
});
|
||||
// No door job yet: pin doc alone is fine to skip (pre-landing posture).
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${GROK_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': 'jobs:\n other-job:\n steps: []\n',
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.out).toContain('SKIP');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: npm_version stamp disagreeing with grok_version can never pass green', () => {
|
||||
const stamps = GROK_PIN_STAMPS_NPM.replace(
|
||||
'<!-- grok-pin: npm_version=1.0.4 -->',
|
||||
'<!-- grok-pin: npm_version=1.0.5 -->',
|
||||
);
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${stamps}\n`,
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow(GROK_NPM_ENV_OK),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('npm_version stamp');
|
||||
});
|
||||
});
|
||||
|
||||
test('single-quoted workflow env values are not read as drift', () => {
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${GROK_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow([
|
||||
"GROK_VERSION: '1.0.4'",
|
||||
"GROK_NPM_PACKAGE: '@example/grok'",
|
||||
"GROK_NPM_INTEGRITY: 'sha512-AAA='",
|
||||
]),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.out).toContain('check-grok-pin: ok');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: workflow GROK_VERSION drifts from the grok_version stamp', () => {
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${GROK_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow([
|
||||
'GROK_VERSION: "1.0.5"',
|
||||
'GROK_NPM_PACKAGE: "@example/grok"',
|
||||
'GROK_NPM_INTEGRITY: "sha512-AAA="',
|
||||
]),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('GROK_VERSION drift');
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: npm mode with a missing workflow integrity pin', () => {
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${GROK_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow([
|
||||
'GROK_VERSION: "1.0.4"',
|
||||
'GROK_NPM_PACKAGE: "@example/grok"',
|
||||
]),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('missing GROK_NPM_INTEGRITY');
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: mode exclusivity — npm mode must not also pin GROK_INSTALL_SHA256 in the workflow', () => {
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${GROK_PIN_STAMPS_NPM}\n`,
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow([
|
||||
...GROK_NPM_ENV_OK,
|
||||
'GROK_INSTALL_SHA256: "abc123"',
|
||||
]),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('mode exclusivity');
|
||||
});
|
||||
});
|
||||
|
||||
test('FAIL: duplicate stamps in the pin doc', () => {
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${GROK_PIN_STAMPS_NPM}\n<!-- grok-pin: grok_version=1.0.5 -->\n`,
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow(GROK_NPM_ENV_OK),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('duplicate grok-pin stamp');
|
||||
});
|
||||
});
|
||||
|
||||
test('installer mode: matching sha passes; npm-integrity co-pin fails exclusivity', () => {
|
||||
const installerStamps = [
|
||||
'<!-- grok-pin: distribution_kind=installer -->',
|
||||
'<!-- grok-pin: grok_version=1.0.4 -->',
|
||||
'<!-- grok-pin: installer_sha256=abc123 -->',
|
||||
].join('\n');
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${installerStamps}\n`,
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow([
|
||||
'GROK_VERSION: "1.0.4"',
|
||||
'GROK_INSTALL_SHA256: "abc123"',
|
||||
]),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.out).toContain('check-grok-pin: ok (installer mode');
|
||||
expect(r.status).toBe(0);
|
||||
});
|
||||
withFixture({
|
||||
'docs/mcp/GROK-CLI-PIN.md': `# pin\n${installerStamps}\n`,
|
||||
'.github/workflows/heavy-tests.yml': grokDoorWorkflow([
|
||||
'GROK_VERSION: "1.0.4"',
|
||||
'GROK_INSTALL_SHA256: "abc123"',
|
||||
'GROK_NPM_INTEGRITY: "sha512-AAA="',
|
||||
]),
|
||||
}, (dir) => {
|
||||
const r = runGrokPinGuard(dir);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.out).toContain('mode exclusivity');
|
||||
});
|
||||
});
|
||||
|
||||
test('verify wiring: run-verify-parallel CHECKS + package.json both carry check:grok-pin', () => {
|
||||
const verify = require('node:fs').readFileSync(join(ROOT, 'scripts/run-verify-parallel.sh'), 'utf-8');
|
||||
expect(verify).toContain('"check:grok-pin"');
|
||||
const pkg = require('node:fs').readFileSync(join(ROOT, 'package.json'), 'utf-8');
|
||||
expect(pkg).toContain('"check:grok-pin": "bash scripts/check-grok-pin.sh"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* issue #5 — `runJobInChild` against REAL child processes (.mjs harnesses run
|
||||
* by process.execPath — the child-worker-supervisor.test.ts pattern).
|
||||
*
|
||||
* Paths pinned:
|
||||
* - success outcome file → resolves with the handler result
|
||||
* - error outcome file → throws the reconstructed error class (exit 0!)
|
||||
* - exit 1 with no file → generic throw naming the exit (attempt burned)
|
||||
* - SIGTERM-ignoring child + aborted signal → group SIGKILL at the injected
|
||||
* grace; "terminated after abort" classification
|
||||
* - pre-aborted signal → child killed promptly
|
||||
* - spawn ENOENT → ChildSpawnInfraError (release, no attempt burned)
|
||||
* - worker-shutdown: child finishes + reports during the drain window →
|
||||
* normal success; child that can't report → ChildWorkerShutdownError
|
||||
* - child env contract (result path, lock token, parent pid, pool bounds)
|
||||
*/
|
||||
|
||||
import { describe, expect, test, beforeAll, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
runJobInChild,
|
||||
ChildSpawnInfraError,
|
||||
ChildWorkerShutdownError,
|
||||
ChildNotClaimedError,
|
||||
} from '../src/core/minions/child-job-runner.ts';
|
||||
import { UnrecoverableError } from '../src/core/minions/types.ts';
|
||||
import { RateLeaseUnavailableError } from '../src/core/minions/handlers/subagent.ts';
|
||||
|
||||
const TEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
let harnessDir: string;
|
||||
|
||||
function makeHarness(name: string, body: string): string {
|
||||
const path = join(harnessDir, `${name}.mjs`);
|
||||
writeFileSync(
|
||||
path,
|
||||
`import { writeFileSync, renameSync } from 'node:fs';\n` +
|
||||
`const RESULT = process.env.GBRAIN_JOB_RESULT_PATH;\n` +
|
||||
`const writeOutcome = (o) => { writeFileSync(RESULT + '.tmp', JSON.stringify(o)); renameSync(RESULT + '.tmp', RESULT); };\n` +
|
||||
body +
|
||||
// Readiness handshake LAST (after the body installed its signal
|
||||
// handlers): fixed sleeps raced child startup on loaded CI runners —
|
||||
// a SIGTERM landing before process.on('SIGTERM') installs takes the
|
||||
// default disposition and flips shutdown-semantics assertions
|
||||
// (testing specialist). The runner's outcome dir is internal, so the
|
||||
// ready path comes from a TEST-provided env var.
|
||||
`\nif (process.env.HARNESS_READY_PATH) writeFileSync(process.env.HARNESS_READY_PATH, '1');\n`,
|
||||
'utf8',
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Make a per-test ready path + the env to hand runJobInChild. */
|
||||
function readiness(name: string): { env: Record<string, string | undefined>; wait: () => Promise<void> } {
|
||||
const readyPath = join(harnessDir, `${name}.ready`);
|
||||
return {
|
||||
env: { ...process.env, HARNESS_READY_PATH: readyPath },
|
||||
wait: async () => {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(readyPath)) return;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
throw new Error('harness never signalled readiness');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
harnessDir = mkdtempSync(join(tmpdir(), 'gbrain-cjr-harness-'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(harnessDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function baseOpts(harnessPath: string) {
|
||||
return {
|
||||
jobId: 77,
|
||||
jobName: 'subagent',
|
||||
lockToken: 'tok-cjr',
|
||||
abortSignal: new AbortController().signal,
|
||||
shutdownSignal: new AbortController().signal,
|
||||
invocation: { cmd: process.execPath, argsPrefix: [harnessPath] },
|
||||
tiniPath: '', // direct spawn in tests; group signaling covers both shapes
|
||||
};
|
||||
}
|
||||
|
||||
describe('runJobInChild (real children)', () => {
|
||||
test('success outcome resolves with the result; env contract honored', async () => {
|
||||
const harness = makeHarness(
|
||||
'success',
|
||||
`writeOutcome({ outcome: 'success', result: {
|
||||
echoedToken: process.env.GBRAIN_JOB_LOCK_TOKEN,
|
||||
isChild: process.env.GBRAIN_JOB_CHILD,
|
||||
parentPid: process.env.GBRAIN_JOB_PARENT_PID,
|
||||
poolSize: process.env.GBRAIN_POOL_SIZE,
|
||||
directPoolSize: process.env.GBRAIN_DIRECT_POOL_SIZE,
|
||||
argv: process.argv.slice(2),
|
||||
}});\n` +
|
||||
`process.exit(0);\n`,
|
||||
);
|
||||
const result = (await runJobInChild(baseOpts(harness))) as Record<string, unknown>;
|
||||
expect(result.echoedToken).toBe('tok-cjr');
|
||||
expect(result.isChild).toBe('1');
|
||||
expect(result.parentPid).toBe(String(process.pid));
|
||||
expect(result.poolSize).toBe('3');
|
||||
expect(result.directPoolSize).toBe('1'); // codex-2 #6: child direct pool bounded
|
||||
expect(result.argv).toEqual(['jobs', 'run-child', '--job-id', '77']);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('error outcome (exit 0) throws the reconstructed class', async () => {
|
||||
const harness = makeHarness(
|
||||
'error-unrecoverable',
|
||||
`writeOutcome({ outcome: 'error', errorKind: 'unrecoverable', message: 'bad schema, never retry' });\n` +
|
||||
`process.exit(0);\n`,
|
||||
);
|
||||
await expect(runJobInChild(baseOpts(harness))).rejects.toBeInstanceOf(UnrecoverableError);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('rate-lease outcome rebuilds RateLeaseUnavailableError with fields', async () => {
|
||||
const harness = makeHarness(
|
||||
'error-lease',
|
||||
`writeOutcome({ outcome: 'error', errorKind: 'rate_lease', message: 'lease full', lease: { key: 'anthropic', active: 4, max: 4 } });\n` +
|
||||
`process.exit(0);\n`,
|
||||
);
|
||||
try {
|
||||
await runJobInChild(baseOpts(harness));
|
||||
throw new Error('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(RateLeaseUnavailableError);
|
||||
expect((e as RateLeaseUnavailableError).key).toBe('anthropic');
|
||||
}
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('exit 1 with no outcome file → generic throw naming the exit code', async () => {
|
||||
const harness = makeHarness('crash', `process.exit(1);\n`);
|
||||
await expect(runJobInChild(baseOpts(harness))).rejects.toThrow(/exit code=1/);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('SIGTERM-ignoring child: abort → group SIGKILL at the injected grace', async () => {
|
||||
const harness = makeHarness(
|
||||
'stubborn',
|
||||
`process.on('SIGTERM', () => {});\n` +
|
||||
`setInterval(() => {}, 1000);\n`, // never exits voluntarily
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const ready = readiness('stubborn');
|
||||
const opts = { ...baseOpts(harness), abortSignal: abort.signal, killGraceMs: 400, env: ready.env };
|
||||
const p = runJobInChild(opts);
|
||||
await ready.wait();
|
||||
abort.abort(new Error('timeout'));
|
||||
const started = Date.now();
|
||||
await expect(p).rejects.toThrow(/terminated after abort/);
|
||||
// Died via the SIGKILL escalation, not the 30s force-evict scale.
|
||||
expect(Date.now() - started).toBeLessThan(5_000);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('pre-aborted signal: child is terminated promptly', async () => {
|
||||
const harness = makeHarness(
|
||||
'prekilled',
|
||||
`setInterval(() => {}, 1000);\n`,
|
||||
);
|
||||
const abort = new AbortController();
|
||||
abort.abort(new Error('cancel'));
|
||||
const opts = { ...baseOpts(harness), abortSignal: abort.signal, killGraceMs: 400 };
|
||||
await expect(runJobInChild(opts)).rejects.toThrow(/terminated after abort/);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('spawn ENOENT → ChildSpawnInfraError (infra release, not a job defect)', async () => {
|
||||
const opts = {
|
||||
...baseOpts('/nonexistent'),
|
||||
invocation: { cmd: '/nonexistent/gbrain-binary', argsPrefix: [] },
|
||||
};
|
||||
await expect(runJobInChild(opts)).rejects.toBeInstanceOf(ChildSpawnInfraError);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('worker shutdown: child finishes + reports during the drain window → normal success', async () => {
|
||||
const harness = makeHarness(
|
||||
'graceful-drain',
|
||||
`let done = false;\n` +
|
||||
`process.on('SIGTERM', () => {\n` +
|
||||
` writeOutcome({ outcome: 'success', result: { finishedDuringDrain: true } });\n` +
|
||||
` done = true; process.exit(0);\n` +
|
||||
`});\n` +
|
||||
`setInterval(() => {}, 1000);\n`,
|
||||
);
|
||||
const shutdown = new AbortController();
|
||||
const ready = readiness('graceful-drain');
|
||||
const opts = { ...baseOpts(harness), shutdownSignal: shutdown.signal, killGraceMs: 5_000, env: ready.env };
|
||||
const p = runJobInChild(opts);
|
||||
await ready.wait();
|
||||
shutdown.abort(new Error('worker-shutdown'));
|
||||
const result = (await p) as Record<string, unknown>;
|
||||
expect(result.finishedDuringDrain).toBe(true);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('worker shutdown: child that cannot report → ChildWorkerShutdownError (no attempt burned)', async () => {
|
||||
const harness = makeHarness(
|
||||
'shutdown-stubborn',
|
||||
`process.on('SIGTERM', () => {});\n` +
|
||||
`setInterval(() => {}, 1000);\n`,
|
||||
);
|
||||
const shutdown = new AbortController();
|
||||
const ready = readiness('shutdown-stubborn');
|
||||
const opts = { ...baseOpts(harness), shutdownSignal: shutdown.signal, killGraceMs: 400, env: ready.env };
|
||||
const p = runJobInChild(opts);
|
||||
await ready.wait();
|
||||
shutdown.abort(new Error('worker-shutdown'));
|
||||
await expect(p).rejects.toBeInstanceOf(ChildWorkerShutdownError);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('ERROR outcome during worker shutdown → ChildWorkerShutdownError, not a burned attempt (adversarial P2)', async () => {
|
||||
// A cooperative handler that bails on shutdown and reports an error must
|
||||
// be RELEASED — punishing exactly the well-behaved handlers on every
|
||||
// deploy inverts the no-burn guarantee.
|
||||
const harness = makeHarness(
|
||||
'shutdown-error-report',
|
||||
`process.on('SIGTERM', () => {\n` +
|
||||
` writeOutcome({ outcome: 'error', errorKind: 'generic', message: 'aborted: shutdown' });\n` +
|
||||
` process.exit(0);\n` +
|
||||
`});\n` +
|
||||
`setInterval(() => {}, 1000);\n`,
|
||||
);
|
||||
const shutdown = new AbortController();
|
||||
const ready = readiness('shutdown-error-report');
|
||||
const opts = { ...baseOpts(harness), shutdownSignal: shutdown.signal, killGraceMs: 5_000, env: ready.env };
|
||||
const p = runJobInChild(opts);
|
||||
await ready.wait();
|
||||
shutdown.abort(new Error('worker-shutdown'));
|
||||
await expect(p).rejects.toBeInstanceOf(ChildWorkerShutdownError);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('watchdog drain (BOTH signals aborted, non-per-job reason) → shutdown class, not a burned attempt (adversarial P3)', async () => {
|
||||
const harness = makeHarness(
|
||||
'watchdog-stubborn',
|
||||
`process.on('SIGTERM', () => {});\n` +
|
||||
`setInterval(() => {}, 1000);\n`,
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const shutdown = new AbortController();
|
||||
const ready = readiness('watchdog-stubborn');
|
||||
const opts = {
|
||||
...baseOpts(harness),
|
||||
abortSignal: abort.signal,
|
||||
shutdownSignal: shutdown.signal,
|
||||
killGraceMs: 400,
|
||||
env: ready.env,
|
||||
};
|
||||
const p = runJobInChild(opts);
|
||||
await ready.wait();
|
||||
// gracefulShutdown('watchdog') aborts BOTH — shutdown classification must win.
|
||||
shutdown.abort(new Error('watchdog'));
|
||||
abort.abort(new Error('watchdog'));
|
||||
await expect(p).rejects.toBeInstanceOf(ChildWorkerShutdownError);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('per-job reason (timeout) wins over a concurrent shutdown — attempt semantics preserved', async () => {
|
||||
const harness = makeHarness(
|
||||
'timeout-during-shutdown',
|
||||
`process.on('SIGTERM', () => {});\n` +
|
||||
`setInterval(() => {}, 1000);\n`,
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const shutdown = new AbortController();
|
||||
const ready = readiness('timeout-during-shutdown');
|
||||
const opts = {
|
||||
...baseOpts(harness),
|
||||
abortSignal: abort.signal,
|
||||
shutdownSignal: shutdown.signal,
|
||||
killGraceMs: 400,
|
||||
env: ready.env,
|
||||
};
|
||||
const p = runJobInChild(opts);
|
||||
await ready.wait();
|
||||
shutdown.abort(new Error('worker-shutdown'));
|
||||
abort.abort(new Error('timeout')); // the JOB was targeted — not shutdown class
|
||||
await expect(p).rejects.toThrow(/terminated after abort/);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('bootstrap exit codes: 13 → ChildSpawnInfraError, 14 → ChildNotClaimedError (no burned attempts)', async () => {
|
||||
const usage = makeHarness('exit13', `process.exit(13);\n`);
|
||||
await expect(runJobInChild(baseOpts(usage))).rejects.toBeInstanceOf(ChildSpawnInfraError);
|
||||
|
||||
const notClaimed = makeHarness('exit14', `process.exit(14);\n`);
|
||||
await expect(runJobInChild(baseOpts(notClaimed))).rejects.toBeInstanceOf(ChildNotClaimedError);
|
||||
}, TEST_TIMEOUT_MS);
|
||||
|
||||
test('child pool-size env: a STRICTER user GBRAIN_POOL_SIZE is respected; explicit child override wins; invalid falls back', async () => {
|
||||
const harness = makeHarness(
|
||||
'pool-echo',
|
||||
`writeOutcome({ outcome: 'success', result: { poolSize: process.env.GBRAIN_POOL_SIZE } });\n` +
|
||||
`process.exit(0);\n`,
|
||||
);
|
||||
const run = (env: Record<string, string | undefined>) =>
|
||||
runJobInChild({ ...baseOpts(harness), env: { ...process.env, ...env } }) as Promise<{ poolSize: string }>;
|
||||
|
||||
expect((await run({ GBRAIN_POOL_SIZE: '2' })).poolSize).toBe('2'); // stricter user tuning respected
|
||||
expect((await run({ GBRAIN_POOL_SIZE: '10' })).poolSize).toBe('3'); // never raised above the child default
|
||||
expect((await run({ GBRAIN_POOL_SIZE: '2', GBRAIN_JOB_CHILD_POOL_SIZE: '5' })).poolSize).toBe('5'); // explicit knob wins
|
||||
expect((await run({ GBRAIN_JOB_CHILD_POOL_SIZE: 'abc' })).poolSize).toBe('3'); // invalid → default
|
||||
}, TEST_TIMEOUT_MS);
|
||||
});
|
||||
@@ -304,6 +304,209 @@ describe('OpenClawRunner invoke env (shim — pins the shared-allowlist leak bar
|
||||
});
|
||||
});
|
||||
|
||||
describe('GrokRunner detection (reliable on box without grok)', () => {
|
||||
test('detect returns the contract shape when GROK_BIN unset', async () => {
|
||||
const orig = process.env.GROK_BIN;
|
||||
delete process.env.GROK_BIN;
|
||||
try {
|
||||
const { GrokRunner } = await import('../src/core/claw-test/runners/grok.ts');
|
||||
const d = await new GrokRunner().detect();
|
||||
expect(typeof d.available).toBe('boolean');
|
||||
if (!d.available) expect(typeof d.reason).toBe('string');
|
||||
else expect(d.binPath?.startsWith('/')).toBe(true);
|
||||
} finally {
|
||||
if (orig !== undefined) process.env.GROK_BIN = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('detect rejects relative GROK_BIN', async () => {
|
||||
const orig = process.env.GROK_BIN;
|
||||
process.env.GROK_BIN = 'relative/grok';
|
||||
try {
|
||||
const { GrokRunner } = await import('../src/core/claw-test/runners/grok.ts');
|
||||
const d = await new GrokRunner().detect();
|
||||
expect(d.available).toBe(false);
|
||||
expect(d.reason).toMatch(/GROK_BIN must be absolute/);
|
||||
} finally {
|
||||
if (orig !== undefined) process.env.GROK_BIN = orig;
|
||||
else delete process.env.GROK_BIN;
|
||||
}
|
||||
});
|
||||
|
||||
test("detect rejects '..' segments in GROK_BIN", async () => {
|
||||
const orig = process.env.GROK_BIN;
|
||||
process.env.GROK_BIN = '/tmp/foo/../grok';
|
||||
try {
|
||||
const { GrokRunner } = await import('../src/core/claw-test/runners/grok.ts');
|
||||
const d = await new GrokRunner().detect();
|
||||
expect(d.available).toBe(false);
|
||||
expect(d.reason).toMatch(/'\.\.' segments/);
|
||||
} finally {
|
||||
if (orig !== undefined) process.env.GROK_BIN = orig;
|
||||
else delete process.env.GROK_BIN;
|
||||
}
|
||||
});
|
||||
|
||||
test('detect rejects shell metacharacters in GROK_BIN (through-runner injection pin)', async () => {
|
||||
// validateBinPathEnv's metachar branch is unit-tested directly below;
|
||||
// this pins that a runner's detect() actually routes through it — the
|
||||
// first through-runner coverage of the injection-relevant branch.
|
||||
const orig = process.env.GROK_BIN;
|
||||
process.env.GROK_BIN = '/tmp/gr"ok';
|
||||
try {
|
||||
const { GrokRunner } = await import('../src/core/claw-test/runners/grok.ts');
|
||||
const d = await new GrokRunner().detect();
|
||||
expect(d.available).toBe(false);
|
||||
expect(d.reason).toMatch(/quotes, backslashes, dollar signs/);
|
||||
} finally {
|
||||
if (orig !== undefined) process.env.GROK_BIN = orig;
|
||||
else delete process.env.GROK_BIN;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GrokRunner invoke argv/env (shim — no grok binary needed)', () => {
|
||||
test('argv is the pinned one-shot shape; GROK_HOME + XAI_API_KEY propagate; unlisted env does not', async () => {
|
||||
const orig = {
|
||||
GROK_BIN: process.env.GROK_BIN,
|
||||
GROK_HOME: process.env.GROK_HOME,
|
||||
XAI_API_KEY: process.env.XAI_API_KEY,
|
||||
LEAK_CANARY: process.env.LEAK_CANARY,
|
||||
GBRAIN_DATABASE_URL: process.env.GBRAIN_DATABASE_URL,
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
};
|
||||
const shim = join(tmp, 'grok-shim');
|
||||
// The runner first execs the shim with a version flag (the transcript
|
||||
// preamble), then spawns the real turn — the shim answers both.
|
||||
writeFileSync(shim, '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "grok 9.9.9 (shimhash)"; exit 0; fi\nprintf "ARGV:%s\\n" "$@"\nprintf "GH:[%s] KEY:[%s] CANARY:[%s] DBURL:[%s] ANT:[%s] OAI:[%s]\\n" "$GROK_HOME" "$XAI_API_KEY" "$LEAK_CANARY" "$GBRAIN_DATABASE_URL" "$ANTHROPIC_API_KEY" "$OPENAI_API_KEY"\n', 'utf-8');
|
||||
chmodSync(shim, 0o755);
|
||||
process.env.GROK_BIN = shim;
|
||||
process.env.GROK_HOME = '/tmp/gh-canary-test';
|
||||
// grok's documented headless auth path (docs/mcp/GROK-CLI-PIN.md): the
|
||||
// grok delta must forward it or env-key operators get "Not signed in"
|
||||
// blamed on the agent.
|
||||
process.env.XAI_API_KEY = 'xai-sentinel-3fa1';
|
||||
process.env.LEAK_CANARY = 'must-not-leak';
|
||||
process.env.GBRAIN_DATABASE_URL = 'postgres://must-not-leak';
|
||||
// Foreign provider keys: in BASE_ENV_ALLOWLIST (hermes needs them) but
|
||||
// deliberately filtered OUT of grok's list — a single-provider third-party
|
||||
// binary must never receive the operator's Anthropic/OpenAI credentials.
|
||||
process.env.ANTHROPIC_API_KEY = 'ant-must-not-leak';
|
||||
process.env.OPENAI_API_KEY = 'oai-must-not-leak';
|
||||
try {
|
||||
const { GrokRunner } = await import('../src/core/claw-test/runners/grok.ts');
|
||||
const chunks: Buffer[] = [];
|
||||
const result = await new GrokRunner().invoke({
|
||||
cwd: tmp,
|
||||
brief: 'BRIEF BODY sentinel-9e4d',
|
||||
env: {},
|
||||
timeoutMs: 10_000,
|
||||
transcriptSink: {
|
||||
write: (e) => { if (e.channel === 'stdout') chunks.push(e.bytes); },
|
||||
nextOffset: () => 0,
|
||||
close: async () => {},
|
||||
},
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const stdout = Buffer.concat(chunks).toString('utf-8');
|
||||
// Version preamble recorded as a plain stdout transcript event.
|
||||
expect(stdout).toContain('[grok-runner preamble] version: grok 9.9.9 (shimhash)');
|
||||
// Pinned argv: single-shot flag, brief, then plain output format
|
||||
// (docs/mcp/GROK-CLI-PIN.md; permission flags deliberately absent
|
||||
// pending the authed observation).
|
||||
expect(stdout).toContain('ARGV:-p\nARGV:BRIEF BODY sentinel-9e4d\nARGV:--output-format\nARGV:plain');
|
||||
// Allowlist held: the grok delta passes; the canary and the
|
||||
// deliberately-delisted GBRAIN_DATABASE_URL don't.
|
||||
expect(stdout).toContain('GH:[/tmp/gh-canary-test]');
|
||||
expect(stdout).toContain('KEY:[xai-sentinel-3fa1]');
|
||||
expect(stdout).toContain('CANARY:[] DBURL:[] ANT:[] OAI:[]');
|
||||
} finally {
|
||||
for (const [k, v] of Object.entries(orig)) {
|
||||
if (v !== undefined) process.env[k] = v;
|
||||
else delete process.env[k];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GrokRunner vendor-config tripwire + preamble resilience (shim)', () => {
|
||||
async function invokeWithHome(home: string): Promise<{ warns: string[]; exitCode: number; stdout: string }> {
|
||||
const shim = join(tmp, 'grok-shim-tripwire');
|
||||
writeFileSync(shim, '#!/bin/sh\nif [ "$1" = "--version" ]; then exit 1; fi\necho ok\n', 'utf-8');
|
||||
chmodSync(shim, 0o755);
|
||||
const origBin = process.env.GROK_BIN;
|
||||
process.env.GROK_BIN = shim;
|
||||
const warns: string[] = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (m: unknown) => { warns.push(String(m)); };
|
||||
try {
|
||||
const { GrokRunner } = await import('../src/core/claw-test/runners/grok.ts');
|
||||
const chunks: Buffer[] = [];
|
||||
const result = await new GrokRunner().invoke({
|
||||
cwd: tmp,
|
||||
brief: 'brief',
|
||||
env: { HOME: home },
|
||||
timeoutMs: 10_000,
|
||||
transcriptSink: {
|
||||
write: (e) => { if (e.channel === 'stdout') chunks.push(e.bytes); },
|
||||
nextOffset: () => 0,
|
||||
close: async () => {},
|
||||
},
|
||||
});
|
||||
return { warns, exitCode: result.exitCode, stdout: Buffer.concat(chunks).toString('utf-8') };
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
if (origBin !== undefined) process.env.GROK_BIN = origBin;
|
||||
else delete process.env.GROK_BIN;
|
||||
}
|
||||
}
|
||||
|
||||
test('warns loudly when ~/.claude.json registers mcpServers.gbrain (real-brain contamination channel)', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'grok-vendor-'));
|
||||
try {
|
||||
writeFileSync(join(home, '.claude.json'), JSON.stringify({ mcpServers: { gbrain: {} } }));
|
||||
const r = await invokeWithHome(home);
|
||||
expect(r.warns.some((w) => w.includes('REAL brain'))).toBe(true);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('stays silent without a gbrain vendor entry; a broken --version never fails the run', async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'grok-vendor-'));
|
||||
try {
|
||||
writeFileSync(join(home, '.claude.json'), JSON.stringify({ mcpServers: { other: {} } }));
|
||||
const r = await invokeWithHome(home);
|
||||
expect(r.warns.some((w) => w.includes('REAL brain'))).toBe(false);
|
||||
// The shim's --version exits 1 (preamble failure path): the run still
|
||||
// completes with the main turn's exit code and simply has no preamble.
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(r.stdout).not.toContain('[grok-runner preamble]');
|
||||
expect(r.stdout).toContain('ok');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('flag-registry accepted over-inclusion — SAFETY_FLAGS collision guard', () => {
|
||||
test("no grok argv literal collides with the generator's SAFETY_FLAGS", async () => {
|
||||
// The claw-test registry entry absorbs grok's long-form argv literals by
|
||||
// design (prose-bleed over-inclusion is tolerated); what must NEVER
|
||||
// happen is one of them colliding with a SAFETY flag the generator
|
||||
// deliberately excludes from validity (a collision would make a safety
|
||||
// flag silently valid on claw-test).
|
||||
const { CLI_FLAG_REGISTRY } = await import('../src/core/cli-flag-registry.generated.ts');
|
||||
const clawFlags: readonly string[] = CLI_FLAG_REGISTRY['claw-test'] ?? [];
|
||||
expect(clawFlags.length).toBeGreaterThan(0);
|
||||
const SAFETY_FLAGS = ['--dry-run']; // mirrors scripts/generate-flag-registry.ts
|
||||
for (const f of SAFETY_FLAGS) {
|
||||
expect(clawFlags).not.toContain(f);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBinPathEnv — shim-quoting hardening', () => {
|
||||
test('rejects quote/metacharacter values that would break out of the generated shim quoting', () => {
|
||||
// The value is interpolated single-quoted into sh shim scripts; each of
|
||||
|
||||
@@ -25,6 +25,7 @@ const HELP_WITHOUT_BRAIN = [
|
||||
'skillopt',
|
||||
'maintain',
|
||||
'extract-conversation-facts',
|
||||
'transcripts',
|
||||
'jobs',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* issue #6 — `runDrainRenewalTick` (cycle drain lock-renewal tick, extracted
|
||||
* from the inline setInterval in synthesize.ts). The previous inline tick had
|
||||
* no per-call timeout: a hung renewLock stacked one checked-out pool slot per
|
||||
* interval firing, forever. The extracted tick:
|
||||
*
|
||||
* - passes a per-call AbortSignal that is aborted when the timeout wins
|
||||
* (the losing UPDATE is cancelled, its slot released)
|
||||
* - invokes onLost exactly once when the token fence is lost (ok === false)
|
||||
* - swallows errors and timeouts (best-effort; the next tick retries)
|
||||
*
|
||||
* Hermetic: injected renewLock, no DB, no real cycle.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runDrainRenewalTick } from '../src/core/cycle/synthesize.ts';
|
||||
|
||||
describe('drain-loop wiring (structural — the shape guard only covers worker.ts)', () => {
|
||||
const src = readFileSync(
|
||||
new URL('../src/core/cycle/synthesize.ts', import.meta.url),
|
||||
'utf-8',
|
||||
);
|
||||
test('the renewTimer interval is guarded and routes through runDrainRenewalTick', () => {
|
||||
expect(src).toContain('if (drainTickInFlight) return;');
|
||||
expect(src).toContain('void runDrainRenewalTick(');
|
||||
// The pre-fix inline shape (an unguarded queue.renewLock(...).then chain
|
||||
// inside the interval) must not come back.
|
||||
expect(src).not.toMatch(/setInterval\(\(\) => \{\s*\n\s*queue\.renewLock\(/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('db-lock heartbeat wiring (structural — issue #6 cancellation)', () => {
|
||||
const src = readFileSync(new URL('../src/core/db-lock.ts', import.meta.url), 'utf-8');
|
||||
test('withRefreshingLock aborts a per-tick signal into handle.refresh and guards re-entrancy', () => {
|
||||
expect(src).toContain('handle.refresh({ signal: tickAbort.signal })');
|
||||
expect(src).toContain('if (refreshTickInFlight) return;');
|
||||
// refresh() forwards the opts to executeRawDirect as the trailing arg.
|
||||
expect(src).toMatch(/executeRawDirect<\{ id: string \}>\([\s\S]*?refreshOpts,\s*\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDrainRenewalTick (issue #6)', () => {
|
||||
test('successful renewal: signal not aborted, onLost not called', async () => {
|
||||
let seenSignal: AbortSignal | undefined;
|
||||
let lost = 0;
|
||||
await runDrainRenewalTick(
|
||||
async (_id, _tok, _ms, opts) => {
|
||||
seenSignal = opts?.signal;
|
||||
return true;
|
||||
},
|
||||
42,
|
||||
'tok',
|
||||
30_000,
|
||||
() => { lost += 1; },
|
||||
1_000,
|
||||
);
|
||||
expect(seenSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(seenSignal!.aborted).toBe(false);
|
||||
expect(lost).toBe(0);
|
||||
});
|
||||
|
||||
test('lost token fence (ok=false): onLost called once', async () => {
|
||||
let lost = 0;
|
||||
await runDrainRenewalTick(
|
||||
async () => false,
|
||||
42,
|
||||
'tok',
|
||||
30_000,
|
||||
() => { lost += 1; },
|
||||
1_000,
|
||||
);
|
||||
expect(lost).toBe(1);
|
||||
});
|
||||
|
||||
test('hung renewLock: tick resolves at the timeout and aborts the per-call signal', async () => {
|
||||
let seenSignal: AbortSignal | undefined;
|
||||
let lost = 0;
|
||||
const started = Date.now();
|
||||
await runDrainRenewalTick(
|
||||
(_id, _tok, _ms, opts) => {
|
||||
seenSignal = opts?.signal;
|
||||
return new Promise<boolean>(() => { /* hangs forever */ });
|
||||
},
|
||||
42,
|
||||
'tok',
|
||||
30_000,
|
||||
() => { lost += 1; },
|
||||
50, // short timeout keeps the test fast
|
||||
);
|
||||
// Resolved via the timeout path (not the hung renewal).
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(40);
|
||||
expect(seenSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(seenSignal!.aborted).toBe(true);
|
||||
expect(lost).toBe(0); // timeout is NOT a lost fence
|
||||
});
|
||||
|
||||
test('throwing renewLock is swallowed (best-effort; next tick retries)', async () => {
|
||||
let lost = 0;
|
||||
await runDrainRenewalTick(
|
||||
async () => { throw new Error('CONNECTION_ENDED'); },
|
||||
42,
|
||||
'tok',
|
||||
30_000,
|
||||
() => { lost += 1; },
|
||||
1_000,
|
||||
);
|
||||
expect(lost).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -120,12 +120,17 @@ describe('claim stamps timeout_at (deadlineAtMs ground truth)', () => {
|
||||
|
||||
describe('deadline plumbing wiring (structural)', () => {
|
||||
const workerSrc = readFileSync(new URL('../src/core/minions/worker.ts', import.meta.url), 'utf-8');
|
||||
// The context builder was extracted from executeJob into job-context.ts
|
||||
// (shared with `jobs run-child` for process isolation) — the deadlineAtMs
|
||||
// derivation lives there now; worker.ts calls buildJobContext.
|
||||
const jobContextSrc = readFileSync(new URL('../src/core/minions/job-context.ts', import.meta.url), 'utf-8');
|
||||
const jobsSrc = readFileSync(new URL('../src/commands/jobs.ts', import.meta.url), 'utf-8');
|
||||
const cycleSrc = readFileSync(new URL('../src/core/cycle.ts', import.meta.url), 'utf-8');
|
||||
const patternsSrc = readFileSync(new URL('../src/core/cycle/patterns.ts', import.meta.url), 'utf-8');
|
||||
|
||||
test('worker exposes deadlineAtMs from the claim-time timeout_at stamp', () => {
|
||||
expect(workerSrc).toContain('deadlineAtMs: job.timeout_at != null ? job.timeout_at.getTime() : null');
|
||||
test('job context exposes deadlineAtMs from the claim-time timeout_at stamp', () => {
|
||||
expect(jobContextSrc).toContain('deadlineAtMs: job.timeout_at != null ? job.timeout_at.getTime() : null');
|
||||
expect(workerSrc).toContain('buildJobContext(');
|
||||
});
|
||||
|
||||
test('worker arms its abort timer from timeout_at when present (one absolute deadline)', () => {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* issue #6 — `resolveMaxLifetimeSeconds`: explicit, env-overridable
|
||||
* max_lifetime for all four postgres() call sites.
|
||||
*
|
||||
* NOT a behavior change at default: postgres.js (verified against 3.4.9)
|
||||
* already defaults max_lifetime to `60 * (30 + Math.random() * 30)`. This
|
||||
* resolver makes the value explicit and adds the GBRAIN_POOL_MAX_LIFETIME_S
|
||||
* incident escape hatch (0 disables recycling; N = seconds).
|
||||
*
|
||||
* Hermetic: resolver only — env injected as a param (rule R1), no pools.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, beforeEach } from 'bun:test';
|
||||
import {
|
||||
resolveMaxLifetimeSeconds,
|
||||
_resetMaxLifetimeWarningForTests,
|
||||
} from '../src/core/db.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
beforeEach(() => {
|
||||
_resetMaxLifetimeWarningForTests();
|
||||
});
|
||||
|
||||
describe('resolveMaxLifetimeSeconds', () => {
|
||||
test('default (no env): a per-CONNECTION jitter FUNCTION, 30-60 minutes', () => {
|
||||
// Must be a function, not a pre-evaluated number: postgres.js re-evaluates
|
||||
// a function default per connection, so connections in one pool get
|
||||
// independent recycle deadlines instead of a synchronized reconnect spike
|
||||
// (data-migration specialist).
|
||||
const v = resolveMaxLifetimeSeconds({});
|
||||
expect(typeof v).toBe('function');
|
||||
const fn = v as () => number;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const n = fn();
|
||||
expect(Number.isInteger(n)).toBe(true);
|
||||
expect(n).toBeGreaterThanOrEqual(1800);
|
||||
expect(n).toBeLessThanOrEqual(3600);
|
||||
}
|
||||
});
|
||||
|
||||
test('env override: positive integer seconds honored verbatim', () => {
|
||||
expect(resolveMaxLifetimeSeconds({ GBRAIN_POOL_MAX_LIFETIME_S: '900' })).toBe(900);
|
||||
expect(resolveMaxLifetimeSeconds({ GBRAIN_POOL_MAX_LIFETIME_S: '1' })).toBe(1);
|
||||
});
|
||||
|
||||
test('env 0 disables recycling (null — postgres.js accepts null)', () => {
|
||||
expect(resolveMaxLifetimeSeconds({ GBRAIN_POOL_MAX_LIFETIME_S: '0' })).toBeNull();
|
||||
});
|
||||
|
||||
test('empty string falls through to the default (function)', () => {
|
||||
const v = resolveMaxLifetimeSeconds({ GBRAIN_POOL_MAX_LIFETIME_S: '' });
|
||||
expect(typeof v).toBe('function');
|
||||
});
|
||||
|
||||
test('invalid values warn once on stderr and fall back to the default', () => {
|
||||
const writes: string[] = [];
|
||||
const realWrite = process.stderr.write.bind(process.stderr);
|
||||
(process.stderr as { write: unknown }).write = (chunk: string) => {
|
||||
writes.push(String(chunk));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
for (const bad of ['abc', '-5', '3.5', 'NaN']) {
|
||||
const v = resolveMaxLifetimeSeconds({ GBRAIN_POOL_MAX_LIFETIME_S: bad });
|
||||
expect(typeof v).toBe('function');
|
||||
}
|
||||
} finally {
|
||||
(process.stderr as { write: unknown }).write = realWrite;
|
||||
}
|
||||
// warn-once latch: 4 bad values, exactly 1 warning
|
||||
const warnings = writes.filter((w) => w.includes('GBRAIN_POOL_MAX_LIFETIME_S'));
|
||||
expect(warnings.length).toBe(1);
|
||||
});
|
||||
|
||||
test('jitter varies per CONNECTION (thundering-herd protection)', () => {
|
||||
const fn = resolveMaxLifetimeSeconds({}) as () => number;
|
||||
const values = new Set<number>();
|
||||
for (let i = 0; i < 30; i++) values.add(fn());
|
||||
expect(values.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('wiring: the env override reaches a REAL constructed pool (ConnectionManager read pool)', async () => {
|
||||
// postgres() is lazy — constructing the pool performs no I/O, so this
|
||||
// pins the construction seam without a database. Without this, the
|
||||
// resolver could be green while GBRAIN_POOL_MAX_LIFETIME_S is silently
|
||||
// dead at every call site (adversarial-review vacuity finding).
|
||||
const { ConnectionManager } = await import('../src/core/connection-manager.ts');
|
||||
const { endPoolBounded } = await import('../src/core/db.ts');
|
||||
await withEnv({ GBRAIN_POOL_MAX_LIFETIME_S: '900' }, async () => {
|
||||
const cm = new ConnectionManager({
|
||||
url: 'postgresql://user@127.0.0.1:5/never-connected',
|
||||
});
|
||||
const pool = await cm.getReadPool();
|
||||
try {
|
||||
expect(
|
||||
(pool as unknown as { options: { max_lifetime: number | null } }).options.max_lifetime,
|
||||
).toBe(900);
|
||||
} finally {
|
||||
await endPoolBounded(pool);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('wiring: the default reaches a real pool as a FUNCTION (per-connection jitter)', async () => {
|
||||
const { ConnectionManager } = await import('../src/core/connection-manager.ts');
|
||||
const { endPoolBounded } = await import('../src/core/db.ts');
|
||||
await withEnv({ GBRAIN_POOL_MAX_LIFETIME_S: undefined }, async () => {
|
||||
const cm = new ConnectionManager({
|
||||
url: 'postgresql://user@127.0.0.1:5/never-connected',
|
||||
});
|
||||
const pool = await cm.getReadPool();
|
||||
try {
|
||||
const v = (pool as unknown as { options: { max_lifetime: unknown } }).options.max_lifetime;
|
||||
expect(typeof v).toBe('function');
|
||||
} finally {
|
||||
await endPoolBounded(pool);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* issue #6 — `runDbProbe` verdict matrix (hermetic; injected deps only).
|
||||
*
|
||||
* read OK → { ok: true }
|
||||
* read FAIL + direct OK → pool_starved (honest disjunction wording)
|
||||
* read FAIL + direct FAIL → server_unreachable
|
||||
* read FAIL + no direct lane → unknown
|
||||
* hung probes → cancelled via their AbortSignals
|
||||
* diagnostics absent/throwing → fail-open (verdict still produced)
|
||||
* tracked=0 subset → message points at untracked traffic
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { runDbProbe } from '../src/core/minions/db-probe.ts';
|
||||
|
||||
const FAST = { timeoutMs: 5_000, directTimeoutMs: 5_000 };
|
||||
const SHORT = { timeoutMs: 50, directTimeoutMs: 50 };
|
||||
|
||||
describe('runDbProbe verdict matrix', () => {
|
||||
test('read OK → ok:true, no verdict', async () => {
|
||||
const res = await runDbProbe({ probeRead: async () => {}, ...FAST });
|
||||
expect(res).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test('read FAIL + direct OK → pool_starved with honest-disjunction wording', async () => {
|
||||
const res = await runDbProbe({
|
||||
probeRead: async () => { throw new Error('probe timeout after 10000ms'); },
|
||||
probeDirect: async () => {},
|
||||
getDiagnostics: () => ({
|
||||
tracked: { raw: 9, direct: 0, reserved: 1, tx: 0 },
|
||||
poolMax: 10,
|
||||
}),
|
||||
...FAST,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (res.ok) throw new Error('unreachable');
|
||||
expect(res.verdict).toBe('pool_starved');
|
||||
expect(res.detail).toContain('server IS reachable');
|
||||
// Codex-2 #2: the message must NOT claim to distinguish client pool
|
||||
// exhaustion from a pooler-layer fault.
|
||||
expect(res.detail).toContain('client pool exhaustion or a pooler-layer fault');
|
||||
expect(res.detail).toContain('raw=9');
|
||||
expect(res.detail).toContain('read pool max 10');
|
||||
// Codex-2 #3: no invented waiter arithmetic anywhere in the message.
|
||||
expect(res.detail).not.toMatch(/waiting/i);
|
||||
expect(res.detail).toContain('subset');
|
||||
});
|
||||
|
||||
test('read FAIL + direct FAIL → server_unreachable', async () => {
|
||||
const res = await runDbProbe({
|
||||
probeRead: async () => { throw new Error('probe timeout after 10000ms'); },
|
||||
probeDirect: async () => { throw new Error('connect ECONNREFUSED'); },
|
||||
...FAST,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (res.ok) throw new Error('unreachable');
|
||||
expect(res.verdict).toBe('server_unreachable');
|
||||
expect(res.detail).toContain('ECONNREFUSED');
|
||||
});
|
||||
|
||||
test('read FAIL + no direct lane → unknown', async () => {
|
||||
const res = await runDbProbe({
|
||||
probeRead: async () => { throw new Error('boom'); },
|
||||
...FAST,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (res.ok) throw new Error('unreachable');
|
||||
expect(res.verdict).toBe('unknown');
|
||||
expect(res.detail).toContain('no direct lane');
|
||||
});
|
||||
|
||||
test('hung read probe: cancelled via its signal at the deadline', async () => {
|
||||
let readSignal: AbortSignal | undefined;
|
||||
const res = await runDbProbe({
|
||||
probeRead: (signal) => {
|
||||
readSignal = signal;
|
||||
return new Promise<never>(() => {});
|
||||
},
|
||||
...SHORT,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(readSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test('hung direct probe: cancelled at its own (shorter) deadline', async () => {
|
||||
let directSignal: AbortSignal | undefined;
|
||||
const res = await runDbProbe({
|
||||
probeRead: async () => { throw new Error('read dead'); },
|
||||
probeDirect: (signal) => {
|
||||
directSignal = signal;
|
||||
return new Promise<never>(() => {});
|
||||
},
|
||||
...SHORT,
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (res.ok) throw new Error('unreachable');
|
||||
expect(res.verdict).toBe('server_unreachable');
|
||||
expect(directSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
test('tracked=0 subset: message names untracked template traffic + runbook', async () => {
|
||||
const res = await runDbProbe({
|
||||
probeRead: async () => { throw new Error('probe timeout'); },
|
||||
probeDirect: async () => {},
|
||||
getDiagnostics: () => ({
|
||||
tracked: { raw: 0, direct: 0, reserved: 0, tx: 0 },
|
||||
poolMax: 10,
|
||||
}),
|
||||
...FAST,
|
||||
});
|
||||
if (res.ok) throw new Error('unreachable');
|
||||
expect(res.detail).toContain('untracked');
|
||||
expect(res.detail).toContain('queue-operations-runbook');
|
||||
});
|
||||
|
||||
test('diagnostics absent → verdict still produced (fail-open)', async () => {
|
||||
const res = await runDbProbe({
|
||||
probeRead: async () => { throw new Error('probe timeout'); },
|
||||
probeDirect: async () => {},
|
||||
...FAST,
|
||||
});
|
||||
if (res.ok) throw new Error('unreachable');
|
||||
expect(res.verdict).toBe('pool_starved');
|
||||
});
|
||||
|
||||
test('diagnostics THROWING → verdict still produced (fail-open)', async () => {
|
||||
const res = await runDbProbe({
|
||||
probeRead: async () => { throw new Error('probe timeout'); },
|
||||
probeDirect: async () => {},
|
||||
getDiagnostics: () => { throw new Error('gauge exploded'); },
|
||||
...FAST,
|
||||
});
|
||||
if (res.ok) throw new Error('unreachable');
|
||||
expect(res.verdict).toBe('pool_starved');
|
||||
});
|
||||
});
|
||||
@@ -123,20 +123,27 @@ describe('gbrain claw-test --scenario fresh-install (scripted)', () => {
|
||||
});
|
||||
|
||||
describe('gbrain claw-test --list-agents', () => {
|
||||
test('reports both built-in runners (available or not — both valid states)', () => {
|
||||
// HERMES_BIN/OPENCLAW_BIN point at a nonexistent path so the output shape
|
||||
// is deterministic regardless of what's installed on the box (detect
|
||||
test('reports all three built-in runners (available or not — both valid states)', () => {
|
||||
// *_BIN vars point at a nonexistent path so the output shape is
|
||||
// deterministic regardless of what's installed on the box (detect
|
||||
// rejects a non-stat-able absolute path with a specific reason).
|
||||
const result = spawnSync(BIN_PATH, ['claw-test', '--list-agents'], {
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, HERMES_BIN: '/nonexistent/hermes', OPENCLAW_BIN: '/nonexistent/openclaw' },
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_BIN: '/nonexistent/hermes',
|
||||
OPENCLAW_BIN: '/nonexistent/openclaw',
|
||||
GROK_BIN: '/nonexistent/grok',
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/^grok: unavailable: /m);
|
||||
expect(result.stdout).toMatch(/^hermes: unavailable: /m);
|
||||
expect(result.stdout).toMatch(/^openclaw: unavailable: /m);
|
||||
// Alphabetical print order (the awaited-detection fix pins this).
|
||||
expect(result.stdout.indexOf('grok:')).toBeLessThan(result.stdout.indexOf('hermes:'));
|
||||
expect(result.stdout.indexOf('hermes:')).toBeLessThan(result.stdout.indexOf('openclaw:'));
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* install-real-grok door e2e — drives the REAL `grok` binary (xAI Grok Build
|
||||
* CLI) against THIS checkout's gbrain over stdio MCP.
|
||||
*
|
||||
* WHAT THIS PROVES: gbrain WIRED INTO grok via the DOCUMENTED command shape
|
||||
* (`grok mcp add gbrain -- gbrain serve --surface verbs`, bare command
|
||||
* resolved via a PATH-staged bin dir) + recall through MCP. Every asserted
|
||||
* shape was observed against v1.0.4 — see docs/mcp/GROK-CLI-PIN.md; update
|
||||
* that file, the heavy-tests grok-door pins, and these assertions together.
|
||||
*
|
||||
* SPLIT GATING (divergence from the hermes door, deliberate): grok's mcp
|
||||
* add/list/doctor all run KEYLESS (observed), so the compat tier needs only
|
||||
* the binary — missing auth must not discard it. Two describes:
|
||||
* - keyless tier (T1 version pin, T2 INSTALL, T2b provenance, T3 direct
|
||||
* TOML): GBRAIN_REAL_GROK_E2E=1 + resolvable binary.
|
||||
* - paid tier (T4 SMOKE): additionally hasGrokAuth() (non-empty
|
||||
* XAI_API_KEY; blank CI secret ⇒ skip, never a paid failure).
|
||||
*
|
||||
* Isolation: every child gets BOTH HOME=<tmp> and GROK_HOME=<tmp>/.grok
|
||||
* (GROK_HOME honoring verified v1.0.4), an explicit tmp cwd on EVERY spawn
|
||||
* (grok reads vendor MCP configs — ~/.claude.json / project .mcp.json — for
|
||||
* trusted folders, and loads .envrc from the cwd by default; fresh tmp HOME +
|
||||
* cwd make both structurally inert: vendor entries report "folder untrusted",
|
||||
* observed). A bounded tripwire hashes the operator's real ~/.grok
|
||||
* config/credential files (NOT the volatile set — grok rewrites
|
||||
* active_sessions/logs/bin/docs on every run) before/after, and a checkout
|
||||
* guard asserts no .grok/ or .mcp.json appeared in the repo root.
|
||||
*
|
||||
* Observed-reality notes (docs/mcp/GROK-CLI-PIN.md, v1.0.4):
|
||||
* - `grok mcp add` is LAZY: exit 0 always, no handshake, no prompt;
|
||||
* `enabled = true` is written unconditionally. Neither its exit code nor
|
||||
* the enabled flag proves anything about server viability.
|
||||
* - THE honest discriminator: `grok mcp doctor <name> --json` SPAWNS the
|
||||
* server — exit 0 + checks [command found, server started, handshake OK,
|
||||
* "7 tools discovered"] on success; exit 1 + a failing check on a broken
|
||||
* command. The verbs surface's seven verbs are proven keyless.
|
||||
* - the env flag is repeatable, one KEY=value per flag.
|
||||
* - `[cli] auto_update = false` must be seeded (config-only kill-switch,
|
||||
* default ON); `mcp add` PRESERVES pre-existing config sections.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
resolveGrokBinary,
|
||||
hasGrokAuth,
|
||||
seedGrokConfig,
|
||||
grokChildEnv,
|
||||
grokOneShotTurn,
|
||||
hermeticChildEnv,
|
||||
stageGbrainBinDir,
|
||||
ensureCompiledGbrain,
|
||||
seedBrainForAgent,
|
||||
} from '../helpers/agent-harness.ts';
|
||||
|
||||
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
|
||||
const CLI = join(REPO_ROOT, 'src', 'cli.ts');
|
||||
const GROK_BIN = resolveGrokBinary();
|
||||
const CAN_RUN_KEYLESS = process.env.GBRAIN_REAL_GROK_E2E === '1' && !!GROK_BIN;
|
||||
const CAN_RUN_PAID = CAN_RUN_KEYLESS && hasGrokAuth();
|
||||
|
||||
if (!CAN_RUN_KEYLESS) {
|
||||
const why = process.env.GBRAIN_REAL_GROK_E2E !== '1'
|
||||
? 'GBRAIN_REAL_GROK_E2E is not 1 (explicit opt-in required)'
|
||||
: 'grok binary not found';
|
||||
console.warn(`[install-real-grok] SKIP (keyless tier): ${why}`);
|
||||
} else if (!CAN_RUN_PAID) {
|
||||
console.warn('[install-real-grok] keyless tier runs; SKIP paid tier: no non-empty XAI_API_KEY');
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
'GBRAIN_HOME', 'GBRAIN_DATABASE_URL', 'DATABASE_URL', 'GBRAIN_BRAIN_ID',
|
||||
'GBRAIN_SOURCE', 'GBRAIN_HOOKS', 'GROK_HOME',
|
||||
];
|
||||
const SAVED_ENV: Record<string, string | undefined> = {};
|
||||
|
||||
// Bounded tripwire over the operator's REAL ~/.grok: config/credential-class
|
||||
// files ONLY (grok rewrites active_sessions/logs/bin/docs on every run — a
|
||||
// whole-tree hash would false-positive on volatile churn, observed v1.0.4).
|
||||
const REAL_GROK_HOME = join(homedir(), '.grok');
|
||||
const TRIPWIRE_FILES = ['config.toml', 'mcp_credentials.json'];
|
||||
let realManifestBefore: string | null = null;
|
||||
function grokHomeManifest(): string | null {
|
||||
try {
|
||||
if (!existsSync(REAL_GROK_HOME)) return 'absent';
|
||||
const h = createHash('sha256');
|
||||
for (const f of TRIPWIRE_FILES) {
|
||||
const p = join(REAL_GROK_HOME, f);
|
||||
h.update(f);
|
||||
h.update(existsSync(p) ? readFileSync(p) : Buffer.from('<absent>'));
|
||||
}
|
||||
return h.digest('hex');
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Checkout guard: a grok child whose cwd escaped to the repo root would drop
|
||||
// a .grok/ (project-scope add) or read/write .mcp.json there.
|
||||
const CHECKOUT_MARKERS = [join(REPO_ROOT, '.grok'), join(REPO_ROOT, '.mcp.json')];
|
||||
let checkoutMarkersBefore: boolean[] = [];
|
||||
|
||||
const EVIDENCE_DIR = process.env.GBRAIN_E2E_EVIDENCE_DIR;
|
||||
const createdHomes: { label: string; home: string }[] = [];
|
||||
function trackHome(label: string): string {
|
||||
const home = mkdtempSync(join(tmpdir(), `gb-grok-${label}-`));
|
||||
createdHomes.push({ label, home });
|
||||
return home;
|
||||
}
|
||||
function copyEvidence(): void {
|
||||
if (!EVIDENCE_DIR) return;
|
||||
for (const { label, home } of createdHomes) {
|
||||
try {
|
||||
const dst = join(EVIDENCE_DIR, label);
|
||||
mkdirSync(dst, { recursive: true });
|
||||
// Copy-allowlist per the Phase-0 volatile inventory; the credential
|
||||
// inventory is pending auth — mcp_credentials.json is defensively
|
||||
// excluded rather than copied.
|
||||
for (const sub of ['.grok/logs', '.grok/config.toml', 'door-config.toml']) {
|
||||
const src = join(home, sub);
|
||||
if (existsSync(src)) {
|
||||
try { cpSync(src, join(dst, sub.replace(/\//g, '_')), { recursive: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
try { rmSync(join(dst, '.grok_mcp_credentials.json'), { force: true }); } catch { /* best-effort */ }
|
||||
// Same content-grep the CI scrub applies: a grok-written log that
|
||||
// embeds the key must never land in an artifact, local or uploaded.
|
||||
const key = process.env.XAI_API_KEY?.trim();
|
||||
if (key && key.length >= 8) {
|
||||
const walk = (d: string): string[] => {
|
||||
try {
|
||||
return require('node:fs').readdirSync(d, { withFileTypes: true }).flatMap((e: { name: string; isDirectory(): boolean; isFile(): boolean }) => {
|
||||
const p = join(d, e.name);
|
||||
return e.isDirectory() ? walk(p) : e.isFile() ? [p] : [];
|
||||
});
|
||||
} catch { return []; }
|
||||
};
|
||||
for (const f of walk(dst)) {
|
||||
try {
|
||||
if (readFileSync(f, 'utf8').includes(key)) rmSync(f, { force: true });
|
||||
} catch { /* unreadable → leave */ }
|
||||
}
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
for (const k of ENV_KEYS) SAVED_ENV[k] = process.env[k];
|
||||
for (const k of ENV_KEYS) delete process.env[k];
|
||||
realManifestBefore = grokHomeManifest();
|
||||
checkoutMarkersBefore = CHECKOUT_MARKERS.map((p) => existsSync(p));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
copyEvidence();
|
||||
for (const { home } of createdHomes) {
|
||||
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
for (const k of ENV_KEYS) {
|
||||
if (SAVED_ENV[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = SAVED_ENV[k];
|
||||
}
|
||||
// Tripwires LAST — scream, don't silently mutate the operator's agent.
|
||||
const after = grokHomeManifest();
|
||||
if (realManifestBefore !== after) {
|
||||
throw new Error(
|
||||
'HERMETICITY BREACH: the operator\'s real ~/.grok config/credential files changed during ' +
|
||||
'the door run — GROK_HOME isolation failed; investigate before trusting this suite again. ' +
|
||||
'(Volatile paths are excluded from this manifest; a fire means config.toml or credentials moved.)',
|
||||
);
|
||||
}
|
||||
const markersAfter = CHECKOUT_MARKERS.map((p) => existsSync(p));
|
||||
for (let i = 0; i < CHECKOUT_MARKERS.length; i++) {
|
||||
if (markersAfter[i] && !checkoutMarkersBefore[i]) {
|
||||
throw new Error(`HERMETICITY BREACH: ${CHECKOUT_MARKERS[i]} appeared in the checkout during the door run.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** Run the real grok binary under a hermetic home with a REQUIRED tmp cwd
|
||||
* (never the checkout: vendor-config + .envrc channels). `binDir`, when
|
||||
* given, is PATH-prepended so bare `gbrain` resolves to the staged binary —
|
||||
* both for grok's own resolution and for the server it spawns. */
|
||||
function runGrok(
|
||||
home: string,
|
||||
cwd: string,
|
||||
argv: string[],
|
||||
binDir?: string,
|
||||
): { code: number | null; stdout: string; stderr: string } {
|
||||
const env = grokChildEnv(home);
|
||||
if (binDir) env.PATH = `${binDir}:${env.PATH ?? ''}`;
|
||||
// The bun-run fallback server cold-transpiles the CLI; grok's default
|
||||
// startup timeout is 30s (observed schema) — widen via the observed env.
|
||||
env.GROK_MCP_STARTUP_TIMEOUT_SECS = '90';
|
||||
const res = spawnSync(GROK_BIN!, argv, {
|
||||
cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 180_000,
|
||||
});
|
||||
return { code: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
|
||||
}
|
||||
|
||||
/** Keyless PGLite brain init (compiled binary preferred, bun-run fallback). */
|
||||
function initBrain(home: string): { code: number | null; stderr: string } {
|
||||
const { binPath } = ensureCompiledGbrain(REPO_ROOT);
|
||||
const argv = binPath
|
||||
? [binPath, 'init', '--pglite', '--no-embedding', '--non-interactive']
|
||||
: ['bun', 'run', CLI, 'init', '--pglite', '--no-embedding', '--non-interactive'];
|
||||
const res = spawnSync(argv[0], argv.slice(1), {
|
||||
cwd: REPO_ROOT,
|
||||
env: hermeticChildEnv({ HOME: home, GBRAIN_HOME: home, GBRAIN_SKIP_STARTUP_HOOKS: '1' }),
|
||||
encoding: 'utf8',
|
||||
timeout: 180_000,
|
||||
});
|
||||
return { code: res.status, stderr: `${res.stdout ?? ''}\n${res.stderr ?? ''}` };
|
||||
}
|
||||
|
||||
interface GrokMcpServerEntry {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
startup_timeout_sec?: number;
|
||||
}
|
||||
interface GrokConfig {
|
||||
cli?: { auto_update?: boolean };
|
||||
mcp_servers?: Record<string, GrokMcpServerEntry>;
|
||||
}
|
||||
|
||||
function readGrokConfig(home: string): GrokConfig {
|
||||
const p = join(home, '.grok', 'config.toml');
|
||||
expect(existsSync(p)).toBe(true);
|
||||
// Bun.TOML.parse — same parser codex-toml.ts uses; zero new deps.
|
||||
return (Bun as unknown as { TOML: { parse(s: string): unknown } }).TOML
|
||||
.parse(readFileSync(p, 'utf-8')) as GrokConfig;
|
||||
}
|
||||
|
||||
interface DoctorCheck { label: string; passed: boolean; detail?: string; hint?: string }
|
||||
interface DoctorReport {
|
||||
sources?: { path: string; status: { status: string; server_count?: number } }[];
|
||||
servers?: { name: string; source?: string; checks?: DoctorCheck[] }[];
|
||||
}
|
||||
|
||||
/** The honest discriminator: doctor SPAWNS the server (observed). */
|
||||
function doctorProbe(home: string, cwd: string, name: string, binDir?: string): { code: number | null; report: DoctorReport } {
|
||||
const res = runGrok(home, cwd, ['mcp', 'doctor', name, '--json'], binDir);
|
||||
let report: DoctorReport = {};
|
||||
try { report = JSON.parse(res.stdout) as DoctorReport; } catch { /* asserted by callers */ }
|
||||
return { code: res.code, report };
|
||||
}
|
||||
|
||||
/** Documented registration shape (GROK.md): bare `gbrain` via staged PATH. */
|
||||
function registerGbrainIntoGrok(
|
||||
home: string,
|
||||
cwd: string,
|
||||
binDir: string,
|
||||
gbrainHome: string,
|
||||
sourceId: string,
|
||||
): { code: number | null; stdout: string; stderr: string } {
|
||||
return runGrok(home, cwd, [
|
||||
'mcp', 'add', 'gbrain',
|
||||
'-e', `GBRAIN_HOME=${gbrainHome}`,
|
||||
'-e', `GBRAIN_SOURCE=${sourceId}`,
|
||||
'--', 'gbrain', 'serve', '--surface', 'verbs',
|
||||
], binDir);
|
||||
}
|
||||
|
||||
describe.skipIf(!CAN_RUN_KEYLESS)('install real-grok door — keyless tier (serial e2e)', () => {
|
||||
test('version pin: grok --version matches GROK_VERSION when the CI pin is set', () => {
|
||||
const pinned = process.env.GROK_VERSION;
|
||||
const home = trackHome('ver');
|
||||
seedGrokConfig(home);
|
||||
const res = runGrok(home, home, ['--version']);
|
||||
expect(res.code).toBe(0);
|
||||
if (pinned) {
|
||||
expect(res.stdout).toContain(`grok ${pinned}`);
|
||||
} else {
|
||||
// Local run without a pin: assert the observed shape — also the
|
||||
// discriminator against the colliding community grok-cli binary.
|
||||
expect(res.stdout).toMatch(/grok \d+\.\d+\.\d+ \([0-9a-f]+\)/);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test('INSTALL: keyless init → documented-shape `grok mcp add` → TOML carries server + env → doctor handshakes 7 verbs', () => {
|
||||
const home = trackHome('install');
|
||||
seedGrokConfig(home);
|
||||
const ws = join(home, 'ws');
|
||||
mkdirSync(ws, { recursive: true });
|
||||
|
||||
const init = initBrain(home);
|
||||
expect(init.code).toBe(0);
|
||||
expect(existsSync(join(home, '.gbrain', 'brain.pglite'))).toBe(true);
|
||||
|
||||
const binDir = join(home, 'staged-bin');
|
||||
const staged = stageGbrainBinDir(REPO_ROOT, binDir);
|
||||
console.warn(`[install-real-grok] staged gbrain kind=${staged.kind}`);
|
||||
|
||||
const add = registerGbrainIntoGrok(home, ws, binDir, home, 'default');
|
||||
// Add is LAZY (observed): exit 0 proves only that the config was written
|
||||
// — a nonexistent command also exits 0. The handshake proof is doctor.
|
||||
expect(add.code).toBe(0);
|
||||
|
||||
const cfg = readGrokConfig(home);
|
||||
try { cpSync(join(home, '.grok', 'config.toml'), join(home, 'door-config.toml')); } catch { /* evidence */ }
|
||||
expect(cfg.mcp_servers?.gbrain).toBeDefined();
|
||||
expect(cfg.mcp_servers!.gbrain.command).toBe('gbrain');
|
||||
expect(cfg.mcp_servers!.gbrain.args).toEqual(['serve', '--surface', 'verbs']);
|
||||
expect(cfg.mcp_servers!.gbrain.env?.GBRAIN_HOME).toBe(home);
|
||||
expect(cfg.mcp_servers!.gbrain.env?.GBRAIN_SOURCE).toBe('default');
|
||||
// Seeded kill-switch survived registration (observed: add preserves
|
||||
// pre-existing sections).
|
||||
expect(cfg.cli?.auto_update).toBe(false);
|
||||
|
||||
const { code, report } = doctorProbe(home, ws, 'gbrain', binDir);
|
||||
expect(code).toBe(0);
|
||||
const checks = report.servers?.find((s) => s.name === 'gbrain')?.checks ?? [];
|
||||
expect(checks.every((c) => c.passed)).toBe(true);
|
||||
expect(checks.some((c) => /handshake OK/i.test(c.label))).toBe(true);
|
||||
// The verbs surface end-to-end, keyless: seven verbs discovered.
|
||||
expect(checks.some((c) => /7 tools discovered/i.test(c.label))).toBe(true);
|
||||
|
||||
// Soft probe (exit-0-only; shape logged, not asserted).
|
||||
const list = runGrok(home, ws, ['mcp', 'list', '--json'], binDir);
|
||||
expect(list.code).toBe(0);
|
||||
if (!list.stdout.includes('gbrain')) {
|
||||
console.warn('[install-real-grok] mcp list --json did not mention gbrain — shape drift? output:', list.stdout.slice(0, 400));
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test('INSTALL 2b: provenance — the entry is grok-native and vendor fallback is structurally inert here', () => {
|
||||
const home = trackHome('prov');
|
||||
seedGrokConfig(home);
|
||||
const ws = join(home, 'ws');
|
||||
mkdirSync(ws, { recursive: true });
|
||||
|
||||
const binDir = join(home, 'staged-bin');
|
||||
stageGbrainBinDir(REPO_ROOT, binDir);
|
||||
const init = initBrain(home);
|
||||
expect(init.code).toBe(0);
|
||||
registerGbrainIntoGrok(home, ws, binDir, home, 'default');
|
||||
|
||||
// Decoy vendor entry in the fresh cwd: grok SEES it but must not
|
||||
// activate it — fresh home ⇒ folder untrusted (observed).
|
||||
writeFileSync(join(ws, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { 'gbrain-vendor-decoy': { command: join(binDir, 'gbrain'), args: ['serve'] } },
|
||||
}), 'utf-8');
|
||||
|
||||
const { code, report } = doctorProbe(home, ws, 'gbrain', binDir);
|
||||
expect(code).toBe(0);
|
||||
const gbrain = report.servers?.find((s) => s.name === 'gbrain');
|
||||
expect(gbrain?.source).toBe('config'); // grok-native, not a vendor pickup
|
||||
const decoy = report.servers?.find((s) => s.name === 'gbrain-vendor-decoy');
|
||||
if (decoy) {
|
||||
// When doctor reports the decoy at all, it must be inert (untrusted).
|
||||
expect(decoy.checks?.some((c) => /untrusted/i.test(c.label))).toBe(true);
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test('INSTALL 3: the direct config.toml surface (documented, not a fallback) is accepted independently', () => {
|
||||
const home = trackHome('toml');
|
||||
const ws = join(home, 'ws');
|
||||
mkdirSync(ws, { recursive: true });
|
||||
|
||||
const init = initBrain(home);
|
||||
expect(init.code).toBe(0);
|
||||
|
||||
const binDir = join(home, 'staged-bin');
|
||||
stageGbrainBinDir(REPO_ROOT, binDir);
|
||||
|
||||
// Full document (not just the server block): the kill-switch and the
|
||||
// observed schema keys, startup_timeout_sec=60 for the bun-run
|
||||
// cold-transpile flake. Parse-validated before write (Bun.TOML has no
|
||||
// stringify — the literal IS the surface users hand-write).
|
||||
const doc = [
|
||||
'[cli]',
|
||||
'auto_update = false',
|
||||
'',
|
||||
'[mcp_servers.gbrain]',
|
||||
'command = "gbrain"',
|
||||
'args = ["serve", "--surface", "verbs"]',
|
||||
'startup_timeout_sec = 60',
|
||||
'enabled = true',
|
||||
'',
|
||||
'[mcp_servers.gbrain.env]',
|
||||
`GBRAIN_HOME = "${home}"`,
|
||||
'GBRAIN_SOURCE = "default"',
|
||||
'',
|
||||
].join('\n');
|
||||
expect(() => (Bun as unknown as { TOML: { parse(s: string): unknown } }).TOML.parse(doc)).not.toThrow();
|
||||
mkdirSync(join(home, '.grok'), { recursive: true });
|
||||
writeFileSync(join(home, '.grok', 'config.toml'), doc, 'utf-8');
|
||||
|
||||
const { code, report } = doctorProbe(home, ws, 'gbrain', binDir);
|
||||
expect(code).toBe(0);
|
||||
const checks = report.servers?.find((s) => s.name === 'gbrain')?.checks ?? [];
|
||||
expect(checks.some((c) => /handshake OK/i.test(c.label))).toBe(true);
|
||||
|
||||
// Config-preservation pin: a subsequent CLI add must not clobber the
|
||||
// hand-written sections (observed; the eng review predicted the clobber).
|
||||
const add2 = runGrok(home, ws, ['mcp', 'add', 'second', '-e', 'A=1', '--', join(binDir, 'gbrain'), 'serve'], binDir);
|
||||
expect(add2.code).toBe(0);
|
||||
const cfg = readGrokConfig(home);
|
||||
expect(cfg.cli?.auto_update).toBe(false);
|
||||
expect(cfg.mcp_servers?.gbrain?.startup_timeout_sec).toBe(60);
|
||||
}, 420_000);
|
||||
});
|
||||
|
||||
describe.skipIf(!CAN_RUN_PAID)('install real-grok door — paid tier (serial e2e)', () => {
|
||||
test('SMOKE: real grok -p answers the per-run nonce fact through the gbrain MCP server', async () => {
|
||||
const home = trackHome('smoke');
|
||||
const ws = join(home, 'ws');
|
||||
mkdirSync(ws, { recursive: true });
|
||||
|
||||
// PER-RUN nonce fact (never the committed Summit/Rivermouth string): grok
|
||||
// has filesystem/shell tools, so recall of a string that is greppable in
|
||||
// ANY reachable file proves nothing. The nonce exists only in the
|
||||
// hermetic brain — which lives under this same hermetic HOME, so an agent
|
||||
// that greps $HOME/.gbrain could still surface it without MCP; web search
|
||||
// is flag-disabled, fs/shell are prompt-forbidden only. The planned
|
||||
// streaming-json toolCall assertion (pending the authed observation) is
|
||||
// what upgrades this to proof of tool mediation.
|
||||
const nonce = `kestrel-${randomBytes(4).toString('hex')}`;
|
||||
const seeded = await seedBrainForAgent(home, 'workspace', {
|
||||
entity: 'Kestrel Logistics',
|
||||
fact: `The Kestrel Logistics staging depot is codenamed ${nonce}.`,
|
||||
query: 'What is the codename of the Kestrel Logistics staging depot?',
|
||||
slug: 'companies/kestrel-logistics',
|
||||
});
|
||||
seedGrokConfig(home);
|
||||
|
||||
const binDir = join(home, 'staged-bin');
|
||||
stageGbrainBinDir(REPO_ROOT, binDir);
|
||||
registerGbrainIntoGrok(home, ws, binDir, home, 'workspace');
|
||||
|
||||
// Doctor pre-flight — the observed-honest discriminator gates the paid
|
||||
// loop: never enter a paid attempt on a broken registration.
|
||||
const pre = doctorProbe(home, ws, 'gbrain', binDir);
|
||||
if (pre.code !== 0) {
|
||||
console.error('[install-real-grok] doctor pre-flight failed; not spending a paid attempt:',
|
||||
JSON.stringify(pre.report).slice(0, 600));
|
||||
}
|
||||
expect(pre.code).toBe(0);
|
||||
|
||||
const prompt =
|
||||
'You have an MCP server named gbrain connected to a knowledge brain. ' +
|
||||
`Using ONLY that brain (no general knowledge, no web search, no filesystem or shell tools), answer: ${seeded.query} ` +
|
||||
'Report exactly what the brain says. If no gbrain tool is available to you, reply with exactly: NO-GBRAIN-TOOL';
|
||||
|
||||
let finalText = '';
|
||||
let lastExit: number | null = null;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
const turn = await grokOneShotTurn({
|
||||
prompt,
|
||||
cwd: ws,
|
||||
home,
|
||||
timeoutMs: 240_000,
|
||||
disableWebSearch: true,
|
||||
// The MCP registration is the DOCUMENTED bare `gbrain` — the turn's
|
||||
// own child env must resolve it too, or grok can't start the server
|
||||
// mid-turn on a clean runner (doctor passing is not enough).
|
||||
binDir,
|
||||
});
|
||||
finalText = turn.finalText;
|
||||
lastExit = turn.exitCode;
|
||||
if (turn.exitCode === 0 && finalText.toLowerCase().includes(nonce)) break;
|
||||
console.warn(`[install-real-grok] SMOKE attempt ${attempt}: exit=${turn.exitCode} text=${finalText.slice(0, 200)} stderr=${turn.stderrText.slice(0, 200)}`);
|
||||
if (attempt < 2) await new Promise((r) => setTimeout(r, 3_000));
|
||||
}
|
||||
|
||||
// Never-soften criteria: the nonce surfaced AND the no-tool control
|
||||
// token did not.
|
||||
expect(lastExit).toBe(0);
|
||||
expect(finalText.toLowerCase()).toContain(nonce);
|
||||
expect(finalText).not.toContain('NO-GBRAIN-TOOL');
|
||||
|
||||
// NOTE (pending observation, GROK-CLI-PIN.md): the JSON event-stream
|
||||
// shapes are unobserved keyless. Once the first authed run pins the
|
||||
// streaming-json tool-call event shape, add the separate non-retried
|
||||
// toolCall test here (one extra paid turn) and a parseGrokJson helper
|
||||
// with fixtures.
|
||||
}, 600_000);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* issue #5 — process isolation against REAL Postgres (DATABASE_URL-gated;
|
||||
* wired into .github/workflows/e2e.yml tier1 — e2e.yml runs only explicitly
|
||||
* NAMED files, so an unwired e2e file is silent coverage loss).
|
||||
*
|
||||
* Legs:
|
||||
* 1. Worker-level concurrency (codex-2 #6): a real MinionWorker with
|
||||
* isolation on drains 6 jobs at concurrency 3 through real child
|
||||
* processes against the real pooler/DB — the child-pool topology the
|
||||
* per-process pool math describes.
|
||||
* 2. Real CLI entrypoint: `bun src/cli.ts jobs run-child` with the env
|
||||
* contract against a genuinely claimed job — proves engine bootstrap,
|
||||
* handler registry (quiet), token validation, and the outcome protocol
|
||||
* end-to-end. Uses the real 'orphans' handler (cheap on a fresh DB).
|
||||
* 3. Stuck-child kill: a SIGTERM-ignoring child is group-SIGKILLed and the
|
||||
* worker keeps claiming.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { hasDatabase, setupDB, teardownDB, getEngine } from './helpers.ts';
|
||||
import { MinionQueue } from '../../src/core/minions/queue.ts';
|
||||
import { MinionWorker } from '../../src/core/minions/worker.ts';
|
||||
import { decodeChildOutcomeFile, CHILD_ENV } from '../../src/core/minions/job-isolation.ts';
|
||||
import { withEnv } from '../helpers/with-env.ts';
|
||||
|
||||
const FIXTURE = resolve(import.meta.dir, '..', 'fixtures', 'fake-run-child.mjs');
|
||||
const CLI = resolve(import.meta.dir, '..', '..', 'src', 'cli.ts');
|
||||
|
||||
const describeDb = hasDatabase() ? describe : describe.skip;
|
||||
|
||||
let queue: MinionQueue;
|
||||
let tmp: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!hasDatabase()) return;
|
||||
await setupDB();
|
||||
queue = new MinionQueue(getEngine());
|
||||
tmp = mkdtempSync(join(tmpdir(), 'gbrain-e2e-isolation-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!hasDatabase()) return;
|
||||
await teardownDB();
|
||||
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function rowStatus(id: number): Promise<string> {
|
||||
const rows = await getEngine().executeRaw<{ status: string }>(
|
||||
'SELECT status FROM minion_jobs WHERE id = $1',
|
||||
[id],
|
||||
);
|
||||
return rows[0]?.status ?? 'missing';
|
||||
}
|
||||
|
||||
describeDb('process isolation on real Postgres', () => {
|
||||
test('concurrency 3: six isolated jobs drain through real children', async () => {
|
||||
await withEnv({ FAKE_RUN_CHILD_MODE: 'success' }, async () => {
|
||||
const jobs = [];
|
||||
for (let i = 0; i < 6; i++) jobs.push(await queue.add('isotest', { i }));
|
||||
|
||||
const worker = new MinionWorker(getEngine(), {
|
||||
queue: 'default',
|
||||
concurrency: 3,
|
||||
pollInterval: 50,
|
||||
healthCheckInterval: 0,
|
||||
maxRssMb: 0,
|
||||
jobIsolation: 'process',
|
||||
childCliInvocation: { cmd: process.execPath, argsPrefix: [FIXTURE] },
|
||||
});
|
||||
worker.register('isotest', async () => {
|
||||
throw new Error('parent handler must not run when isolated');
|
||||
});
|
||||
|
||||
const run = worker.start();
|
||||
const deadline = Date.now() + 30_000;
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const statuses = await Promise.all(jobs.map((j) => rowStatus(j.id)));
|
||||
if (statuses.every((s) => s === 'completed')) break;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
} finally {
|
||||
worker.stop();
|
||||
await run;
|
||||
}
|
||||
const statuses = await Promise.all(jobs.map((j) => rowStatus(j.id)));
|
||||
expect(statuses).toEqual(['completed', 'completed', 'completed', 'completed', 'completed', 'completed']);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test('real CLI run-child: engine bootstrap + protocol end-to-end', async () => {
|
||||
const job = await queue.add('orphans', {});
|
||||
const claimed = await queue.claim('e2e-cli-tok', 60_000, 'default', ['orphans']);
|
||||
expect(claimed?.id).toBe(job.id);
|
||||
|
||||
const resultPath = join(tmp, `cli-${job.id}.json`);
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[CLI, 'jobs', 'run-child', '--job-id', String(job.id)],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
GBRAIN_DATABASE_URL: process.env.DATABASE_URL,
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL: '1',
|
||||
[CHILD_ENV.lockToken]: 'e2e-cli-tok',
|
||||
[CHILD_ENV.resultPath]: resultPath,
|
||||
[CHILD_ENV.parentPid]: String(process.pid),
|
||||
},
|
||||
timeout: 60_000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
);
|
||||
if (res.status !== 0) {
|
||||
throw new Error(
|
||||
`run-child exited ${res.status}: ${String(res.stderr)}\n${String(res.stdout)}`,
|
||||
);
|
||||
}
|
||||
expect(existsSync(resultPath)).toBe(true);
|
||||
const outcome = decodeChildOutcomeFile(resultPath);
|
||||
// The protocol completed; the real handler's own verdict (success or a
|
||||
// reported error on a bare DB) is out of scope for this leg.
|
||||
expect(outcome.outcome === 'success' || outcome.outcome === 'error').toBe(true);
|
||||
}, 90_000);
|
||||
|
||||
// The stuck-child group-SIGKILL path is pinned with REAL processes in
|
||||
// test/child-job-runner.test.ts (SIGTERM-ignorer → SIGKILL at grace) and
|
||||
// the crash/burn semantics in test/worker-job-isolation.test.ts — this
|
||||
// lane deliberately doesn't duplicate them against the shared CI DB.
|
||||
});
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* transcripts-ingest e2e (PGLite) — cathedral-4.
|
||||
*
|
||||
* Pins the import lane end-to-end against a real embedded engine:
|
||||
* cross-harness round-trip, dry-run zero-writes, idempotent re-runs,
|
||||
* redaction-before-write, part splitting under the embed-skip threshold,
|
||||
* the DANGEROUS TRANSITIONS (split→shrink stale-part deletion), since/limit
|
||||
* clean-scan semantics, and the putRawData zero-row parity fix.
|
||||
*
|
||||
* R3/R4: engine in beforeAll, disconnect in afterAll; state reset per test.
|
||||
*/
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
||||
import { runTranscriptsIngest } from '../../src/core/transcripts/ingest.ts';
|
||||
import { runIngestFacts } from '../../src/core/transcripts/ingest-facts.ts';
|
||||
import {
|
||||
buildStatusRows,
|
||||
discoverTranscriptFiles,
|
||||
indexImportedSessions,
|
||||
} from '../../src/core/transcripts/discover.ts';
|
||||
import type { HarnessRoot } from '../../src/core/transcripts/detect.ts';
|
||||
import { MESSAGE_CHAR_CAP } from '../../src/core/transcripts/render.ts';
|
||||
import { buildTranscriptSlug } from '../../src/core/transcripts/types.ts';
|
||||
import { buildHermesFixture } from '../fixtures/transcripts/hermes-fixture-builder.ts';
|
||||
|
||||
const CODEX_SLUG = buildTranscriptSlug('codex', '2026-08-02T09:00:00.000Z', {
|
||||
sessionId: 'codex-fixture-session-1',
|
||||
});
|
||||
const AGENT_SLUG = buildTranscriptSlug('openclaw', '2026-08-03T14:00:00.000Z', {
|
||||
sessionId: 'agent-fixture-session-1',
|
||||
});
|
||||
|
||||
const CODEX_FIXTURE = join(import.meta.dir, '..', 'fixtures', 'transcripts', 'codex-rollout.jsonl');
|
||||
const AGENT_FIXTURE = join(import.meta.dir, '..', 'fixtures', 'transcripts', 'agent-session.jsonl');
|
||||
const CLAUDE_CODE_FIXTURE = join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'fixtures',
|
||||
'conversation-formats',
|
||||
'claude-code.jsonl',
|
||||
);
|
||||
const CHATGPT_FIXTURE = join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'fixtures',
|
||||
'transcripts',
|
||||
'chatgpt-conversations.json',
|
||||
);
|
||||
const CLAUDE_EXPORT_FIXTURE = join(
|
||||
import.meta.dir,
|
||||
'..',
|
||||
'fixtures',
|
||||
'transcripts',
|
||||
'claude-export.json',
|
||||
);
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let tmp: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'gb-ingest-e2e-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const NO_PATTERNS = { userPatternsPath: '/nonexistent-patterns.txt' };
|
||||
|
||||
// Synthetic AWS-shaped token, built at runtime so the literal never lands in
|
||||
// committed bytes (the pre-push credential guard would flag it — correctly).
|
||||
const PLANTED_KEY = ['AKIA', 'ABCDEFGHIJKLMNOP'].join('');
|
||||
|
||||
function baseOpts(paths: string[], extra: Record<string, unknown> = {}) {
|
||||
return { paths, sourceId: 'default', ...NO_PATTERNS, ...extra };
|
||||
}
|
||||
|
||||
/** Synthetic openclaw-format session with N large messages. */
|
||||
function writeBigAgentSession(dir: string, id: string, messageCount: number): string {
|
||||
const lines: string[] = [
|
||||
JSON.stringify({ type: 'session', version: 3, id, timestamp: '2026-08-10T08:00:00.000Z', cwd: '/tmp' }),
|
||||
];
|
||||
const filler = 'lorem widget fact '.repeat(Math.ceil((MESSAGE_CHAR_CAP - 100) / 18));
|
||||
for (let i = 0; i < messageCount; i++) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
id: `m-${i}`,
|
||||
timestamp: `2026-08-10T08:${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}.000Z`,
|
||||
message: {
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
timestamp: `2026-08-10T08:${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}.000Z`,
|
||||
content: [{ type: 'text', text: `marker-${i} ${filler}` }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const p = join(dir, `${id}.jsonl`);
|
||||
writeFileSync(p, lines.join('\n') + '\n');
|
||||
return p;
|
||||
}
|
||||
|
||||
describe('cross-harness round-trip', () => {
|
||||
test('codex + openclaw fixtures land as conversation pages in one source', async () => {
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([CODEX_FIXTURE, AGENT_FIXTURE]));
|
||||
expect(r.sessionsImported).toBe(2);
|
||||
expect(r.pages.imported).toBe(2);
|
||||
// The committed fixtures carry deliberate malformed tail lines — a
|
||||
// possibly-dropped record must freeze the watermark, so this is NOT a
|
||||
// clean scan (pristine-file cleanliness is pinned in the since/limit
|
||||
// suite below).
|
||||
expect(r.cleanScan).toBe(false);
|
||||
expect(r.erroredFiles).toBe(0);
|
||||
|
||||
const codexPage = await engine.getPage(CODEX_SLUG, {
|
||||
sourceId: 'default',
|
||||
});
|
||||
expect(codexPage).not.toBeNull();
|
||||
expect(codexPage!.type).toBe('conversation');
|
||||
expect(codexPage!.compiled_truth).toContain('fund-a led the widget-co seed');
|
||||
expect(codexPage!.compiled_truth).not.toContain('PREAMBLE-ONLY-TEXT');
|
||||
|
||||
const agentPage = await engine.getPage(AGENT_SLUG, {
|
||||
sourceId: 'default',
|
||||
});
|
||||
expect(agentPage).not.toBeNull();
|
||||
expect(agentPage!.compiled_truth).toContain('acme-seed memo');
|
||||
// Cross-harness continuity substrate: both sessions in ONE brain source.
|
||||
const fm = agentPage!.frontmatter as Record<string, any>;
|
||||
expect(fm.transcript_import.harness).toBe('openclaw');
|
||||
expect(fm.transcript_import.session_id).toBe('agent-fixture-session-1');
|
||||
expect(fm.date).toBe('2026-08-03');
|
||||
|
||||
// Session metadata rode putRawData onto the base page.
|
||||
const raw = await engine.getRawData(agentPage!.slug, undefined, { sourceId: 'default' });
|
||||
expect(raw.length).toBeGreaterThan(0);
|
||||
expect((raw[0].data as Record<string, unknown>).session_id).toBe('agent-fixture-session-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dry-run', () => {
|
||||
test('writes NOTHING — no pages, no raw data — and never advances watermarks', async () => {
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([CODEX_FIXTURE], { dryRun: true }));
|
||||
expect(r.pages.planned).toBe(1);
|
||||
expect(r.pages.imported).toBe(0);
|
||||
expect(r.cleanScan).toBe(false); // dry-runs must not advance the watermark
|
||||
const pages = await engine.listPages({ type: 'conversation', sourceId: 'default', limit: 10 });
|
||||
expect(pages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency', () => {
|
||||
test('second run hash-skips every page; slugsTouched still includes them (facts re-runs)', async () => {
|
||||
const r1 = await runTranscriptsIngest(engine, baseOpts([CODEX_FIXTURE, AGENT_FIXTURE]));
|
||||
expect(r1.pages.imported).toBe(2);
|
||||
const r2 = await runTranscriptsIngest(engine, baseOpts([CODEX_FIXTURE, AGENT_FIXTURE]));
|
||||
expect(r2.pages.imported).toBe(0);
|
||||
expect(r2.pages.skipped).toBe(2);
|
||||
// The facts lane must see hash-skipped slugs too (CX14).
|
||||
expect(r2.slugsTouched.sort()).toEqual(r1.slugsTouched.sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('redaction before write', () => {
|
||||
test('planted secret never reaches the page; redaction counted', async () => {
|
||||
const p = join(tmp, 'secret-session.jsonl');
|
||||
writeFileSync(
|
||||
p,
|
||||
[
|
||||
JSON.stringify({ type: 'session', version: 3, id: 'secret-session-01', timestamp: '2026-08-09T10:00:00.000Z' }),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
id: 'm-1',
|
||||
timestamp: '2026-08-09T10:00:01.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
timestamp: '2026-08-09T10:00:01.000Z',
|
||||
content: [{ type: 'text', text: `the deploy key is ${PLANTED_KEY} keep it safe` }],
|
||||
},
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([p]));
|
||||
expect(r.sessionsImported).toBe(1);
|
||||
expect(r.redactions).toBeGreaterThanOrEqual(1);
|
||||
const page = await engine.getPage(r.slugsTouched[0], { sourceId: 'default' });
|
||||
expect(page).not.toBeNull();
|
||||
expect(page!.compiled_truth).not.toContain(PLANTED_KEY);
|
||||
expect(page!.compiled_truth).toContain('<REDACTED:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('part splitting + dangerous transitions', () => {
|
||||
test('big session splits under the embed-skip threshold and every part is a real page', async () => {
|
||||
const p = writeBigAgentSession(tmp, 'bigsession-0001', 150);
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([p]));
|
||||
expect(r.sessionsImported).toBe(1);
|
||||
expect(r.pages.imported).toBeGreaterThan(1);
|
||||
expect(r.cleanScan).toBe(true); // pristine synthetic file: clean scan holds
|
||||
const base = buildTranscriptSlug('openclaw', '2026-08-10T08:00:00.000Z', {
|
||||
sessionId: 'bigsession-0001',
|
||||
});
|
||||
const p1 = await engine.getPage(base, { sourceId: 'default' });
|
||||
const p2 = await engine.getPage(`${base}-p2`, { sourceId: 'default' });
|
||||
expect(p1).not.toBeNull();
|
||||
expect(p2).not.toBeNull();
|
||||
// Split pages must stay embeddable: no embed_skip marker on any part.
|
||||
for (const page of [p1!, p2!]) {
|
||||
const fm = page.frontmatter as Record<string, any>;
|
||||
expect(fm.embed_skip).toBeUndefined();
|
||||
expect(fm.transcript_import.of).toBe(r.pages.imported);
|
||||
}
|
||||
// Unique per-part identity (a shared id would dedup-skip parts 2..N).
|
||||
expect((p1!.frontmatter as any).id).not.toBe((p2!.frontmatter as any).id);
|
||||
});
|
||||
|
||||
test('split → shrink deletes stale higher parts (reconciliation)', async () => {
|
||||
const big = writeBigAgentSession(tmp, 'shrinksession-01', 150);
|
||||
const r1 = await runTranscriptsIngest(engine, baseOpts([big]));
|
||||
const parts = r1.pages.imported;
|
||||
expect(parts).toBeGreaterThan(1);
|
||||
// Same session id, now tiny: re-render to ONE part.
|
||||
const small = writeBigAgentSession(join(tmp), 'shrinksession-01', 2);
|
||||
const r2 = await runTranscriptsIngest(engine, baseOpts([small]));
|
||||
expect(r2.sessionsImported).toBe(1);
|
||||
expect(r2.partsDeleted).toBe(parts - 1);
|
||||
const base = buildTranscriptSlug('openclaw', '2026-08-10T08:00:00.000Z', {
|
||||
sessionId: 'shrinksession-01',
|
||||
});
|
||||
expect(await engine.getPage(base, { sourceId: 'default' })).not.toBeNull();
|
||||
expect(await engine.getPage(`${base}-p2`, { sourceId: 'default' })).toBeNull();
|
||||
});
|
||||
|
||||
test('reconciliation heals crash holes (deleted -p2, surviving -p3)', async () => {
|
||||
const big = writeBigAgentSession(tmp, 'holesession-0001', 300);
|
||||
const r1 = await runTranscriptsIngest(engine, baseOpts([big]));
|
||||
expect(r1.pages.imported).toBeGreaterThan(2); // need at least p3 for the hole
|
||||
const base = buildTranscriptSlug('openclaw', '2026-08-10T08:00:00.000Z', {
|
||||
sessionId: 'holesession-0001',
|
||||
});
|
||||
// Simulate a crash mid-reconciliation on a prior shrink: -p2 already
|
||||
// deleted, higher parts survive.
|
||||
await engine.deletePage(`${base}-p2`, { sourceId: 'default' });
|
||||
const small = writeBigAgentSession(join(tmp), 'holesession-0001', 2);
|
||||
const r2 = await runTranscriptsIngest(engine, baseOpts([small]));
|
||||
expect(r2.sessionsImported).toBe(1);
|
||||
// SQL enumeration walks past the -p2 hole and removes every survivor.
|
||||
expect(await engine.getPage(`${base}-p3`, { sourceId: 'default' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('since/limit clean-scan semantics', () => {
|
||||
test('sinceIso filters old sessions; limit counts NEW WORK; truncation breaks cleanScan', async () => {
|
||||
// Two pristine synthetic sessions (no malformed lines → clean scans).
|
||||
const a = writeBigAgentSession(tmp, 'sincesession-0001', 2);
|
||||
const b = writeBigAgentSession(tmp, 'sincesession-0002', 2);
|
||||
|
||||
// Both are older than the since bound → filtered, clean scan holds.
|
||||
const rSince = await runTranscriptsIngest(
|
||||
engine,
|
||||
baseOpts([a, b], { sinceIso: '2027-01-01T00:00:00.000Z' }),
|
||||
);
|
||||
expect(rSince.sessionsFiltered).toBe(2);
|
||||
expect(rSince.sessionsImported).toBe(0);
|
||||
expect(rSince.cleanScan).toBe(true);
|
||||
expect(rSince.maxSessionTs > '2026-08-01').toBe(true);
|
||||
|
||||
// limit=1 over two files → truncated, NOT a clean scan (watermark frozen).
|
||||
const rLimit = await runTranscriptsIngest(engine, baseOpts([a, b], { limit: 1 }));
|
||||
expect(rLimit.sessionsImported).toBe(1);
|
||||
expect(rLimit.cleanScan).toBe(false);
|
||||
|
||||
// Kill/rerun convergence WITH the same limit: hash-skipped re-scans are
|
||||
// FREE (they don't burn the limit), so run 2 reaches the second session
|
||||
// instead of looping over the imported prefix forever.
|
||||
const rLimit2 = await runTranscriptsIngest(engine, baseOpts([a, b], { limit: 1 }));
|
||||
expect(rLimit2.sessionsImported).toBe(2); // 1 hash-skip + 1 new import
|
||||
const rFull = await runTranscriptsIngest(engine, baseOpts([a, b]));
|
||||
expect(rFull.pages.imported).toBe(0);
|
||||
expect(rFull.pages.skipped).toBe(2);
|
||||
expect(rFull.cleanScan).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error taxonomy', () => {
|
||||
test('unknown-format file is a per-file error; the run continues', async () => {
|
||||
const junk = join(tmp, 'junk.jsonl');
|
||||
writeFileSync(junk, '{"unrelated":true}\n');
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([junk, CODEX_FIXTURE]));
|
||||
expect(r.erroredFiles).toBe(1);
|
||||
expect(r.sessionsImported).toBe(1);
|
||||
expect(r.cleanScan).toBe(false);
|
||||
});
|
||||
|
||||
test('zero-session file raises the drift signal', async () => {
|
||||
const empty = join(tmp, 'empty.jsonl');
|
||||
writeFileSync(
|
||||
empty,
|
||||
JSON.stringify({ type: 'session', version: 3, id: 'empty-session-1', timestamp: '2026-08-09T10:00:00.000Z' }) + '\n',
|
||||
);
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([empty], { format: 'openclaw' }));
|
||||
expect(r.driftFiles).toBe(1);
|
||||
expect(r.sessionsImported).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('all six formats travel the FULL pipeline (parse → redact → render → import)', () => {
|
||||
test('claude-code: the shipped fixture imports as a page with placeholders and real timestamps', async () => {
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([CLAUDE_CODE_FIXTURE]));
|
||||
expect(r.sessionsImported).toBe(1);
|
||||
expect(r.pages.imported).toBe(1);
|
||||
const slug = buildTranscriptSlug('claude-code', '2026-08-01T10:00:00.000Z', {
|
||||
sessionId: 'fixture-session-1',
|
||||
});
|
||||
const page = await engine.getPage(slug, { sourceId: 'default' });
|
||||
expect(page).not.toBeNull();
|
||||
expect(page!.type).toBe('conversation');
|
||||
const fm = page!.frontmatter as Record<string, any>;
|
||||
expect(fm.transcript_import.harness).toBe('claude-code');
|
||||
expect(fm.date).toBe('2026-08-01');
|
||||
// Text turns land; tool traffic appears only as placeholders; the
|
||||
// anchor lines carry the fixture's REAL timestamps.
|
||||
expect(page!.compiled_truth).toContain("widget-co's seed round");
|
||||
expect(page!.compiled_truth).toContain('[tool: search_brain]');
|
||||
expect(page!.compiled_truth).toContain('(2026-08-01 10:00 AM)');
|
||||
});
|
||||
|
||||
test('hermes: ONE store file yields MANY pages (multi-session ingest path)', async () => {
|
||||
const dbPath = buildHermesFixture(tmp);
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([dbPath]));
|
||||
// 3 sessions in the store; the tool-only one never yields → 2 imported.
|
||||
expect(r.sessionsImported).toBe(2);
|
||||
expect(r.pages.imported).toBe(2);
|
||||
expect(r.cleanScan).toBe(true);
|
||||
const s1 = buildTranscriptSlug('hermes', '2026-08-05T08:00:00.000Z', {
|
||||
sessionId: 'hermes-fixture-1',
|
||||
});
|
||||
const s2 = buildTranscriptSlug('hermes', '2026-08-06T08:00:00.000Z', {
|
||||
sessionId: 'hermes-fixture-2',
|
||||
});
|
||||
const p1 = await engine.getPage(s1, { sourceId: 'default' });
|
||||
const p2 = await engine.getPage(s2, { sourceId: 'default' });
|
||||
expect(p1).not.toBeNull();
|
||||
expect(p2).not.toBeNull();
|
||||
// Title is promoted to the page COLUMN at import (not kept in frontmatter).
|
||||
expect(p1!.title).toContain('widget planning');
|
||||
// Session 2's JSON block-array contents unwrapped to text in the page.
|
||||
expect(p2!.compiled_truth).toContain('acme-seed closes at the end of the month.');
|
||||
// Session metadata rode raw_data for BOTH sessions of the one file.
|
||||
const raw1 = await engine.getRawData(s1, 'transcript:hermes', { sourceId: 'default' });
|
||||
const raw2 = await engine.getRawData(s2, 'transcript:hermes', { sourceId: 'default' });
|
||||
expect(raw1.length).toBe(1);
|
||||
expect(raw2.length).toBe(1);
|
||||
|
||||
// limit interplay on a multi-session FILE: limit=1 imports one session,
|
||||
// truncates cleanly, and the follow-up run converges.
|
||||
await resetPgliteState(engine);
|
||||
const rLimit = await runTranscriptsIngest(engine, baseOpts([dbPath], { limit: 1 }));
|
||||
expect(rLimit.sessionsImported).toBe(1);
|
||||
expect(rLimit.cleanScan).toBe(false);
|
||||
const rRest = await runTranscriptsIngest(engine, baseOpts([dbPath]));
|
||||
expect(rRest.pages.imported + rRest.pages.skipped).toBe(2);
|
||||
});
|
||||
|
||||
test('chatgpt export: one file → per-thread pages under conversations/chatgpt/ with title slugs', async () => {
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([CHATGPT_FIXTURE]));
|
||||
// Conversation 3 is system-only → skipped by the adapter.
|
||||
expect(r.sessionsImported).toBe(2);
|
||||
expect(r.pages.imported).toBe(2);
|
||||
const slug = buildTranscriptSlug('chatgpt', new Date(1786080000 * 1000).toISOString(), {
|
||||
sessionId: 'cgpt-conv-0001',
|
||||
title: 'Widget launch naming',
|
||||
});
|
||||
expect(slug).toContain('conversations/chatgpt/');
|
||||
expect(slug).toContain('widget-launch-naming');
|
||||
const page = await engine.getPage(slug, { sourceId: 'default' });
|
||||
expect(page).not.toBeNull();
|
||||
expect(page!.title).toBe('Widget launch naming');
|
||||
// Canonical path only — the abandoned branch never lands in the page.
|
||||
expect(page!.compiled_truth).toContain('Call it LaunchPanel.');
|
||||
expect(page!.compiled_truth).not.toContain('BRANCH-A-ONLY-TEXT');
|
||||
});
|
||||
|
||||
test('claude.ai export: one file → pages under conversations/claude/ with title slugs', async () => {
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([CLAUDE_EXPORT_FIXTURE]));
|
||||
expect(r.sessionsImported).toBe(1);
|
||||
expect(r.pages.imported).toBe(1);
|
||||
const slug = buildTranscriptSlug('claude-export', '2026-08-07T12:00:00.000Z', {
|
||||
sessionId: 'claude-conv-0001',
|
||||
title: 'Deal memo review',
|
||||
});
|
||||
expect(slug).toContain('conversations/claude/');
|
||||
expect(slug).toContain('deal-memo-review');
|
||||
const page = await engine.getPage(slug, { sourceId: 'default' });
|
||||
expect(page).not.toBeNull();
|
||||
expect(page!.compiled_truth).toContain('fund-a term sheet date');
|
||||
const fm = page!.frontmatter as Record<string, any>;
|
||||
expect(fm.transcript_import.harness).toBe('claude-export');
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw metadata redaction [security: raw rides the REDACTED copy]', () => {
|
||||
test('secrets in session metadata never reach raw_data', async () => {
|
||||
const p = join(tmp, 'meta-secret.jsonl');
|
||||
writeFileSync(
|
||||
p,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: 'meta-secret-01',
|
||||
timestamp: '2026-08-09T10:00:00.000Z',
|
||||
cwd: `/home/alice/${PLANTED_KEY}-project`,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
id: 'm-1',
|
||||
timestamp: '2026-08-09T10:00:01.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
timestamp: '2026-08-09T10:00:01.000Z',
|
||||
content: [{ type: 'text', text: 'plain question' }],
|
||||
},
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([p]));
|
||||
expect(r.sessionsImported).toBe(1);
|
||||
const raw = await engine.getRawData(r.slugsTouched[0], undefined, { sourceId: 'default' });
|
||||
expect(raw.length).toBeGreaterThan(0);
|
||||
const stored = JSON.stringify(raw[0].data);
|
||||
expect(stored).not.toContain(PLANTED_KEY);
|
||||
expect(stored).toContain('<REDACTED:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('embed-OFF default', () => {
|
||||
test('imported pages carry zero embedded chunks unless embed is opted in', async () => {
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([CODEX_FIXTURE]));
|
||||
expect(r.pages.imported).toBe(1);
|
||||
const rows = await engine.executeRaw<{ n: number }>(
|
||||
`SELECT COUNT(*)::int AS n FROM content_chunks cc
|
||||
JOIN pages p ON p.id = cc.page_id
|
||||
WHERE p.slug = $1 AND cc.embedding IS NOT NULL`,
|
||||
[r.slugsTouched[0]],
|
||||
);
|
||||
expect(Number(rows[0].n)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discovery + status [injected roots, never the real home]', () => {
|
||||
test('discovery filters symlinks/checkpoints; status gap math catches late arrivals', async () => {
|
||||
// Fake harness layout: an openclaw agents tree with one real session,
|
||||
// one checkpoint sibling, one symlink; plus a codex sessions tree.
|
||||
const openclawRoot = join(tmp, 'agents');
|
||||
const codexRoot = join(tmp, 'sessions');
|
||||
mkdirSync(join(openclawRoot, 'main', 'sessions'), { recursive: true });
|
||||
mkdirSync(codexRoot, { recursive: true });
|
||||
const realSession = join(openclawRoot, 'main', 'sessions', 'agent-fixture-session-1.jsonl');
|
||||
copyFileSync(AGENT_FIXTURE, realSession);
|
||||
copyFileSync(
|
||||
AGENT_FIXTURE,
|
||||
join(openclawRoot, 'main', 'sessions', 'agent-fixture-session-1.checkpoint.aaaa-bbbb.jsonl'),
|
||||
);
|
||||
symlinkSync(realSession, join(openclawRoot, 'main', 'sessions', 'link.jsonl'));
|
||||
copyFileSync(CODEX_FIXTURE, join(codexRoot, 'rollout-codex-fixture-session-1.jsonl'));
|
||||
|
||||
const roots: HarnessRoot[] = [
|
||||
{ format: 'openclaw', root: openclawRoot, extension: '.jsonl' },
|
||||
{ format: 'codex', root: codexRoot, extension: '.jsonl' },
|
||||
];
|
||||
const discovered = discoverTranscriptFiles(roots);
|
||||
// Checkpoint + symlink excluded: one file per harness.
|
||||
expect(discovered.map((d) => d.format).sort()).toEqual(['codex', 'openclaw']);
|
||||
|
||||
// Import ONLY the openclaw session; codex stays a gap (late arrival).
|
||||
const r = await runTranscriptsIngest(engine, baseOpts([realSession]));
|
||||
expect(r.sessionsImported).toBe(1);
|
||||
const rows = buildStatusRows(discovered, await indexImportedSessions(engine, 'default'), roots);
|
||||
const oc = rows.find((x) => x.format === 'openclaw')!;
|
||||
const cx = rows.find((x) => x.format === 'codex')!;
|
||||
expect(oc.found).toBe(1);
|
||||
expect(oc.importedSessions).toBe(1);
|
||||
expect(oc.gapFiles).toBe(0);
|
||||
expect(cx.found).toBe(1);
|
||||
expect(cx.importedSessions).toBe(0);
|
||||
expect(cx.gapFiles).toBe(1); // the watermark-blind late arrival, caught here
|
||||
});
|
||||
});
|
||||
|
||||
describe('facts kill-switch pre-check', () => {
|
||||
test('facts.extraction_enabled=false skips with a notice result, never a throw', async () => {
|
||||
await engine.setConfig('facts.extraction_enabled', 'false');
|
||||
try {
|
||||
const r = await runIngestFacts(engine, {
|
||||
sourceId: 'default',
|
||||
slugs: ['conversations/sessions/whatever'],
|
||||
quiet: true,
|
||||
});
|
||||
expect(r.skippedDisabled).toBe(true);
|
||||
expect(r.pages).toBe(0);
|
||||
} finally {
|
||||
await engine.unsetConfig('facts.extraction_enabled');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('putRawData zero-row parity (PGLite)', () => {
|
||||
test('missing page throws instead of silently no-opping', async () => {
|
||||
await expect(
|
||||
engine.putRawData('conversations/sessions/never-imported', 'transcript:codex', { a: 1 }, { sourceId: 'default' }),
|
||||
).rejects.toThrow(/not found/);
|
||||
await expect(
|
||||
engine.putRawData('conversations/sessions/never-imported-2', 'transcript:codex', { a: 1 }),
|
||||
).rejects.toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Write-back fidelity THROUGH THE ADAPTERS (cathedral-4, deterministic).
|
||||
*
|
||||
* The BrainBench write-back suite renders normalized fixture turns directly —
|
||||
* it never exercises raw-format parsing, detection, redaction, or the
|
||||
* importer. This e2e closes that gap in-repo: raw fixture FILES (a codex
|
||||
* rollout and an openclaw session) enter via runTranscriptsIngest
|
||||
* (parse → redact → render → import), then the SHIPPED extractor core runs
|
||||
* with an injected GOLD extractor (the BrainBench decision-15 seam — zero
|
||||
* LLM calls), and planted facts are probed in the facts table with
|
||||
* provenance intact. Cross-harness continuity: facts from BOTH harnesses'
|
||||
* sessions coexist in one source, queryable together.
|
||||
*
|
||||
* The full BrainBench raw-fixture schema (sidecar type + loader + corpus-hash
|
||||
* coverage + baseline re-cut) lives in the sibling gbrain-evals repo and is a
|
||||
* filed follow-up; this test is the in-repo fidelity pin.
|
||||
*
|
||||
* R3/R4: engine in beforeAll, disconnect in afterAll.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { join } from 'node:path';
|
||||
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from '../helpers/reset-pglite.ts';
|
||||
import { runTranscriptsIngest } from '../../src/core/transcripts/ingest.ts';
|
||||
import { runExtractConversationFactsCore } from '../../src/commands/extract-conversation-facts.ts';
|
||||
import type { ExtractInput, ExtractedFact } from '../../src/core/facts/extract.ts';
|
||||
|
||||
const CODEX_FIXTURE = join(import.meta.dir, '..', 'fixtures', 'transcripts', 'codex-rollout.jsonl');
|
||||
const AGENT_FIXTURE = join(import.meta.dir, '..', 'fixtures', 'transcripts', 'agent-session.jsonl');
|
||||
|
||||
/** Gold facts planted in the raw fixtures, keyed by a probe substring. */
|
||||
const GOLD: Array<{ probe: string; fact: string; entity_slug: string | null }> = [
|
||||
{ probe: 'fund-a led the widget-co seed', fact: 'fund-a led the widget-co seed round', entity_slug: 'widget-co' },
|
||||
{ probe: 'bridge check-in', fact: 'the bridge check-in happens every Thursday', entity_slug: null },
|
||||
{ probe: 'acme-seed memo', fact: 'alice-example is drafting the acme-seed memo', entity_slug: 'alice-example' },
|
||||
];
|
||||
|
||||
/** Deterministic gold extractor: emits gold facts whose probe is in the segment. */
|
||||
async function goldExtractor(input: ExtractInput): Promise<ExtractedFact[]> {
|
||||
return GOLD.filter((g) => input.turnText.includes(g.probe)).map((g) => ({
|
||||
fact: g.fact,
|
||||
kind: 'event',
|
||||
source: input.source,
|
||||
confidence: 0.95,
|
||||
notability: 'medium',
|
||||
entity_slug: g.entity_slug,
|
||||
})) as ExtractedFact[];
|
||||
}
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
describe('write-back fidelity through the adapter path', () => {
|
||||
test('raw codex + openclaw files → pages → gold extraction → facts with provenance', async () => {
|
||||
const ingest = await runTranscriptsIngest(engine, {
|
||||
paths: [CODEX_FIXTURE, AGENT_FIXTURE],
|
||||
sourceId: 'default',
|
||||
userPatternsPath: '/nonexistent-patterns.txt',
|
||||
});
|
||||
expect(ingest.sessionsImported).toBe(2);
|
||||
|
||||
const extract = await runExtractConversationFactsCore(engine, {
|
||||
sourceId: 'default',
|
||||
slugs: [...new Set(ingest.slugsTouched)],
|
||||
extractor: goldExtractor,
|
||||
overrideDisabled: true,
|
||||
});
|
||||
expect(extract.pages_processed).toBe(2);
|
||||
expect(extract.facts_inserted).toBeGreaterThanOrEqual(GOLD.length);
|
||||
|
||||
// Probe survival + provenance via the raw facts table (deterministic read).
|
||||
const facts = await engine.executeRaw<{ fact: string; source: string; source_markdown_slug: string }>(
|
||||
`SELECT fact, source, source_markdown_slug FROM facts
|
||||
WHERE source_id = 'default' AND source LIKE 'cli:extract-conversation-facts%'`,
|
||||
);
|
||||
for (const g of GOLD) {
|
||||
const hit = facts.find((f) => f.fact === g.fact);
|
||||
expect(hit).toBeTruthy();
|
||||
// Provenance points back at an imported conversation page.
|
||||
expect(hit!.source_markdown_slug).toMatch(/^conversations\/sessions\//);
|
||||
}
|
||||
|
||||
// CROSS-HARNESS CONTINUITY: one source holds facts grounded in BOTH
|
||||
// harnesses' sessions — "what did I decide, in whichever agent I said it".
|
||||
const slugsWithFacts = new Set(facts.map((f) => f.source_markdown_slug));
|
||||
expect([...slugsWithFacts].some((s) => s.includes('-codex-'))).toBe(true);
|
||||
expect([...slugsWithFacts].some((s) => s.includes('-openclaw-'))).toBe(true);
|
||||
});
|
||||
|
||||
test('re-extraction is deduped by the durable-outcome gate (no double facts)', async () => {
|
||||
const ingest = await runTranscriptsIngest(engine, {
|
||||
paths: [CODEX_FIXTURE],
|
||||
sourceId: 'default',
|
||||
userPatternsPath: '/nonexistent-patterns.txt',
|
||||
});
|
||||
const slugs = [...new Set(ingest.slugsTouched)];
|
||||
const first = await runExtractConversationFactsCore(engine, {
|
||||
sourceId: 'default',
|
||||
slugs,
|
||||
extractor: goldExtractor,
|
||||
overrideDisabled: true,
|
||||
});
|
||||
expect(first.facts_inserted).toBeGreaterThan(0);
|
||||
const second = await runExtractConversationFactsCore(engine, {
|
||||
sourceId: 'default',
|
||||
slugs,
|
||||
extractor: goldExtractor,
|
||||
overrideDisabled: true,
|
||||
});
|
||||
expect(second.facts_inserted).toBe(0);
|
||||
expect(second.pages_skipped_completed).toBe(1);
|
||||
});
|
||||
});
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
// Fake `jobs run-child` for the isolation tests (worker-job-isolation.test.ts
|
||||
// + test/e2e/job-isolation.test.ts): honors the isolation env contract
|
||||
// without needing a compiled gbrain binary or a Postgres engine.
|
||||
// Mode via FAKE_RUN_CHILD_MODE: success | error | exit15 | crash.
|
||||
import { writeFileSync, renameSync } from 'node:fs';
|
||||
|
||||
const resultPath = process.env.GBRAIN_JOB_RESULT_PATH;
|
||||
const mode = process.env.FAKE_RUN_CHILD_MODE ?? 'success';
|
||||
|
||||
function writeOutcome(o) {
|
||||
writeFileSync(resultPath + '.tmp', JSON.stringify(o));
|
||||
renameSync(resultPath + '.tmp', resultPath);
|
||||
}
|
||||
|
||||
if (!resultPath) {
|
||||
process.stderr.write('[fake-run-child] missing GBRAIN_JOB_RESULT_PATH\n');
|
||||
process.exit(13);
|
||||
}
|
||||
|
||||
if (mode === 'success') {
|
||||
writeOutcome({
|
||||
outcome: 'success',
|
||||
result: {
|
||||
fromChild: true,
|
||||
token: process.env.GBRAIN_JOB_LOCK_TOKEN ?? null,
|
||||
argv: process.argv.slice(2),
|
||||
},
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (mode === 'error') {
|
||||
writeOutcome({
|
||||
outcome: 'error',
|
||||
errorKind: 'generic',
|
||||
message: 'fake child handler failure',
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (mode === 'exit15') {
|
||||
// Simulates a result-write failure (JOB_CHILD_EXIT_RESULT_WRITE_FAILED):
|
||||
// handler ran, outcome could not be persisted.
|
||||
process.exit(15);
|
||||
}
|
||||
|
||||
// crash: no outcome file
|
||||
process.exit(1);
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
{"type":"session","version":3,"id":"agent-fixture-session-1","timestamp":"2026-08-03T14:00:00.000Z","cwd":"/home/alice-example/agent-workspace"}
|
||||
{"type":"message","id":"m-1","parentId":null,"timestamp":"2026-08-03T14:00:03.000Z","message":{"role":"user","timestamp":"2026-08-03T14:00:03.000Z","content":[{"type":"text","text":"CHECKPOINT-ONLY-TEXT: snapshot copy that must never be imported"}]}}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{"type":"session","version":3,"id":"agent-fixture-session-1","timestamp":"2026-08-03T14:00:00.000Z","cwd":"/home/alice-example/agent-workspace"}
|
||||
{"type":"model_change","id":"mc-1","parentId":null,"provider":"provider-example","modelId":"model-example","timestamp":"2026-08-03T14:00:01.000Z"}
|
||||
{"type":"thinking_level_change","id":"tl-1","parentId":"mc-1","thinkingLevel":"high","timestamp":"2026-08-03T14:00:02.000Z"}
|
||||
{"type":"message","id":"m-1","parentId":"tl-1","timestamp":"2026-08-03T14:00:03.000Z","message":{"role":"user","timestamp":"2026-08-03T14:00:03.000Z","content":[{"type":"text","text":"The acme-seed deal memo is due Friday. Who is drafting it?"}]}}
|
||||
{"type":"custom","id":"c-1","parentId":"m-1","customType":"telemetry","data":{"CUSTOM-ONLY-TEXT":"never imported"},"timestamp":"2026-08-03T14:00:04.000Z"}
|
||||
{"type":"message","id":"m-2","parentId":"m-1","timestamp":"2026-08-03T14:00:05.000Z","message":{"role":"assistant","timestamp":"2026-08-03T14:00:05.000Z","content":[{"type":"text","text":"alice-example is drafting the acme-seed memo; charlie-example reviews Thursday."},{"type":"toolCall","id":"tc-1","name":"search_brain"}]}}
|
||||
{"type":"compaction","id":"cp-1","parentId":"m-2","summary":"COMPACTION-ONLY-TEXT: never imported","firstKeptEntryId":"m-1","tokensBefore":1000,"timestamp":"2026-08-03T14:00:06.000Z"}
|
||||
{"type":"message","id":"m-3","parentId":"m-2","timestamp":"2026-08-03T14:00:07.000Z","message":{"role":"user","timestamp":"2026-08-03T14:00:07.000Z","content":[{"type":"text","text":"Good. And confirm the bridge check-in stays on Thursday."}]}}
|
||||
{"type":"message","id":"m-4","parentId":"m-3","timestamp":"2026-08-03T14:00:08.000Z","message":{"role":"assistant","timestamp":"2026-08-03T14:00:08.000Z","content":[{"type":"text","text":"Confirmed: the bridge check-in stays on Thursday."}]}}
|
||||
{another malformed line — counted as skipped, never fatal
|
||||
@@ -0,0 +1,116 @@
|
||||
[
|
||||
{
|
||||
"title": "Widget launch naming",
|
||||
"create_time": 1786080000,
|
||||
"update_time": 1786080300,
|
||||
"conversation_id": "cgpt-conv-0001",
|
||||
"current_node": "n4",
|
||||
"mapping": {
|
||||
"root": { "id": "root", "parent": null, "children": ["n1"], "message": null },
|
||||
"n1": {
|
||||
"id": "n1",
|
||||
"parent": "root",
|
||||
"children": ["n2a", "n2b"],
|
||||
"message": {
|
||||
"author": { "role": "user" },
|
||||
"create_time": 1786080005,
|
||||
"content": { "content_type": "text", "parts": ["Suggest a name for the widget-co launcher."] }
|
||||
}
|
||||
},
|
||||
"n2a": {
|
||||
"id": "n2a",
|
||||
"parent": "n1",
|
||||
"children": [],
|
||||
"message": {
|
||||
"author": { "role": "assistant" },
|
||||
"create_time": 1786080010,
|
||||
"content": { "content_type": "text", "parts": ["BRANCH-A-ONLY-TEXT: an abandoned regeneration that must never be imported"] }
|
||||
}
|
||||
},
|
||||
"n2b": {
|
||||
"id": "n2b",
|
||||
"parent": "n1",
|
||||
"children": ["nt"],
|
||||
"message": {
|
||||
"author": { "role": "assistant" },
|
||||
"create_time": 1786080015,
|
||||
"content": { "content_type": "text", "parts": ["Call it LaunchPanel."] }
|
||||
}
|
||||
},
|
||||
"nt": {
|
||||
"id": "nt",
|
||||
"parent": "n2b",
|
||||
"children": ["n3"],
|
||||
"message": {
|
||||
"author": { "role": "tool" },
|
||||
"create_time": 1786080017,
|
||||
"content": { "content_type": "text", "parts": ["TOOL-ONLY-TEXT: never imported"] }
|
||||
}
|
||||
},
|
||||
"n3": {
|
||||
"id": "n3",
|
||||
"parent": "nt",
|
||||
"children": ["n4"],
|
||||
"message": {
|
||||
"author": { "role": "user" },
|
||||
"create_time": 1786080020,
|
||||
"content": { "content_type": "multimodal_text", "parts": ["LaunchPanel works. Ship it Friday.", { "asset_pointer": "file-service://ignored" }] }
|
||||
}
|
||||
},
|
||||
"n4": {
|
||||
"id": "n4",
|
||||
"parent": "n3",
|
||||
"children": [],
|
||||
"message": {
|
||||
"author": { "role": "assistant" },
|
||||
"create_time": 1786080025,
|
||||
"content": { "content_type": "text", "parts": ["LaunchPanel it is; shipping Friday."] }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Fallback thread",
|
||||
"create_time": 1786166400,
|
||||
"id": "cgpt-conv-0002",
|
||||
"mapping": {
|
||||
"m1": {
|
||||
"id": "m1",
|
||||
"parent": "gone-root",
|
||||
"children": ["m2"],
|
||||
"message": {
|
||||
"author": { "role": "user" },
|
||||
"create_time": 1786166405,
|
||||
"content": { "content_type": "text", "parts": ["Where did we land on pricing?"] }
|
||||
}
|
||||
},
|
||||
"m2": {
|
||||
"id": "m2",
|
||||
"parent": "m1",
|
||||
"children": [],
|
||||
"message": {
|
||||
"author": { "role": "assistant" },
|
||||
"create_time": 1786166410,
|
||||
"content": { "content_type": "text", "parts": ["Pricing lands at 49."] }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Empty conversation",
|
||||
"create_time": 1786252800,
|
||||
"id": "cgpt-conv-0003",
|
||||
"mapping": {
|
||||
"s1": {
|
||||
"id": "s1",
|
||||
"parent": null,
|
||||
"children": [],
|
||||
"message": {
|
||||
"author": { "role": "system" },
|
||||
"create_time": 1786252805,
|
||||
"content": { "content_type": "text", "parts": ["SYSTEM-ONLY-TEXT: never imported"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
[
|
||||
{
|
||||
"uuid": "claude-conv-0001",
|
||||
"name": "Deal memo review",
|
||||
"created_at": "2026-08-07T12:00:00.000Z",
|
||||
"updated_at": "2026-08-07T12:10:00.000Z",
|
||||
"chat_messages": [
|
||||
{
|
||||
"uuid": "cm-1",
|
||||
"sender": "human",
|
||||
"created_at": "2026-08-07T12:00:05.000Z",
|
||||
"text": "Review the acme-seed memo intro paragraph."
|
||||
},
|
||||
{
|
||||
"uuid": "cm-2",
|
||||
"sender": "assistant",
|
||||
"created_at": "2026-08-07T12:00:30.000Z",
|
||||
"text": "The intro should lead with the fund-a term sheet date."
|
||||
},
|
||||
{
|
||||
"uuid": "cm-3",
|
||||
"sender": "assistant",
|
||||
"created_at": "2026-08-07T12:00:40.000Z",
|
||||
"text": "",
|
||||
"attachments": [{ "file_name": "ignored.pdf" }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"uuid": "claude-conv-0002",
|
||||
"name": "Empty thread",
|
||||
"created_at": "2026-08-08T09:00:00.000Z",
|
||||
"chat_messages": []
|
||||
}
|
||||
]
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{"timestamp":"2026-08-02T09:00:00.000Z","type":"session_meta","payload":{"id":"rollout-1","session_id":"codex-fixture-session-1","timestamp":"2026-08-02T09:00:00.000Z","cwd":"/home/alice-example/agent-workspace","cli_version":"0.99.0","model_provider":"provider-example","source":"cli","git":{"branch":"main"}}}
|
||||
{"timestamp":"2026-08-02T09:00:01.000Z","type":"response_item","payload":{"type":"message","role":"developer","id":"ri-1","content":[{"type":"input_text","text":"PREAMBLE-ONLY-TEXT: injected app context that must never reach the archive"}]}}
|
||||
{"timestamp":"2026-08-02T09:00:01.500Z","type":"response_item","payload":{"type":"message","role":"user","id":"ri-2","content":[{"type":"input_text","text":"PLUGIN-LIST-ONLY-TEXT: recommended plugin preamble that must never reach the archive"}]}}
|
||||
{"timestamp":"2026-08-02T09:00:02.000Z","type":"event_msg","payload":{"type":"task_started","turn_id":"t-1"}}
|
||||
{"timestamp":"2026-08-02T09:00:03.000Z","type":"event_msg","payload":{"type":"user_message","message":"Remind me: which fund led the widget-co seed round?","images":[],"text_elements":[]}}
|
||||
{"timestamp":"2026-08-02T09:00:04.000Z","type":"event_msg","payload":{"type":"agent_reasoning","text":"REASONING-ONLY-TEXT: never extracted"}}
|
||||
{"timestamp":"2026-08-02T09:00:05.000Z","type":"response_item","payload":{"type":"reasoning","id":"ri-3","summary":[]}}
|
||||
{"timestamp":"2026-08-02T09:00:06.000Z","type":"response_item","payload":{"type":"custom_tool_call","id":"ri-4","name":"search_brain","input":"{\"query\":\"widget-co seed\"}"}}
|
||||
{"timestamp":"2026-08-02T09:00:07.000Z","type":"response_item","payload":{"type":"custom_tool_call_output","id":"ri-5","output":"TOOL-OUTPUT-ONLY-TEXT: 3 pages found"}}
|
||||
{"timestamp":"2026-08-02T09:00:08.000Z","type":"response_item","payload":{"type":"message","role":"assistant","id":"ri-6","content":[{"type":"output_text","text":"fund-a led the widget-co seed; fund-b participated. charlie-example made the intro."}]}}
|
||||
{"timestamp":"2026-08-02T09:00:09.000Z","type":"event_msg","payload":{"type":"agent_message","message":"fund-a led the widget-co seed; fund-b participated. charlie-example made the intro.","phase":"final"}}
|
||||
{"timestamp":"2026-08-02T09:00:10.000Z","type":"event_msg","payload":{"type":"token_count","info":{"total":123}}}
|
||||
{"timestamp":"2026-08-02T09:00:11.000Z","type":"event_msg","payload":{"type":"user_message","message":"Great. Note that the bridge check-in is every Thursday."}}
|
||||
{"timestamp":"2026-08-02T09:00:12.000Z","type":"response_item","payload":{"type":"message","role":"assistant","id":"ri-7","content":[{"type":"output_text","text":"Noted: bridge check-in every Thursday."},{"type":"output_text","text":"I will keep that in the plan."}]}}
|
||||
{"timestamp":"2026-08-02T09:00:13.000Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"t-1","duration_ms":10000}}
|
||||
{malformed rollout line — parser must count it as skipped and continue
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* hermes-fixture-builder.ts — builds a SYNTHETIC hermes state.db matching the
|
||||
* schema verified from the installed hermes-agent v0.20.0 source
|
||||
* (hermes_state_common.py SCHEMA_SQL, columns subset). Synthetic by
|
||||
* declaration: the adapter's SPEC_TARGET stays provisional and this builder
|
||||
* never claims to be a production sample. Content uses the repo's generic
|
||||
* placeholder names only.
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const HERMES_FIXTURE_DB = 'state.db';
|
||||
|
||||
/** Create `<dir>/state.db` with two text sessions + skip-worthy noise. */
|
||||
export function buildHermesFixture(dir: string): string {
|
||||
const path = join(dir, HERMES_FIXTURE_DB);
|
||||
const db = new Database(path);
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
model TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
cwd TEXT,
|
||||
title TEXT
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
timestamp REAL NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
compacted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
const insSession = db.prepare(
|
||||
'INSERT INTO sessions (id, source, display_name, model, started_at, cwd, title) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
const insMsg = db.prepare(
|
||||
'INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)',
|
||||
);
|
||||
|
||||
// Session 1: plain-text contents. 1785916800 = 2026-08-05T08:00:00Z.
|
||||
insSession.run('hermes-fixture-1', 'cli', 'widget planning', 'model-example', 1785916800, '/home/alice-example/agent-workspace', 'widget planning');
|
||||
insMsg.run('hermes-fixture-1', 'user', 'Draft the widget-co launch checklist.', 1785916805);
|
||||
insMsg.run('hermes-fixture-1', 'tool', 'TOOL-ONLY-TEXT: never imported', 1785916806);
|
||||
insMsg.run('hermes-fixture-1', 'assistant', 'Launch checklist drafted: pricing page, demo, fund-a update.', 1785916810);
|
||||
|
||||
// Session 2: JSON block-array contents (the unwrap path) + an empty row.
|
||||
insSession.run('hermes-fixture-2', 'gateway', null, null, 1786003200, null, null);
|
||||
insMsg.run('hermes-fixture-2', 'user', '[{"type":"text","text":"When is the acme-seed close?"}]', 1786003205);
|
||||
insMsg.run('hermes-fixture-2', 'assistant', '[{"type":"text","text":"acme-seed closes at the end of the month."}]', 1786003210);
|
||||
insMsg.run('hermes-fixture-2', 'assistant', '', 1786003211);
|
||||
|
||||
// Session 3: tool-only rows — yields no messages, session skipped.
|
||||
insSession.run('hermes-fixture-3', 'cli', null, null, 1786089600, null, null);
|
||||
insMsg.run('hermes-fixture-3', 'tool', 'TOOL-ONLY-TEXT: never imported', 1786089605);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -670,6 +670,205 @@ export async function hermesOneShotTurn(opts: HermesTurnOpts): Promise<HermesTur
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 5a-grok. Grok Build (xAI) — all shapes observed against v1.0.4
|
||||
// (docs/mcp/GROK-CLI-PIN.md). grok ≠ groq ≠ ngrok.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Locate the real `grok` binary (xAI Grok Build). $GROK_BIN (absolute,
|
||||
* executable — same override the claw-test runner honors) first, then
|
||||
* Bun.which, then the npm-global and installer landing spots. Collision
|
||||
* note: the community superagent-ai grok-cli ships a colliding `grok`
|
||||
* binary — the door's version-shape pin (T1) is the discriminator. */
|
||||
export function resolveGrokBinary(): string | null {
|
||||
// FAIL-CLOSED on a set-but-invalid GROK_BIN (matching the runner's
|
||||
// detectBinary posture): silently falling through to `which grok` could
|
||||
// bind the colliding community grok-cli binary DESPITE the operator's
|
||||
// explicit pin — the exact mis-bind the pin exists to prevent.
|
||||
const fromEnv = process.env.GROK_BIN?.trim();
|
||||
if (fromEnv) {
|
||||
if (!fromEnv.startsWith('/') || fromEnv.split('/').includes('..')) return null;
|
||||
return firstExecutable([fromEnv]);
|
||||
}
|
||||
const which = whichBin('grok');
|
||||
if (which) return which;
|
||||
const home = process.env.HOME ?? os.homedir();
|
||||
const candidates = [
|
||||
'/opt/homebrew/bin/grok',
|
||||
'/usr/local/bin/grok',
|
||||
`${home}/.local/bin/grok`,
|
||||
`${home}/.npm-global/bin/grok`,
|
||||
`${home}/.bun/bin/grok`,
|
||||
];
|
||||
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
||||
if (dir) candidates.push(path.join(dir, 'grok'));
|
||||
}
|
||||
return firstExecutable(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grok is usable BY THE PAID TIER if a NON-EMPTY XAI_API_KEY is exported.
|
||||
* Env-only on purpose: the keyless one-shot exits 1 with "Not signed in …
|
||||
* set the XAI_API_KEY environment variable" (observed v1.0.4), and no
|
||||
* credential file was observed keyless — if the authed observation finds one
|
||||
* under ~/.grok, add it here as a second probe (GROK-CLI-PIN.md marks that
|
||||
* item pending auth). Blank CI secret ⇒ skip, never a paid failing test.
|
||||
*/
|
||||
export function hasGrokAuth(): boolean {
|
||||
return Boolean(process.env.XAI_API_KEY?.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermetic env for spawning grok itself: standard scrub + HOME/GROK_HOME
|
||||
* overrides; XAI_API_KEY re-admitted EXPLICITLY (it is deliberately not in
|
||||
* ALLOW_EXACT — default-deny stays intact for every other child). Deletes:
|
||||
* - other providers' keys (defensive; grok is single-provider, but scrubbing
|
||||
* is free and keeps the door single-auth-source like the hermes lane), and
|
||||
* - GITHUB_ENV/GITHUB_PATH/GITHUB_OUTPUT/GITHUB_STATE — the GITHUB_ prefix
|
||||
* rule would forward these CI step-metadata files to an UNTRUSTED agent
|
||||
* child, which could append to them and poison later workflow steps.
|
||||
*/
|
||||
export function grokChildEnv(home: string, opts?: { binDir?: string }): NodeJS.ProcessEnv {
|
||||
const env = hermeticChildEnv({
|
||||
HOME: home,
|
||||
GROK_HOME: path.join(home, '.grok'),
|
||||
XAI_API_KEY: process.env.XAI_API_KEY?.trim() || undefined,
|
||||
});
|
||||
for (const k of ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY']) delete env[k];
|
||||
// Writable step-metadata files the GITHUB_ prefix rule would otherwise
|
||||
// forward: appending to any of them poisons later workflow steps (ENV/
|
||||
// PATH/OUTPUT/STATE) or the run summary UI (STEP_SUMMARY).
|
||||
for (const k of ['GITHUB_ENV', 'GITHUB_PATH', 'GITHUB_OUTPUT', 'GITHUB_STATE', 'GITHUB_STEP_SUMMARY', 'GITHUB_ACTION_PATH']) delete env[k];
|
||||
// PATH-prepend the staged gbrain bin dir when given — the MCP registration
|
||||
// uses the DOCUMENTED bare `gbrain` command, so EVERY grok spawn that may
|
||||
// start the server (doctor probes AND the paid turn) must resolve it.
|
||||
if (opts?.binDir) env.PATH = `${opts.binDir}:${env.PATH ?? ''}`;
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a hermetic <home>/.grok/config.toml BEFORE any grok spawn:
|
||||
* - `[cli] auto_update = false` — auto-update is config-only and defaults ON
|
||||
* (observed); without this seed a door run can self-update mid-suite and
|
||||
* break the version pin.
|
||||
* - `[models] default = <model>` when given — `grok mcp add` PRESERVES
|
||||
* pre-existing sections (observed), so the seed survives registration.
|
||||
* No credentials are written: auth travels via XAI_API_KEY env only.
|
||||
*/
|
||||
export function seedGrokConfig(home: string, opts?: { defaultModel?: string }): string {
|
||||
const grokHome = path.join(home, '.grok');
|
||||
fs.mkdirSync(grokHome, { recursive: true });
|
||||
const model = opts?.defaultModel;
|
||||
const doc = `[cli]\nauto_update = false\n${model ? `\n[models]\ndefault = "${model}"\n` : ''}`;
|
||||
fs.writeFileSync(path.join(grokHome, 'config.toml'), doc, 'utf-8');
|
||||
return grokHome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage a `gbrain` binary into a fresh bin dir so the DOCUMENTED registration
|
||||
* shape (`grok mcp add gbrain -- gbrain serve --surface verbs`, bare command
|
||||
* resolved via PATH — observed working v1.0.4) is what the door exercises.
|
||||
* Compiled binary copy when available; otherwise an executable sh wrapper
|
||||
* exec'ing `bun run src/cli.ts` so the fallback lane survives staging.
|
||||
*/
|
||||
export function stageGbrainBinDir(repoRoot: string, dir: string): { kind: 'compiled' | 'bun-run' } {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const target = path.join(dir, 'gbrain');
|
||||
const { binPath } = ensureCompiledGbrain(repoRoot);
|
||||
if (binPath) {
|
||||
fs.copyFileSync(binPath, target);
|
||||
fs.chmodSync(target, 0o755);
|
||||
return { kind: 'compiled' };
|
||||
}
|
||||
const cli = path.join(repoRoot, 'src', 'cli.ts');
|
||||
// The path is interpolated single-quoted into an sh shim — reject the same
|
||||
// metachar set validateBinPathEnv guards, rather than trying to escape.
|
||||
if (/['"`$\\\n\r]/.test(cli)) {
|
||||
throw new Error(`stageGbrainBinDir: repo path contains shell-active characters unsafe for the shim: ${cli}`);
|
||||
}
|
||||
fs.writeFileSync(target, `#!/bin/sh\nexec bun run '${cli}' "$@"\n`, { mode: 0o755 });
|
||||
return { kind: 'bun-run' };
|
||||
}
|
||||
|
||||
export interface GrokTurnOpts {
|
||||
prompt: string;
|
||||
cwd: string;
|
||||
home: string;
|
||||
timeoutMs?: number;
|
||||
/** Per-call model pin (authoritative — immune to config rewrites). */
|
||||
model?: string;
|
||||
/** Disable grok's built-in web search + fetch tools (observed flag). */
|
||||
disableWebSearch?: boolean;
|
||||
/** Staged gbrain bin dir — PATH-prepended so the bare-`gbrain` MCP
|
||||
* registration resolves when grok spawns the server DURING the turn
|
||||
* (without this the doctor preflight passes but the paid turn cannot
|
||||
* start the server on a clean runner). */
|
||||
binDir?: string;
|
||||
}
|
||||
|
||||
export interface GrokTurnResult {
|
||||
/** plain output format: stdout is the final response text. */
|
||||
finalText: string;
|
||||
exitCode: number | null;
|
||||
timedOut: boolean;
|
||||
stderrText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one `grok -p` turn against a hermetic HOME + GROK_HOME. Plain output
|
||||
* format (final text on stdout — observed). The permission posture is
|
||||
* deliberately unset pending the authed observation (GROK-CLI-PIN.md); the
|
||||
* JSON event-stream shapes are also unobserved, so there is no parseGrokJson
|
||||
* yet — the door's tool-call assertion stays gated on that observation.
|
||||
*/
|
||||
export async function grokOneShotTurn(opts: GrokTurnOpts): Promise<GrokTurnResult> {
|
||||
const bin = resolveGrokBinary();
|
||||
if (!bin) throw new Error('grokOneShotTurn: grok binary not found');
|
||||
const timeoutMs = opts.timeoutMs ?? 240_000;
|
||||
|
||||
const argv = [
|
||||
bin, '-p', opts.prompt, '--output-format', 'plain',
|
||||
...(opts.model ? ['-m', opts.model] : []),
|
||||
...(opts.disableWebSearch ? ['--disable-web-search'] : []),
|
||||
];
|
||||
const proc = Bun.spawn(argv, {
|
||||
cwd: opts.cwd,
|
||||
env: grokChildEnv(opts.home, { binDir: opts.binDir }),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
stdin: 'ignore',
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try { proc.kill(); } catch { /* already dead */ }
|
||||
// grok may leave its leader daemon / MCP server child holding the pipes
|
||||
// open past the parent's death — escalate so the stream drain below
|
||||
// cannot hang the retry loop indefinitely.
|
||||
setTimeout(() => { try { proc.kill(9); } catch { /* already dead */ } }, 5_000);
|
||||
}, timeoutMs);
|
||||
|
||||
// Bounded drain: even a SIGKILLed parent can leave a grandchild holding
|
||||
// the pipe fds; cap the post-timeout wait instead of awaiting EOF forever.
|
||||
const drainCap = timeoutMs + 30_000;
|
||||
const bounded = <T>(p: Promise<T>, fallback: T): Promise<T> =>
|
||||
Promise.race([p, new Promise<T>((r) => setTimeout(() => r(fallback), drainCap))]);
|
||||
const [stdout, stderrText] = await Promise.all([
|
||||
bounded(new Response(proc.stdout).text(), ''),
|
||||
bounded(new Response(proc.stderr).text().catch(() => ''), ''),
|
||||
]);
|
||||
const exitCode = await bounded(proc.exited, 124);
|
||||
clearTimeout(timer);
|
||||
|
||||
return {
|
||||
finalText: stdout.trim(),
|
||||
exitCode: timedOut ? 124 : exitCode,
|
||||
timedOut,
|
||||
stderrText,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// 5b. Fast gbrain MCP server command (compiled binary, cached; bun-run fallback)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -754,6 +953,16 @@ function probeCompiledPglite(binPath: string): { ok: boolean; reason: string } {
|
||||
* bun-run fallback instead of hard-failing the door suite.
|
||||
*/
|
||||
export function ensureCompiledGbrain(repoRoot: string): { binPath: string | null; reason: string } {
|
||||
// CI short-circuit: a workflow can compile ONCE in a dedicated step and
|
||||
// export GBRAIN_COMPILED_BIN — the module-global cache below is per-process,
|
||||
// so two `bun test` invocations in one job would otherwise compile twice.
|
||||
const prebuilt = process.env.GBRAIN_COMPILED_BIN?.trim();
|
||||
if (prebuilt && prebuilt.startsWith('/')) {
|
||||
try {
|
||||
fs.accessSync(prebuilt, fs.constants.X_OK);
|
||||
return { binPath: prebuilt, reason: '' };
|
||||
} catch { /* fall through to the normal compile path */ }
|
||||
}
|
||||
if (_compileTried) return { binPath: _compiledBin, reason: _compileReason };
|
||||
_compileTried = true;
|
||||
try {
|
||||
@@ -882,12 +1091,20 @@ export interface SeededBrain {
|
||||
* restores it — the door test sets GBRAIN_HOME on the spawned child via the
|
||||
* MCP config's env block, not on this process.
|
||||
*/
|
||||
export async function seedBrainForAgent(home: string, sourceId: string): Promise<SeededBrain> {
|
||||
export async function seedBrainForAgent(
|
||||
home: string,
|
||||
sourceId: string,
|
||||
opts?: { entity?: string; fact?: string; query?: string; slug?: string },
|
||||
): Promise<SeededBrain> {
|
||||
if (!put_page) throw new Error('seedBrainForAgent: put_page op not registered');
|
||||
|
||||
const entity = 'Summit Robotics';
|
||||
const fact = 'Summit Robotics runs the Rivermouth fulfillment center.';
|
||||
const query = 'Where does Summit Robotics run its fulfillment center?';
|
||||
// Defaults are the committed synthetic fact (hermes/claude doors). Callers
|
||||
// whose agent has filesystem/shell tools in reach pass a PER-RUN nonce fact
|
||||
// instead — the committed string is greppable in the checkout, so recall of
|
||||
// it proves nothing against an agent that can read the repo (grok door).
|
||||
const entity = opts?.entity ?? 'Summit Robotics';
|
||||
const fact = opts?.fact ?? 'Summit Robotics runs the Rivermouth fulfillment center.';
|
||||
const query = opts?.query ?? 'Where does Summit Robotics run its fulfillment center?';
|
||||
|
||||
const savedHome = process.env.GBRAIN_HOME;
|
||||
process.env.GBRAIN_HOME = home;
|
||||
@@ -918,8 +1135,8 @@ export async function seedBrainForAgent(home: string, sourceId: string): Promise
|
||||
sourceId,
|
||||
};
|
||||
await put_page.handler(ctx, {
|
||||
slug: 'companies/summit-robotics',
|
||||
content: `# Summit Robotics\n\n${fact}\n`,
|
||||
slug: opts?.slug ?? 'companies/summit-robotics',
|
||||
content: `# ${entity}\n\n${fact}\n`,
|
||||
});
|
||||
} finally {
|
||||
// Release the PGLite lock so the spawned `gbrain serve` can open the
|
||||
|
||||
@@ -24,13 +24,17 @@ import {
|
||||
parseCodexJsonl,
|
||||
hermeticChildEnv,
|
||||
hermesChildEnv,
|
||||
grokChildEnv,
|
||||
promotedEnv,
|
||||
resolveClaudeBinary,
|
||||
resolveCodexBinary,
|
||||
resolveHermesBinary,
|
||||
resolveGrokBinary,
|
||||
hasHermesAuth,
|
||||
hasGrokAuth,
|
||||
parseDotenvFile,
|
||||
seedHermesHome,
|
||||
seedGrokConfig,
|
||||
} from './agent-harness.ts';
|
||||
import { withEnv } from './with-env.ts';
|
||||
|
||||
@@ -334,3 +338,133 @@ describe('seedHermesHome single-key copy (injectable source — never the operat
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasGrokAuth truth table (env-only — pins the non-empty-value gate)', () => {
|
||||
// Env-only on purpose: keyless grok exits 1 with "Not signed in … set the
|
||||
// XAI_API_KEY environment variable" (observed v1.0.4); no credential file
|
||||
// was observed keyless (GROK-CLI-PIN.md marks the authed inventory pending).
|
||||
test('non-empty XAI_API_KEY → true', async () => {
|
||||
await withEnv({ XAI_API_KEY: 'xai-sentinel' }, () => {
|
||||
expect(hasGrokAuth()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('blank XAI_API_KEY (empty CI secret) → false, never a paid failure', async () => {
|
||||
await withEnv({ XAI_API_KEY: ' ' }, () => {
|
||||
expect(hasGrokAuth()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('unset XAI_API_KEY → false', async () => {
|
||||
await withEnv({ XAI_API_KEY: undefined }, () => {
|
||||
expect(hasGrokAuth()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('grokChildEnv — explicit key re-admission + CI metadata scrub', () => {
|
||||
test('XAI_API_KEY survives via explicit override; HOME/GROK_HOME point at the temp home', async () => {
|
||||
await withEnv({ XAI_API_KEY: 'xai-child-sentinel' }, () => {
|
||||
const env = grokChildEnv('/tmp/grok-child-test');
|
||||
// Not in ALLOW_EXACT — only the explicit override carries it through.
|
||||
expect(env.XAI_API_KEY).toBe('xai-child-sentinel');
|
||||
expect(env.HOME).toBe('/tmp/grok-child-test');
|
||||
expect(env.GROK_HOME).toBe('/tmp/grok-child-test/.grok');
|
||||
});
|
||||
});
|
||||
|
||||
test('other provider keys and GITHUB_* step-metadata files are deleted', async () => {
|
||||
await withEnv({
|
||||
XAI_API_KEY: 'xai-x',
|
||||
ANTHROPIC_API_KEY: 'ant-must-not-leak',
|
||||
OPENAI_API_KEY: 'oai-must-not-leak',
|
||||
GITHUB_ENV: '/tmp/gh-env-file',
|
||||
GITHUB_PATH: '/tmp/gh-path-file',
|
||||
GITHUB_OUTPUT: '/tmp/gh-output-file',
|
||||
GITHUB_STATE: '/tmp/gh-state-file',
|
||||
GITHUB_ACTIONS: 'true',
|
||||
}, () => {
|
||||
const env = grokChildEnv('/tmp/grok-child-test');
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.OPENAI_API_KEY).toBeUndefined();
|
||||
// The GITHUB_ prefix rule would forward these writable step-metadata
|
||||
// files to an untrusted agent child — poisoning later workflow steps.
|
||||
expect(env.GITHUB_ENV).toBeUndefined();
|
||||
expect(env.GITHUB_PATH).toBeUndefined();
|
||||
expect(env.GITHUB_OUTPUT).toBeUndefined();
|
||||
expect(env.GITHUB_STATE).toBeUndefined();
|
||||
// Read-only CI metadata stays allowed (prefix rule intact).
|
||||
expect(env.GITHUB_ACTIONS).toBe('true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedGrokConfig — the auto-update kill-switch seed', () => {
|
||||
test('writes [cli] auto_update=false, optional model pin, never credentials', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'grok-seed-'));
|
||||
try {
|
||||
const grokHome = seedGrokConfig(home, { defaultModel: 'grok-4.5' });
|
||||
const doc = readFileSync(join(grokHome, 'config.toml'), 'utf-8');
|
||||
expect(doc).toContain('[cli]');
|
||||
expect(doc).toContain('auto_update = false');
|
||||
expect(doc).toContain('default = "grok-4.5"');
|
||||
expect(doc).not.toMatch(/XAI|api[_-]?key/i);
|
||||
// Parseable by the same parser the door reads with.
|
||||
expect(() => (Bun as unknown as { TOML: { parse(s: string): unknown } }).TOML.parse(doc)).not.toThrow();
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('without a model opt, only the [cli] section is written', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'grok-seed-'));
|
||||
try {
|
||||
seedGrokConfig(home);
|
||||
const doc = readFileSync(join(home, '.grok', 'config.toml'), 'utf-8');
|
||||
expect(doc).toContain('auto_update = false');
|
||||
expect(doc).not.toContain('[models]');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrokBinary — fail-closed GROK_BIN handling', () => {
|
||||
test('returns a string-or-null; a string is an absolute path', () => {
|
||||
const p = resolveGrokBinary();
|
||||
if (p !== null) expect(p.startsWith('/')).toBe(true);
|
||||
else expect(p).toBeNull();
|
||||
});
|
||||
|
||||
test('valid executable GROK_BIN is returned verbatim', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'grok-resolve-'));
|
||||
const shim = join(dir, 'grok');
|
||||
try {
|
||||
writeFileSync(shim, '#!/bin/sh\n', { mode: 0o755 });
|
||||
await withEnv({ GROK_BIN: shim }, () => {
|
||||
expect(resolveGrokBinary()).toBe(shim);
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('set-but-invalid GROK_BIN fails CLOSED — never falls through to PATH (community-binary mis-bind guard)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'grok-resolve-'));
|
||||
const notExec = join(dir, 'grok-noexec');
|
||||
try {
|
||||
writeFileSync(notExec, '#!/bin/sh\n', { mode: 0o644 });
|
||||
await withEnv({ GROK_BIN: notExec }, () => {
|
||||
expect(resolveGrokBinary()).toBeNull();
|
||||
});
|
||||
await withEnv({ GROK_BIN: 'relative/grok' }, () => {
|
||||
expect(resolveGrokBinary()).toBeNull();
|
||||
});
|
||||
await withEnv({ GROK_BIN: '/tmp/foo/../grok' }, () => {
|
||||
expect(resolveGrokBinary()).toBeNull();
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+114
-7
@@ -465,7 +465,14 @@ export function launchTty(argv: string[], opts: TtyLaunchOpts = {}): TtySession
|
||||
const since = waitOpts?.since;
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
const visible = since !== undefined ? stripAnsi(buffer.slice(since)) : stripAnsi(buffer);
|
||||
// Bounded per-poll strip: under a repaint-heavy TUI the (since-scoped)
|
||||
// buffer grows by MBs, and re-stripping all of it every poll is the
|
||||
// quadratic hot loop this harness's consumers keep re-finding. A 256KB
|
||||
// tail caps per-poll work while staying far wider than any single
|
||||
// screen repaint; a pattern would have to scroll >256KB past between
|
||||
// two 200ms polls to be missed.
|
||||
const windowStart = Math.max(since ?? 0, buffer.length - 262_144);
|
||||
const visible = stripAnsi(buffer.slice(windowStart));
|
||||
for (let i = 0; i < patterns.length; i++) {
|
||||
const p = patterns[i]!;
|
||||
const idx = typeof p === 'string' ? visible.indexOf(p) : visible.search(p);
|
||||
@@ -562,6 +569,93 @@ export interface TranscriptMeta {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum secret-value length the redaction machinery acts on. Shorter values
|
||||
* are too collision-prone to blank out (a 3-char "key" would redact innocent
|
||||
* substrings across the transcript). Shared by redactSecrets, dx-explore's
|
||||
* buildRedactMap, and assertNoSecrets — one constant so the layers can never
|
||||
* disagree about what counts as a redactable secret.
|
||||
*/
|
||||
export const MIN_REDACT_SECRET_LEN = 8;
|
||||
|
||||
/**
|
||||
* Replace every occurrence of each secret VALUE with `[REDACTED:<name>]`.
|
||||
* Pure, single pass per secret over the whole string. NOTE: this only
|
||||
* catches CONTIGUOUS occurrences — a value split across PTY frame records
|
||||
* stays split in the serialized frames.jsonl (every frame boundary is a JSON
|
||||
* record boundary), which is why saveTranscript coalesces straddling frames
|
||||
* BEFORE serialization (coalesceSecretStraddles below).
|
||||
*/
|
||||
export function redactSecrets(text: string, redact?: Record<string, string>): string {
|
||||
if (!redact) return text;
|
||||
let out = text;
|
||||
for (const [name, value] of Object.entries(redact)) {
|
||||
if (!value || value.length < MIN_REDACT_SECRET_LEN) continue;
|
||||
out = out.split(value).join(`[REDACTED:${name}]`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge any run of frames that a secret value straddles into one frame, so a
|
||||
* subsequent per-string redaction pass sees the value contiguously. Without
|
||||
* this, a key split across two output bursts survives frames.jsonl as two
|
||||
* innocent-looking halves that are trivially joinable (verified empirically
|
||||
* in review). Pure: returns a new array; timing of the merged frame is the
|
||||
* first covered frame's tMs.
|
||||
*/
|
||||
export function coalesceSecretStraddles(
|
||||
frames: readonly PtyFrame[],
|
||||
redact?: Record<string, string>,
|
||||
): PtyFrame[] {
|
||||
const values = Object.values(redact ?? {}).filter((v) => v && v.length >= MIN_REDACT_SECRET_LEN);
|
||||
if (values.length === 0 || frames.length === 0) return [...frames];
|
||||
|
||||
// Frame start offsets in the joined stream.
|
||||
const starts: number[] = new Array(frames.length);
|
||||
let acc = 0;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
starts[i] = acc;
|
||||
acc += frames[i]!.data.length;
|
||||
}
|
||||
const joined = frames.map((f) => f.data).join('');
|
||||
|
||||
// Mark every frame boundary that falls INSIDE a secret occurrence:
|
||||
// mergeWithNext[i] = the boundary between frame i and i+1 must go away.
|
||||
const mergeWithNext = new Array<boolean>(frames.length - 1).fill(false);
|
||||
let anyMerge = false;
|
||||
for (const value of values) {
|
||||
let idx = joined.indexOf(value);
|
||||
while (idx >= 0) {
|
||||
const end = idx + value.length; // exclusive
|
||||
for (let b = 0; b < mergeWithNext.length; b++) {
|
||||
const boundary = starts[b + 1]!;
|
||||
if (boundary > idx && boundary < end) {
|
||||
mergeWithNext[b] = true;
|
||||
anyMerge = true;
|
||||
}
|
||||
}
|
||||
idx = joined.indexOf(value, idx + 1);
|
||||
}
|
||||
}
|
||||
if (!anyMerge) return [...frames];
|
||||
|
||||
const out: PtyFrame[] = [];
|
||||
let i = 0;
|
||||
while (i < frames.length) {
|
||||
let data = frames[i]!.data;
|
||||
const tMs = frames[i]!.tMs;
|
||||
let j = i;
|
||||
while (j < mergeWithNext.length && mergeWithNext[j]) {
|
||||
data += frames[j + 1]!.data;
|
||||
j++;
|
||||
}
|
||||
out.push({ tMs, data });
|
||||
i = j + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a transcript bundle into `dir`:
|
||||
* meta.json — scenario, argv, timing, exit code, notes
|
||||
@@ -572,16 +666,29 @@ export interface TranscriptMeta {
|
||||
*/
|
||||
export function saveTranscript(
|
||||
dir: string,
|
||||
data: { frames: readonly PtyFrame[]; raw: string; meta: TranscriptMeta },
|
||||
data: {
|
||||
frames: readonly PtyFrame[];
|
||||
raw: string;
|
||||
meta: TranscriptMeta;
|
||||
/** Secret values to redact (name → value) from EVERY written artifact.
|
||||
* The explicit seam: callers own which values are secret. */
|
||||
redact?: Record<string, string>;
|
||||
},
|
||||
): void {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'meta.json'), JSON.stringify(data.meta, null, 2));
|
||||
fs.writeFileSync(path.join(dir, 'raw.txt'), data.raw);
|
||||
fs.writeFileSync(path.join(dir, 'visible.txt'), stripAnsi(data.raw));
|
||||
const r = (s: string) => redactSecrets(s, data.redact);
|
||||
fs.writeFileSync(path.join(dir, 'meta.json'), r(JSON.stringify(data.meta, null, 2)));
|
||||
fs.writeFileSync(path.join(dir, 'raw.txt'), r(data.raw));
|
||||
fs.writeFileSync(path.join(dir, 'visible.txt'), r(stripAnsi(data.raw)));
|
||||
// A secret split across PTY frames is two innocent halves in frames.jsonl
|
||||
// (every frame boundary is a JSON record boundary — the serialized string
|
||||
// never contains the contiguous value). Coalesce straddling frames FIRST,
|
||||
// then redact; per-frame timing granularity is lost only for the merged run.
|
||||
const coalesced = coalesceSecretStraddles(data.frames, data.redact);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'frames.jsonl'),
|
||||
data.frames.map((f) => JSON.stringify(f)).join('\n') + (data.frames.length ? '\n' : ''),
|
||||
r(coalesced.map((f) => JSON.stringify(f)).join('\n') + (coalesced.length ? '\n' : '')),
|
||||
);
|
||||
const stalls = computeStalls(data.frames, { endMs: data.meta.durationMs });
|
||||
fs.writeFileSync(path.join(dir, 'stalls.md'), renderStallsReport(stalls, data.meta.durationMs));
|
||||
fs.writeFileSync(path.join(dir, 'stalls.md'), r(renderStallsReport(stalls, data.meta.durationMs)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* issue #5 — job-isolation protocol unit tests.
|
||||
*
|
||||
* Covers: outcome round-trip, missing/malformed/oversized decode paths
|
||||
* (oversize → UnrecoverableError: deterministic dead on attempt 1, never
|
||||
* silent truncation), instanceof reconstruction for the two error classes
|
||||
* executeJob branches on, child-CLI invocation resolution (env override /
|
||||
* binary / bun-dev fallback / fail-fast null), and killProcessGroup against
|
||||
* REAL detached processes — including the grandchild-death guarantee that
|
||||
* motivated group signaling (SIGKILL on a wrapper pid alone orphans the
|
||||
* handler; Bun rejects negative pids so the /bin/kill fallback is what
|
||||
* actually runs under `bun test`, making this a real-runtime regression
|
||||
* test for oven-sh/bun#15791).
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
CHILD_OUTCOME_MAX_BYTES,
|
||||
buildChildArgs,
|
||||
decodeChildOutcomeFile,
|
||||
encodeHandlerError,
|
||||
killProcessGroup,
|
||||
reconstructHandlerError,
|
||||
resolveChildCliInvocation,
|
||||
writeChildOutcomeFile,
|
||||
type ChildOutcome,
|
||||
} from '../src/core/minions/job-isolation.ts';
|
||||
import { UnrecoverableError } from '../src/core/minions/types.ts';
|
||||
import { RateLeaseUnavailableError } from '../src/core/minions/handlers/subagent.ts';
|
||||
|
||||
function tmpFile(name: string): { dir: string; path: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-isolation-test-'));
|
||||
return { dir, path: join(dir, name) };
|
||||
}
|
||||
|
||||
describe('outcome file round-trip', () => {
|
||||
test('success outcome survives write + decode; write is atomic (no .tmp left)', () => {
|
||||
const { dir, path } = tmpFile('outcome.json');
|
||||
try {
|
||||
writeChildOutcomeFile(path, { outcome: 'success', result: { pages: 3, ok: true } });
|
||||
expect(existsSync(`${path}.tmp`)).toBe(false);
|
||||
const decoded = decodeChildOutcomeFile(path);
|
||||
expect(decoded).toEqual({ outcome: 'success', result: { pages: 3, ok: true } });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('missing file → generic Error naming the crash class', () => {
|
||||
const { dir, path } = tmpFile('never-written.json');
|
||||
try {
|
||||
expect(() => decodeChildOutcomeFile(path)).toThrow(/without writing its outcome file/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed JSON → generic Error with byte count, NEVER file content', () => {
|
||||
const { dir, path } = tmpFile('garbage.json');
|
||||
try {
|
||||
writeFileSync(path, 'sk-secret-key-do-not-leak {{{', 'utf8');
|
||||
try {
|
||||
decodeChildOutcomeFile(path);
|
||||
throw new Error('should have thrown');
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message;
|
||||
expect(msg).toContain('not valid JSON');
|
||||
expect(msg).toContain('bytes');
|
||||
expect(msg).not.toContain('sk-secret');
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('oversize file → UnrecoverableError naming the cap (dead on attempt 1)', () => {
|
||||
const { dir, path } = tmpFile('huge.json');
|
||||
try {
|
||||
writeFileSync(path, '{"outcome":"success","result":"xx"}', 'utf8');
|
||||
try {
|
||||
decodeChildOutcomeFile(path, 16); // tiny injected cap keeps the test fast
|
||||
throw new Error('should have thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(UnrecoverableError);
|
||||
expect((e as Error).message).toContain('outcome cap');
|
||||
}
|
||||
// Default cap sanity.
|
||||
expect(CHILD_OUTCOME_MAX_BYTES).toBe(32 * 1024 * 1024);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('unrecognized shape → generic Error (byte count only)', () => {
|
||||
const { dir, path } = tmpFile('weird.json');
|
||||
try {
|
||||
writeFileSync(path, '{"totally":"unrelated"}', 'utf8');
|
||||
expect(() => decodeChildOutcomeFile(path)).toThrow(/unrecognized shape/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('handler-error encode → reconstruct (instanceof parity with inline mode)', () => {
|
||||
test('UnrecoverableError survives the boundary', () => {
|
||||
const enc = encodeHandlerError(new UnrecoverableError('bad config, never retry'));
|
||||
expect(enc.outcome).toBe('error');
|
||||
const rebuilt = reconstructHandlerError(enc as Extract<ChildOutcome, { outcome: 'error' }>);
|
||||
expect(rebuilt).toBeInstanceOf(UnrecoverableError);
|
||||
expect(rebuilt.message).toBe('bad config, never retry');
|
||||
});
|
||||
|
||||
test('RateLeaseUnavailableError survives with lease fields', () => {
|
||||
const enc = encodeHandlerError(new RateLeaseUnavailableError('anthropic', 4, 4));
|
||||
const rebuilt = reconstructHandlerError(enc as Extract<ChildOutcome, { outcome: 'error' }>);
|
||||
expect(rebuilt).toBeInstanceOf(RateLeaseUnavailableError);
|
||||
const lease = rebuilt as RateLeaseUnavailableError;
|
||||
expect(lease.key).toBe('anthropic');
|
||||
expect(lease.active).toBe(4);
|
||||
expect(lease.max).toBe(4);
|
||||
});
|
||||
|
||||
test('generic Error carries message + child stack; unknown kinds degrade to generic', () => {
|
||||
const boom = new Error('handler exploded');
|
||||
const enc = encodeHandlerError(boom) as Extract<ChildOutcome, { outcome: 'error' }>;
|
||||
const rebuilt = reconstructHandlerError(enc);
|
||||
expect(rebuilt).toBeInstanceOf(Error);
|
||||
expect(rebuilt).not.toBeInstanceOf(UnrecoverableError);
|
||||
expect(rebuilt.message).toBe('handler exploded');
|
||||
expect((rebuilt as Error & { childStack?: string }).childStack).toContain('handler exploded');
|
||||
|
||||
const weird = reconstructHandlerError({
|
||||
outcome: 'error',
|
||||
errorKind: 'generic',
|
||||
message: 'from a hostile file',
|
||||
});
|
||||
expect(weird).toBeInstanceOf(Error);
|
||||
expect(weird).not.toBeInstanceOf(UnrecoverableError);
|
||||
});
|
||||
|
||||
test('non-Error throws (strings) encode without crashing', () => {
|
||||
const enc = encodeHandlerError('plain string throw');
|
||||
expect(enc.outcome).toBe('error');
|
||||
if (enc.outcome === 'error') expect(enc.message).toBe('plain string throw');
|
||||
});
|
||||
});
|
||||
|
||||
describe('child CLI invocation resolution', () => {
|
||||
test('env override wins', () => {
|
||||
const inv = resolveChildCliInvocation(
|
||||
{ GBRAIN_JOB_CHILD_CLI: '/opt/custom/gbrain' },
|
||||
'/usr/bin/bun',
|
||||
'/repo/src/cli.ts',
|
||||
() => '/usr/local/bin/gbrain',
|
||||
);
|
||||
expect(inv).toEqual({ cmd: '/opt/custom/gbrain', argsPrefix: [] });
|
||||
});
|
||||
|
||||
test('compiled binary next', () => {
|
||||
const inv = resolveChildCliInvocation({}, '/usr/bin/bun', '/repo/src/cli.ts', () => '/usr/local/bin/gbrain');
|
||||
expect(inv).toEqual({ cmd: '/usr/local/bin/gbrain', argsPrefix: [] });
|
||||
});
|
||||
|
||||
test('bun-dev fallback when no binary resolves', () => {
|
||||
const inv = resolveChildCliInvocation({}, '/usr/bin/bun', '/repo/src/cli.ts', () => null);
|
||||
expect(inv).toEqual({ cmd: '/usr/bin/bun', argsPrefix: ['/repo/src/cli.ts'] });
|
||||
});
|
||||
|
||||
test('nothing resolves → null (caller must fail fast at startup)', () => {
|
||||
const inv = resolveChildCliInvocation({}, '/usr/bin/bun', '/repo/dist/other.js', () => null);
|
||||
expect(inv).toBeNull();
|
||||
});
|
||||
|
||||
test('throwing binary resolver falls through to the dev fallback', () => {
|
||||
const inv = resolveChildCliInvocation({}, '/usr/bin/bun', '/repo/src/cli.ts', () => {
|
||||
throw new Error('which failed');
|
||||
});
|
||||
expect(inv).toEqual({ cmd: '/usr/bin/bun', argsPrefix: ['/repo/src/cli.ts'] });
|
||||
});
|
||||
|
||||
test('child argv shape', () => {
|
||||
expect(buildChildArgs(42)).toEqual(['jobs', 'run-child', '--job-id', '42']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('killProcessGroup (real detached processes; Bun negative-pid fallback)', () => {
|
||||
async function waitFor(cond: () => boolean, ms: number): Promise<boolean> {
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
if (cond()) return true;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return cond();
|
||||
}
|
||||
|
||||
function alive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('group SIGKILL kills the child AND its grandchild', async () => {
|
||||
const { dir, path: pidFile } = tmpFile('grandchild.pid');
|
||||
try {
|
||||
// Child shell spawns a long-lived grandchild and records its pid.
|
||||
const child = spawn('/bin/sh', ['-c', `sleep 300 & echo $! > ${pidFile}; wait`], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
expect(child.pid).toBeGreaterThan(0);
|
||||
const gotPid = await waitFor(() => existsSync(pidFile) && readFileSync(pidFile, 'utf8').trim() !== '', 3_000);
|
||||
expect(gotPid).toBe(true);
|
||||
const grandchildPid = parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
||||
expect(alive(child.pid!)).toBe(true);
|
||||
expect(alive(grandchildPid)).toBe(true);
|
||||
|
||||
killProcessGroup(child.pid!, 'SIGKILL');
|
||||
|
||||
const bothDead = await waitFor(() => !alive(child.pid!) && !alive(grandchildPid), 3_000);
|
||||
expect(bothDead).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test('SIGTERM delivery to a live group returns true; dead group returns false', async () => {
|
||||
const child = spawn('/bin/sh', ['-c', 'sleep 300'], { detached: true, stdio: 'ignore' });
|
||||
const delivered = killProcessGroup(child.pid!, 'SIGTERM');
|
||||
expect(delivered).toBe(true);
|
||||
await waitFor(() => {
|
||||
try {
|
||||
process.kill(child.pid!, 0);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}, 3_000);
|
||||
// Group is gone now — a second signal reports not-delivered.
|
||||
expect(killProcessGroup(child.pid!, 'SIGKILL')).toBe(false);
|
||||
}, 15_000);
|
||||
|
||||
test('nonsense pids are refused without throwing', () => {
|
||||
expect(killProcessGroup(0, 'SIGTERM')).toBe(false);
|
||||
expect(killProcessGroup(1, 'SIGTERM')).toBe(false);
|
||||
expect(killProcessGroup(-5, 'SIGTERM')).toBe(false);
|
||||
expect(killProcessGroup(1.5 as unknown as number, 'SIGTERM')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* issue #5 — `parseJobIsolationFlag` (jobs-nice-flag.test.ts pattern: pure
|
||||
* parser, env injected as the 2nd param so tests never mutate process.env).
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseJobIsolationFlag } from '../src/commands/jobs.ts';
|
||||
|
||||
describe('parseJobIsolationFlag', () => {
|
||||
test('default: inline', () => {
|
||||
expect(parseJobIsolationFlag([], {})).toBe('inline');
|
||||
});
|
||||
|
||||
test('space form', () => {
|
||||
expect(parseJobIsolationFlag(['--job-isolation', 'process'], {})).toBe('process');
|
||||
expect(parseJobIsolationFlag(['--job-isolation', 'inline'], {})).toBe('inline');
|
||||
});
|
||||
|
||||
test('= form', () => {
|
||||
expect(parseJobIsolationFlag(['--job-isolation=process'], {})).toBe('process');
|
||||
expect(parseJobIsolationFlag(['--job-isolation=inline'], {})).toBe('inline');
|
||||
});
|
||||
|
||||
test('env fallback, flag wins over env', () => {
|
||||
expect(parseJobIsolationFlag([], { GBRAIN_JOB_ISOLATION: 'process' })).toBe('process');
|
||||
expect(
|
||||
parseJobIsolationFlag(['--job-isolation', 'inline'], { GBRAIN_JOB_ISOLATION: 'process' }),
|
||||
).toBe('inline');
|
||||
});
|
||||
|
||||
test('empty env value falls through to the default', () => {
|
||||
expect(parseJobIsolationFlag([], { GBRAIN_JOB_ISOLATION: '' })).toBe('inline');
|
||||
});
|
||||
|
||||
test('other flags are untouched', () => {
|
||||
expect(parseJobIsolationFlag(['--queue', 'q', '--concurrency', '3'], {})).toBe('inline');
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,9 @@ describe('jobs --help and jobs <subcommand> --help print real help, never the st
|
||||
expect(out).toContain('--max-rss');
|
||||
expect(out).toContain('--health-interval');
|
||||
expect(out).toContain('GBRAIN_WORKER_CONCURRENCY');
|
||||
// issue #5 process isolation: flag + env fallback documented.
|
||||
expect(out).toContain('--job-isolation');
|
||||
expect(out).toContain('GBRAIN_JOB_ISOLATION');
|
||||
expect(out).not.toContain(STUB_MARKER);
|
||||
// A real `jobs work` on this fixture would refuse (thin-client/engine
|
||||
// path) or start a daemon; either output would differ from the help.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* issue #6 — `MinionQueue.renewLock` forwards its optional AbortSignal to
|
||||
* `engine.executeRawDirect` so the lock-renewal tick's timeout race can
|
||||
* CANCEL a hung UPDATE (postgres.js `.cancel()`) instead of abandoning it on
|
||||
* a checked-out pool slot.
|
||||
*
|
||||
* Hermetic: stub engine object literal (`as unknown as BrainEngine`), no DB —
|
||||
* the worker-conn-resilience-1720 pattern.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { MinionQueue } from '../src/core/minions/queue.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
|
||||
function makeCaptureEngine() {
|
||||
const calls: Array<{
|
||||
sql: string;
|
||||
params: unknown[] | undefined;
|
||||
opts: { signal?: AbortSignal } | undefined;
|
||||
}> = [];
|
||||
const engine = {
|
||||
kind: 'postgres',
|
||||
executeRawDirect: async (
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
opts?: { signal?: AbortSignal },
|
||||
) => {
|
||||
calls.push({ sql, params, opts });
|
||||
return [{ id: 1 }];
|
||||
},
|
||||
} as unknown as BrainEngine;
|
||||
return { engine, calls };
|
||||
}
|
||||
|
||||
describe('MinionQueue.renewLock signal forwarding (issue #6)', () => {
|
||||
test('forwards opts.signal to executeRawDirect', async () => {
|
||||
const { engine, calls } = makeCaptureEngine();
|
||||
const queue = new MinionQueue(engine);
|
||||
const ac = new AbortController();
|
||||
|
||||
const ok = await queue.renewLock(7, 'tok-xyz', 30_000, { signal: ac.signal });
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(calls.length).toBe(1);
|
||||
expect(calls[0].sql).toContain('UPDATE minion_jobs SET lock_until');
|
||||
expect(calls[0].params).toEqual([30_000, 7, 'tok-xyz']);
|
||||
expect(calls[0].opts?.signal).toBe(ac.signal);
|
||||
});
|
||||
|
||||
test('legacy 3-arg call still works (opts undefined)', async () => {
|
||||
const { engine, calls } = makeCaptureEngine();
|
||||
const queue = new MinionQueue(engine);
|
||||
|
||||
const ok = await queue.renewLock(7, 'tok-xyz', 30_000);
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(calls[0].opts).toBeUndefined();
|
||||
});
|
||||
|
||||
test('token-fence miss returns false regardless of signal', async () => {
|
||||
const engine = {
|
||||
kind: 'postgres',
|
||||
executeRawDirect: async () => [],
|
||||
} as unknown as BrainEngine;
|
||||
const queue = new MinionQueue(engine);
|
||||
const ac = new AbortController();
|
||||
|
||||
const ok = await queue.renewLock(7, 'tok-stale', 30_000, { signal: ac.signal });
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user