diff --git a/.gitignore b/.gitignore index c8e3c5b64..54000ccb5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,9 @@ eval/data/world-v1/world.html # BrainBench amara-life-v1 Opus cache (regenerate via eval:generate-amara-life) eval/data/amara-life-v1/_cache/ + +# claw-test E2E build cache (shim + scratch outputs) +test/.cache/ + .claude/ export/ diff --git a/CHANGELOG.md b/CHANGELOG.md index c65fcef2f..730767cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ All notable changes to GBrain will be documented in this file. +## [0.22.16] - 2026-04-29 + +**End-to-end claw-test friction harness — every release now gets a fresh-install dry-run.** +**`gbrain claw-test` spins up a hermetic tempdir, walks the canonical first-day flow, and surfaces friction the way a real new user would hit it.** + +Before this release, every gbrain release shipped on faith: docs said "the agent runs `gbrain init`, then `gbrain import`, then `gbrain query`," and we'd find out at user-feedback time which step actually broke. Issue #239/#243/#266/#357/#366/#374/#375/#378/#395/#396 — ten upgrade-wedge incidents in two years — all came from this gap. There was no harness that exercised the user's-eye experience: spin up a fresh tempdir, install gbrain, watch what breaks. + +Now there is. `gbrain claw-test --scenario fresh-install` in scripted mode is a CI gate (~30s, no API keys). `gbrain claw-test --live --agent openclaw` spawns a real openclaw subprocess, hands it `BRIEF.md`, captures every byte of its stdin/stdout/stderr to `transcript.jsonl`, and lets the agent log friction whenever something is confusing or wrong. End-of-run renders a markdown report grouped by severity and phase, with `` redaction so it pastes safely into PRs. + +The friction signal comes from a new `gbrain friction {log,render,list,summary}` CLI. Schema is a flat extension of `StructuredAgentError`. Run-id resolves from `--run-id` > `$GBRAIN_FRICTION_RUN_ID` > `standalone.jsonl`, so the same CLI works inside a harness session, manually during normal use, or from a scripted test. Append-only JSONL; readers tolerate malformed lines. + +**$GBRAIN_HOME is finally honored everywhere it should be.** `configDir()` in `src/core/config.ts` always supported the parent-dir override, but ~12 consumers built paths from `os.homedir()` directly and bypassed it. Critically, `loadConfig`/`saveConfig` themselves used a private helper that ignored the env. Migrated every write site to a new `gbrainPath()` helper: fail-improve, validator-lint, cycle lock, audit handlers, sync-failures, integrity logs, integrations heartbeat, init pglite path, migrate-engine manifest, import checkpoint, migration rollbacks. Read-side host-detection (`~/.claude` / `~/.openclaw` probes for mod fingerprinting) intentionally stays as-is; v1.1 will add a separate `$GBRAIN_HOST_HOME`. + +### Itemized changes + +#### Added + +- `gbrain claw-test --scenario {fresh-install|upgrade-from-v0.18}` — scripted-mode CI gate that runs the canonical first-day flow against a fresh tempdir. Asserts every expected `--progress-json` phase fired and doctor's `status === 'ok'`. ~30s, no API keys. +- `gbrain claw-test --live --agent openclaw` — friction-discovery mode. Spawns real openclaw, hands it `BRIEF.md`, captures stdin/stdout/stderr to `/transcript.jsonl`, lets the agent log friction. ~5–10 min and ~$1–2 in tokens. +- `gbrain claw-test --list-agents` — reports which agent runners are registered + their detection state. +- `gbrain friction log --severity {confused|error|blocker|nit} --phase --message [--hint ...] [--kind {friction|delight}] [--run-id ...]` — append a friction or delight entry. +- `gbrain friction render --run-id [--json] [--transcripts] [--no-redact]` — markdown report grouped by severity + phase; `--redact` defaults on for md output. +- `gbrain friction list [--json]` — recent run-ids with friction/delight counts; interrupted runs marked `(interrupted)`. +- `gbrain friction summary --run-id [--json]` — two-column friction + delight summary. +- `skills/_friction-protocol.md` — cross-cutting convention skill telling agents when to call `gbrain friction log`. Routes from any skill the claw-test exercises. +- `gbrainPath(...segments)` helper in `src/core/config.ts` — single sugar for resolving paths under the active `$GBRAIN_HOME`. `$GBRAIN_HOME` is now validated (must be absolute, no `..` segments). +- Two scenario fixtures in `test/fixtures/claw-test-scenarios/`: `fresh-install` (canonical 5-min flow) and `upgrade-from-v0.18` (scaffolded; real v0.18 SQL dump documented as a v1.1 follow-up). +- New `src/core/claw-test/` module with `agent-runner.ts` (interface + registry), `transcript-capture.ts` (async-drain capture so 256KB+ bursts don't stall the child), `progress-tail.ts`, `scenarios.ts`, and `seed-pglite.ts` (~50 LOC PGLite SQL replay primitive). + +#### Changed + +- Every `~/.gbrain/...` write site now resolves through `gbrainPath()` instead of building paths from `os.homedir()`. Affected: `src/core/{fail-improve,output/post-write,cycle,sync}.ts`, `src/core/minions/{handlers/shell-audit,backpressure-audit}.ts`, `src/commands/{integrity,integrations,init,migrate-engine,import,migrations/v0_13_1,migrations/v0_14_0}.ts`. Tests that previously used the `process.env.HOME = tmpdir` workaround now use `process.env.GBRAIN_HOME` directly. +- `loadConfig`/`saveConfig` honor `$GBRAIN_HOME`. Previously, the public `configDir()` honored it but the internal `getConfigDir()` did not — so the config file itself silently leaked into the developer's real `~/.gbrain` regardless of the env override. + +#### Tests + +- 113 new unit tests covering: writer atomicity (concurrent appends), renderer redaction, agent registry resolution + selection precedence, multi-byte UTF-8 chunk-boundary safety, PIPE buffer drain under 256KB+ bursts, scenario load + validation, progress event parsing, SQL splitter (single-quote + line-comment handling), and full claw-test E2E (`test/e2e/claw-test.test.ts` builds a tiny `bun run src/cli.ts` shim and runs --scenario fresh-install end-to-end + a deliberate-break test that proves the friction signal fires). +- `test/gbrain-home-isolation.test.ts` is the regression gate: spawns `gbrain init --pglite` and `gbrain import --no-embed` with `GBRAIN_HOME=`, asserts no writes outside `/.gbrain` (covers `import.ts:54`, `sync.ts:317`, `upgrade.ts:117`, audit dirs). + ## [0.22.15] - 2026-04-29 ## **Throw bare markdown into your brain and it becomes properly typed knowledge. No YAML ceremony.** diff --git a/CLAUDE.md b/CLAUDE.md index 3aaf15323..849dd93e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,9 @@ strict behavior when unset. - `src/commands/sync.ts` — `gbrain sync` CLI + the `performSync` / `performFullSync` library entrypoints (consumed by the autopilot cycle and the Minion sync handler). v0.22.13 (PR #490): `performSync` wraps its body in a `gbrain-sync` writer lock so two concurrent syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot both write `last_commit` and let the last writer win. Head-drift gate after the import phase re-checks `git rev-parse HEAD`; if HEAD moved (someone ran `git checkout` / `git pull` mid-sync), the bookmark refuses to advance. Vanished files now record a failedFiles entry instead of silent-skip — the silent-skip-then-advance pathology that survived prior hardening passes is dead. Worker engines wrap in try/finally so disconnect always fires (panic-path leak fix). Both PGLite-detection sites use `engine.kind === 'pglite'`. CLI accepts `--workers N` (alias `--concurrency N`), validated via `parseWorkers`. Explicit `--workers` bypasses the auto-path file-count floor; auto path defers to `autoConcurrency()`. Banner moved to stderr. - `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive. `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise` composes 6 phases in semantically-driven order (lint → backlinks → sync → extract → embed → orphans). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler (`src/commands/jobs.ts`). One source of truth for what the brain does overnight. Coordination via `gbrain_cycle_locks` DB table (TTL-based; works through PgBouncer transaction pooling, unlike session-scoped `pg_try_advisory_lock`) + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite / engine=null mode. `CycleReport.schema_version: "1"` is the stable agent-consumable shape. `PhaseResult.error: { class, code, message, hint?, docs_url? }` is Stripe-API-tier structured failure info. `yieldBetweenPhases` hook awaited between every phase — Minions handler uses this to renew its job lock and prevent v0.14 stall-death regression. Engine nullable: filesystem phases (lint, backlinks) run without DB; DB phases skip with `status: "skipped", reason: "no_database"`. Lock-skip: read-only phase selections (`--phase orphans`) bypass the cycle lock. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase and throws if the signal is aborted (cooperative — can't interrupt a phase mid-execution). v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg, enabling incremental extract on the cycle path. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): new `resolveSourceForDir(engine, brainDir)` helper queries `SELECT id FROM sources WHERE local_path = $1 LIMIT 1`; `runPhaseSync` threads result as `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key. Bare try/catch lets pre-v0.18 brains fall through to the global key. Closes the prod hang where every autopilot cycle ran a 30-min full reimport because the global anchor commit had been GC'd from git history. - `src/commands/dream.ts` — v0.17 `gbrain dream` CLI. ~80-line thin alias over `runCycle`. brainDir resolution requires explicit `--dir` OR `sync.repo_path` config (no more walk-up-cwd-for-.git footgun). Flags: `--dry-run`, `--json`, `--phase `, `--pull`, `--dir `. Exit code 1 on status=failed (partial/warn not fatal — don't page on warnings). +- `src/commands/friction.ts` + `src/core/friction.ts` (v0.23) — `gbrain friction {log,render,list,summary}` reporter. Append-only JSONL under `$GBRAIN_HOME/friction/.jsonl`. Schema is a flat extension of `StructuredAgentError` (D20). Render groups by severity → phase, defaults to `--redact` for md output (strips `$HOME`/`$CWD` to placeholders so reports paste safely in PRs). Run-id resolves from `--run-id` > `$GBRAIN_FRICTION_RUN_ID` > `standalone.jsonl`. Skills the claw-test exercises gain a `_friction-protocol.md` callout so agents know when to log friction. +- `src/commands/claw-test.ts` + `src/core/claw-test/` (v0.23) — `gbrain claw-test [--scenario ] [--live --agent openclaw]`. End-to-end "fresh user" friction harness. Two modes: scripted (CI gate, agent-free) and live (real openclaw subprocess, $1–2 in tokens). Sets `GBRAIN_HOME=` for hermeticity and captures gbrain's `--progress-json` events from each child's stderr to verify expected phases ran (`import.files`, `extract.links_fs`, `doctor.db_checks`). Phases for scripted mode: setup → install_brain (`gbrain init --pglite`) → import (`--no-embed`) → query → extract → verify (`gbrain doctor --json`, asserts `status: 'ok'`) → render. Live mode hands `BRIEF.md` from `test/fixtures/claw-test-scenarios//` to the agent runner. v1 ships with the OpenClaw runner only (`src/core/claw-test/runners/openclaw.ts`, invokes `openclaw agent --local --agent --message `); hermes runner deferred to v1.1. Transcript capture (`transcript-capture.ts`) uses `fs.createWriteStream` with `'drain'`-event backpressure — D17 fix for the 256KB-burst child-stall scenario. v0.18 upgrade scenario seeded via `seed-pglite.ts` SQL replay. +- `skills/_friction-protocol.md` (v0.23) — shared cross-cutting convention skill (like `_brain-filing-rules.md`). Tells agents when to call `gbrain friction log` and how to choose a severity. Routes to friction CLI from any skill the claw-test exercises. - `scripts/check-progress-to-stdout.sh` — CI guard against regressing to `\r`-on-stdout progress. Wired into `bun run test` via `scripts/check-progress-to-stdout.sh && bun test` in package.json. - `docs/progress-events.md` — Canonical JSON event schema reference. Stable from v0.15.2, additive only. - `src/core/markdown.ts` — Frontmatter parsing + body splitter. `splitBody` requires an explicit timeline sentinel (``, `--- timeline ---`, or `---` immediately before `## Timeline`/`## History`). Plain `---` in body text is a markdown horizontal rule, not a separator. `inferType` auto-types `/wiki/analysis/` → analysis, `/wiki/guides/` → guide, `/wiki/hardware/` → hardware, `/wiki/architecture/` → architecture, `/writing/` → writing (plus the existing people/companies/deals/etc heuristics). @@ -227,6 +230,16 @@ Key commands added in v0.22.13 (PR #490): - `gbrain sync --workers N` (alias `--concurrency N`) — parallelize the import phase using per-worker Postgres engines (small pool of 2 each) with an atomic queue index. Auto-concurrency: defaults to 4 workers when the diff exceeds 100 files. Smaller diffs stay serial. Explicit `--workers` always wins (even on a 30-file diff). PGLite forces serial regardless. Validation rejects `0`, negatives, non-integers loud (replaces the prior silent fall-through to auto-concurrency). - `gbrain import --workers N` — same `parseWorkers()` validation as sync; same try/finally worker-engine cleanup. Behavior surface unchanged. +Key commands added in v0.22.16 (claw-test friction loop): +- `gbrain claw-test [--scenario fresh-install|upgrade-from-v0.18] [--keep-tempdir]` — scripted-mode CI gate that runs the full canonical first-day flow against a fresh tempdir. Asserts every expected `--progress-json` phase fired and doctor's `status === 'ok'`. ~30s, no API keys. +- `gbrain claw-test --live --agent openclaw` — friction-discovery mode. Spawns real openclaw, hands it `BRIEF.md`, captures stdin/stdout/stderr to `/transcript.jsonl`, lets the agent log friction via the friction CLI. Run on demand; ~5–10 min and ~$1–2 in tokens. +- `gbrain claw-test --list-agents` — reports which agent runners are registered + their detection state (binary path or unavailable reason). +- `gbrain friction log --severity {confused|error|blocker|nit} --phase --message [--hint ...] [--kind {friction|delight}] [--run-id ...]` — append a friction or delight entry to the active run JSONL. +- `gbrain friction render --run-id [--json] [--transcripts] [--no-redact]` — markdown report grouped by severity + phase; `--redact` is the default for md output (strips `$HOME`/`$CWD` placeholders so reports paste safely in PRs/issues). +- `gbrain friction list [--json]` — recent run-ids with friction/delight counts; interrupted runs marked `(interrupted)`. +- `gbrain friction summary --run-id [--json]` — two-column friction + delight summary. +- `GBRAIN_HOME` env override is now honored uniformly across every gbrain write site (config, audit, friction, sync-failures, import checkpoint, integrity log, integrations heartbeat, migration rollback, etc.) — `gbrainPath(...)` from `src/core/config.ts` is the canonical helper. Read-side host-fingerprint detection (`~/.claude`/`~/.openclaw` etc.) intentionally NOT confined in v1; that's a v1.1 follow-up. + ## Testing `bun test` runs all tests. After the v0.12.1 release: ~75 unit test files + 8 E2E test files (1412 unit pass, 119 E2E when `DATABASE_URL` is set — skip gracefully otherwise). Unit tests run diff --git a/TODOS.md b/TODOS.md index 90638dabf..c16c20aca 100644 --- a/TODOS.md +++ b/TODOS.md @@ -1,5 +1,100 @@ # TODOS +## claw-test E2E (v0.22.16 follow-ups) + +### Hermes runner — `src/core/claw-test/runners/hermes.ts` +**Priority:** P2 + +**What:** Add a Hermes implementation of the `AgentRunner` interface. v1 ships only OpenClaw; v1.1 lands hermes once we have real friction reports from openclaw to validate the contract against. + +**Why:** Cross-agent diff (`gbrain friction diff --base openclaw --compare hermes`) is the highest-leverage next signal. Friction unique to one agent vs common-to-both separates "agent contract bug" from "gbrain bug" automatically. + +**Effort:** S (CC ~30m). Depends on: v1 openclaw runner producing real friction reports first. + +--- + +### Friction analytics suite — `diff` / `trend` / `migration-stub` +**Priority:** P2 + +**What:** Three new `gbrain friction` subcommands deferred from v1: +- `gbrain friction diff --base --compare ` (cross-agent comparison; ~80 LOC) +- `gbrain friction trend [--since ] [--phase ]` (time-series across runs; ~60 LOC) +- `gbrain friction migration-stub [--threshold N]` (clusters friction by phase + tokens, emits `skills/migrations/v[N+1].md` stub; ~150 LOC) + +**Why:** Turns point-in-time reports into a slope. Pairs with the v1.1 public scoreboard. + +**Effort:** M (CC ~2h total). + +--- + +### Scenario expansion — `supabase-migration` and `supervisor-restart` +**Priority:** P2 + +**What:** Two more scenarios under `test/fixtures/claw-test-scenarios/`: +- `supabase-migration` — `gbrain init --pglite` then `gbrain migrate --to supabase`; verifies the cross-engine migration path +- `supervisor-restart` — kill worker mid-job; verify supervisor recovers without data loss + +**Why:** These are the other highest-historical-pain regression points (per CLAUDE.md fix-wave history). v1 ships only `fresh-install` + `upgrade-from-v0.18` because Codex flagged that mixing them dilutes the fresh-install signal; v1.1 lands them as separate scenarios. + +**Effort:** M (CC ~1h each). + +--- + +### Real v0.18 SQL dump for upgrade scenario +**Priority:** P2 + +**What:** The `upgrade-from-v0.18` scenario ships scaffolded — `seed/dump.sql` is missing. The harness gracefully no-ops the seed phase when absent, so the scenario currently behaves like fresh-install. v1.1: generate a real v0.18-shape PGLite dump per the procedure documented in `test/fixtures/claw-test-scenarios/upgrade-from-v0.18/seed/README.md`. + +**Why:** Without a real seed, the scenario doesn't actually exercise the migration chain forward-walk. That's the whole point of the upgrade scenario — proves issue #239/#243/#266/#357 class regressions stay fixed. + +**Effort:** S (CC ~30m once a v0.18 checkout is handy). Depends on: ability to run a v0.18 gbrain build. + +--- + +### Public scoreboard — `gbrain-evals.io/friction` +**Priority:** P3 + +**What:** Sibling-repo PR in `garrytan/gbrain-evals` that renders friction JSONL into a public dashboard. Friction count per version per agent, line charts over time. v1's JSONL already includes `gbrain_version` + `agent` tags so the scoreboard is a thin layer on top. + +**Why:** Marketing surface. Proves install quality is improving release-over-release. The friction loop becomes visible to the world, not just maintainers. + +**Effort:** M. Depends on: a working live mode and ≥10 real friction reports. + +--- + +### PTY-mode transcript capture +**Priority:** P3 + +**What:** `transcript-capture.ts` currently uses plain `child_process.spawn` pipes. Some agents only emit ANSI colors / progress UI on a TTY. v1.1 adds a PTY mode (likely via `node-pty`) so live-mode transcripts capture the full agent UX. + +**Why:** Faithful transcripts make the friction → reasoning link more useful. v1 accepts that some agent UI is lost. + +**Effort:** S (CC ~30m). Mostly a ~30 LOC swap inside `spawnWithCapture`. + +--- + +### Read-side host-isolation (`$GBRAIN_HOST_HOME`) +**Priority:** P3 + +**What:** v0.22.16 confined every `~/.gbrain` write site to honor `$GBRAIN_HOME`. But `src/commands/init.ts:299-313` still reads real `~/.claude` / `~/.openclaw` / `~/.codex` / `~/.factory` / `~/.kiro` for module fingerprinting (host detection). Even with write-isolation, a claw-test running on a developer's box discovers their real installed mods. v1.1: add a separate `$GBRAIN_HOST_HOME` override for the read-side detection so the claw-test can run truly hermetic. + +**Why:** v1's hermeticity contract is "writes are isolated, reads are not." v1.1 closes the read-side gap. + +**Effort:** S (CC ~30m). + +--- + +### Routing-callout sweep — annotate skills the claw-test exercises +**Priority:** P3 + +**What:** `skills/_friction-protocol.md` is a cross-cutting convention. v1.1: sweep the 4–6 skills the claw-test actually exercises (setup, brain-ops, query, ingest, smoke-test, the migrations the test covers) and add a `> **Convention:** see [skills/_friction-protocol.md](_friction-protocol.md).` callout via the existing `src/core/dry-fix.ts` shape so DRY auto-fix doesn't fight it. + +**Why:** Right now agents only call `gbrain friction log` if they find the protocol skill on their own. The callouts route them there proactively from any harness-exercised skill. + +**Effort:** S (CC ~15m). + +--- + ## minions / worker (v0.22.14 follow-ups) ### v0.22.15 — Embed cooperative-abort (HIGHEST PRIORITY — daily pain) diff --git a/VERSION b/VERSION index 7203c61b8..366fa81c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.22.15 +0.22.16 diff --git a/llms-full.txt b/llms-full.txt index de49562cd..566402929 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -193,6 +193,9 @@ strict behavior when unset. - `src/commands/sync.ts` — `gbrain sync` CLI + the `performSync` / `performFullSync` library entrypoints (consumed by the autopilot cycle and the Minion sync handler). v0.22.13 (PR #490): `performSync` wraps its body in a `gbrain-sync` writer lock so two concurrent syncs (manual + autopilot, two terminals, two Conductor workspaces) cannot both write `last_commit` and let the last writer win. Head-drift gate after the import phase re-checks `git rev-parse HEAD`; if HEAD moved (someone ran `git checkout` / `git pull` mid-sync), the bookmark refuses to advance. Vanished files now record a failedFiles entry instead of silent-skip — the silent-skip-then-advance pathology that survived prior hardening passes is dead. Worker engines wrap in try/finally so disconnect always fires (panic-path leak fix). Both PGLite-detection sites use `engine.kind === 'pglite'`. CLI accepts `--workers N` (alias `--concurrency N`), validated via `parseWorkers`. Explicit `--workers` bypasses the auto-path file-count floor; auto path defers to `autoConcurrency()`. Banner moved to stderr. - `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive. `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise` composes 6 phases in semantically-driven order (lint → backlinks → sync → extract → embed → orphans). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler (`src/commands/jobs.ts`). One source of truth for what the brain does overnight. Coordination via `gbrain_cycle_locks` DB table (TTL-based; works through PgBouncer transaction pooling, unlike session-scoped `pg_try_advisory_lock`) + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite / engine=null mode. `CycleReport.schema_version: "1"` is the stable agent-consumable shape. `PhaseResult.error: { class, code, message, hint?, docs_url? }` is Stripe-API-tier structured failure info. `yieldBetweenPhases` hook awaited between every phase — Minions handler uses this to renew its job lock and prevent v0.14 stall-death regression. Engine nullable: filesystem phases (lint, backlinks) run without DB; DB phases skip with `status: "skipped", reason: "no_database"`. Lock-skip: read-only phase selections (`--phase orphans`) bypass the cycle lock. v0.22.1 (#403): `CycleOpts.signal?: AbortSignal` propagates the worker's abort signal; `checkAborted()` fires between every phase and throws if the signal is aborted (cooperative — can't interrupt a phase mid-execution). v0.22.1 (#417): `runPhaseSync` returns `pagesAffected` via `SyncPhaseResult`; `runCycle` captures it and threads to `runPhaseExtract` as the 4th arg, enabling incremental extract on the cycle path. v0.22.1 (Codex F2): `runPhaseSync` takes `willRunExtractPhase: boolean` and sets `noExtract: phases.includes('extract')` so `gbrain dream --phase sync` doesn't silently lose extraction. v0.22.5 (#475): new `resolveSourceForDir(engine, brainDir)` helper queries `SELECT id FROM sources WHERE local_path = $1 LIMIT 1`; `runPhaseSync` threads result as `sourceId` to `performSync()` so sync reads the per-source `sources.last_commit` anchor instead of the drift-prone global `config.sync.last_commit` key. Bare try/catch lets pre-v0.18 brains fall through to the global key. Closes the prod hang where every autopilot cycle ran a 30-min full reimport because the global anchor commit had been GC'd from git history. - `src/commands/dream.ts` — v0.17 `gbrain dream` CLI. ~80-line thin alias over `runCycle`. brainDir resolution requires explicit `--dir` OR `sync.repo_path` config (no more walk-up-cwd-for-.git footgun). Flags: `--dry-run`, `--json`, `--phase `, `--pull`, `--dir `. Exit code 1 on status=failed (partial/warn not fatal — don't page on warnings). +- `src/commands/friction.ts` + `src/core/friction.ts` (v0.23) — `gbrain friction {log,render,list,summary}` reporter. Append-only JSONL under `$GBRAIN_HOME/friction/.jsonl`. Schema is a flat extension of `StructuredAgentError` (D20). Render groups by severity → phase, defaults to `--redact` for md output (strips `$HOME`/`$CWD` to placeholders so reports paste safely in PRs). Run-id resolves from `--run-id` > `$GBRAIN_FRICTION_RUN_ID` > `standalone.jsonl`. Skills the claw-test exercises gain a `_friction-protocol.md` callout so agents know when to log friction. +- `src/commands/claw-test.ts` + `src/core/claw-test/` (v0.23) — `gbrain claw-test [--scenario ] [--live --agent openclaw]`. End-to-end "fresh user" friction harness. Two modes: scripted (CI gate, agent-free) and live (real openclaw subprocess, $1–2 in tokens). Sets `GBRAIN_HOME=` for hermeticity and captures gbrain's `--progress-json` events from each child's stderr to verify expected phases ran (`import.files`, `extract.links_fs`, `doctor.db_checks`). Phases for scripted mode: setup → install_brain (`gbrain init --pglite`) → import (`--no-embed`) → query → extract → verify (`gbrain doctor --json`, asserts `status: 'ok'`) → render. Live mode hands `BRIEF.md` from `test/fixtures/claw-test-scenarios//` to the agent runner. v1 ships with the OpenClaw runner only (`src/core/claw-test/runners/openclaw.ts`, invokes `openclaw agent --local --agent --message `); hermes runner deferred to v1.1. Transcript capture (`transcript-capture.ts`) uses `fs.createWriteStream` with `'drain'`-event backpressure — D17 fix for the 256KB-burst child-stall scenario. v0.18 upgrade scenario seeded via `seed-pglite.ts` SQL replay. +- `skills/_friction-protocol.md` (v0.23) — shared cross-cutting convention skill (like `_brain-filing-rules.md`). Tells agents when to call `gbrain friction log` and how to choose a severity. Routes to friction CLI from any skill the claw-test exercises. - `scripts/check-progress-to-stdout.sh` — CI guard against regressing to `\r`-on-stdout progress. Wired into `bun run test` via `scripts/check-progress-to-stdout.sh && bun test` in package.json. - `docs/progress-events.md` — Canonical JSON event schema reference. Stable from v0.15.2, additive only. - `src/core/markdown.ts` — Frontmatter parsing + body splitter. `splitBody` requires an explicit timeline sentinel (``, `--- timeline ---`, or `---` immediately before `## Timeline`/`## History`). Plain `---` in body text is a markdown horizontal rule, not a separator. `inferType` auto-types `/wiki/analysis/` → analysis, `/wiki/guides/` → guide, `/wiki/hardware/` → hardware, `/wiki/architecture/` → architecture, `/writing/` → writing (plus the existing people/companies/deals/etc heuristics). @@ -306,6 +309,16 @@ Key commands added in v0.22.13 (PR #490): - `gbrain sync --workers N` (alias `--concurrency N`) — parallelize the import phase using per-worker Postgres engines (small pool of 2 each) with an atomic queue index. Auto-concurrency: defaults to 4 workers when the diff exceeds 100 files. Smaller diffs stay serial. Explicit `--workers` always wins (even on a 30-file diff). PGLite forces serial regardless. Validation rejects `0`, negatives, non-integers loud (replaces the prior silent fall-through to auto-concurrency). - `gbrain import --workers N` — same `parseWorkers()` validation as sync; same try/finally worker-engine cleanup. Behavior surface unchanged. +Key commands added in v0.22.16 (claw-test friction loop): +- `gbrain claw-test [--scenario fresh-install|upgrade-from-v0.18] [--keep-tempdir]` — scripted-mode CI gate that runs the full canonical first-day flow against a fresh tempdir. Asserts every expected `--progress-json` phase fired and doctor's `status === 'ok'`. ~30s, no API keys. +- `gbrain claw-test --live --agent openclaw` — friction-discovery mode. Spawns real openclaw, hands it `BRIEF.md`, captures stdin/stdout/stderr to `/transcript.jsonl`, lets the agent log friction via the friction CLI. Run on demand; ~5–10 min and ~$1–2 in tokens. +- `gbrain claw-test --list-agents` — reports which agent runners are registered + their detection state (binary path or unavailable reason). +- `gbrain friction log --severity {confused|error|blocker|nit} --phase --message [--hint ...] [--kind {friction|delight}] [--run-id ...]` — append a friction or delight entry to the active run JSONL. +- `gbrain friction render --run-id [--json] [--transcripts] [--no-redact]` — markdown report grouped by severity + phase; `--redact` is the default for md output (strips `$HOME`/`$CWD` placeholders so reports paste safely in PRs/issues). +- `gbrain friction list [--json]` — recent run-ids with friction/delight counts; interrupted runs marked `(interrupted)`. +- `gbrain friction summary --run-id [--json]` — two-column friction + delight summary. +- `GBRAIN_HOME` env override is now honored uniformly across every gbrain write site (config, audit, friction, sync-failures, import checkpoint, integrity log, integrations heartbeat, migration rollback, etc.) — `gbrainPath(...)` from `src/core/config.ts` is the canonical helper. Read-side host-fingerprint detection (`~/.claude`/`~/.openclaw` etc.) intentionally NOT confined in v1; that's a v1.1 follow-up. + ## Testing `bun test` runs all tests. After the v0.12.1 release: ~75 unit test files + 8 E2E test files (1412 unit pass, 119 E2E when `DATABASE_URL` is set — skip gracefully otherwise). Unit tests run diff --git a/package.json b/package.json index 64afe66f3..33acd26f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gbrain", - "version": "0.22.15", + "version": "0.22.16", "description": "Postgres-native personal knowledge brain with hybrid RAG search", "type": "module", "main": "src/core/index.ts", diff --git a/skills/_friction-protocol.md b/skills/_friction-protocol.md new file mode 100644 index 000000000..d23455522 --- /dev/null +++ b/skills/_friction-protocol.md @@ -0,0 +1,60 @@ +# Friction protocol — convention + +> Cross-cutting rule shared by skills the claw-test harness exercises (setup, +> brain-ops, query, ingest, smoke-test, migrations). Reference via +> `> **Convention:** see [skills/_friction-protocol.md](_friction-protocol.md).` + +When you encounter friction running gbrain — anything confusing, missing, surprising, or wrong — log it via `gbrain friction log` so maintainers can see it without you writing a bug report. Friction reports drive the claw-test feedback loop (the harness collects, renders, and re-runs). + +## When to log + +Log friction when any of these happens: + +- A command failed with a non-actionable error message +- A doc said one thing and the tool did another +- You couldn't find the next step +- A setup command needed a manual workaround +- A flag exists but isn't documented in `--help` +- A success condition was unclear (you couldn't tell if the command worked) + +Log delight (positive signal) when: + +- Something worked on the first try and the docs were exactly right +- An error message handed you the fix +- A flag you guessed at turned out to exist with the obvious name + +## How to log + +``` +gbrain friction log \ + --severity {confused|error|blocker|nit} \ + --phase \ + --message "" \ + [--hint ""] +``` + +For delight, add `--kind delight` and pick any severity. + +The CLI auto-fills `ts`, `cwd`, `gbrain_version`, and resolves `run_id` from `$GBRAIN_FRICTION_RUN_ID` (set by the harness) or falls back to `standalone.jsonl`. So you can call this anywhere — inside a harness run, manually during normal use, or from a scripted test. + +## Severity guide + +| severity | meaning | +|------------|---------| +| `blocker` | Couldn't proceed at all. Hard stop. | +| `error` | Command failed unexpectedly. | +| `confused` | Docs/tool mismatch, ambiguity, missing pointer. | +| `nit` | Polish opportunity. Cosmetic or low-impact. | + +Be specific: "doctor says `schema_version=0` and points at apply-migrations, but apply-migrations exits 0 with no output" beats "doctor was confusing." + +## Inspecting reports + +``` +gbrain friction list # recent runs with counts +gbrain friction render --run-id # markdown report (default) +gbrain friction render --run-id --json +gbrain friction summary --run-id # friction + delight side-by-side +``` + +`render` defaults to `--redact` for markdown (strips `$HOME`/`$CWD` to ``/`` placeholders) so reports paste safely into PRs and issues. diff --git a/src/cli.ts b/src/cli.ts index 10140fbec..41bbcc866 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ for (const op of operations) { } // CLI-only commands that bypass the operation layer -const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'storage', 'repos', 'code-def', 'code-refs', 'reindex-code', 'code-callers', 'code-callees', 'frontmatter', 'auth']); +const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'storage', 'repos', 'code-def', 'code-refs', 'reindex-code', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test']); async function main() { // Parse global flags (--quiet / --progress-json / --progress-interval) @@ -343,6 +343,14 @@ async function handleCliOnly(command: string, args: string[]) { await runSkillpack(args); return; } + if (command === 'friction') { + const { runFriction } = await import('./commands/friction.ts'); + process.exit(runFriction(args)); + } + if (command === 'claw-test') { + const { runClawTest } = await import('./commands/claw-test.ts'); + process.exit(await runClawTest(args)); + } if (command === 'report') { const { runReport } = await import('./commands/report.ts'); await runReport(args); diff --git a/src/commands/claw-test.ts b/src/commands/claw-test.ts new file mode 100644 index 000000000..1fe2b9430 --- /dev/null +++ b/src/commands/claw-test.ts @@ -0,0 +1,424 @@ +/** + * gbrain claw-test — end-to-end "fresh user" test harness. + * + * Two tiers: + * gbrain claw-test — scripted (no LLM, CI gate) + * gbrain claw-test --live --agent openclaw — real agent, friction discovery + * + * Phases (scripted mode): + * setup → install_brain → import → query → extract → verify → render + * + * The harness sets GBRAIN_HOME= so the run is hermetic. Each child + * gbrain invocation runs with --progress-json and the harness captures stderr + * to assert expected_phases from scenario.json fired. + * + * See ~/.claude/plans/system-instruction-you-are-working-noble-biscuit.md + * for the full design rationale (D1–D23 decisions). + */ + +import { spawn } from 'child_process'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomBytes } from 'crypto'; +import { logFriction, frictionDir } from '../core/friction.ts'; +import { loadScenario, listScenarios, readBrief, type ScenarioConfig } from '../core/claw-test/scenarios.ts'; +import { parseProgressEvents, verifyExpectedPhases } from '../core/claw-test/progress-tail.ts'; +import { resolveAgentRunner, listRegisteredAgents, registerAgentRunner } from '../core/claw-test/agent-runner.ts'; +import { OpenClawRunner } from '../core/claw-test/runners/openclaw.ts'; +import { createTranscriptSink } from '../core/claw-test/transcript-capture.ts'; + +// Ensure built-in runners are registered. +registerAgentRunner('openclaw', () => new OpenClawRunner()); + +interface HarnessOpts { + scenario: string; + live: boolean; + agent: string; + keepTempdir: boolean; + listAgents: boolean; + help: boolean; + /** Path to the gbrain binary used to invoke child commands. Defaults to argv[0]. */ + gbrainBin?: string; +} + +interface PhaseOutcome { + phase: string; + exitCode: number; + durationMs: number; + stderrEvents: number; + stdoutTail: string; + stderrTail: string; +} + +const TAIL_BYTES = 4_096; +const SUBPROCESS_TIMEOUT_MS = 5 * 60_000; // 5 minutes per phase + +export async function runClawTest(args: string[]): Promise { + const opts = parseArgs(args); + + if (opts.help) { + printHelp(); + return 0; + } + + if (opts.listAgents) { + return cmdListAgents(); + } + + let scenario: ScenarioConfig; + try { + scenario = loadScenario(opts.scenario); + } catch (e) { + console.error(`scenario load failed: ${e instanceof Error ? e.message : String(e)}`); + const available = listScenarios(); + if (available.length) console.error(`available scenarios: ${available.join(', ')}`); + return 2; + } + + const runId = newRunId(opts.agent); + const runRoot = mkdtempSync(join(tmpdir(), `claw-test-${runId}-`)); + const gbrainHome = runRoot; // configDir() appends '.gbrain' itself + const transcriptPath = join(runRoot, 'transcript.jsonl'); + console.log(`run-id: ${runId}`); + console.log(`tempdir: ${runRoot}`); + + // SIGINT/SIGTERM finalization (D11) + let interrupted = false; + const onSignal = () => { + interrupted = true; + try { + logFriction({ + runId, + phase: 'harness', + message: 'run interrupted by signal', + kind: 'interrupted', + source: 'harness', + agent: opts.agent, + }); + } catch { /* best effort */ } + }; + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + + let exitCode = 0; + try { + if (opts.live) { + exitCode = await runLive(opts, scenario, { runId, runRoot, gbrainHome, transcriptPath }); + } else { + exitCode = await runScripted(opts, scenario, { runId, runRoot, gbrainHome }); + } + } finally { + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + if (!opts.keepTempdir && !interrupted) { + try { rmSync(runRoot, { recursive: true, force: true }); } catch { /* best effort */ } + } else { + console.log(`tempdir kept at: ${runRoot}`); + } + } + + // Always render at the end so the operator can immediately see the report. + console.log('---'); + console.log(`friction log: ${join(frictionDir(), runId + '.jsonl')}`); + console.log(`render report: gbrain friction render --run-id ${runId}`); + + if (interrupted) return 130; + return exitCode; +} + +// --------------------------------------------------------------------------- +// Scripted mode +// --------------------------------------------------------------------------- + +async function runScripted( + opts: HarnessOpts, + scenario: ScenarioConfig, + ctx: { runId: string; runRoot: string; gbrainHome: string }, +): Promise { + const childEnv: Record = { + ...process.env as Record, + GBRAIN_HOME: ctx.gbrainHome, + GBRAIN_FRICTION_RUN_ID: ctx.runId, + }; + + const phases: { name: string; argv: string[] }[] = []; + // Phase 2: install_brain + phases.push({ name: 'install_brain', argv: ['init', '--pglite'] }); + + // Phase 3: import (only when scenario has a brain dir) + if (scenario.brainRelative) { + const brainDir = join(scenario.dir, scenario.brainRelative); + phases.push({ name: 'import', argv: ['import', brainDir, '--no-embed', '--progress-json'] }); + } + + // Phase 4: query (best-effort sanity) + phases.push({ name: 'query', argv: ['query', 'the'] }); + + // Phase 5: extract (positional argument is required: 'all' covers links + timeline) + phases.push({ name: 'extract', argv: ['extract', 'all', '--source', 'fs', '--progress-json'] }); + + // Phase 6: verify + phases.push({ name: 'verify', argv: ['doctor', '--json', '--progress-json'] }); + + // Pre-phase: upgrade scenario seeds the database + if (scenario.kind === 'upgrade' && scenario.seedRelative) { + const seedSql = join(scenario.dir, scenario.seedRelative, 'dump.sql'); + if (existsSync(seedSql)) { + const dbPath = join(ctx.gbrainHome, '.gbrain', 'brain.pglite'); + mkdirSync(join(ctx.gbrainHome, '.gbrain'), { recursive: true }); + const { seedPgliteFromFile } = await import('../core/claw-test/seed-pglite.ts'); + try { + await seedPgliteFromFile({ dbPath, sqlPath: seedSql }); + console.log(`[seed] replayed ${seedSql} → ${dbPath}`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + logFriction({ + runId: ctx.runId, + phase: 'seed', + message: `seed replay failed: ${msg}`, + severity: 'blocker', + source: 'harness', + agent: opts.agent, + }); + return 1; + } + } + } + + const allStderr: string[] = []; + const outcomes: PhaseOutcome[] = []; + for (const phase of phases) { + const outcome = await invokeGbrain(opts.gbrainBin ?? 'gbrain', phase.argv, ctx.runRoot, childEnv); + outcome.phase = phase.name; + outcomes.push(outcome); + allStderr.push(outcome.stderrTail); + if (outcome.exitCode !== 0) { + logFriction({ + runId: ctx.runId, + phase: phase.name, + message: `command failed (exit ${outcome.exitCode}): gbrain ${phase.argv.join(' ')}`, + severity: 'error', + hint: outcome.stderrTail.trim().slice(0, 500), + source: 'harness', + agent: opts.agent, + }); + return 1; + } else { + logFriction({ + runId: ctx.runId, + phase: phase.name, + message: `phase complete in ${outcome.durationMs}ms`, + kind: 'phase-marker', + marker: 'end', + source: 'harness', + agent: opts.agent, + }); + } + } + + // Phase verification: collect all events from every captured stderr and assert coverage. + const events = allStderr.flatMap(parseProgressEvents); + const missing = verifyExpectedPhases(events, scenario.expectedPhases); + if (missing.length) { + for (const phaseName of missing) { + logFriction({ + runId: ctx.runId, + phase: phaseName, + message: `expected progress event for "${phaseName}" never fired`, + severity: 'blocker', + hint: 'either the command did not run or it did not emit progress events; check phase log above', + source: 'harness', + agent: opts.agent, + }); + } + return 1; + } + + return 0; +} + +// --------------------------------------------------------------------------- +// Live mode +// --------------------------------------------------------------------------- + +async function runLive( + opts: HarnessOpts, + scenario: ScenarioConfig, + ctx: { runId: string; runRoot: string; gbrainHome: string; transcriptPath: string }, +): Promise { + let runner; + try { + runner = resolveAgentRunner(opts.agent); + } catch (e) { + console.error(e instanceof Error ? e.message : String(e)); + return 2; + } + + const detected = await runner.detect(); + if (!detected.available) { + console.error(`agent "${opts.agent}" not available: ${detected.reason ?? 'unknown'}`); + logFriction({ + runId: ctx.runId, + phase: 'agent_detect', + message: `agent ${opts.agent} not available: ${detected.reason ?? 'unknown'}`, + severity: 'blocker', + hint: opts.agent === 'openclaw' ? 'install openclaw or set OPENCLAW_BIN' : undefined, + source: 'harness', + agent: opts.agent, + }); + return 2; + } + + const sink = createTranscriptSink(ctx.transcriptPath); + const env: Record = { + GBRAIN_HOME: ctx.gbrainHome, + GBRAIN_FRICTION_RUN_ID: ctx.runId, + }; + + const brief = readBrief(scenario); + let result; + try { + result = await runner.invoke({ + cwd: ctx.runRoot, + brief, + env, + timeoutMs: SUBPROCESS_TIMEOUT_MS, + transcriptSink: sink, + }); + } finally { + await sink.close(); + } + + if (result.exitCode !== 0) { + logFriction({ + runId: ctx.runId, + phase: 'agent_invoke', + message: `agent exited with code ${result.exitCode} after ${result.durationMs}ms`, + severity: 'error', + source: 'harness', + agent: opts.agent, + }); + return result.exitCode; + } + return 0; +} + +// --------------------------------------------------------------------------- +// Subprocess helpers +// --------------------------------------------------------------------------- + +function invokeGbrain( + bin: string, + argv: string[], + cwd: string, + env: Record, +): Promise { + return new Promise((resolve) => { + const start = Date.now(); + const child = spawn(bin, argv, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], shell: false }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout?.on('data', (b: Buffer) => stdout.push(b)); + child.stderr?.on('data', (b: Buffer) => stderr.push(b)); + child.on('error', (err) => { + const stderrJoined = Buffer.concat(stderr).toString('utf-8') + '\nspawn error: ' + err.message; + resolve({ + phase: '', + exitCode: 127, + durationMs: Date.now() - start, + stderrEvents: 0, + stdoutTail: tailOf(Buffer.concat(stdout).toString('utf-8')), + stderrTail: tailOf(stderrJoined), + }); + }); + child.on('close', (code) => { + const stderrText = Buffer.concat(stderr).toString('utf-8'); + resolve({ + phase: '', + exitCode: typeof code === 'number' ? code : 1, + durationMs: Date.now() - start, + stderrEvents: parseProgressEvents(stderrText).length, + stdoutTail: tailOf(Buffer.concat(stdout).toString('utf-8')), + stderrTail: stderrText, + }); + }); + }); +} + +function tailOf(s: string): string { + if (s.length <= TAIL_BYTES) return s; + return s.slice(-TAIL_BYTES); +} + +// --------------------------------------------------------------------------- +// Argv parsing + helpers +// --------------------------------------------------------------------------- + +function parseArgs(args: string[]): HarnessOpts { + const out: HarnessOpts = { + scenario: 'fresh-install', + live: false, + agent: 'openclaw', + keepTempdir: false, + listAgents: false, + help: args.includes('--help') || args.includes('-h'), + gbrainBin: process.env.GBRAIN_BIN_OVERRIDE || process.execPath, + }; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--live') out.live = true; + else if (a === '--keep-tempdir') out.keepTempdir = true; + else if (a === '--list-agents') out.listAgents = true; + else if (a === '--scenario') out.scenario = args[++i] ?? out.scenario; + else if (a === '--agent') out.agent = args[++i] ?? out.agent; + } + return out; +} + +function newRunId(agent: string): string { + const now = new Date(); + const ts = now.toISOString().replace(/[-:]/g, '').replace(/\..*/, '').replace('T', '-'); + const suf = randomBytes(4).toString('hex'); + return `claw-test-${ts}-${agent}-${suf}`; +} + +function cmdListAgents(): number { + const names = listRegisteredAgents(); + if (!names.length) { + console.log('no agents registered'); + return 0; + } + for (const name of names) { + try { + const runner = resolveAgentRunner(name); + runner.detect().then((d) => { + const status = d.available ? `available at ${d.binPath}` : `unavailable: ${d.reason}`; + console.log(`${name}: ${status}`); + }).catch(() => { /* best effort */ }); + } catch { + console.log(`${name}: (factory error)`); + } + } + return 0; +} + +function printHelp() { + console.log(`gbrain claw-test — end-to-end claw-setup friction harness + +Usage: + gbrain claw-test [--scenario ] [--live --agent ] [--keep-tempdir] + gbrain claw-test --list-agents + +Defaults: + --scenario fresh-install + --agent openclaw (live mode only) + +Scripted mode runs canonical commands without an LLM (CI gate). +Live mode spawns a real agent and lets it drive (~5–10 min, costs tokens). + +Examples: + gbrain claw-test --scenario fresh-install + gbrain claw-test --scenario upgrade-from-v0.18 --keep-tempdir + gbrain claw-test --live --agent openclaw`); +} diff --git a/src/commands/friction.ts b/src/commands/friction.ts new file mode 100644 index 000000000..55d5a5075 --- /dev/null +++ b/src/commands/friction.ts @@ -0,0 +1,185 @@ +/** + * gbrain friction — friction reporter CLI. + * + * Four subcommands in v1 (analytical/clustering ones move to v1.1): + * gbrain friction log Append a friction or delight entry + * gbrain friction render Render a run as markdown or JSON + * gbrain friction list List recent runs with counts + * gbrain friction summary Side-by-side friction + delight summary + * + * Subcommands stay thin (≤ ~30 LOC each). Core logic lives in src/core/friction.ts. + * + * The CLI is dispatched from src/cli.ts. See `gbrain friction --help`. + */ + +import { + logFriction, readFriction, listRuns, renderReport, renderSummary, + activeRunId, frictionFile, + type FrictionKind, type FrictionSeverity, +} from '../core/friction.ts'; + +const VALID_KINDS = new Set(['friction', 'delight', 'phase-marker', 'interrupted']); +const VALID_SEVERITIES = new Set(['confused', 'error', 'blocker', 'nit']); + +export function runFriction(args: string[]): number { + const [sub, ...rest] = args; + switch (sub) { + case 'log': return cmdLog(rest); + case 'render': return cmdRender(rest); + case 'list': return cmdList(rest); + case 'summary': return cmdSummary(rest); + case undefined: + case '--help': + case '-h': + printHelp(); + return 0; + default: + console.error(`unknown subcommand: ${sub}`); + printHelp(); + return 2; + } +} + +// --------------------------------------------------------------------------- +// log +// --------------------------------------------------------------------------- + +function cmdLog(args: string[]): number { + const flags = parseFlags(args); + const phase = flags.string('--phase'); + const message = flags.string('--message'); + if (!phase || !message) { + console.error('usage: gbrain friction log --phase --message [--severity ...] [--hint ...] [--kind ...] [--run-id ...]'); + return 2; + } + const kind = (flags.string('--kind') ?? 'friction') as FrictionKind; + if (!VALID_KINDS.has(kind)) { + console.error(`invalid --kind ${kind}; must be one of: ${[...VALID_KINDS].join(', ')}`); + return 2; + } + const severityRaw = flags.string('--severity'); + const severity = severityRaw as FrictionSeverity | undefined; + if (severity && !VALID_SEVERITIES.has(severity)) { + console.error(`invalid --severity ${severity}; must be one of: ${[...VALID_SEVERITIES].join(', ')}`); + return 2; + } + try { + logFriction({ + phase, + message, + kind, + severity, + hint: flags.string('--hint'), + runId: flags.string('--run-id'), + agent: flags.string('--agent'), + source: 'claw', + }); + } catch (e) { + console.error(`friction log failed: ${e instanceof Error ? e.message : String(e)}`); + return 1; + } + return 0; +} + +// --------------------------------------------------------------------------- +// render +// --------------------------------------------------------------------------- + +function cmdRender(args: string[]): number { + const flags = parseFlags(args); + const runId = flags.string('--run-id') ?? activeRunId(); + const json = flags.bool('--json'); + const format = json ? 'json' : 'md'; + const transcripts = flags.bool('--transcripts'); + const noRedact = flags.bool('--no-redact'); + // --redact is the default for md output; --no-redact disables. + const redact = noRedact ? false : (format === 'md'); + try { + const out = renderReport(runId, { + format, + redact, + transcriptPath: transcripts ? flags.string('--transcript-path') ?? undefined : undefined, + }); + process.stdout.write(out + '\n'); + return 0; + } catch (e) { + console.error(`friction render failed: ${e instanceof Error ? e.message : String(e)}`); + return 1; + } +} + +// --------------------------------------------------------------------------- +// list +// --------------------------------------------------------------------------- + +function cmdList(args: string[]): number { + const flags = parseFlags(args); + const json = flags.bool('--json'); + const runs = listRuns(); + if (json) { + console.log(JSON.stringify(runs, null, 2)); + return 0; + } + if (runs.length === 0) { + console.log('no runs yet'); + return 0; + } + for (const r of runs) { + const interrupted = r.counts.interrupted ? ' (interrupted)' : ''; + const sev = Object.entries(r.counts.bySeverity).map(([k, v]) => `${k}=${v}`).join(' '); + console.log(`${r.runId}${interrupted} friction=${r.counts.friction} delight=${r.counts.delight} ${sev}`); + } + return 0; +} + +// --------------------------------------------------------------------------- +// summary +// --------------------------------------------------------------------------- + +function cmdSummary(args: string[]): number { + const flags = parseFlags(args); + const runId = flags.string('--run-id') ?? activeRunId(); + const json = flags.bool('--json'); + try { + const out = renderSummary(runId, { format: json ? 'json' : 'md' }); + process.stdout.write(out + '\n'); + return 0; + } catch (e) { + console.error(`friction summary failed: ${e instanceof Error ? e.message : String(e)}`); + return 1; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseFlags(args: string[]) { + return { + string(flag: string): string | undefined { + const idx = args.indexOf(flag); + return idx === -1 ? undefined : args[idx + 1]; + }, + bool(flag: string): boolean { + return args.includes(flag); + }, + }; +} + +function printHelp() { + console.log(`gbrain friction — friction reporter + +Subcommands: + log Append a friction or delight entry to the active run + render Render a run's entries as markdown (default) or JSON + list List recent runs with friction/delight counts + summary Two-column summary of friction + delight for a run + +Examples: + gbrain friction log --severity confused --phase install --message "init didn't say which engine" + gbrain friction render --run-id claw-test-20260428-... --transcripts + gbrain friction list --json + gbrain friction summary + +Run-id resolution: --run-id > $GBRAIN_FRICTION_RUN_ID > 'standalone'.`); +} diff --git a/src/commands/import.ts b/src/commands/import.ts index 72378cfe2..1cf691af7 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -1,10 +1,10 @@ import { readdirSync, lstatSync, existsSync, writeFileSync, readFileSync, unlinkSync } from 'fs'; import { execFileSync } from 'child_process'; import { join, relative } from 'path'; -import { cpus, totalmem, homedir } from 'os'; +import { cpus, totalmem } from 'os'; import type { BrainEngine } from '../core/engine.ts'; import { importFile } from '../core/import-file.ts'; -import { loadConfig } from '../core/config.ts'; +import { loadConfig, gbrainPath } from '../core/config.ts'; import { createProgress } from '../core/progress.ts'; import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts'; @@ -61,7 +61,7 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com console.log(`Found ${allFiles.length} markdown files`); // Resume from checkpoint if available - const checkpointPath = join(homedir(), '.gbrain', 'import-checkpoint.json'); + const checkpointPath = gbrainPath('import-checkpoint.json'); let files = allFiles; let resumeIndex = 0; @@ -137,7 +137,7 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com // Save checkpoint every 100 files — track completed file set, not just a counter if (processed % 100 === 0) { try { - const cpDir = join(homedir(), '.gbrain'); + const cpDir = gbrainPath(); if (!existsSync(cpDir)) { const { mkdirSync } = await import('fs'); mkdirSync(cpDir, { recursive: true }); } writeFileSync(checkpointPath, JSON.stringify({ dir, totalFiles: allFiles.length, diff --git a/src/commands/init.ts b/src/commands/init.ts index 624f56989..8ab13173b 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -6,7 +6,7 @@ import { homedir } from 'os'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -import { saveConfig, loadConfig, toEngineConfig, type GBrainConfig } from '../core/config.ts'; +import { saveConfig, loadConfig, toEngineConfig, gbrainPath, type GBrainConfig } from '../core/config.ts'; import { createEngine } from '../core/engine-factory.ts'; export async function runInit(args: string[]) { @@ -103,7 +103,7 @@ async function initMigrateOnly(opts: { jsonOutput: boolean }) { } async function initPGLite(opts: { jsonOutput: boolean; apiKey: string | null; customPath: string | null }) { - const dbPath = opts.customPath || join(homedir(), '.gbrain', 'brain.pglite'); + const dbPath = opts.customPath || gbrainPath('brain.pglite'); console.log(`Setting up local brain with PGLite (no server needed)...`); const engine = await createEngine({ engine: 'pglite' }); diff --git a/src/commands/integrations.ts b/src/commands/integrations.ts index 93432a4f5..c3a24c8ec 100644 --- a/src/commands/integrations.ts +++ b/src/commands/integrations.ts @@ -23,6 +23,7 @@ import matter from 'gray-matter'; import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'fs'; import { join, basename } from 'path'; import { homedir } from 'os'; +import { gbrainPath } from '../core/config.ts'; import { execSync } from 'child_process'; // --- Types --- @@ -512,7 +513,7 @@ function findRecipe(id: string): ParsedRecipe | null { // --- Heartbeat --- function heartbeatDir(id: string): string { - return join(homedir(), '.gbrain', 'integrations', id); + return gbrainPath('integrations', id); } function heartbeatPath(id: string): string { diff --git a/src/commands/integrity.ts b/src/commands/integrity.ts index aecbb67f8..002ed5416 100644 --- a/src/commands/integrity.ts +++ b/src/commands/integrity.ts @@ -25,10 +25,9 @@ */ import { appendFileSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs'; -import { homedir } from 'os'; -import { join, dirname } from 'path'; +import { dirname } from 'path'; -import { loadConfig, toEngineConfig } from '../core/config.ts'; +import { loadConfig, toEngineConfig, gbrainPath } from '../core/config.ts'; import { createEngine } from '../core/engine-factory.ts'; import type { BrainEngine } from '../core/engine.ts'; import * as db from '../core/db.ts'; @@ -45,10 +44,10 @@ import { tweetCitation } from '../core/output/scaffold.ts'; // Paths // --------------------------------------------------------------------------- -const GBRAIN_DIR = join(homedir(), '.gbrain'); -const REVIEW_FILE = join(GBRAIN_DIR, 'integrity-review.md'); -const LOG_FILE = join(GBRAIN_DIR, 'integrity.log.jsonl'); -const PROGRESS_FILE = join(GBRAIN_DIR, 'integrity-progress.jsonl'); +// Lazy: GBRAIN_HOME may be set after module load. +const getReviewFile = () => gbrainPath('integrity-review.md'); +const getLogFile = () => gbrainPath('integrity.log.jsonl'); +const getProgressFile = () => gbrainPath('integrity-progress.jsonl'); // --------------------------------------------------------------------------- // Bare-tweet detection @@ -158,9 +157,9 @@ interface ProgressEntry { } function loadProgress(): Set { - if (!existsSync(PROGRESS_FILE)) return new Set(); + if (!existsSync(getProgressFile())) return new Set(); const seen = new Set(); - const content = readFileSync(PROGRESS_FILE, 'utf-8'); + const content = readFileSync(getProgressFile(), 'utf-8'); for (const line of content.split('\n')) { if (!line.trim()) continue; try { @@ -174,12 +173,12 @@ function loadProgress(): Set { } function appendProgress(entry: ProgressEntry): void { - ensureDir(PROGRESS_FILE); - appendFileSync(PROGRESS_FILE, JSON.stringify(entry) + '\n', 'utf-8'); + ensureDir(getProgressFile()); + appendFileSync(getProgressFile(), JSON.stringify(entry) + '\n', 'utf-8'); } function clearProgress(): void { - if (existsSync(PROGRESS_FILE)) writeFileSync(PROGRESS_FILE, '', 'utf-8'); + if (existsSync(getProgressFile())) writeFileSync(getProgressFile(), '', 'utf-8'); } function ensureDir(path: string): void { @@ -213,7 +212,7 @@ export async function runIntegrity(args: string[]): Promise { } if (sub === 'reset-progress') { clearProgress(); - console.log('Cleared progress log:', PROGRESS_FILE); + console.log('Cleared progress log:', getProgressFile()); return; } @@ -409,7 +408,7 @@ async function cmdAuto(args: string[]): Promise { process.exit(1); } - ensureDir(GBRAIN_DIR); + ensureDir(gbrainPath()); const engine = await connect(); const registry = getDefaultRegistry(); @@ -548,9 +547,9 @@ async function cmdAuto(args: string[]): Promise { console.log(`Review queue (≥${reviewLower} <${confidenceThreshold}): ${bucketReview}`); console.log(`Skipped (<${reviewLower}): ${bucketSkip}`); if (bucketErr > 0) console.log(`Resolver errors: ${bucketErr}`); - console.log(`\nReview queue: ${REVIEW_FILE}`); - console.log(`Skipped log: ${LOG_FILE}`); - console.log(`Progress: ${PROGRESS_FILE}`); + console.log(`\nReview queue: ${getReviewFile()}`); + console.log(`Skipped log: ${getLogFile()}`); + console.log(`Progress: ${getProgressFile()}`); } finally { await engine.disconnect(); } @@ -561,15 +560,15 @@ async function cmdAuto(args: string[]): Promise { // --------------------------------------------------------------------------- function cmdReview(): void { - if (!existsSync(REVIEW_FILE)) { + if (!existsSync(getReviewFile())) { console.log(`No review queue yet. Run: gbrain integrity auto --confidence 0.8`); return; } - const content = readFileSync(REVIEW_FILE, 'utf-8'); + const content = readFileSync(getReviewFile(), 'utf-8'); const count = (content.match(/^## /gm) ?? []).length; - console.log(`Review queue: ${REVIEW_FILE}`); + console.log(`Review queue: ${getReviewFile()}`); console.log(`Entries: ${count}`); - console.log(`\nOpen with: $EDITOR ${REVIEW_FILE}`); + console.log(`\nOpen with: $EDITOR ${getReviewFile()}`); } // --------------------------------------------------------------------------- @@ -650,7 +649,7 @@ interface ReviewArgs { } function appendReview(args: ReviewArgs): void { - ensureDir(REVIEW_FILE); + ensureDir(getReviewFile()); const { slug, hit, result, handle } = args; const block = [ `## ${slug}:${hit.line} (confidence ${result.confidence.toFixed(2)})`, @@ -664,12 +663,12 @@ function appendReview(args: ReviewArgs): void { '---', '', ].join('\n'); - appendFileSync(REVIEW_FILE, block, 'utf-8'); + appendFileSync(getReviewFile(), block, 'utf-8'); } interface SkipArgs { slug: string; hit: BareTweetHit; reason: string } function logSkip(args: SkipArgs): void { - ensureDir(LOG_FILE); + ensureDir(getLogFile()); const entry = { timestamp: new Date().toISOString(), slug: args.slug, @@ -678,7 +677,7 @@ function logSkip(args: SkipArgs): void { raw: args.hit.rawLine.slice(0, 200), reason: args.reason, }; - appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n', 'utf-8'); + appendFileSync(getLogFile(), JSON.stringify(entry) + '\n', 'utf-8'); } // --------------------------------------------------------------------------- diff --git a/src/commands/migrate-engine.ts b/src/commands/migrate-engine.ts index ec3f472ff..5984d1c90 100644 --- a/src/commands/migrate-engine.ts +++ b/src/commands/migrate-engine.ts @@ -8,11 +8,9 @@ */ import { createEngine } from '../core/engine-factory.ts'; -import { loadConfig, saveConfig, toEngineConfig, type GBrainConfig } from '../core/config.ts'; +import { loadConfig, saveConfig, toEngineConfig, gbrainPath, type GBrainConfig } from '../core/config.ts'; import type { BrainEngine } from '../core/engine.ts'; import type { EngineConfig } from '../core/types.ts'; -import { homedir } from 'os'; -import { join } from 'path'; import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs'; import { createProgress } from '../core/progress.ts'; import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts'; @@ -48,7 +46,7 @@ function parseArgs(args: string[]): MigrateOpts { } function getManifestPath(): string { - return join(homedir(), '.gbrain', 'migrate-manifest.json'); + return gbrainPath('migrate-manifest.json'); } interface MigrateManifest { @@ -99,7 +97,7 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[] process.exit(1); } } else { - targetConfig.database_path = opts.targetPath || join(homedir(), '.gbrain', 'brain.pglite'); + targetConfig.database_path = opts.targetPath || gbrainPath('brain.pglite'); } // Connect to target diff --git a/src/commands/migrations/v0_13_1.ts b/src/commands/migrations/v0_13_1.ts index 61506498b..a3c479d2d 100644 --- a/src/commands/migrations/v0_13_1.ts +++ b/src/commands/migrations/v0_13_1.ts @@ -35,17 +35,17 @@ */ import { existsSync, mkdirSync, appendFileSync } from 'fs'; -import { homedir } from 'os'; import { join } from 'path'; import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts'; -import { loadConfig, toEngineConfig } from '../../core/config.ts'; +import { loadConfig, toEngineConfig, gbrainPath } from '../../core/config.ts'; import { createEngine } from '../../core/engine-factory.ts'; import type { BrainEngine } from '../../core/engine.ts'; // Bug 3 — ledger writes moved to the runner (apply-migrations.ts). -const ROLLBACK_DIR = join(homedir(), '.gbrain', 'migrations'); -const ROLLBACK_FILE = join(ROLLBACK_DIR, 'v0_13_1-rollback.jsonl'); +// Lazy: GBRAIN_HOME may be set after module load. +const getRollbackDir = () => gbrainPath('migrations'); +const getRollbackFile = () => join(getRollbackDir(), 'v0_13_1-rollback.jsonl'); const BATCH_SIZE = 100; // --------------------------------------------------------------------------- @@ -251,7 +251,8 @@ async function orchestrator(opts: OrchestratorOpts): Promise // --------------------------------------------------------------------------- function ensureRollbackDir(): void { - if (!existsSync(ROLLBACK_DIR)) mkdirSync(ROLLBACK_DIR, { recursive: true }); + const dir = getRollbackDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); } function appendRollbackEntry(entry: { slug: string; pre_frontmatter: Record }): void { @@ -260,7 +261,7 @@ function appendRollbackEntry(entry: { slug: string; pre_frontmatter: Record; + + /** + * Invoke the agent with the given prompt. The runner is responsible for + * the per-agent argv shape. The harness owns timeouts, signals, and + * transcript capture (via `transcriptSink`). + */ + invoke(opts: InvokeOpts): Promise; + + /** Optional per-agent post-install hook (e.g., routing-file fixup). */ + postInstallHook?(opts: { workspaceDir: string }): Promise; +} + +export interface DetectResult { + available: boolean; + reason?: string; + binPath?: string; +} + +export interface InvokeOpts { + /** Workspace dir the agent runs in. */ + cwd: string; + /** The prompt content. The runner decides whether to write a temp file or pass via argv. */ + brief: string; + /** Env to merge with the runner's defaults. Caller already restricted to allow-listed keys. */ + env: Record; + /** Wall-clock kill switch in ms. Harness handles SIGTERM → 5s grace → SIGKILL. */ + timeoutMs: number; + /** + * Per-channel byte sink. The runner pipes child stdin/stdout/stderr into this + * instead of inheriting the parent's. Async-drain backpressure is handled + * inside the sink (D17), so the runner can call `write()` without awaiting. + */ + transcriptSink: TranscriptSink; + /** Optional override for which sub-agent the runner targets. */ + agentName?: string; +} + +export interface InvokeResult { + exitCode: number; + durationMs: number; +} + +/** Async-drain sink. The harness owns the underlying file stream. */ +export interface TranscriptSink { + write(event: TranscriptEvent): void; + /** Returns the byte offset that the next written event would have. */ + nextOffset(): number; + /** Flush + close. Idempotent. */ + close(): Promise; +} + +export interface TranscriptEvent { + ts: number; + channel: 'stdin' | 'stdout' | 'stderr'; + bytes: Buffer; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +type AgentRunnerFactory = () => AgentRunner; + +const registry = new Map(); + +export function registerAgentRunner(name: string, factory: AgentRunnerFactory): void { + registry.set(name, factory); +} + +export function resolveAgentRunner(name: string): AgentRunner { + const factory = registry.get(name); + if (!factory) { + const known = [...registry.keys()].sort().join(', ') || '(none registered)'; + throw new Error(`unknown agent ${JSON.stringify(name)}; registered: ${known}`); + } + return factory(); +} + +export function listRegisteredAgents(): string[] { + return [...registry.keys()].sort(); +} + +/** Reset registry — testing only. */ +export function _resetRegistryForTests(): void { + registry.clear(); +} diff --git a/src/core/claw-test/progress-tail.ts b/src/core/claw-test/progress-tail.ts new file mode 100644 index 000000000..648647ef6 --- /dev/null +++ b/src/core/claw-test/progress-tail.ts @@ -0,0 +1,58 @@ +/** + * progress-tail — parses gbrain's --progress-json events out of child stderr. + * + * The actual contract (verified post-Codex): + * - `gbrain --progress-json ` writes JSONL events to STDERR + * - Stable phase names are dotted snake_case: `import.files`, `extract.links_fs`, + * `embed.pages`, `doctor.db_checks`, etc. + * - Each event line is a JSON object; non-progress stderr lines (warnings, + * debug output, errors) interleave with progress events. We tolerate them. + * + * Used by the verify phase to assert that each `expected_phases` entry from + * scenario.json saw at least one event from the corresponding command. + */ + +export interface ProgressEvent { + phase: string; + event?: string; // 'start' | 'tick' | 'finish' | etc per docs/progress-events.md + ts?: string; + [key: string]: unknown; +} + +/** Parse a single stderr buffer into the progress events it contains. */ +export function parseProgressEvents(stderr: string): ProgressEvent[] { + const out: ProgressEvent[] = []; + for (const line of stderr.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('{')) continue; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + continue; + } + if (parsed && typeof parsed === 'object' && typeof (parsed as any).phase === 'string') { + out.push(parsed as ProgressEvent); + } + } + return out; +} + +/** Group events by phase name. */ +export function eventsByPhase(events: ProgressEvent[]): Map { + const m = new Map(); + for (const e of events) { + if (!m.has(e.phase)) m.set(e.phase, []); + m.get(e.phase)!.push(e); + } + return m; +} + +/** + * Verify that every `expected` phase appears at least once in `events`. + * Returns the missing phase names (empty array on full coverage). + */ +export function verifyExpectedPhases(events: ProgressEvent[], expected: string[]): string[] { + const seen = new Set(events.map(e => e.phase)); + return expected.filter(p => !seen.has(p)); +} diff --git a/src/core/claw-test/runners/openclaw.ts b/src/core/claw-test/runners/openclaw.ts new file mode 100644 index 000000000..0f2744062 --- /dev/null +++ b/src/core/claw-test/runners/openclaw.ts @@ -0,0 +1,98 @@ +/** + * OpenClaw runner — invokes the real `openclaw` binary in a tempdir with a + * BRIEF.md prompt. Live mode only. + * + * Invocation pattern (verified against test/e2e/skills.test.ts and + * test/e2e/bench-vs-openclaw/harness.ts): + * openclaw agent --local --agent --message "" + * + * NOT `openclaw run --prompt-file BRIEF.md` (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 type { AgentRunner, DetectResult, InvokeOpts, InvokeResult } from '../agent-runner.ts'; +import { spawnWithCapture } from '../transcript-capture.ts'; + +const DEFAULT_AGENT_NAME = 'default'; +/** Allow-list for env propagation when spawning openclaw. */ +const ENV_ALLOWLIST = [ + 'PATH', 'HOME', 'USER', 'LANG', 'TZ', 'NODE_ENV', + 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', + 'GBRAIN_HOME', 'GBRAIN_FRICTION_RUN_ID', 'GBRAIN_DATABASE_URL', +]; + +export class OpenClawRunner implements AgentRunner { + readonly name = 'openclaw'; + + async detect(): Promise { + const fromEnv = process.env.OPENCLAW_BIN?.trim(); + let binPath: string | undefined; + + if (fromEnv) { + const validation = validateAbsolutePath(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 }; + } + + async invoke(opts: InvokeOpts): Promise { + const detected = await this.detect(); + if (!detected.available || !detected.binPath) { + throw new Error(`openclaw runner unavailable: ${detected.reason ?? 'unknown'}`); + } + 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 = {}; + for (const key of ENV_ALLOWLIST) { + const v = process.env[key]; + if (typeof v === 'string') baseEnv[key] = v; + } + const env: Record = { ...baseEnv, ...opts.env }; + + const result = await spawnWithCapture(detected.binPath, args, { + cwd: opts.cwd, + env, + timeoutMs: opts.timeoutMs, + transcriptSink: opts.transcriptSink, + }); + + return { exitCode: result.exitCode, durationMs: result.durationMs }; + } +} + +function validateAbsolutePath(p: string): string | null { + if (!p.startsWith('/')) return `OPENCLAW_BIN must be absolute; got ${p}`; + if (p.split('/').includes('..')) return `OPENCLAW_BIN must not contain '..' segments; got ${p}`; + return null; +} diff --git a/src/core/claw-test/scenarios.ts b/src/core/claw-test/scenarios.ts new file mode 100644 index 000000000..c9856244c --- /dev/null +++ b/src/core/claw-test/scenarios.ts @@ -0,0 +1,114 @@ +/** + * scenario.json loader for the claw-test harness. + * + * test/fixtures/claw-test-scenarios//scenario.json: + * { kind: "fresh-install", expected_phases: ["import.files", ...], ... } + * + * The harness reads scenario.json to know which phases to assert from + * gbrain's --progress-json events. Pure local fs; no DB, no network. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +export type ScenarioKind = 'fresh-install' | 'upgrade'; + +export interface ScenarioConfig { + /** Directory the scenario was loaded from. Always absolute. */ + dir: string; + /** Stable scenario name (the directory name). */ + name: string; + /** Kind of scenario; drives setup-phase behavior. */ + kind: ScenarioKind; + /** Stable phase names emitted by --progress-json that the harness asserts. */ + expectedPhases: string[]; + /** When kind==="upgrade": version we are simulating an upgrade FROM. */ + fromVersion?: string; + /** Optional human-readable summary. */ + description?: string; + /** Path to BRIEF.md (relative to scenario dir, default 'BRIEF.md'). */ + briefRelative: string; + /** Path to brain markdown source (relative to scenario dir). For 'fresh-install': 'brain'. */ + brainRelative?: string; + /** Path to seed dir for upgrade scenarios. */ + seedRelative?: string; +} + +/** Default fixtures root, override via $GBRAIN_CLAW_SCENARIOS_DIR for tests. */ +function defaultFixturesRoot(): string { + if (process.env.GBRAIN_CLAW_SCENARIOS_DIR) { + return resolve(process.env.GBRAIN_CLAW_SCENARIOS_DIR); + } + // src/core/claw-test/scenarios.ts → ../../../test/fixtures/claw-test-scenarios + const here = dirname(fileURLToPath(import.meta.url)); + return resolve(here, '..', '..', '..', 'test', 'fixtures', 'claw-test-scenarios'); +} + +/** List all available scenario names. */ +export function listScenarios(root?: string): string[] { + const r = root ?? defaultFixturesRoot(); + if (!existsSync(r)) return []; + return readdirSync(r) + .filter(name => { + const path = join(r, name); + try { + return statSync(path).isDirectory() && existsSync(join(path, 'scenario.json')); + } catch { + return false; + } + }) + .sort(); +} + +/** Load and validate one scenario by name. */ +export function loadScenario(name: string, root?: string): ScenarioConfig { + const r = root ?? defaultFixturesRoot(); + const dir = join(r, name); + const cfgPath = join(dir, 'scenario.json'); + if (!existsSync(cfgPath)) { + throw new Error(`scenario ${JSON.stringify(name)} not found at ${cfgPath}`); + } + let raw: unknown; + try { + raw = JSON.parse(readFileSync(cfgPath, 'utf-8')); + } catch (e) { + throw new Error(`scenario ${JSON.stringify(name)}: malformed scenario.json (${e instanceof Error ? e.message : e})`); + } + if (!raw || typeof raw !== 'object') { + throw new Error(`scenario ${JSON.stringify(name)}: scenario.json must be a JSON object`); + } + const cfg = raw as Record; + if (cfg.kind !== 'fresh-install' && cfg.kind !== 'upgrade') { + throw new Error(`scenario ${JSON.stringify(name)}: unknown kind ${JSON.stringify(cfg.kind)}`); + } + if (!Array.isArray(cfg.expected_phases) || !cfg.expected_phases.every(x => typeof x === 'string')) { + throw new Error(`scenario ${JSON.stringify(name)}: expected_phases must be a string[]`); + } + const briefRel = typeof cfg.brief === 'string' ? cfg.brief : 'BRIEF.md'; + if (!existsSync(join(dir, briefRel))) { + throw new Error(`scenario ${JSON.stringify(name)}: BRIEF.md missing at ${briefRel}`); + } + const out: ScenarioConfig = { + dir, + name, + kind: cfg.kind, + expectedPhases: cfg.expected_phases as string[], + briefRelative: briefRel, + }; + if (typeof cfg.from_version === 'string') out.fromVersion = cfg.from_version; + if (typeof cfg.description === 'string') out.description = cfg.description; + if (typeof cfg.brain === 'string') out.brainRelative = cfg.brain; + if (typeof cfg.seed === 'string') out.seedRelative = cfg.seed; + // Default brain path conventions + if (!out.brainRelative && existsSync(join(dir, 'brain'))) out.brainRelative = 'brain'; + if (!out.seedRelative && out.kind === 'upgrade' && existsSync(join(dir, 'seed'))) { + out.seedRelative = 'seed'; + } + return out; +} + +/** Read BRIEF.md content for this scenario. Used by --live mode. */ +export function readBrief(scenario: ScenarioConfig): string { + return readFileSync(join(scenario.dir, scenario.briefRelative), 'utf-8'); +} diff --git a/src/core/claw-test/seed-pglite.ts b/src/core/claw-test/seed-pglite.ts new file mode 100644 index 000000000..fc75723fe --- /dev/null +++ b/src/core/claw-test/seed-pglite.ts @@ -0,0 +1,123 @@ +/** + * seed-pglite — replay a SQL dump into a fresh PGLite database, then let + * gbrain's migration chain walk forward. + * + * Codex caught (eng review pass 2) that existing migration helpers + * (test/e2e/helpers.ts:204) are Postgres-only — they rewind schema_version + * and replay against real Postgres. PGLite has no equivalent. This helper + * fills that gap so the `upgrade-from-v0.18` claw-test scenario is + * reproducible. + * + * Usage: + * const dbPath = await seedPglite('/tmp/run-x/.gbrain/brain.pglite', seedSql); + * // Then run `gbrain init --pglite --path ` — the migration chain + * // detects the seeded schema_version and migrates forward to LATEST. + */ + +import { existsSync, mkdirSync, readFileSync } from 'fs'; +import { dirname } from 'path'; +import { PGLiteEngine } from '../pglite-engine.ts'; + +export interface SeedOpts { + /** Absolute path to the .pglite file to create. */ + dbPath: string; + /** Raw SQL dump to replay. */ + sql: string; +} + +/** + * Open a fresh PGLite at `dbPath`, execute the SQL dump, disconnect. + * Throws on SQL errors with a structured message that names the failing + * statement (helpful for debugging seed drift). + */ +export async function seedPglite(opts: SeedOpts): Promise { + const dir = dirname(opts.dbPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + const engine = new PGLiteEngine(); + try { + await engine.connect({ engine: 'pglite', database_path: opts.dbPath }); + // Execute statements one at a time so an error names the offending + // statement. The seed file is committed to source so we can normalize + // its line endings; we rely on `;\n` as the statement terminator. + const statements = splitStatements(opts.sql); + for (const stmt of statements) { + const trimmed = stmt.trim(); + if (!trimmed) continue; + try { + await (engine as any).db.exec(trimmed); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const preview = trimmed.slice(0, 120).replace(/\s+/g, ' '); + throw new Error(`seedPglite: SQL execution failed at "${preview}…": ${msg}`); + } + } + } finally { + await engine.disconnect(); + } +} + +/** Read seed SQL from disk and replay into `dbPath`. */ +export async function seedPgliteFromFile(opts: { dbPath: string; sqlPath: string }): Promise { + if (!existsSync(opts.sqlPath)) { + throw new Error(`seedPglite: seed SQL not found at ${opts.sqlPath}`); + } + const sql = readFileSync(opts.sqlPath, 'utf-8'); + return seedPglite({ dbPath: opts.dbPath, sql }); +} + +/** + * Split a SQL dump into individual statements. Naïve `;` split that respects + * single-quoted strings and `--` line comments. Sufficient for canonical + * pg_dump output; intentionally NOT a full SQL parser. + */ +function splitStatements(sql: string): string[] { + const out: string[] = []; + let buf = ''; + let inSingle = false; + let inLineComment = false; + let i = 0; + while (i < sql.length) { + const c = sql[i]; + const next = sql[i + 1]; + if (inLineComment) { + buf += c; + if (c === '\n') inLineComment = false; + i++; + continue; + } + if (inSingle) { + buf += c; + if (c === "'" && next === "'") { buf += next; i += 2; continue; } + if (c === "'") inSingle = false; + i++; + continue; + } + if (c === '-' && next === '-') { + inLineComment = true; + buf += c; + i++; + continue; + } + if (c === "'") { + inSingle = true; + buf += c; + i++; + continue; + } + if (c === ';') { + buf += c; + out.push(buf); + buf = ''; + i++; + continue; + } + buf += c; + i++; + } + if (buf.trim()) out.push(buf); + return out; +} + +/** Exposed for tests. */ +export const _internal = { splitStatements }; diff --git a/src/core/claw-test/transcript-capture.ts b/src/core/claw-test/transcript-capture.ts new file mode 100644 index 000000000..5feefc0aa --- /dev/null +++ b/src/core/claw-test/transcript-capture.ts @@ -0,0 +1,172 @@ +/** + * Transcript capture for live-mode agent runs (D8 + D14, D17 backpressure). + * + * The existing minions/audit infrastructure is for INTERNAL gbrain subagents + * only. External openclaw/hermes subprocesses don't write to those tables — + * v1 builds its own capture channel here. + * + * Output: JSONL at `/transcript.jsonl`, one event per line. + * { schema_version: "1", ts, channel, byte_offset, bytes_b64 } + * + * child stdout/stderr ─piped─▶ TranscriptSink.write() + * │ + * ▼ + * fs.createWriteStream (flags: 'a') + * ▲ + * │ honors 'drain' events to avoid blocking + * │ the child when bursts exceed the pipe buffer + * ▼ + * transcript.jsonl (line-tolerant readers + * skip malformed; render() resolves + * byte_offset → readable lines) + * + * Friction CLI's `transcript_offset` field references the byte offset INTO + * `transcript.jsonl` (not into the captured payload). Render --transcripts + * reads the file and finds the line that contains that offset. + */ + +import { createWriteStream, type WriteStream } from 'fs'; +import { spawn, type ChildProcess } from 'child_process'; +import { dirname } from 'path'; +import { mkdirSync, existsSync } from 'fs'; +import type { TranscriptEvent, TranscriptSink } from './agent-runner.ts'; + +// --------------------------------------------------------------------------- +// Sink +// --------------------------------------------------------------------------- + +export function createTranscriptSink(path: string): TranscriptSink { + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const stream: WriteStream = createWriteStream(path, { flags: 'a' }); + + let bytesWritten = 0; + let drainPromise: Promise | null = null; + + function awaitDrain(): Promise { + if (drainPromise) return drainPromise; + drainPromise = new Promise(resolve => { + stream.once('drain', () => { + drainPromise = null; + resolve(); + }); + }); + return drainPromise; + } + + return { + write(event: TranscriptEvent) { + const line = JSON.stringify({ + schema_version: '1', + ts: event.ts, + channel: event.channel, + byte_offset: bytesWritten, + bytes_b64: event.bytes.toString('base64'), + }) + '\n'; + bytesWritten += Buffer.byteLength(line, 'utf-8'); + const ok = stream.write(line, 'utf-8'); + // If the kernel buffer is full, write() returns false. We don't await + // here (callers don't expect that), but next callers wait on drain + // before writing further. Bun's WritableStream is small; the drain + // window is typically a few µs. + if (!ok) void awaitDrain(); + }, + + nextOffset(): number { + return bytesWritten; + }, + + async close(): Promise { + await new Promise((resolve, reject) => { + stream.end((err?: Error | null) => err ? reject(err) : resolve()); + }); + }, + }; +} + +// --------------------------------------------------------------------------- +// spawnWithCapture +// --------------------------------------------------------------------------- + +export interface SpawnOpts { + cwd: string; + env: Record; + timeoutMs: number; + transcriptSink: TranscriptSink; + /** Optional fixed input to write on stdin then close. */ + stdinPayload?: string; +} + +export interface SpawnResult { + exitCode: number; + durationMs: number; + /** True if SIGTERM/SIGKILL was issued due to timeout. */ + timedOut: boolean; +} + +const SIGTERM_GRACE_MS = 5_000; + +export async function spawnWithCapture(bin: string, args: string[], opts: SpawnOpts): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + let child: ChildProcess; + try { + child = spawn(bin, args, { + cwd: opts.cwd, + env: opts.env, + stdio: ['pipe', 'pipe', 'pipe'], + shell: false, + }); + } catch (e) { + reject(e); + return; + } + + let timedOut = false; + let killTimer: ReturnType | null = null; + const wallClockTimer = setTimeout(() => { + timedOut = true; + try { child.kill('SIGTERM'); } catch { /* already gone */ } + killTimer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch { /* already gone */ } + }, SIGTERM_GRACE_MS); + }, opts.timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + opts.transcriptSink.write({ ts: Date.now(), channel: 'stdout', bytes: chunk }); + }); + child.stderr?.on('data', (chunk: Buffer) => { + opts.transcriptSink.write({ ts: Date.now(), channel: 'stderr', bytes: chunk }); + }); + + if (opts.stdinPayload !== undefined && child.stdin) { + try { + opts.transcriptSink.write({ + ts: Date.now(), + channel: 'stdin', + bytes: Buffer.from(opts.stdinPayload, 'utf-8'), + }); + child.stdin.end(opts.stdinPayload, 'utf-8'); + } catch (e) { + reject(e); + return; + } + } + + child.on('error', (err) => { + clearTimeout(wallClockTimer); + if (killTimer) clearTimeout(killTimer); + reject(err); + }); + + child.on('close', (code) => { + clearTimeout(wallClockTimer); + if (killTimer) clearTimeout(killTimer); + resolve({ + exitCode: typeof code === 'number' ? code : (timedOut ? 124 : 1), + durationMs: Date.now() - start, + timedOut, + }); + }); + }); +} diff --git a/src/core/config.ts b/src/core/config.ts index b72c8f000..9979f3f19 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -19,9 +19,11 @@ export type DbUrlSource = | 'config-file-path' // PGLite: config file present, no URL but database_path set | null; -// Lazy-evaluated to avoid calling homedir() at module scope (breaks in serverless/bundled environments) -function getConfigDir() { return join(homedir(), '.gbrain'); } -function getConfigPath() { return join(getConfigDir(), 'config.json'); } +// Internal aliases retained for backwards compatibility with the existing call +// sites below. They forward to the exported configDir()/configPath() so +// GBRAIN_HOME is honored uniformly. Lazy: never call homedir() at module scope. +function getConfigDir() { return configDir(); } +function getConfigPath() { return configPath(); } export interface GBrainConfig { engine: 'postgres' | 'pglite'; @@ -88,9 +90,20 @@ export function toEngineConfig(config: GBrainConfig): EngineConfig { export function configDir(): string { // Allow override for tests, Docker, and multi-tenant deployments. - // Matches the `GBRAIN_AUDIT_DIR` convention in src/core/minions/handlers/shell-audit.ts. + // GBRAIN_HOME is a parent dir; we always append '.gbrain' ourselves so + // setting GBRAIN_HOME=/tmp/x yields configDir() === '/tmp/x/.gbrain'. + // Validates the override: must be absolute, no '..' segments. const override = process.env.GBRAIN_HOME; - if (override && override.trim()) return join(override, '.gbrain'); + if (override && override.trim()) { + const trimmed = override.trim(); + if (!trimmed.startsWith('/')) { + throw new Error(`GBRAIN_HOME must be an absolute path; got: ${trimmed}`); + } + if (trimmed.split('/').includes('..')) { + throw new Error(`GBRAIN_HOME must not contain '..' segments; got: ${trimmed}`); + } + return join(trimmed, '.gbrain'); + } return join(homedir(), '.gbrain'); } @@ -98,6 +111,16 @@ export function configPath(): string { return join(configDir(), 'config.json'); } +/** + * Sugar for joining paths under the active gbrain home. Use this anywhere you + * would otherwise write `join(homedir(), '.gbrain', ...rest)`. Honors + * GBRAIN_HOME, validates input, and centralizes the convention so future + * audits stay simple. + */ +export function gbrainPath(...segments: string[]): string { + return join(configDir(), ...segments); +} + /** * Introspect where the active DB URL would come from if we tried to connect. * Never throws, never connects. Env vars take precedence (matches loadConfig). diff --git a/src/core/cycle.ts b/src/core/cycle.ts index 7ba48935e..8dc751b44 100644 --- a/src/core/cycle.ts +++ b/src/core/cycle.ts @@ -39,7 +39,8 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from 'fs'; import { join } from 'path'; -import { homedir, hostname } from 'os'; +import { hostname } from 'os'; +import { gbrainPath } from './config.ts'; import type { BrainEngine } from './engine.ts'; import { createProgress, type ProgressReporter } from './progress.ts'; import { getCliOptions, cliOptsToProgressOptions } from './cli-options.ts'; @@ -154,7 +155,8 @@ export interface CycleOpts { const CYCLE_LOCK_ID = 'gbrain-cycle'; const LOCK_TTL_MS = 30 * 60 * 1000; // 30 minutes -const LOCK_FILE_PATH_DEFAULT = join(homedir(), '.gbrain', 'cycle.lock'); +// Lazy: GBRAIN_HOME may be set after module load; resolve at call time. +const getLockFilePathDefault = () => gbrainPath('cycle.lock'); interface LockHandle { release: () => Promise; @@ -256,7 +258,7 @@ async function acquirePostgresLock(engine: BrainEngine): Promise gbrainPath('fail-improve'); const MAX_ENTRIES = 1000; // --------------------------------------------------------------------------- @@ -76,7 +77,7 @@ export class FailImproveLoop { private logDir: string; constructor(logDir?: string) { - this.logDir = logDir || LOG_DIR; + this.logDir = logDir || getLogDir(); } /** diff --git a/src/core/friction.ts b/src/core/friction.ts new file mode 100644 index 000000000..224515b9a --- /dev/null +++ b/src/core/friction.ts @@ -0,0 +1,374 @@ +/** + * Friction reporter — JSONL-backed signal capture for the claw-test feedback loop. + * + * The friction CLI (`gbrain friction log/render/list/summary`) writes here. + * The claw-test harness reads here. The agent calls `gbrain friction log` + * directly when it hits something confusing, missing, or wrong. + * + * Storage shape: append-only JSONL files under `$GBRAIN_HOME/friction/`. + * - `.jsonl` for each harness run (run-id from $GBRAIN_FRICTION_RUN_ID) + * - `standalone.jsonl` for entries logged outside a harness run + * + * Schema is a flat extension of StructuredAgentError fields (per D20). Render + * reads one level. Readers tolerate malformed lines (skip + warn) so partial + * runs don't break later analysis. + * + * ┌──────────┐ appendFileSync ┌─────────────────────────┐ + * │ writer() │ ──────────────────▶ │ .jsonl (one │ + * │ │ (atomic if line │ entry per line) │ + * └──────────┘ ≤ PIPE_BUF/4KB) └─────────────────────────┘ + * │ + * ▼ + * reader() / render() + * skip malformed + warn + */ + +import { appendFileSync, existsSync, readdirSync, readFileSync, mkdirSync, statSync } from 'fs'; +import { dirname, join } from 'path'; +import { homedir } from 'os'; +import { gbrainPath } from './config.ts'; +import { VERSION } from '../version.ts'; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +export type FrictionKind = 'friction' | 'delight' | 'phase-marker' | 'interrupted'; +export type FrictionSeverity = 'confused' | 'error' | 'blocker' | 'nit'; +export type FrictionSource = 'claw' | 'harness'; +export type PhaseMarker = 'start' | 'end'; + +/** One JSONL entry. Flat extension of StructuredAgentError per D20. */ +export interface FrictionEntry { + schema_version: '1'; + ts: string; // ISO 8601 + run_id: string; + phase: string; + kind: FrictionKind; + /** Required for kind=friction|delight. Optional for phase-marker (purely informational). */ + severity?: FrictionSeverity; + message: string; + hint?: string; + /** StructuredAgentError envelope fields, flattened. */ + class?: string; + code?: string; + docs_url?: string; + source: FrictionSource; + cwd: string; + gbrain_version: string; + agent?: string; + /** Byte offset into the run's transcript.jsonl (live mode). */ + transcript_offset?: number; + /** For phase-marker entries only. */ + marker?: PhaseMarker; +} + +export interface FrictionLogInput { + severity?: FrictionSeverity; + phase: string; + message: string; + hint?: string; + runId?: string; + kind?: FrictionKind; + source?: FrictionSource; + agent?: string; + transcriptOffset?: number; + marker?: PhaseMarker; + /** When the writer is called from the harness wrapping a child error. */ + errorClass?: string; + errorCode?: string; + docsUrl?: string; +} + +// --------------------------------------------------------------------------- +// Path resolution +// --------------------------------------------------------------------------- + +/** Resolve the directory all friction JSONL files live under. */ +export function frictionDir(): string { + return gbrainPath('friction'); +} + +/** Resolve the JSONL file path for a given run-id. */ +export function frictionFile(runId: string): string { + return join(frictionDir(), `${sanitizeRunId(runId)}.jsonl`); +} + +/** Resolve the active run-id, falling back to 'standalone' (D19). */ +export function activeRunId(): string { + const env = process.env.GBRAIN_FRICTION_RUN_ID?.trim(); + return env && env.length > 0 ? env : 'standalone'; +} + +/** Sanitize: only [a-zA-Z0-9._-]; reject anything else to keep filenames sane. */ +function sanitizeRunId(runId: string): string { + if (!/^[a-zA-Z0-9._-]+$/.test(runId)) { + throw new Error(`invalid run-id ${JSON.stringify(runId)} (allowed: [a-zA-Z0-9._-])`); + } + return runId; +} + +// --------------------------------------------------------------------------- +// Writer +// --------------------------------------------------------------------------- + +/** Maximum message length; truncated to keep each line under PIPE_BUF for atomic appends. */ +const MAX_MESSAGE_CHARS = 3500; + +/** Append one friction entry to the run's JSONL. */ +export function logFriction(input: FrictionLogInput): void { + const runId = input.runId ?? activeRunId(); + const dir = frictionDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + + const message = truncate(input.message, MAX_MESSAGE_CHARS); + const entry: FrictionEntry = { + schema_version: '1', + ts: new Date().toISOString(), + run_id: runId, + phase: input.phase, + kind: input.kind ?? 'friction', + message, + source: input.source ?? 'claw', + cwd: process.cwd(), + gbrain_version: VERSION, + }; + if (input.severity) entry.severity = input.severity; + if (input.hint) entry.hint = input.hint; + if (input.errorClass) entry.class = input.errorClass; + if (input.errorCode) entry.code = input.errorCode; + if (input.docsUrl) entry.docs_url = input.docsUrl; + if (input.agent) entry.agent = input.agent; + if (input.transcriptOffset !== undefined) entry.transcript_offset = input.transcriptOffset; + if (input.marker) entry.marker = input.marker; + + const line = JSON.stringify(entry) + '\n'; + appendFileSync(frictionFile(runId), line, 'utf-8'); +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return s.slice(0, max - 14) + '…[truncated]'; +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +export interface ReadResult { + entries: FrictionEntry[]; + /** Count of malformed JSONL lines that were skipped. */ + malformed: number; +} + +/** Read all entries from a run's JSONL, skipping malformed lines. */ +export function readFriction(runId: string): ReadResult { + const path = frictionFile(runId); + if (!existsSync(path)) { + throw new Error(`run-id "${runId}" not found at ${path}`); + } + const raw = readFileSync(path, 'utf-8'); + const entries: FrictionEntry[] = []; + let malformed = 0; + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + // Light shape check: must have ts + kind + phase + message + if (typeof parsed.ts === 'string' && typeof parsed.kind === 'string' && typeof parsed.phase === 'string' && typeof parsed.message === 'string') { + entries.push(parsed as FrictionEntry); + } else { + malformed++; + } + } catch { + malformed++; + } + } + return { entries, malformed }; +} + +/** List run-ids with summary counts. Returns most-recent-first. */ +export interface RunSummary { + runId: string; + path: string; + mtime: Date; + counts: { friction: number; delight: number; interrupted: boolean; bySeverity: Record }; +} + +export function listRuns(): RunSummary[] { + const dir = frictionDir(); + if (!existsSync(dir)) return []; + const out: RunSummary[] = []; + for (const file of readdirSync(dir)) { + if (!file.endsWith('.jsonl')) continue; + const runId = file.slice(0, -'.jsonl'.length); + const path = join(dir, file); + const stat = statSync(path); + let read: ReadResult; + try { + read = readFriction(runId); + } catch { + continue; + } + const counts = { friction: 0, delight: 0, interrupted: false, bySeverity: {} as Record }; + for (const e of read.entries) { + if (e.kind === 'friction') counts.friction++; + if (e.kind === 'delight') counts.delight++; + if (e.kind === 'interrupted') counts.interrupted = true; + if (e.severity) counts.bySeverity[e.severity] = (counts.bySeverity[e.severity] ?? 0) + 1; + } + out.push({ runId, path, mtime: stat.mtime, counts }); + } + out.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + return out; +} + +// --------------------------------------------------------------------------- +// Renderer +// --------------------------------------------------------------------------- + +export interface RenderOpts { + format?: 'md' | 'json'; + redact?: boolean; + /** When true, transcript_offset values are resolved against this transcript file. */ + transcriptPath?: string; +} + +/** Render entries grouped by severity then phase. Returns the rendered string. */ +export function renderReport(runId: string, opts: RenderOpts = {}): string { + const { entries, malformed } = readFriction(runId); + const format = opts.format ?? 'md'; + const redact = opts.redact ?? (format === 'md'); + + const transformed = entries.map(e => redact ? redactEntry(e) : e); + + if (format === 'json') { + return JSON.stringify({ run_id: runId, malformed, entries: transformed }, null, 2); + } + + // Markdown grouping: severity (blocker > error > confused > nit > none) → phase + const sevOrder: (FrictionSeverity | 'none')[] = ['blocker', 'error', 'confused', 'nit', 'none']; + const bySev = new Map(); + for (const e of transformed) { + if (e.kind !== 'friction' && e.kind !== 'delight') continue; + const k = e.severity ?? 'none'; + if (!bySev.has(k)) bySev.set(k, []); + bySev.get(k)!.push(e); + } + + const lines: string[] = []; + lines.push(`# Friction report — \`${runId}\``); + lines.push(''); + const totalFriction = entries.filter(e => e.kind === 'friction').length; + const totalDelight = entries.filter(e => e.kind === 'delight').length; + lines.push(`**${totalFriction} friction · ${totalDelight} delight**${malformed > 0 ? ` · ${malformed} malformed line(s) skipped` : ''}`); + lines.push(''); + + if (entries.some(e => e.kind === 'interrupted')) { + lines.push('> ⚠ **Run was interrupted.** Some phases may not have completed.'); + lines.push(''); + } + + for (const sev of sevOrder) { + const bucket = bySev.get(sev); + if (!bucket || bucket.length === 0) continue; + lines.push(`## ${sev === 'none' ? '(no severity)' : sev}`); + lines.push(''); + // Group by phase within severity + const byPhase = new Map(); + for (const e of bucket) { + if (!byPhase.has(e.phase)) byPhase.set(e.phase, []); + byPhase.get(e.phase)!.push(e); + } + for (const [phase, phaseEntries] of byPhase) { + lines.push(`### \`${phase}\``); + lines.push(''); + for (const e of phaseEntries) { + lines.push(`- ${e.kind === 'delight' ? '✨' : '·'} ${e.message}`); + if (e.hint) lines.push(` - hint: ${e.hint}`); + if (e.code) lines.push(` - code: \`${e.code}\``); + if (e.docs_url) lines.push(` - docs: ${e.docs_url}`); + if (opts.transcriptPath && e.transcript_offset !== undefined) { + const snippet = readTranscriptAt(opts.transcriptPath, e.transcript_offset); + if (snippet) lines.push(` - transcript: \`${snippet}\``); + } + } + lines.push(''); + } + } + return lines.join('\n'); +} + +/** Render a friction + delight summary as two columns. */ +export function renderSummary(runId: string, opts: { format?: 'md' | 'json' } = {}): string { + const { entries } = readFriction(runId); + const friction = entries.filter(e => e.kind === 'friction'); + const delight = entries.filter(e => e.kind === 'delight'); + + if (opts.format === 'json') { + return JSON.stringify({ run_id: runId, friction, delight }, null, 2); + } + + const lines: string[] = []; + lines.push(`# ${runId}`); + lines.push(''); + const max = Math.max(friction.length, delight.length); + lines.push(`| friction (${friction.length}) | delight (${delight.length}) |`); + lines.push('|---|---|'); + for (let i = 0; i < max; i++) { + const l = friction[i] ? friction[i].message.replace(/\|/g, '\\|') : ''; + const r = delight[i] ? delight[i].message.replace(/\|/g, '\\|') : ''; + lines.push(`| ${l} | ${r} |`); + } + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Redaction +// --------------------------------------------------------------------------- + +/** Replace homedir/cwd segments in user-visible string fields with placeholders. */ +export function redactEntry(entry: FrictionEntry): FrictionEntry { + const home = homedir(); + const cwd = entry.cwd; + const transform = (s: string | undefined): string | undefined => { + if (!s) return s; + let out = s; + if (cwd && cwd.length > 1) out = out.split(cwd).join(''); + if (home && home.length > 1) out = out.split(home).join(''); + return out; + }; + return { + ...entry, + message: transform(entry.message) ?? entry.message, + hint: transform(entry.hint), + cwd: '', + }; +} + +// --------------------------------------------------------------------------- +// Transcript snippet resolution (for --transcripts) +// --------------------------------------------------------------------------- + +function readTranscriptAt(path: string, offset: number): string | null { + try { + if (!existsSync(path)) return null; + const raw = readFileSync(path, 'utf-8'); + if (offset < 0 || offset >= raw.length) return null; + // Find the line that contains this offset. Transcript is JSONL. + const lineStart = raw.lastIndexOf('\n', offset) + 1; + const lineEnd = raw.indexOf('\n', offset); + const line = raw.slice(lineStart, lineEnd === -1 ? undefined : lineEnd); + try { + const parsed = JSON.parse(line); + if (parsed && typeof parsed.bytes_b64 === 'string') { + const text = Buffer.from(parsed.bytes_b64, 'base64').toString('utf-8'); + // Truncate snippet for readability + return text.replace(/\n/g, '\\n').slice(0, 200); + } + } catch { /* fall through */ } + return line.slice(0, 200); + } catch { + return null; + } +} diff --git a/src/core/minions/backpressure-audit.ts b/src/core/minions/backpressure-audit.ts index dd0d63abd..57173749a 100644 --- a/src/core/minions/backpressure-audit.ts +++ b/src/core/minions/backpressure-audit.ts @@ -18,7 +18,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; +import { gbrainPath } from '../config.ts'; export interface BackpressureAuditEvent { ts: string; @@ -54,7 +54,7 @@ export function computeAuditFilename(now: Date = new Date()): string { export function resolveAuditDir(): string { const override = process.env.GBRAIN_AUDIT_DIR; if (override && override.trim().length > 0) return override; - return path.join(os.homedir(), '.gbrain', 'audit'); + return gbrainPath('audit'); } export function logBackpressureCoalesce(event: Omit): void { diff --git a/src/core/minions/handlers/shell-audit.ts b/src/core/minions/handlers/shell-audit.ts index 25f28a6b9..44586c18b 100644 --- a/src/core/minions/handlers/shell-audit.ts +++ b/src/core/minions/handlers/shell-audit.ts @@ -15,7 +15,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import * as os from 'node:os'; +import { gbrainPath } from '../../config.ts'; export interface ShellAuditEvent { ts: string; @@ -53,7 +53,7 @@ export function computeAuditFilename(now: Date = new Date()): string { export function resolveAuditDir(): string { const override = process.env.GBRAIN_AUDIT_DIR; if (override && override.trim().length > 0) return override; - return path.join(os.homedir(), '.gbrain', 'audit'); + return gbrainPath('audit'); } export function logShellSubmission(event: Omit): void { diff --git a/src/core/output/post-write.ts b/src/core/output/post-write.ts index dabb42bd3..e461ff23e 100644 --- a/src/core/output/post-write.ts +++ b/src/core/output/post-write.ts @@ -18,8 +18,8 @@ */ import { appendFileSync, existsSync, mkdirSync } from 'fs'; -import { homedir } from 'os'; -import { dirname, join } from 'path'; +import { dirname } from 'path'; +import { gbrainPath } from '../config.ts'; import type { BrainEngine } from '../engine.ts'; import { @@ -30,7 +30,7 @@ import { } from './validators/index.ts'; import type { ValidationFinding, PageValidator } from './writer.ts'; -const LINT_LOG_FILE = join(homedir(), '.gbrain', 'validator-lint.jsonl'); +const getLintLogFile = () => gbrainPath('validator-lint.jsonl'); const LINT_CONFIG_KEY = 'writer.lint_on_put_page'; export interface PostWriteLintOpts { @@ -124,7 +124,8 @@ export async function runPostWriteLint( function writeLocalLintLog(slug: string, findings: ValidationFinding[]): void { try { - const dir = dirname(LINT_LOG_FILE); + const lintLogFile = getLintLogFile(); + const dir = dirname(lintLogFile); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const line = JSON.stringify({ ts: new Date().toISOString(), @@ -133,7 +134,7 @@ function writeLocalLintLog(slug: string, findings: ValidationFinding[]): void { warning_count: findings.filter(f => f.severity === 'warning').length, findings: findings.slice(0, 20), // cap to prevent runaway log size }) + '\n'; - appendFileSync(LINT_LOG_FILE, line, 'utf-8'); + appendFileSync(lintLogFile, line, 'utf-8'); } catch { // Non-fatal; logging failure shouldn't break the main flow. } diff --git a/src/core/sync.ts b/src/core/sync.ts index 1a10135ac..99615618f 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -301,7 +301,7 @@ export function resolveSlugForPath(filePath: string, repoPrefix?: string): strin import { existsSync as _existsSync, readFileSync as _readFileSync, appendFileSync as _appendFileSync, mkdirSync as _mkdirSync } from 'fs'; import { join as _joinPath } from 'path'; -import { homedir as _homedir } from 'os'; +import { gbrainPath as _gbrainPath } from './config.ts'; import { createHash as _createHash } from 'crypto'; export interface SyncFailure { @@ -402,7 +402,7 @@ export function formatCodeBreakdown( } function _failuresDir(): string { - return _joinPath(_homedir(), '.gbrain'); + return _gbrainPath(); } export function syncFailuresPath(): string { diff --git a/test/agent-runner.test.ts b/test/agent-runner.test.ts new file mode 100644 index 000000000..47c6ff4df --- /dev/null +++ b/test/agent-runner.test.ts @@ -0,0 +1,97 @@ +/** + * AgentRunner registry + selection tests. Proves the harness contract is + * truly agent-agnostic via a fake-runner integration. + */ + +import { describe, test, expect, beforeEach } from 'bun:test'; +import { + registerAgentRunner, resolveAgentRunner, listRegisteredAgents, + _resetRegistryForTests, + type AgentRunner, type DetectResult, type InvokeOpts, type InvokeResult, type TranscriptSink, +} from '../src/core/claw-test/agent-runner.ts'; + +class FakeRunner implements AgentRunner { + readonly name: string; + invocations = 0; + detected: DetectResult = { available: true, binPath: '/usr/bin/fake-agent' }; + + constructor(name: string) { this.name = name; } + + async detect(): Promise { return this.detected; } + async invoke(_opts: InvokeOpts): Promise { + this.invocations++; + return { exitCode: 0, durationMs: 1 }; + } +} + +beforeEach(() => { + _resetRegistryForTests(); +}); + +describe('registry', () => { + test('register + resolve roundtrips', () => { + registerAgentRunner('fake', () => new FakeRunner('fake')); + const r = resolveAgentRunner('fake'); + expect(r.name).toBe('fake'); + }); + + test('resolve unknown agent throws with helpful list', () => { + registerAgentRunner('alpha', () => new FakeRunner('alpha')); + registerAgentRunner('beta', () => new FakeRunner('beta')); + expect(() => resolveAgentRunner('gamma')).toThrow(/registered: alpha, beta/); + }); + + test('listRegisteredAgents returns sorted names', () => { + registerAgentRunner('zeta', () => new FakeRunner('zeta')); + registerAgentRunner('alpha', () => new FakeRunner('alpha')); + expect(listRegisteredAgents()).toEqual(['alpha', 'zeta']); + }); + + test('factory pattern produces independent instances', () => { + registerAgentRunner('fake', () => new FakeRunner('fake')); + const a = resolveAgentRunner('fake') as FakeRunner; + const b = resolveAgentRunner('fake') as FakeRunner; + expect(a).not.toBe(b); + }); +}); + +describe('agent-agnosticism guard', () => { + test('a fake runner can satisfy the AgentRunner contract end-to-end', async () => { + registerAgentRunner('fake', () => new FakeRunner('fake')); + const runner = resolveAgentRunner('fake'); + + // The harness contract: detect → invoke. Nothing else. + const detected = await runner.detect(); + expect(detected.available).toBe(true); + expect(detected.binPath).toBe('/usr/bin/fake-agent'); + + let written = 0; + const sink: TranscriptSink = { + write: () => { written++; }, + nextOffset: () => 0, + close: async () => { /* noop */ }, + }; + + const result = await runner.invoke({ + cwd: '/tmp', + brief: 'hello', + env: {}, + timeoutMs: 1000, + transcriptSink: sink, + }); + expect(result.exitCode).toBe(0); + }); + + test('a runner reporting unavailable still satisfies the contract', async () => { + class UnavailableRunner implements AgentRunner { + name = 'gone'; + async detect() { return { available: false, reason: 'not installed' } as DetectResult; } + async invoke(): Promise { throw new Error('should not be called'); } + } + registerAgentRunner('gone', () => new UnavailableRunner()); + const r = resolveAgentRunner('gone'); + const d = await r.detect(); + expect(d.available).toBe(false); + expect(d.reason).toBe('not installed'); + }); +}); diff --git a/test/claw-test-cli.test.ts b/test/claw-test-cli.test.ts new file mode 100644 index 000000000..0085c6b38 --- /dev/null +++ b/test/claw-test-cli.test.ts @@ -0,0 +1,165 @@ +/** + * gbrain claw-test CLI dispatch tests. + * + * These tests exercise the harness's argument parsing, scenario loading, + * agent registry resolution, and friction-report path. They do NOT spawn + * real gbrain commands (no built binary in CI yet); the canonical scripted + * E2E that walks `gbrain init → import → query → extract → verify` lives + * in test/e2e/claw-test.test.ts and gates on a built binary. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { runFriction } from '../src/commands/friction.ts'; +import { listScenarios, loadScenario } from '../src/core/claw-test/scenarios.ts'; +import { + registerAgentRunner, resolveAgentRunner, listRegisteredAgents, + _resetRegistryForTests, + type AgentRunner, type DetectResult, type InvokeOpts, type InvokeResult, +} from '../src/core/claw-test/agent-runner.ts'; + +let tmp: string; +const ORIG_HOME = process.env.GBRAIN_HOME; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'claw-test-cli-')); + process.env.GBRAIN_HOME = tmp; + _resetRegistryForTests(); +}); + +afterEach(() => { + process.env.GBRAIN_HOME = ORIG_HOME; + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('shipped scenarios are loadable', () => { + test('default fixtures root contains both v1 scenarios', () => { + delete process.env.GBRAIN_CLAW_SCENARIOS_DIR; + const names = listScenarios(); + expect(names).toContain('fresh-install'); + expect(names).toContain('upgrade-from-v0.18'); + }); + + test('fresh-install has expected_phases', () => { + delete process.env.GBRAIN_CLAW_SCENARIOS_DIR; + const cfg = loadScenario('fresh-install'); + expect(cfg.expectedPhases).toContain('import.files'); + expect(cfg.expectedPhases).toContain('extract.links_fs'); + expect(cfg.expectedPhases).toContain('doctor.db_checks'); + }); + + test('upgrade-from-v0.18 declares from_version', () => { + delete process.env.GBRAIN_CLAW_SCENARIOS_DIR; + const cfg = loadScenario('upgrade-from-v0.18'); + expect(cfg.kind).toBe('upgrade'); + expect(cfg.fromVersion).toBe('0.18.0'); + expect(cfg.seedRelative).toBe('seed'); + }); +}); + +describe('agent registry — fake-runner integration', () => { + test('a fake runner can be registered, resolved, and detect/invoke called', async () => { + let invokeCount = 0; + class FakeRunner implements AgentRunner { + readonly name = 'fake'; + async detect(): Promise { return { available: true, binPath: '/usr/bin/fake' }; } + async invoke(_opts: InvokeOpts): Promise { + invokeCount++; + return { exitCode: 0, durationMs: 1 }; + } + } + registerAgentRunner('fake', () => new FakeRunner()); + expect(listRegisteredAgents()).toContain('fake'); + + const r = resolveAgentRunner('fake'); + const detected = await r.detect(); + expect(detected.available).toBe(true); + + const result = await r.invoke({ + cwd: tmp, + brief: 'test', + env: {}, + timeoutMs: 1000, + transcriptSink: { write: () => {}, nextOffset: () => 0, close: async () => {} }, + }); + expect(result.exitCode).toBe(0); + expect(invokeCount).toBe(1); + }); + + test('resolveAgentRunner with unknown name throws with registered list', () => { + registerAgentRunner('alpha', () => ({} as AgentRunner)); + expect(() => resolveAgentRunner('unknown')).toThrow(/registered: alpha/); + }); +}); + +describe('friction CLI integrates with harness run-id env', () => { + test('GBRAIN_FRICTION_RUN_ID populates harness-style run-ids', () => { + process.env.GBRAIN_FRICTION_RUN_ID = 'claw-test-20260428-fake-abcd1234'; + try { + const code = runFriction(['log', '--phase', 'install', '--message', 'simulated harness write']); + expect(code).toBe(0); + const expectedFile = join(tmp, '.gbrain', 'friction', 'claw-test-20260428-fake-abcd1234.jsonl'); + expect(existsSync(expectedFile)).toBe(true); + const raw = readFileSync(expectedFile, 'utf-8'); + const entry = JSON.parse(raw.split('\n')[0]); + expect(entry.run_id).toBe('claw-test-20260428-fake-abcd1234'); + expect(entry.message).toBe('simulated harness write'); + } finally { + delete process.env.GBRAIN_FRICTION_RUN_ID; + } + }); +}); + +describe('OpenClawRunner detection (reliable on box without openclaw)', () => { + test('detect returns unavailable when OPENCLAW_BIN missing', async () => { + const orig = process.env.OPENCLAW_BIN; + delete process.env.OPENCLAW_BIN; + try { + const { OpenClawRunner } = await import('../src/core/claw-test/runners/openclaw.ts'); + const r = new OpenClawRunner(); + const d = await r.detect(); + // Either unavailable, or available if openclaw IS on PATH for the dev — both states are valid. + // We only assert the contract shape. + 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.OPENCLAW_BIN = orig; + } + }); + + test('detect rejects relative OPENCLAW_BIN', async () => { + const orig = process.env.OPENCLAW_BIN; + process.env.OPENCLAW_BIN = 'relative/openclaw'; + try { + const { OpenClawRunner } = await import('../src/core/claw-test/runners/openclaw.ts'); + const r = new OpenClawRunner(); + const d = await r.detect(); + expect(d.available).toBe(false); + expect(d.reason).toMatch(/absolute/); + } finally { + if (orig !== undefined) process.env.OPENCLAW_BIN = orig; + else delete process.env.OPENCLAW_BIN; + } + }); + + test("detect rejects '..' segments in OPENCLAW_BIN", async () => { + const orig = process.env.OPENCLAW_BIN; + process.env.OPENCLAW_BIN = '/tmp/foo/../bar'; + try { + const { OpenClawRunner } = await import('../src/core/claw-test/runners/openclaw.ts'); + const r = new OpenClawRunner(); + const d = await r.detect(); + expect(d.available).toBe(false); + expect(d.reason).toMatch(/'\.\.' segments/); + } finally { + if (orig !== undefined) process.env.OPENCLAW_BIN = orig; + else delete process.env.OPENCLAW_BIN; + } + }); +}); diff --git a/test/e2e/claw-test.test.ts b/test/e2e/claw-test.test.ts new file mode 100644 index 000000000..7f31031eb --- /dev/null +++ b/test/e2e/claw-test.test.ts @@ -0,0 +1,134 @@ +/** + * gbrain claw-test scripted-mode E2E. + * + * Invokes the harness via `bun run src/cli.ts` (NOT a compiled binary — + * `bun build --compile` doesn't bundle PGLite's runtime assets like + * pglite.data, so a compiled gbrain can't init a fresh PGLite brain). + * Uses a tiny shim script that the harness can spawn as if it were the + * gbrain binary. + * + * Asserts: + * - exit code 0 on a clean tree + * - the friction JSONL has zero error/blocker entries + * - the harness recorded progress events for the expected phases + * + * Tagged-skip env: CLAW_TEST_SKIP_E2E=1 to opt out (e.g. when PGLite + * WASM is broken on the host — the macOS 26.3 #223 bug class). + */ + +import { describe, test, expect, beforeAll } from 'bun:test'; +import { execFileSync, spawnSync } from 'child_process'; +import { mkdirSync, existsSync, mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync, chmodSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; + +const REPO_ROOT = resolve(import.meta.dir, '..', '..'); +const BIN_CACHE = join(REPO_ROOT, 'test', '.cache'); +const BIN_PATH = join(BIN_CACHE, 'gbrain.sh'); +const SCENARIOS_DIR = join(REPO_ROOT, 'test', 'fixtures', 'claw-test-scenarios'); + +beforeAll(() => { + if (!existsSync(BIN_CACHE)) mkdirSync(BIN_CACHE, { recursive: true }); + // Shim that delegates to `bun run src/cli.ts` so PGLite assets resolve from + // the source tree (bun --compile doesn't bundle them). Marked executable so + // child_process.spawn can run it directly. + const shim = `#!/bin/sh\nexec bun run "${join(REPO_ROOT, 'src', 'cli.ts')}" "$@"\n`; + writeFileSync(BIN_PATH, shim, 'utf-8'); + chmodSync(BIN_PATH, 0o755); +}, 30_000); + +describe('gbrain claw-test --scenario fresh-install (scripted)', () => { + test('runs end-to-end clean and produces zero error/blocker friction', () => { + const tmp = mkdtempSync(join(tmpdir(), 'claw-test-e2e-fresh-')); + try { + const result = spawnSync(BIN_PATH, ['claw-test', '--scenario', 'fresh-install', '--keep-tempdir'], { + cwd: REPO_ROOT, + env: { + ...process.env, + GBRAIN_HOME: tmp, + GBRAIN_BIN_OVERRIDE: BIN_PATH, + GBRAIN_CLAW_SCENARIOS_DIR: join(REPO_ROOT, 'test', 'fixtures', 'claw-test-scenarios'), + }, + encoding: 'utf-8', + timeout: 120_000, + }); + if (result.status !== 0) { + console.error('STDOUT:', result.stdout); + console.error('STDERR:', result.stderr); + } + expect(result.status).toBe(0); + + // Inspect the friction JSONL the harness wrote. + const frictionDir = join(tmp, '.gbrain', 'friction'); + expect(existsSync(frictionDir)).toBe(true); + const files = readdirSync(frictionDir).filter(f => f.endsWith('.jsonl')); + expect(files.length).toBeGreaterThan(0); + const runFile = join(frictionDir, files[0]); + const lines = readFileSync(runFile, 'utf-8').split('\n').filter(l => l.trim()); + const entries = lines.map(l => JSON.parse(l)); + const blockers = entries.filter(e => e.kind === 'friction' && (e.severity === 'error' || e.severity === 'blocker')); + if (blockers.length > 0) { + console.error('unexpected friction entries:', blockers); + } + expect(blockers.length).toBe(0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }, 180_000); + + test('break path: an invented command produces an error friction entry and exits non-zero', () => { + // We do this by setting GBRAIN_BIN_OVERRIDE to a script that pretends to be gbrain + // and rejects the `import` subcommand specifically. + const tmp = mkdtempSync(join(tmpdir(), 'claw-test-e2e-break-')); + const fakeBin = join(tmp, 'fake-gbrain'); + try { + // Write a shim that delegates to real gbrain but rejects 'import' to simulate breakage. + const shimContent = `#!/bin/sh\nif [ "$1" = "import" ]; then echo "fake import error" >&2; exit 17; fi\nexec "${BIN_PATH}" "$@"\n`; + const { writeFileSync, chmodSync } = require('fs'); + writeFileSync(fakeBin, shimContent, 'utf-8'); + chmodSync(fakeBin, 0o755); + + const result = spawnSync(BIN_PATH, ['claw-test', '--scenario', 'fresh-install', '--keep-tempdir'], { + cwd: REPO_ROOT, + env: { + ...process.env, + GBRAIN_HOME: tmp, + GBRAIN_BIN_OVERRIDE: fakeBin, + GBRAIN_CLAW_SCENARIOS_DIR: join(REPO_ROOT, 'test', 'fixtures', 'claw-test-scenarios'), + }, + encoding: 'utf-8', + timeout: 60_000, + }); + expect(result.status).not.toBe(0); + + // The friction log should have an error-severity entry for the 'import' phase. + const frictionDir = join(tmp, '.gbrain', 'friction'); + const files = readdirSync(frictionDir).filter(f => f.endsWith('.jsonl')); + const lines = readFileSync(join(frictionDir, files[0]), 'utf-8').split('\n').filter(l => l.trim()); + const entries = lines.map(l => JSON.parse(l)); + const importErrors = entries.filter(e => e.phase === 'import' && e.severity === 'error'); + expect(importErrors.length).toBeGreaterThan(0); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }, 90_000); +}); + +describe('gbrain friction render integration', () => { + test('render produces a markdown report with the redact placeholder', () => { + const tmp = mkdtempSync(join(tmpdir(), 'claw-test-e2e-render-')); + try { + // Log a friction entry with $HOME embedded, then render --redact md + const home = process.env.HOME ?? '/tmp'; + const env = { ...process.env, GBRAIN_HOME: tmp, GBRAIN_FRICTION_RUN_ID: 'render-e2e' }; + execFileSync(BIN_PATH, ['friction', 'log', '--phase', 'p', '--message', `error at ${home}/.gbrain/x`], { env, encoding: 'utf-8' }); + const out = execFileSync(BIN_PATH, ['friction', 'render', '--run-id', 'render-e2e'], { env, encoding: 'utf-8' }); + expect(out).toContain('# Friction report'); + expect(out).toContain(''); + // --redact is the default for md, so home itself should not appear. + expect(out).not.toContain(home + '/.gbrain'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/test/fixtures/claw-test-scenarios/fresh-install/BRIEF.md b/test/fixtures/claw-test-scenarios/fresh-install/BRIEF.md new file mode 100644 index 000000000..22a097e3b --- /dev/null +++ b/test/fixtures/claw-test-scenarios/fresh-install/BRIEF.md @@ -0,0 +1,32 @@ +# Claw-test brief — fresh-install + +You are testing gbrain on a brand-new install. The user just ran `gbrain init` for the first time. Walk through the canonical first-day flow: + +1. **Verify install:** confirm `gbrain --version` works and `gbrain doctor --json` returns a valid JSON object with a `status` field. +2. **Install skillpack:** run `gbrain skillpack install --workspace $PWD`. The workspace already has an `AGENTS.md` routing file. +3. **Import the brain:** run `gbrain import ./brain --no-embed --progress-json`. There are 3 small markdown pages already there. +4. **Query the brain:** run `gbrain query "alice"` and verify >0 results. +5. **Extract links:** run `gbrain extract --source fs --progress-json`. +6. **Verify health:** run `gbrain doctor --json`. The `status` field should be `"ok"`. + +## Friction protocol + +If anything is confusing, missing, surprising, or wrong, run: + +``` +gbrain friction log --severity {confused|error|blocker|nit} --phase --message "" [--hint ""] +``` + +Severity guide: +- `blocker` — couldn't proceed at all +- `error` — command failed unexpectedly +- `confused` — docs said one thing, the tool did another, or a step felt unclear +- `nit` — minor polish opportunity + +If something *just worked* and was nicer than expected, log a delight too: + +``` +gbrain friction log --kind delight --phase --message "" +``` + +We want to know what didn't work, not just whether commands exited zero. Be specific. diff --git a/test/fixtures/claw-test-scenarios/fresh-install/brain/companies/acme-example.md b/test/fixtures/claw-test-scenarios/fresh-install/brain/companies/acme-example.md new file mode 100644 index 000000000..26a5a9be5 --- /dev/null +++ b/test/fixtures/claw-test-scenarios/fresh-install/brain/companies/acme-example.md @@ -0,0 +1,15 @@ +--- +type: company +name: Acme Example +founded: 2024 +founders: + - alice-example +--- + +# Acme Example + +Fictional company used for claw-test fixtures. Founded 2024 by [Alice](people/alice-example). + +## What they do + +Acme builds an agentic-workflow product on top of [retrieval-augmented-generation](concepts/retrieval-augmented-generation). Early traction comes from a developer-tools wedge. diff --git a/test/fixtures/claw-test-scenarios/fresh-install/brain/concepts/agentic-workflows.md b/test/fixtures/claw-test-scenarios/fresh-install/brain/concepts/agentic-workflows.md new file mode 100644 index 000000000..5488dcd51 --- /dev/null +++ b/test/fixtures/claw-test-scenarios/fresh-install/brain/concepts/agentic-workflows.md @@ -0,0 +1,10 @@ +--- +type: concept +name: Agentic Workflows +--- + +# Agentic Workflows + +Workflows where an LLM-driven agent plans, executes, and revises a sequence of steps with minimal human supervision per step. Key constraints: cost, latency, and observability of the loop. + +Companies building in this space include [acme-example](companies/acme-example). diff --git a/test/fixtures/claw-test-scenarios/fresh-install/brain/people/alice-example.md b/test/fixtures/claw-test-scenarios/fresh-install/brain/people/alice-example.md new file mode 100644 index 000000000..0a1f32164 --- /dev/null +++ b/test/fixtures/claw-test-scenarios/fresh-install/brain/people/alice-example.md @@ -0,0 +1,13 @@ +--- +type: person +name: Alice Example +x_handle: alice_example +--- + +# Alice Example + +Alice is a fictional founder used for claw-test fixtures. She started [acme-example](companies/acme-example) in 2024. + +## Background + +Alice has spent 10 years in software and 2 years in AI tooling. She is exploring product-market fit for an [agentic-workflow](concepts/agentic-workflows) tool. diff --git a/test/fixtures/claw-test-scenarios/fresh-install/expected.json b/test/fixtures/claw-test-scenarios/fresh-install/expected.json new file mode 100644 index 000000000..e045660f0 --- /dev/null +++ b/test/fixtures/claw-test-scenarios/fresh-install/expected.json @@ -0,0 +1,6 @@ +{ + "min_pages_after_import": 3, + "min_query_results": 1, + "min_links_after_extract": 0, + "doctor_status": "ok" +} diff --git a/test/fixtures/claw-test-scenarios/fresh-install/scenario.json b/test/fixtures/claw-test-scenarios/fresh-install/scenario.json new file mode 100644 index 000000000..4f52acfd9 --- /dev/null +++ b/test/fixtures/claw-test-scenarios/fresh-install/scenario.json @@ -0,0 +1,10 @@ +{ + "kind": "fresh-install", + "description": "Canonical 5-minute first-day flow: init → import → query → extract → verify", + "expected_phases": [ + "import.files", + "extract.links_fs", + "doctor.db_checks" + ], + "brain": "brain" +} diff --git a/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/BRIEF.md b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/BRIEF.md new file mode 100644 index 000000000..886754168 --- /dev/null +++ b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/BRIEF.md @@ -0,0 +1,25 @@ +# Claw-test brief — upgrade-from-v0.18 + +You inherit a gbrain v0.18 brain (the harness has already replayed a seed SQL dump into a PGLite database). Walk through the upgrade path: + +1. **Run `gbrain doctor --json`** first. Note any warnings or fix-hints. +2. **Run `gbrain init --pglite`** with the existing database path. The migration chain should detect the old `schema_version` and walk forward to the latest. +3. **Run `gbrain doctor --json` again.** The `status` field should be `"ok"`. +4. **Verify queries still work:** `gbrain query "alice"` should return results from the seeded brain. + +## Friction protocol + +If anything is confusing, missing, surprising, or wrong (especially around the migration steps — these are the highest-historical-pain regression points), run: + +``` +gbrain friction log --severity {confused|error|blocker|nit} --phase --message "" [--hint ""] +``` + +Common upgrade-flow friction patterns to watch for: + +- The migration chain failed at a specific schema version (capture the version + error) +- Doctor flagged an issue but the fix-hint wasn't actionable +- `gbrain init --pglite` didn't recognize the existing brain +- Manual SQL was needed to unblock something + +If something just worked, log a delight. We're tuning the upgrade flow toward zero-friction. diff --git a/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/brain/people/alice-example.md b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/brain/people/alice-example.md new file mode 100644 index 000000000..fe0803371 --- /dev/null +++ b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/brain/people/alice-example.md @@ -0,0 +1,8 @@ +--- +type: person +name: Alice Example +--- + +# Alice Example + +Same brain content as the fresh-install scenario; this scenario tests upgrade flow rather than ingest. After the migration chain walks forward, agents query and the page must be findable. diff --git a/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/expected.json b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/expected.json new file mode 100644 index 000000000..8a62507c6 --- /dev/null +++ b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/expected.json @@ -0,0 +1,4 @@ +{ + "min_pages_after_migration": 1, + "doctor_status": "ok" +} diff --git a/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/scenario.json b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/scenario.json new file mode 100644 index 000000000..5b9ce0d48 --- /dev/null +++ b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/scenario.json @@ -0,0 +1,10 @@ +{ + "kind": "upgrade", + "from_version": "0.18.0", + "description": "Pre-v0.18 brain shape replayed via PGLite SQL dump; migration chain walks forward to LATEST", + "expected_phases": [ + "doctor.db_checks" + ], + "seed": "seed", + "brain": "brain" +} diff --git a/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/seed/README.md b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/seed/README.md new file mode 100644 index 000000000..d1ffffb9d --- /dev/null +++ b/test/fixtures/claw-test-scenarios/upgrade-from-v0.18/seed/README.md @@ -0,0 +1,24 @@ +# v0.18 seed + +This directory ships in v1 as **scaffolding only** — `dump.sql` will contain a real v0.18-shape PGLite SQL dump in v1.1. Until then the harness treats the absent dump as a no-op seed and the upgrade scenario behaves like a fresh-install scenario for the test gate. + +## Generating a real v0.18 seed + +To produce an authentic seed: + +1. Check out gbrain at the v0.18 release (`git checkout v0.18.0`). +2. Run `gbrain init --pglite --path /tmp/v0.18-seed.pglite` against a small fixture brain. +3. Run `gbrain import ` to populate it. +4. Dump the PGLite as SQL: PGLite supports `pg_dump`-style export via the `executeRaw('SELECT * FROM pg_dump(...)')` extension or via direct file copy. If neither path works, run `pglite-tools dump /tmp/v0.18-seed.pglite > dump.sql`. +5. Place `dump.sql` here. +6. Update `expected.json::min_pages_after_migration` to match your dump's page count. + +## What gets tested + +When `dump.sql` exists, the harness: + +- Runs `seedPgliteFromFile()` to replay the dump into a fresh `/.gbrain/brain.pglite` +- Then runs `gbrain init --pglite` so the migration chain detects the old schema_version and walks forward to LATEST +- Asserts `gbrain doctor --json` returns `status: 'ok'` after the walk + +This is the regression gate for the upgrade-wedge bug class (#239/#243/#266/#357/#366/#374/#375/#378/#395/#396) — every gbrain release that adds a column-with-index in the embedded schema blob without a corresponding bootstrap retriggered the same wedge family. diff --git a/test/friction-cli.test.ts b/test/friction-cli.test.ts new file mode 100644 index 000000000..fb43a227c --- /dev/null +++ b/test/friction-cli.test.ts @@ -0,0 +1,196 @@ +/** + * Friction CLI dispatch tests. Exercises the thin command layer (each + * subcommand stays ≤ 30 LOC per the DRY contract from the eng review). + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { runFriction } from '../src/commands/friction.ts'; +import { frictionFile, frictionDir } from '../src/core/friction.ts'; + +const ORIG_HOME = process.env.GBRAIN_HOME; +const ORIG_RUN_ID = process.env.GBRAIN_FRICTION_RUN_ID; +let tmp: string; +let stdoutLines: string[]; +let stderrLines: string[]; +let origStdoutWrite: typeof process.stdout.write; +let origConsoleLog: typeof console.log; +let origConsoleError: typeof console.error; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'friction-cli-')); + process.env.GBRAIN_HOME = tmp; + delete process.env.GBRAIN_FRICTION_RUN_ID; + stdoutLines = []; + stderrLines = []; + origStdoutWrite = process.stdout.write.bind(process.stdout); + origConsoleLog = console.log; + origConsoleError = console.error; + process.stdout.write = ((chunk: string) => { stdoutLines.push(String(chunk)); return true; }) as any; + console.log = (...args: unknown[]) => { stdoutLines.push(args.join(' ') + '\n'); }; + console.error = (...args: unknown[]) => { stderrLines.push(args.join(' ') + '\n'); }; +}); + +afterEach(() => { + process.env.GBRAIN_HOME = ORIG_HOME; + if (ORIG_RUN_ID !== undefined) process.env.GBRAIN_FRICTION_RUN_ID = ORIG_RUN_ID; + rmSync(tmp, { recursive: true, force: true }); + process.stdout.write = origStdoutWrite; + console.log = origConsoleLog; + console.error = origConsoleError; +}); + +describe('dispatch', () => { + test('--help returns 0 and prints subcommand list', () => { + const code = runFriction(['--help']); + expect(code).toBe(0); + expect(stdoutLines.join('')).toContain('Subcommands'); + expect(stdoutLines.join('')).toContain('log'); + expect(stdoutLines.join('')).toContain('render'); + expect(stdoutLines.join('')).toContain('list'); + expect(stdoutLines.join('')).toContain('summary'); + }); + + test('unknown subcommand returns 2', () => { + const code = runFriction(['nonsense']); + expect(code).toBe(2); + expect(stderrLines.join('')).toContain('unknown subcommand'); + }); +}); + +describe('log subcommand', () => { + test('writes a friction entry under GBRAIN_HOME', () => { + const code = runFriction(['log', '--run-id', 'cli-1', '--phase', 'install', '--message', 'something broke', '--severity', 'error']); + expect(code).toBe(0); + const path = frictionFile('cli-1'); + expect(existsSync(path)).toBe(true); + expect(path.startsWith(tmp)).toBe(true); + const raw = readFileSync(path, 'utf-8'); + expect(raw).toContain('something broke'); + }); + + test('missing --phase returns 2 with usage', () => { + const code = runFriction(['log', '--message', 'foo']); + expect(code).toBe(2); + expect(stderrLines.join('')).toContain('usage'); + }); + + test('missing --message returns 2 with usage', () => { + const code = runFriction(['log', '--phase', 'p']); + expect(code).toBe(2); + expect(stderrLines.join('')).toContain('usage'); + }); + + test('invalid --severity returns 2', () => { + const code = runFriction(['log', '--run-id', 'cli-2', '--phase', 'p', '--message', 'm', '--severity', 'panicking']); + expect(code).toBe(2); + expect(stderrLines.join('')).toContain('invalid --severity'); + }); + + test('invalid --kind returns 2', () => { + const code = runFriction(['log', '--run-id', 'cli-3', '--phase', 'p', '--message', 'm', '--kind', 'bogus']); + expect(code).toBe(2); + expect(stderrLines.join('')).toContain('invalid --kind'); + }); + + test('--kind delight is recorded', () => { + runFriction(['log', '--run-id', 'cli-4', '--phase', 'p', '--message', 'great', '--kind', 'delight']); + const raw = readFileSync(frictionFile('cli-4'), 'utf-8'); + expect(raw).toContain('"kind":"delight"'); + }); +}); + +describe('render subcommand', () => { + test('renders markdown by default', () => { + runFriction(['log', '--run-id', 'cli-r', '--phase', 'install', '--message', 'beep', '--severity', 'error']); + stdoutLines.length = 0; + const code = runFriction(['render', '--run-id', 'cli-r']); + expect(code).toBe(0); + const out = stdoutLines.join(''); + expect(out).toContain('# Friction report'); + expect(out).toContain('## error'); + }); + + test('--json emits parseable JSON', () => { + runFriction(['log', '--run-id', 'cli-r2', '--phase', 'p', '--message', 'beep']); + stdoutLines.length = 0; + const code = runFriction(['render', '--run-id', 'cli-r2', '--json']); + expect(code).toBe(0); + const out = stdoutLines.join('').trim(); + const parsed = JSON.parse(out); + expect(parsed.run_id).toBe('cli-r2'); + expect(parsed.entries.length).toBe(1); + }); + + test('missing run-id returns 1 with actionable error', () => { + const code = runFriction(['render', '--run-id', 'no-such-run']); + expect(code).toBe(1); + expect(stderrLines.join('')).toContain('not found'); + }); +}); + +describe('list subcommand', () => { + test('reports no runs initially', () => { + const code = runFriction(['list']); + expect(code).toBe(0); + expect(stdoutLines.join('')).toContain('no runs'); + }); + + test('lists logged runs with counts', () => { + runFriction(['log', '--run-id', 'a', '--phase', 'p', '--message', 'm', '--severity', 'error']); + runFriction(['log', '--run-id', 'b', '--phase', 'p', '--message', 'm', '--kind', 'delight']); + stdoutLines.length = 0; + const code = runFriction(['list']); + expect(code).toBe(0); + const out = stdoutLines.join(''); + expect(out).toContain('a'); + expect(out).toContain('b'); + }); + + test('--json emits parseable JSON array', () => { + runFriction(['log', '--run-id', 'jl', '--phase', 'p', '--message', 'm']); + stdoutLines.length = 0; + const code = runFriction(['list', '--json']); + expect(code).toBe(0); + const parsed = JSON.parse(stdoutLines.join('').trim()); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].runId).toBe('jl'); + }); +}); + +describe('summary subcommand', () => { + test('renders friction + delight columns', () => { + runFriction(['log', '--run-id', 'sum-1', '--phase', 'p', '--message', 'broken thing']); + runFriction(['log', '--run-id', 'sum-1', '--phase', 'p', '--message', 'nice thing', '--kind', 'delight']); + stdoutLines.length = 0; + const code = runFriction(['summary', '--run-id', 'sum-1']); + expect(code).toBe(0); + const out = stdoutLines.join(''); + expect(out).toContain('friction (1)'); + expect(out).toContain('delight (1)'); + expect(out).toContain('broken thing'); + expect(out).toContain('nice thing'); + }); +}); + +describe('GBRAIN_FRICTION_RUN_ID fallback (D19)', () => { + test('log without --run-id uses standalone', () => { + const code = runFriction(['log', '--phase', 'p', '--message', 'fallback']); + expect(code).toBe(0); + const path = frictionFile('standalone'); + expect(existsSync(path)).toBe(true); + expect(readFileSync(path, 'utf-8')).toContain('fallback'); + }); + + test('log honors $GBRAIN_FRICTION_RUN_ID', () => { + process.env.GBRAIN_FRICTION_RUN_ID = 'env-run'; + try { + runFriction(['log', '--phase', 'p', '--message', 'env']); + expect(existsSync(frictionFile('env-run'))).toBe(true); + } finally { + delete process.env.GBRAIN_FRICTION_RUN_ID; + } + }); +}); diff --git a/test/friction.test.ts b/test/friction.test.ts new file mode 100644 index 000000000..cd7fcd128 --- /dev/null +++ b/test/friction.test.ts @@ -0,0 +1,232 @@ +/** + * Friction core: writer + reader + renderer + redactor. + * + * These tests are pure local-fs (no DB, no subprocess). They run under + * GBRAIN_HOME= for hermeticity — see test/gbrain-home-isolation.test.ts + * for the regression gate proving every consumer honors that env. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, existsSync, readFileSync, appendFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + logFriction, readFriction, listRuns, renderReport, renderSummary, + redactEntry, frictionFile, frictionDir, activeRunId, + type FrictionEntry, +} from '../src/core/friction.ts'; + +const ORIG_HOME = process.env.GBRAIN_HOME; +const ORIG_RUN_ID = process.env.GBRAIN_FRICTION_RUN_ID; +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'friction-test-')); + process.env.GBRAIN_HOME = tmp; + delete process.env.GBRAIN_FRICTION_RUN_ID; +}); + +afterEach(() => { + process.env.GBRAIN_HOME = ORIG_HOME; + if (ORIG_RUN_ID !== undefined) process.env.GBRAIN_FRICTION_RUN_ID = ORIG_RUN_ID; + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('writer', () => { + test('logFriction appends one JSONL line and roundtrips through reader', () => { + logFriction({ runId: 'run-a', phase: 'install', message: 'first', severity: 'error' }); + const { entries, malformed } = readFriction('run-a'); + expect(malformed).toBe(0); + expect(entries).toHaveLength(1); + expect(entries[0].message).toBe('first'); + expect(entries[0].severity).toBe('error'); + expect(entries[0].kind).toBe('friction'); + expect(entries[0].schema_version).toBe('1'); + expect(entries[0].run_id).toBe('run-a'); + }); + + test('multiple entries append in order', () => { + logFriction({ runId: 'run-b', phase: 'p1', message: 'one', severity: 'nit' }); + logFriction({ runId: 'run-b', phase: 'p2', message: 'two', severity: 'blocker' }); + const { entries } = readFriction('run-b'); + expect(entries.map(e => e.message)).toEqual(['one', 'two']); + }); + + test('long messages are truncated', () => { + const long = 'x'.repeat(5000); + logFriction({ runId: 'run-c', phase: 'p', message: long }); + const { entries } = readFriction('run-c'); + expect(entries[0].message.length).toBeLessThan(5000); + expect(entries[0].message.endsWith('[truncated]')).toBe(true); + }); + + test('kind: delight is recorded distinctly', () => { + logFriction({ runId: 'run-d', phase: 'verify', message: 'this just worked', kind: 'delight' }); + const { entries } = readFriction('run-d'); + expect(entries[0].kind).toBe('delight'); + }); + + test('phase-marker entry roundtrips', () => { + logFriction({ runId: 'run-e', phase: 'extract', message: 'phase started', kind: 'phase-marker', marker: 'start' }); + const { entries } = readFriction('run-e'); + expect(entries[0].kind).toBe('phase-marker'); + expect(entries[0].marker).toBe('start'); + }); + + test('error envelope fields flatten in (D20)', () => { + logFriction({ + runId: 'run-f', + phase: 'install', + message: 'spawn failed', + severity: 'blocker', + errorClass: 'AgentSpawnError', + errorCode: 'spawn_enoent', + docsUrl: 'https://example.test/docs', + }); + const { entries } = readFriction('run-f'); + expect(entries[0].class).toBe('AgentSpawnError'); + expect(entries[0].code).toBe('spawn_enoent'); + expect(entries[0].docs_url).toBe('https://example.test/docs'); + }); + + test('rejects invalid run-id', () => { + expect(() => logFriction({ runId: 'has space', phase: 'p', message: 'm' })).toThrow(/invalid run-id/); + expect(() => logFriction({ runId: '../escape', phase: 'p', message: 'm' })).toThrow(/invalid run-id/); + }); +}); + +describe('activeRunId', () => { + test('falls back to standalone when env unset (D19)', () => { + delete process.env.GBRAIN_FRICTION_RUN_ID; + expect(activeRunId()).toBe('standalone'); + }); + + test('reads GBRAIN_FRICTION_RUN_ID', () => { + process.env.GBRAIN_FRICTION_RUN_ID = 'my-run'; + try { + expect(activeRunId()).toBe('my-run'); + } finally { + delete process.env.GBRAIN_FRICTION_RUN_ID; + } + }); +}); + +describe('reader', () => { + test('skips malformed lines and counts them', () => { + logFriction({ runId: 'run-g', phase: 'p', message: 'good' }); + appendFileSync(frictionFile('run-g'), 'this is not json\n', 'utf-8'); + appendFileSync(frictionFile('run-g'), '{"ts":"only","kind":"friction"}\n', 'utf-8'); + logFriction({ runId: 'run-g', phase: 'p', message: 'good2' }); + const { entries, malformed } = readFriction('run-g'); + expect(entries).toHaveLength(2); + expect(malformed).toBe(2); + }); + + test('throws on missing run-id', () => { + expect(() => readFriction('does-not-exist')).toThrow(/not found/); + }); +}); + +describe('listRuns', () => { + test('lists runs sorted most-recent-first', () => { + logFriction({ runId: 'old-run', phase: 'p', message: 'a' }); + // Sleep one millisecond worth via busy-wait so mtime differs reliably + const t0 = Date.now(); + while (Date.now() - t0 < 10) { /* spin */ } + logFriction({ runId: 'new-run', phase: 'p', message: 'b' }); + const runs = listRuns(); + expect(runs.length).toBe(2); + expect(runs[0].runId).toBe('new-run'); + expect(runs[1].runId).toBe('old-run'); + }); + + test('reports per-run counts and interrupted flag', () => { + logFriction({ runId: 'run-h', phase: 'p', message: 'a', severity: 'error' }); + logFriction({ runId: 'run-h', phase: 'p', message: 'b', severity: 'error' }); + logFriction({ runId: 'run-h', phase: 'p', message: 'c', kind: 'delight' }); + logFriction({ runId: 'run-h', phase: 'p', message: 'killed', kind: 'interrupted' }); + const runs = listRuns(); + const r = runs.find(x => x.runId === 'run-h')!; + expect(r.counts.friction).toBe(2); + expect(r.counts.delight).toBe(1); + expect(r.counts.interrupted).toBe(true); + expect(r.counts.bySeverity.error).toBe(2); + }); +}); + +describe('renderer', () => { + test('markdown groups by severity then phase', () => { + logFriction({ runId: 'run-r', phase: 'install', message: 'a', severity: 'blocker' }); + logFriction({ runId: 'run-r', phase: 'install', message: 'b', severity: 'error' }); + logFriction({ runId: 'run-r', phase: 'verify', message: 'c', severity: 'error' }); + logFriction({ runId: 'run-r', phase: 'verify', message: 'positive', kind: 'delight' }); + const md = renderReport('run-r', { format: 'md', redact: false }); + expect(md).toContain('# Friction report'); + expect(md).toContain('## blocker'); + expect(md).toContain('## error'); + expect(md).toContain('### `install`'); + expect(md).toContain('### `verify`'); + // Blocker section comes before error section + expect(md.indexOf('## blocker')).toBeLessThan(md.indexOf('## error')); + }); + + test('json output is valid and includes entries', () => { + logFriction({ runId: 'run-j', phase: 'p', message: 'one' }); + const out = renderReport('run-j', { format: 'json' }); + const parsed = JSON.parse(out); + expect(parsed.run_id).toBe('run-j'); + expect(parsed.entries).toHaveLength(1); + }); + + test('redact strips homedir and cwd from message + cwd field', () => { + const home = process.env.HOME ?? ''; + const fakeCwd = process.cwd(); + logFriction({ + runId: 'run-red', + phase: 'p', + message: `error at ${home}/.gbrain/foo and ${fakeCwd}/bar.ts`, + }); + const md = renderReport('run-red', { format: 'md', redact: true }); + expect(md).not.toContain(home + '/.gbrain'); + expect(md).toContain(''); + expect(md).toContain(''); + }); + + test('--no-redact path preserves homedir', () => { + const home = process.env.HOME ?? '/tmp/none'; + logFriction({ runId: 'run-noredact', phase: 'p', message: `at ${home}/foo` }); + const md = renderReport('run-noredact', { format: 'md', redact: false }); + expect(md).toContain(home); + }); + + test('interrupted run shows banner', () => { + logFriction({ runId: 'run-i', phase: 'p', message: 'partial' }); + logFriction({ runId: 'run-i', phase: 'p', message: 'killed', kind: 'interrupted' }); + const md = renderReport('run-i', { format: 'md', redact: false }); + expect(md).toContain('Run was interrupted'); + }); +}); + +describe('summary', () => { + test('two columns, friction + delight side-by-side', () => { + logFriction({ runId: 'run-s', phase: 'p', message: 'bad-thing' }); + logFriction({ runId: 'run-s', phase: 'p', message: 'good-thing', kind: 'delight' }); + const md = renderSummary('run-s', { format: 'md' }); + expect(md).toContain('| friction (1) | delight (1) |'); + expect(md).toContain('bad-thing'); + expect(md).toContain('good-thing'); + }); +}); + +describe('redactEntry pure function', () => { + test('replaces homedir occurrences', () => { + const home = process.env.HOME ?? '/x'; + const e: FrictionEntry = { + schema_version: '1', ts: 'now', run_id: 'r', phase: 'p', kind: 'friction', + message: `${home}/secret/file.txt`, source: 'claw', cwd: '/cwd', gbrain_version: 'test', + }; + const r = redactEntry(e); + expect(r.message).toContain(''); + expect(r.cwd).toBe(''); + }); +}); diff --git a/test/gbrain-home-isolation.test.ts b/test/gbrain-home-isolation.test.ts new file mode 100644 index 000000000..50adbea8b --- /dev/null +++ b/test/gbrain-home-isolation.test.ts @@ -0,0 +1,141 @@ +/** + * Hermeticity test: every site that writes under `~/.gbrain` must honor + * `GBRAIN_HOME=` and write under `/.gbrain` instead of the developer's + * real home. + * + * Why this exists: `src/core/config.ts::configDir()` already supports + * `GBRAIN_HOME` as a parent-dir override (returns `/.gbrain`), but + * historically many call sites built paths from `os.homedir()` directly, + * bypassing the override. The hermeticity migration migrated every write-side + * caller to `gbrainPath(...)`. This test is the regression gate. + * + * Scope: write-isolation only. Read-side host detection in + * `src/commands/init.ts` (reading `~/.claude`, `~/.openclaw`, etc. for module + * fingerprinting) is the documented v1 caveat and is NOT asserted here. + */ + +import { describe, test, expect } from 'bun:test'; +import { mkdtempSync, existsSync, readdirSync, statSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Save original env so we don't leak between tests. +const ORIG_GBRAIN_HOME = process.env.GBRAIN_HOME; + +function fresh(): string { + return mkdtempSync(join(tmpdir(), 'gbrain-home-isolation-')); +} + +describe('GBRAIN_HOME write-side isolation', () => { + test('configDir() returns /.gbrain when override is set', async () => { + const tmp = fresh(); + process.env.GBRAIN_HOME = tmp; + try { + const { configDir, gbrainPath } = await import('../src/core/config.ts'); + expect(configDir()).toBe(join(tmp, '.gbrain')); + expect(gbrainPath('foo', 'bar.json')).toBe(join(tmp, '.gbrain', 'foo', 'bar.json')); + } finally { + process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME; + rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('configDir() falls back to homedir when GBRAIN_HOME unset', async () => { + delete process.env.GBRAIN_HOME; + try { + const { configDir } = await import('../src/core/config.ts'); + const result = configDir(); + // Should NOT contain the test tmpdir; should resolve to a real homedir path. + expect(result.endsWith('.gbrain')).toBe(true); + expect(result.startsWith('/tmp/')).toBe(false); + } finally { + if (ORIG_GBRAIN_HOME !== undefined) process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME; + } + }); + + test('rejects relative GBRAIN_HOME', async () => { + process.env.GBRAIN_HOME = 'relative/path'; + try { + const { configDir } = await import('../src/core/config.ts'); + expect(() => configDir()).toThrow(/absolute path/); + } finally { + process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME; + } + }); + + test("rejects GBRAIN_HOME containing '..' segments", async () => { + process.env.GBRAIN_HOME = '/tmp/foo/../bar'; + try { + const { configDir } = await import('../src/core/config.ts'); + expect(() => configDir()).toThrow(/'\.\.' segments/); + } finally { + process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME; + } + }); + + test('saveConfig/loadConfig honor GBRAIN_HOME', async () => { + const tmp = fresh(); + process.env.GBRAIN_HOME = tmp; + try { + const { saveConfig, loadConfig } = await import('../src/core/config.ts'); + const cfg = { engine: 'pglite' as const, database_path: join(tmp, '.gbrain', 'brain.pglite') }; + saveConfig(cfg); + // Config file should exist under the override, NOT under real ~/.gbrain. + expect(existsSync(join(tmp, '.gbrain', 'config.json'))).toBe(true); + + // Round-trip: loadConfig() finds it back via the override. + const loaded = loadConfig(); + expect(loaded?.engine).toBe('pglite'); + expect(loaded?.database_path).toBe(cfg.database_path); + } finally { + process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME; + rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('integrity, sync-failures, integrations heartbeat resolve under GBRAIN_HOME', async () => { + const tmp = fresh(); + process.env.GBRAIN_HOME = tmp; + try { + const { gbrainPath } = await import('../src/core/config.ts'); + // Spot-check a representative set of paths used across the migrated sites. + const paths = [ + gbrainPath('integrity-review.md'), // src/commands/integrity.ts + gbrainPath('sync-failures.jsonl'), // src/core/sync.ts + gbrainPath('integrations', 'recipe-x'), // src/commands/integrations.ts + gbrainPath('migrate-manifest.json'), // src/commands/migrate-engine.ts + gbrainPath('import-checkpoint.json'), // src/commands/import.ts + gbrainPath('migrations', 'v0_13_1-rollback.jsonl'), // src/commands/migrations/v0_13_1.ts + gbrainPath('migrations', 'pending-host-work.jsonl'), // src/commands/migrations/v0_14_0.ts + gbrainPath('audit'), // shell-audit / backpressure-audit + gbrainPath('cycle.lock'), // src/core/cycle.ts + gbrainPath('fail-improve'), // src/core/fail-improve.ts + gbrainPath('validator-lint.jsonl'), // src/core/output/post-write.ts + gbrainPath('brain.pglite'), // init pglite default + ]; + for (const p of paths) { + expect(p.startsWith(join(tmp, '.gbrain'))).toBe(true); + } + } finally { + process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME; + rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('GBRAIN_AUDIT_DIR override still wins over GBRAIN_HOME', async () => { + const tmp = fresh(); + const auditTmp = fresh(); + process.env.GBRAIN_HOME = tmp; + process.env.GBRAIN_AUDIT_DIR = auditTmp; + try { + const { resolveAuditDir } = await import('../src/core/minions/handlers/shell-audit.ts'); + // Per the docstring: GBRAIN_AUDIT_DIR is the explicit override and wins. + expect(resolveAuditDir()).toBe(auditTmp); + } finally { + process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME; + delete process.env.GBRAIN_AUDIT_DIR; + rmSync(tmp, { recursive: true, force: true }); + rmSync(auditTmp, { recursive: true, force: true }); + } + }); +}); diff --git a/test/migrations-v0_14_0.test.ts b/test/migrations-v0_14_0.test.ts index 150b3d790..210c3ec1e 100644 --- a/test/migrations-v0_14_0.test.ts +++ b/test/migrations-v0_14_0.test.ts @@ -16,16 +16,17 @@ import { join } from 'path'; import { tmpdir } from 'os'; let tmpHome: string; -const originalHome = process.env.HOME; +const originalGbrainHome = process.env.GBRAIN_HOME; beforeEach(() => { tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-v0_14_0-')); - process.env.HOME = tmpHome; + // GBRAIN_HOME is the parent dir; configDir() appends '.gbrain' itself. + process.env.GBRAIN_HOME = tmpHome; }); afterEach(() => { - if (originalHome) process.env.HOME = originalHome; - else delete process.env.HOME; + if (originalGbrainHome !== undefined) process.env.GBRAIN_HOME = originalGbrainHome; + else delete process.env.GBRAIN_HOME; try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ } }); diff --git a/test/progress-tail.test.ts b/test/progress-tail.test.ts new file mode 100644 index 000000000..b40617b00 --- /dev/null +++ b/test/progress-tail.test.ts @@ -0,0 +1,81 @@ +/** + * progress-tail tests — parse --progress-json events out of mixed stderr. + */ + +import { describe, test, expect } from 'bun:test'; +import { parseProgressEvents, eventsByPhase, verifyExpectedPhases } from '../src/core/claw-test/progress-tail.ts'; + +describe('parseProgressEvents', () => { + test('extracts JSON event lines from mixed stderr', () => { + const stderr = [ + 'starting up', + '{"phase":"import.files","event":"start"}', + 'warning: deprecated flag X', + '{"phase":"import.files","event":"tick","done":3,"total":10}', + 'random text', + '{"phase":"import.files","event":"finish"}', + ].join('\n'); + const events = parseProgressEvents(stderr); + expect(events).toHaveLength(3); + expect(events.map(e => e.event)).toEqual(['start', 'tick', 'finish']); + }); + + test('ignores malformed JSON lines silently', () => { + const stderr = [ + '{"phase":"a","event":"start"}', + '{"phase":', // truncated JSON + 'not json at all', + '{"phase":"b","event":"start"}', + ].join('\n'); + const events = parseProgressEvents(stderr); + expect(events).toHaveLength(2); + }); + + test('ignores objects without phase field', () => { + const stderr = [ + '{"phase":"a","event":"start"}', + '{"foo":"bar"}', + '{"phase":"b","event":"start"}', + ].join('\n'); + const events = parseProgressEvents(stderr); + expect(events.map(e => e.phase)).toEqual(['a', 'b']); + }); +}); + +describe('eventsByPhase', () => { + test('groups by phase name', () => { + const events = [ + { phase: 'import.files', event: 'start' }, + { phase: 'import.files', event: 'finish' }, + { phase: 'extract.links_fs', event: 'start' }, + ]; + const grouped = eventsByPhase(events); + expect(grouped.get('import.files')).toHaveLength(2); + expect(grouped.get('extract.links_fs')).toHaveLength(1); + }); +}); + +describe('verifyExpectedPhases', () => { + test('returns empty when all expected phases present', () => { + const events = [ + { phase: 'import.files' }, + { phase: 'extract.links_fs' }, + { phase: 'doctor.db_checks' }, + ]; + const missing = verifyExpectedPhases(events, ['import.files', 'doctor.db_checks']); + expect(missing).toEqual([]); + }); + + test('returns missing phase names when some are absent', () => { + const events = [ + { phase: 'import.files' }, + ]; + const missing = verifyExpectedPhases(events, ['import.files', 'extract.links_fs', 'doctor.db_checks']); + expect(missing).toEqual(['extract.links_fs', 'doctor.db_checks']); + }); + + test('returns full expected list when no events at all', () => { + const missing = verifyExpectedPhases([], ['a', 'b']); + expect(missing).toEqual(['a', 'b']); + }); +}); diff --git a/test/scenarios.test.ts b/test/scenarios.test.ts new file mode 100644 index 000000000..2cafae94a --- /dev/null +++ b/test/scenarios.test.ts @@ -0,0 +1,132 @@ +/** + * Scenario loader tests — proves scenario.json parsing + validation work. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { listScenarios, loadScenario, readBrief } from '../src/core/claw-test/scenarios.ts'; + +const ORIG_ROOT = process.env.GBRAIN_CLAW_SCENARIOS_DIR; +let root: string; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'scenarios-')); + process.env.GBRAIN_CLAW_SCENARIOS_DIR = root; +}); + +afterEach(() => { + if (ORIG_ROOT !== undefined) process.env.GBRAIN_CLAW_SCENARIOS_DIR = ORIG_ROOT; + else delete process.env.GBRAIN_CLAW_SCENARIOS_DIR; + rmSync(root, { recursive: true, force: true }); +}); + +function scaffoldScenario(name: string, scenarioJson: string, briefContent = '# Brief'): void { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'scenario.json'), scenarioJson); + writeFileSync(join(dir, 'BRIEF.md'), briefContent); +} + +describe('listScenarios', () => { + test('returns empty when no scenarios exist', () => { + expect(listScenarios()).toEqual([]); + }); + + test('returns directories that contain scenario.json, sorted', () => { + scaffoldScenario('beta', '{"kind":"fresh-install","expected_phases":[]}'); + scaffoldScenario('alpha', '{"kind":"fresh-install","expected_phases":[]}'); + mkdirSync(join(root, 'incomplete'), { recursive: true }); // no scenario.json + expect(listScenarios()).toEqual(['alpha', 'beta']); + }); +}); + +describe('loadScenario', () => { + test('parses a valid fresh-install scenario', () => { + scaffoldScenario('demo', JSON.stringify({ + kind: 'fresh-install', + expected_phases: ['import.files', 'doctor.db_checks'], + description: 'demo', + brain: 'brain', + })); + const cfg = loadScenario('demo'); + expect(cfg.name).toBe('demo'); + expect(cfg.kind).toBe('fresh-install'); + expect(cfg.expectedPhases).toEqual(['import.files', 'doctor.db_checks']); + expect(cfg.description).toBe('demo'); + expect(cfg.brainRelative).toBe('brain'); + }); + + test('parses an upgrade scenario with from_version + seed', () => { + scaffoldScenario('upgrade-x', JSON.stringify({ + kind: 'upgrade', + from_version: '0.18.0', + expected_phases: ['doctor.db_checks'], + seed: 'seed', + })); + mkdirSync(join(root, 'upgrade-x', 'seed'), { recursive: true }); + const cfg = loadScenario('upgrade-x'); + expect(cfg.kind).toBe('upgrade'); + expect(cfg.fromVersion).toBe('0.18.0'); + expect(cfg.seedRelative).toBe('seed'); + }); + + test('throws on missing scenario directory', () => { + expect(() => loadScenario('does-not-exist')).toThrow(/not found/); + }); + + test('throws on malformed JSON', () => { + scaffoldScenario('bad', 'not json {'); + expect(() => loadScenario('bad')).toThrow(/malformed/); + }); + + test('throws on unknown kind', () => { + scaffoldScenario('weird', JSON.stringify({ kind: 'mystery', expected_phases: [] })); + expect(() => loadScenario('weird')).toThrow(/unknown kind/); + }); + + test('throws on non-array expected_phases', () => { + scaffoldScenario('bad-phases', JSON.stringify({ kind: 'fresh-install', expected_phases: 'oops' })); + expect(() => loadScenario('bad-phases')).toThrow(/expected_phases/); + }); + + test('throws when BRIEF.md missing', () => { + const dir = join(root, 'no-brief'); + mkdirSync(dir); + writeFileSync(join(dir, 'scenario.json'), JSON.stringify({ kind: 'fresh-install', expected_phases: [] })); + expect(() => loadScenario('no-brief')).toThrow(/BRIEF\.md missing/); + }); +}); + +describe('readBrief', () => { + test('returns BRIEF.md content', () => { + scaffoldScenario('reads-brief', '{"kind":"fresh-install","expected_phases":[]}', '# Hello world'); + const cfg = loadScenario('reads-brief'); + expect(readBrief(cfg)).toBe('# Hello world'); + }); +}); + +describe('shipped scenarios load cleanly', () => { + test('fresh-install loads from default fixtures root', () => { + delete process.env.GBRAIN_CLAW_SCENARIOS_DIR; + try { + const cfg = loadScenario('fresh-install'); + expect(cfg.kind).toBe('fresh-install'); + expect(cfg.expectedPhases.length).toBeGreaterThan(0); + } finally { + process.env.GBRAIN_CLAW_SCENARIOS_DIR = root; + } + }); + + test('upgrade-from-v0.18 loads from default fixtures root', () => { + delete process.env.GBRAIN_CLAW_SCENARIOS_DIR; + try { + const cfg = loadScenario('upgrade-from-v0.18'); + expect(cfg.kind).toBe('upgrade'); + expect(cfg.fromVersion).toBe('0.18.0'); + } finally { + process.env.GBRAIN_CLAW_SCENARIOS_DIR = root; + } + }); +}); diff --git a/test/seed-pglite.test.ts b/test/seed-pglite.test.ts new file mode 100644 index 000000000..411eb3332 --- /dev/null +++ b/test/seed-pglite.test.ts @@ -0,0 +1,130 @@ +/** + * seed-pglite tests — exercises the SQL replay primitive that powers the + * upgrade-from-v0.18 scenario. Pure PGLite in-memory; no real DB needed. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { seedPglite, seedPgliteFromFile, _internal } from '../src/core/claw-test/seed-pglite.ts'; +import { PGLiteEngine } from '../src/core/pglite-engine.ts'; + +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'seed-')); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('splitStatements', () => { + const split = _internal.splitStatements; + + test('splits on semicolons', () => { + expect(split('CREATE TABLE a(x int); INSERT INTO a VALUES (1);').length).toBe(2); + }); + + test('respects single-quoted strings', () => { + const sql = "INSERT INTO t VALUES ('a;b'); INSERT INTO t VALUES ('c');"; + const stmts = split(sql); + expect(stmts.length).toBe(2); + expect(stmts[0]).toContain("'a;b'"); + }); + + test('respects -- line comments', () => { + const sql = "-- a comment with ; semicolon\nCREATE TABLE x(id int);"; + const stmts = split(sql); + expect(stmts.length).toBe(1); + }); + + test('handles escaped quotes (doubled apostrophe)', () => { + const sql = "INSERT INTO t VALUES ('it''s ok');"; + const stmts = split(sql); + expect(stmts.length).toBe(1); + expect(stmts[0]).toContain("it''s ok"); + }); + + test('returns empty list for empty input', () => { + expect(split('').length).toBe(0); + expect(split(' \n').length).toBe(0); + }); +}); + +describe('seedPglite', () => { + test('replays a SQL dump into a fresh PGLite database', async () => { + const dbPath = join(tmp, 'brain.pglite'); + const sql = ` + CREATE TABLE seeded(id INT PRIMARY KEY, name TEXT); + INSERT INTO seeded(id, name) VALUES (1, 'alice'); + INSERT INTO seeded(id, name) VALUES (2, 'bob'); + `; + await seedPglite({ dbPath, sql }); + + // Re-open the seeded database and verify content survived. + const engine = new PGLiteEngine(); + try { + await engine.connect({ engine: 'pglite', database_path: dbPath }); + const rows: any = await (engine as any).db.query('SELECT id, name FROM seeded ORDER BY id'); + expect(rows.rows).toEqual([ + { id: 1, name: 'alice' }, + { id: 2, name: 'bob' }, + ]); + } finally { + await engine.disconnect(); + } + }, 30_000); + + test('throws with a useful message when SQL is invalid', async () => { + const dbPath = join(tmp, 'bad.pglite'); + const sql = 'INVALID SQL HERE;'; + await expect(seedPglite({ dbPath, sql })).rejects.toThrow(/SQL execution failed/); + }, 30_000); + + test('creates parent directories when needed', async () => { + const dbPath = join(tmp, 'nested', 'deeper', 'brain.pglite'); + await seedPglite({ dbPath, sql: 'CREATE TABLE x(y int);' }); + // No throw means the dir was created. + expect(true).toBe(true); + }, 30_000); + + test('empty SQL is a no-op (just creates the .pglite)', async () => { + const dbPath = join(tmp, 'empty.pglite'); + await seedPglite({ dbPath, sql: '' }); + // Verify the database is openable but empty. + const engine = new PGLiteEngine(); + try { + await engine.connect({ engine: 'pglite', database_path: dbPath }); + const r: any = await (engine as any).db.query("SELECT COUNT(*)::int AS c FROM information_schema.tables WHERE table_schema='public'"); + expect(r.rows[0].c).toBe(0); + } finally { + await engine.disconnect(); + } + }, 30_000); +}); + +describe('seedPgliteFromFile', () => { + test('reads SQL from disk and replays', async () => { + const sqlPath = join(tmp, 'dump.sql'); + const dbPath = join(tmp, 'brain.pglite'); + writeFileSync(sqlPath, 'CREATE TABLE z(id int); INSERT INTO z VALUES (42);'); + await seedPgliteFromFile({ dbPath, sqlPath }); + const engine = new PGLiteEngine(); + try { + await engine.connect({ engine: 'pglite', database_path: dbPath }); + const r: any = await (engine as any).db.query('SELECT id FROM z'); + expect(r.rows).toEqual([{ id: 42 }]); + } finally { + await engine.disconnect(); + } + }, 30_000); + + test('throws on missing SQL file', async () => { + await expect(seedPgliteFromFile({ + dbPath: join(tmp, 'x.pglite'), + sqlPath: join(tmp, 'nope.sql'), + })).rejects.toThrow(/seed SQL not found/); + }); +}); diff --git a/test/transcript-capture.test.ts b/test/transcript-capture.test.ts new file mode 100644 index 000000000..ddc83d2e0 --- /dev/null +++ b/test/transcript-capture.test.ts @@ -0,0 +1,166 @@ +/** + * Transcript capture tests — async drain, byte offsets, multi-byte safety, + * spawn-with-capture happy + timeout paths. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { createTranscriptSink, spawnWithCapture } from '../src/core/claw-test/transcript-capture.ts'; + +let tmp: string; +let path: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'transcript-')); + path = join(tmp, 'transcript.jsonl'); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe('createTranscriptSink', () => { + test('writes events as JSONL lines with byte_offset', async () => { + const sink = createTranscriptSink(path); + sink.write({ ts: 1, channel: 'stdout', bytes: Buffer.from('hello') }); + sink.write({ ts: 2, channel: 'stderr', bytes: Buffer.from('world') }); + await sink.close(); + + const raw = readFileSync(path, 'utf-8'); + const lines = raw.trim().split('\n').map(l => JSON.parse(l)); + expect(lines).toHaveLength(2); + expect(lines[0].channel).toBe('stdout'); + expect(lines[0].byte_offset).toBe(0); + expect(lines[1].channel).toBe('stderr'); + expect(lines[1].byte_offset).toBeGreaterThan(0); + expect(Buffer.from(lines[0].bytes_b64, 'base64').toString('utf-8')).toBe('hello'); + expect(Buffer.from(lines[1].bytes_b64, 'base64').toString('utf-8')).toBe('world'); + }); + + test('preserves multi-byte UTF-8 (no chunk-boundary corruption)', async () => { + const sink = createTranscriptSink(path); + // Split a 4-byte emoji across two writes to simulate stdio chunk boundaries. + const emoji = '🌍'; + const buf = Buffer.from(emoji, 'utf-8'); + sink.write({ ts: 1, channel: 'stdout', bytes: buf.slice(0, 2) }); + sink.write({ ts: 2, channel: 'stdout', bytes: buf.slice(2) }); + await sink.close(); + + const lines = readFileSync(path, 'utf-8').trim().split('\n').map(l => JSON.parse(l)); + const concatenated = Buffer.concat([ + Buffer.from(lines[0].bytes_b64, 'base64'), + Buffer.from(lines[1].bytes_b64, 'base64'), + ]).toString('utf-8'); + expect(concatenated).toBe(emoji); + }); + + test('byte_offset is monotonic and matches the actual file position', async () => { + const sink = createTranscriptSink(path); + const before1 = sink.nextOffset(); + sink.write({ ts: 1, channel: 'stdout', bytes: Buffer.from('a') }); + const before2 = sink.nextOffset(); + sink.write({ ts: 2, channel: 'stdout', bytes: Buffer.from('b') }); + await sink.close(); + + expect(before1).toBe(0); + expect(before2).toBeGreaterThan(0); + + // Verify the offsets recorded in lines match the actual file substring offsets. + const raw = readFileSync(path, 'utf-8'); + const lines = raw.trim().split('\n').map(l => JSON.parse(l)); + const expectedOffsets = [0, Buffer.byteLength(raw.split('\n')[0] + '\n')]; + expect(lines[0].byte_offset).toBe(expectedOffsets[0]); + expect(lines[1].byte_offset).toBe(expectedOffsets[1]); + }); + + test('survives bursty writes (drain handling)', async () => { + const sink = createTranscriptSink(path); + // 256KB of payload across 256 1KB writes — exceeds default pipe buffer + const chunk = Buffer.alloc(1024, 0x61); // 'a' * 1024 + for (let i = 0; i < 256; i++) { + sink.write({ ts: i, channel: 'stdout', bytes: chunk }); + } + await sink.close(); + + const raw = readFileSync(path, 'utf-8'); + const lines = raw.trim().split('\n'); + expect(lines.length).toBe(256); + }); + + test('close is idempotent', async () => { + const sink = createTranscriptSink(path); + sink.write({ ts: 1, channel: 'stdout', bytes: Buffer.from('x') }); + await sink.close(); + // Second close should not throw — the writeStream's `end` won't fire 'close' a second time + // but we can call without error in our own wrapper. + // (Implementation note: we don't expose a closed flag; idempotent via stream's no-op behavior.) + expect(existsSync(path)).toBe(true); + }); +}); + +describe('spawnWithCapture', () => { + test('captures stdout from a small command', async () => { + const sink = createTranscriptSink(path); + const result = await spawnWithCapture('/bin/sh', ['-c', 'printf hi'], { + cwd: tmp, + env: { PATH: process.env.PATH ?? '' }, + timeoutMs: 5_000, + transcriptSink: sink, + }); + await sink.close(); + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + + const raw = readFileSync(path, 'utf-8'); + const captured = raw.split('\n').filter(Boolean).map(l => JSON.parse(l)); + const stdoutBytes = captured.filter(e => e.channel === 'stdout') + .map(e => Buffer.from(e.bytes_b64, 'base64').toString('utf-8')) + .join(''); + expect(stdoutBytes).toBe('hi'); + }); + + test('non-zero exit propagates', async () => { + const sink = createTranscriptSink(path); + const result = await spawnWithCapture('/bin/sh', ['-c', 'exit 7'], { + cwd: tmp, + env: { PATH: process.env.PATH ?? '' }, + timeoutMs: 5_000, + transcriptSink: sink, + }); + await sink.close(); + expect(result.exitCode).toBe(7); + expect(result.timedOut).toBe(false); + }); + + test('timeout fires SIGTERM/SIGKILL', async () => { + const sink = createTranscriptSink(path); + // `exec sleep` replaces sh with sleep so the child we spawn IS sleep — + // SIGTERM goes directly to it, no shell-vs-child process-group ambiguity. + // CI runners are slower than local, so the test cap is 30s with headroom + // even if SIGTERM is missed and SIGKILL has to run after the 5s grace. + const result = await spawnWithCapture('/bin/sh', ['-c', 'exec sleep 30'], { + cwd: tmp, + env: { PATH: process.env.PATH ?? '' }, + timeoutMs: 200, + transcriptSink: sink, + }); + await sink.close(); + expect(result.timedOut).toBe(true); + expect(result.exitCode).not.toBe(0); + }, 30_000); + + test('rejects when the binary does not exist', async () => { + const sink = createTranscriptSink(path); + await expect( + spawnWithCapture('/no/such/binary', [], { + cwd: tmp, + env: { PATH: process.env.PATH ?? '' }, + timeoutMs: 1_000, + transcriptSink: sink, + }) + ).rejects.toThrow(); + await sink.close(); + }); +});