mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8b6894c72 | ||
|
|
7aae6ec2b0 | ||
|
|
189da55f46 | ||
|
|
bbc8bba0ff | ||
|
|
8200c275d6 | ||
|
|
953c8e75f6 | ||
|
|
6c64670b33 | ||
|
|
4fc261a18c | ||
|
|
fe09ffaa71 | ||
|
|
00843f2a5a | ||
|
|
ce6af836e7 |
@@ -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/
|
||||
|
||||
@@ -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 `<HOME>` 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 `<run>/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 <name> --message <text> [--hint ...] [--kind {friction|delight}] [--run-id ...]` — append a friction or delight entry.
|
||||
- `gbrain friction render --run-id <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 <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=<tmp>`, asserts no writes outside `<tmp>/.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.**
|
||||
|
||||
@@ -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<CycleReport>` 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 <name>`, `--pull`, `--dir <path>`. 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/<run-id>.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 <name>] [--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=<tempdir>` 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/<name>/` to the agent runner. v1 ships with the OpenClaw runner only (`src/core/claw-test/runners/openclaw.ts`, invokes `openclaw agent --local --agent <name> --message <brief>`); 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 -->`, `--- 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 `<run>/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 <name> --message <text> [--hint ...] [--kind {friction|delight}] [--run-id ...]` — append a friction or delight entry to the active run JSONL.
|
||||
- `gbrain friction render --run-id <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 <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
|
||||
|
||||
@@ -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 <run-or-agent> --compare <run-or-agent>` (cross-agent comparison; ~80 LOC)
|
||||
- `gbrain friction trend [--since <version-or-date>] [--phase <name>]` (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)
|
||||
|
||||
@@ -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<CycleReport>` 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 <name>`, `--pull`, `--dir <path>`. 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/<run-id>.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 <name>] [--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=<tempdir>` 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/<name>/` to the agent runner. v1 ships with the OpenClaw runner only (`src/core/claw-test/runners/openclaw.ts`, invokes `openclaw agent --local --agent <name> --message <brief>`); 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 -->`, `--- 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 `<run>/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 <name> --message <text> [--hint ...] [--kind {friction|delight}] [--run-id ...]` — append a friction or delight entry to the active run JSONL.
|
||||
- `gbrain friction render --run-id <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 <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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 <which-phase-or-command> \
|
||||
--message "<one-line-what-happened>" \
|
||||
[--hint "<one-line-what-could-be-better>"]
|
||||
```
|
||||
|
||||
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 <id> # markdown report (default)
|
||||
gbrain friction render --run-id <id> --json
|
||||
gbrain friction summary --run-id <id> # friction + delight side-by-side
|
||||
```
|
||||
|
||||
`render` defaults to `--redact` for markdown (strips `$HOME`/`$CWD` to `<HOME>`/`<CWD>` placeholders) so reports paste safely into PRs and issues.
|
||||
+9
-1
@@ -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);
|
||||
|
||||
@@ -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=<tempdir> 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<number> {
|
||||
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<number> {
|
||||
const childEnv: Record<string, string> = {
|
||||
...process.env as Record<string, string>,
|
||||
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<number> {
|
||||
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<string, string> = {
|
||||
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<string, string>,
|
||||
): Promise<PhaseOutcome> {
|
||||
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 <name>] [--live --agent <name>] [--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`);
|
||||
}
|
||||
@@ -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<FrictionKind>(['friction', 'delight', 'phase-marker', 'interrupted']);
|
||||
const VALID_SEVERITIES = new Set<FrictionSeverity>(['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 <name> --message <text> [--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'.`);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+24
-25
@@ -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<string> {
|
||||
if (!existsSync(PROGRESS_FILE)) return new Set();
|
||||
if (!existsSync(getProgressFile())) return new Set();
|
||||
const seen = new Set<string>();
|
||||
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<string> {
|
||||
}
|
||||
|
||||
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<void> {
|
||||
}
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<OrchestratorResult>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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<string, unknown> }): void {
|
||||
@@ -260,7 +261,7 @@ function appendRollbackEntry(entry: { slug: string; pre_frontmatter: Record<stri
|
||||
timestamp: new Date().toISOString(),
|
||||
...entry,
|
||||
}) + '\n';
|
||||
appendFileSync(ROLLBACK_FILE, line, 'utf-8');
|
||||
appendFileSync(getRollbackFile(), line, 'utf-8');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,19 +22,17 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, 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';
|
||||
|
||||
// Resolve HOME at CALL time, not module-load time — Bun caches os.homedir()
|
||||
// and ignores later HOME mutations, which breaks test isolation and scripted
|
||||
// installs. Match the preferences.ts pattern.
|
||||
function resolveHome(): string { return process.env.HOME || homedir(); }
|
||||
function pendingHostWorkDir(): string { return join(resolveHome(), '.gbrain', 'migrations'); }
|
||||
// gbrainPath() honors GBRAIN_HOME at call time (not module-load) and routes
|
||||
// through the centralized config dir, so the prior resolveHome()/HOME-env
|
||||
// trick is no longer needed.
|
||||
function pendingHostWorkDir(): string { return gbrainPath('migrations'); }
|
||||
function pendingHostWorkPath(): string { return join(pendingHostWorkDir(), 'pending-host-work.jsonl'); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* AgentRunner — pluggable contract for invoking external agents (openclaw,
|
||||
* hermes, codex, …) inside the claw-test harness. v1 ships a single
|
||||
* implementation (openclaw); the interface stays narrow and concrete so
|
||||
* adding a second runner in v1.1 is a ~50-line file.
|
||||
*
|
||||
* The harness wraps spawn/timeout/transcript-capture; runners only have to
|
||||
* answer "where's your binary?" and "how do I invoke it with this prompt?".
|
||||
*
|
||||
* ┌────────────────────┐
|
||||
* │ harness │
|
||||
* │ ─ resolve(name) ─▶│ registry → AgentRunner instance
|
||||
* │ ─ detect() ─▶│ runner reports binary path/availability
|
||||
* │ ─ invoke(...) ─▶│ runner spawns child, harness captures via TranscriptSink
|
||||
* └────────────────────┘
|
||||
*/
|
||||
|
||||
export interface AgentRunner {
|
||||
/** Stable agent name used by --agent flag and friction `agent` field. */
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* Locate the agent binary and confirm it is executable. Pure check; never
|
||||
* spawns. `binPath` is always an absolute path on success. `available=false`
|
||||
* with a `reason` if not found / not executable.
|
||||
*/
|
||||
detect(): Promise<DetectResult>;
|
||||
|
||||
/**
|
||||
* 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<InvokeResult>;
|
||||
|
||||
/** Optional per-agent post-install hook (e.g., routing-file fixup). */
|
||||
postInstallHook?(opts: { workspaceDir: string }): Promise<void>;
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
/** 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<void>;
|
||||
}
|
||||
|
||||
export interface TranscriptEvent {
|
||||
ts: number;
|
||||
channel: 'stdin' | 'stdout' | 'stderr';
|
||||
bytes: Buffer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AgentRunnerFactory = () => AgentRunner;
|
||||
|
||||
const registry = new Map<string, AgentRunnerFactory>();
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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 <subcommand>` 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<string, ProgressEvent[]> {
|
||||
const m = new Map<string, ProgressEvent[]>();
|
||||
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));
|
||||
}
|
||||
@@ -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 <agent-name> --message "<brief>"
|
||||
*
|
||||
* NOT `openclaw run --prompt-file BRIEF.md` (that flag does not exist —
|
||||
* Codex pass 2 of the eng review caught the speculative shape).
|
||||
*
|
||||
* 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<DetectResult> {
|
||||
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<InvokeResult> {
|
||||
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<string, string> = {};
|
||||
for (const key of ENV_ALLOWLIST) {
|
||||
const v = process.env[key];
|
||||
if (typeof v === 'string') baseEnv[key] = v;
|
||||
}
|
||||
const env: Record<string, string> = { ...baseEnv, ...opts.env };
|
||||
|
||||
const 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;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* scenario.json loader for the claw-test harness.
|
||||
*
|
||||
* test/fixtures/claw-test-scenarios/<name>/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<string, unknown>;
|
||||
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');
|
||||
}
|
||||
@@ -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 <dbPath>` — 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<void> {
|
||||
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<void> {
|
||||
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 };
|
||||
@@ -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 `<run-tempdir>/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<void> | null = null;
|
||||
|
||||
function awaitDrain(): Promise<void> {
|
||||
if (drainPromise) return drainPromise;
|
||||
drainPromise = new Promise<void>(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<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.end((err?: Error | null) => err ? reject(err) : resolve());
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// spawnWithCapture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SpawnOpts {
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
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<SpawnResult> {
|
||||
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<typeof setTimeout> | 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
+28
-5
@@ -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).
|
||||
|
||||
+5
-3
@@ -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<void>;
|
||||
@@ -256,7 +258,7 @@ async function acquirePostgresLock(engine: BrainEngine): Promise<LockHandle | nu
|
||||
* The file contains `{pid}\n{iso-timestamp}`. Staleness = mtime older
|
||||
* than LOCK_TTL_MS OR the PID is no longer alive on this host.
|
||||
*/
|
||||
function acquireFileLock(lockPath = LOCK_FILE_PATH_DEFAULT): LockHandle | null {
|
||||
function acquireFileLock(lockPath = getLockFilePathDefault()): LockHandle | null {
|
||||
mkdirSync(join(lockPath, '..'), { recursive: true });
|
||||
const pid = process.pid;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { appendFileSync, readFileSync, existsSync, mkdirSync, writeFileSync, renameSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { gbrainPath } from './config.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -45,7 +45,8 @@ export interface TestCase {
|
||||
source: 'fail-improve-loop';
|
||||
}
|
||||
|
||||
const LOG_DIR = join(homedir(), '.gbrain', 'fail-improve');
|
||||
// Lazy: GBRAIN_HOME may be set after module load, so resolve at call time.
|
||||
const getLogDir = () => 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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/`.
|
||||
* - `<run-id>.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() │ ──────────────────▶ │ <runId>.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<string, number> };
|
||||
}
|
||||
|
||||
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<string, number> };
|
||||
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<string, FrictionEntry[]>();
|
||||
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<string, FrictionEntry[]>();
|
||||
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('<CWD>');
|
||||
if (home && home.length > 1) out = out.split(home).join('<HOME>');
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
...entry,
|
||||
message: transform(entry.message) ?? entry.message,
|
||||
hint: transform(entry.hint),
|
||||
cwd: '<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;
|
||||
}
|
||||
}
|
||||
@@ -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<BackpressureAuditEvent, 'ts' | 'decision'>): void {
|
||||
|
||||
@@ -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<ShellAuditEvent, 'ts'>): void {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
@@ -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<DetectResult> { return this.detected; }
|
||||
async invoke(_opts: InvokeOpts): Promise<InvokeResult> {
|
||||
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<InvokeResult> { 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');
|
||||
});
|
||||
});
|
||||
@@ -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<DetectResult> { return { available: true, binPath: '/usr/bin/fake' }; }
|
||||
async invoke(_opts: InvokeOpts): Promise<InvokeResult> {
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('<HOME>');
|
||||
// --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);
|
||||
});
|
||||
@@ -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 <which-step> --message "<what-happened>" [--hint "<what-could-be-better>"]
|
||||
```
|
||||
|
||||
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 <step> --message "<what-was-nice>"
|
||||
```
|
||||
|
||||
We want to know what didn't work, not just whether commands exited zero. Be specific.
|
||||
+15
@@ -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.
|
||||
+10
@@ -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).
|
||||
+13
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"min_pages_after_import": 3,
|
||||
"min_query_results": 1,
|
||||
"min_links_after_extract": 0,
|
||||
"doctor_status": "ok"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 <which-step> --message "<what-happened>" [--hint "<what-could-be-better>"]
|
||||
```
|
||||
|
||||
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.
|
||||
+8
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"min_pages_after_migration": 1,
|
||||
"doctor_status": "ok"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 <fixture-brain>` 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 `<tempdir>/.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.
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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=<tmp> 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('<HOME>');
|
||||
expect(md).toContain('<CWD>');
|
||||
});
|
||||
|
||||
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('<HOME>');
|
||||
expect(r.cwd).toBe('<CWD>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Hermeticity test: every site that writes under `~/.gbrain` must honor
|
||||
* `GBRAIN_HOME=<tmp>` and write under `<tmp>/.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 `<override>/.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_HOME>/.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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 */ }
|
||||
});
|
||||
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user