Research (2026-08-24) confirmed no widely-adopted cross-framework protocol exists for streaming test results. The streaming formats are ecosystem-bound (TAP, subunit, go test -json, Bazel BEP); the modern cross-framework standard, CTRF, is batch-only. Every live-test product invents its own NDJSON ingestion — and Bob just did too: bob-check (worktree shim → .bob/check-events.ndjson → runner tail → check WS events → cockpit phase bars) shipped in the cockpit V2 cut.
This plan promotes that proven pipeline into a shared standard — fg-check — that covers Rust, Python, Ruby, Go, JS/TS and more by adapting each framework's native live output, and bakes it into ForgeGraph best practices: conventional target names, sample package.json scripts / justfiles / Makefiles, a forge-ci.toml [check] section, and CI pages that render live per-phase bars and per-test results. Bob's cockpit and ForgeGraph's CI UI become two consumers of one contract.
| Question | Decision | Why |
|---|---|---|
| Wire format | Own NDJSON envelope, v2 of bob-check's events | File-append + tail already proven in prod; JSON end-to-end (hub WS, event bus, React UIs); binary subunit would need bespoke encoders anyway. |
| "subunit with TAP fallback"? | Rejected as wire format; adopted as design | subunit's timestamps/routing/attachments shape the envelope; TAP demoted to one adapter rung, not the primary. |
| Test object shape | CTRF's test schema inside our envelope | End-of-run fold becomes a reduce into a valid CTRF report; off-the-shelf reporters/tooling; mergeable, comparable, storable. |
| Fallback ladder | native JSON → TeamCity service messages → TAP → regex scrape | TeamCity messages are the de-facto cross-framework streaming stdout format (PHPUnit built-in, pytest/mocha/RSpec libs, every IntelliJ runner); ~50-line parser. |
| Transport | Append to .fg/check-events.ndjson; consumer tails | Identical to bob-check today; works for any agent or CI runner; watcher failure degrades to "no events", never a failed run. |
| Parallelism | Required stream discriminator per event | subunit's route-code lesson: nextest per-binary, go per-package, sharded vitest interleave; counts stay coherent per-source. |
| Repo contract | Conventional targets typecheck · lint · test · e2e · build in npm scripts / justfile / Makefile; no new manifest section — fg-check wraps the existing forge-ci.toml test.command / test.integration / build.command | bob-check already detects package.json scripts; the manifest schema already models unit vs integration — reuse it instead of inventing [check]. |
| Configurations | Unit tests, e2e/integration, and each pre-check (lint, typecheck) stream as separate configurations | Distinct phase bars and verdicts per configuration; Bob identifies pre-checks as streaming checks explicitly instead of inferring from output. |
| CTRF storage | Full CTRF report uploaded as a build artifact; ci-report carries an inline summary (counts, duration, top failures) | Keeps the report row small and the API payload bounded; full per-test detail fetched on demand by the CI page. |
| Vitest / Jest | Build first-class reporter injection (in-process NDJSON reporters), not TAP/scrape | These are the fleet's dominant frameworks; exact per-test events there pay for themselves immediately. |
| JVM adapter | Deferred — no JVM apps in the fleet; envelope reserves nothing JVM-specific | Open Test Reporting adapter can be added later without schema changes. |
{"v":2,"phase":"test","event":"run_started","framework":"pytest","stream":"api-tests","command":"pytest -q","at":"…"}
{"v":2,"phase":"test","event":"test_finished","stream":"api-tests",
"test":{"name":"test_streak_gaps","suite":"tests/lib/test_streak.py","status":"failed",
"duration":812,"message":"expected 3, got 4","trace":"…"},
"counts":{"passed":41,"failed":1,"total":58},"at":"…"}
{"v":2,"phase":"test","event":"run_finished","stream":"api-tests","status":"failed",
"counts":{"passed":57,"failed":1,"skipped":0,"total":58},"durationMs":8120,"at":"…"}
phase: typecheck | lint | test | e2e | build — each a separately-streamed configuration. Unit tests and end-to-end/integration tests are distinct configurations (mapping to forge-ci.toml's existing test.command and test.integration), and lint/typecheck pre-checks are their own configurations so Bob can identify them as streaming checks rather than test noise. Cockpit/CI phase bars render one segment per configuration.event: run_started | test_started | test_finished | output | run_finished | skipped. v1 events (no v field) remain parseable — consumers treat them as run_* only.test: CTRF test object (name, status, duration, suite, message, trace, flaky, retries).stream: source discriminator (package, test binary, shard). Required whenever a phase has >1 concurrent producer.counts: running totals per stream; consumers sum across streams.| Runtime | First choice | Notes | Fallback |
|---|---|---|---|
| Go | go test -json | Stable; Package field → stream | — |
| Rust | cargo nextest --message-format libtest-json-plus (NEXTEST_EXPERIMENTAL_LIBTEST_JSON=1) | Immediate per-test results; nextest subobject disambiguates binaries. Experimental — pin nextest in toolchain evidence. | cargo test scrape; cargo build --message-format=json (stable) for build phase |
| Python | pytest --report-log=FILE (pytest-dev's reportlog) | JSON-lines, flushed per line by design; tail the file | teamcity-messages (unittest); pytest-tap |
| Ruby | RSpec formatter: --require .fg/fg_formatter.rb --format FgFormatter --out .fg/rspec-events.ndjson | One .rb file we ship into the worktree — no gem install; stable formatter API | minitest-reporters; rspec_tap_formatter |
| JS/TS | node:test --test-reporter=tap; mocha json-stream; vitest/jest reporters | NDJSON or TAP, both stream | current bob-check scrape (vitest/jest human output) |
| JVM deferred | JUnit Open Test Reporting event stream (file or …open.xml.socket) | Not built now (no JVM apps in fleet); adapter slot reserved, no envelope changes needed later | JUnit XML at end (Gradle/Maven) |
| PHP | PHPUnit --teamcity (built-in) | Streams service messages | — |
| .NET | TeamCity VSTest adapter | Service messages | TRX at end |
| Shell | bats (native TAP) | — | — |
"confidence":"scraped"The contract: a repo exposes some subset of typecheck · lint · test · build as runnable targets. fg-check detects them in priority order — package.json scripts → justfile → Makefile → forge-ci.toml [check] explicit override — and picks the adapter from lockfiles/manifests (Cargo.toml, pyproject.toml, Gemfile, go.mod…). Samples ship in docs and in the fg-onboard-repo skill.
{
"scripts": {
"typecheck": "tsc --noEmit",
"lint": "oxlint .",
"test": "vitest run",
"build": "next build"
}
}
typecheck:
cargo check --all-targets
lint:
cargo clippy -- -D warnings
test:
cargo nextest run
build:
cargo build --release
.PHONY: typecheck lint test build
typecheck:
mypy src
lint:
ruff check .
test:
pytest -q
build:
python -m build
version = 1
[build]
command = "pnpm build"
[test] # unit tests → configuration "test"
command = "pnpm vitest run --project unit"
[test.integration] # e2e → separate configuration "e2e"
command = "pnpm playwright test"
stage = "beta"
# lint/typecheck configurations come from conventional
# script targets — detected, streamed, never invented
skipped, not failed (bob-check semantics). For just/make, targets are discovered via just --summary / parsing .PHONY + top-level rules; anything unparseable falls back to attempting the conventional names. Where a forge-ci.toml exists, its test.command / test.integration / build.command win over detection — one source of truth, already deployed fleet-wide.flowchart LR
subgraph Repo["repo conventions"]
C1["npm scripts / justfile / Makefile<br/>typecheck · lint · test · build"]
C2["forge-ci.toml [check] overrides"]
end
subgraph Wrapper["fg-check wrapper (per run)"]
D[detect runtime + targets] --> R[run command<br/>tee stdout to human log]
R --> A{adapter}
A -->|go test -json| N[normalize]
A -->|nextest libtest-json| N
A -->|pytest --report-log tail| N
A -->|rspec formatter --out tail| N
A -->|mocha json-stream / node:test tap| N
A -->|teamcity msgs| N
A -->|TAP| N
A -->|scrape fallback| N
end
N --> E[".fg/check-events.ndjson<br/>NDJSON, CTRF-shaped tests"]
E -->|tail| AG[node agent / bob runner]
AG -->|WS events| HUB[(hub ws.forgegraf.com<br/>bob ws-gateway)]
HUB --> UI["CI run page · cockpit tiles<br/>live phase bars + counts"]
E -->|fold at end| CT["CTRF report"]
CT -->|ci-report tests field| DB[(builds row<br/>evidence)]
C1 --> D
C2 --> D
classDef new stroke-dasharray: 5 5,stroke:#e6a23c,color:#e6a23c;
class D,R,A,N,E,CT new;
Two injection modes cover every adapter: parse-stdout (go, nextest, TAP, TeamCity, mocha json-stream — tee and parse, human log stays pristine for logHead/logTail evidence) and side-channel file/socket (pytest --report-log, RSpec --out, JUnit socket — tail it, exactly like bob-check's events file today).
Mockups A, C, D, and E are live simulations, not stills: each plays a looped run driven by pure CSS keyframes (postplan executes no scripts), showing exactly what a viewer sees as v2 events stream in — counts ticking, bars creeping, failures popping the moment they happen. Under prefers-reduced-motion every mockup degrades to its final static frame, matching the cockpit's own motion-level philosophy.
counts events arrive, the test bar creeps then flips red on the failure, the live line follows the current file, then the e2e/build configuration takes over. Everything above CHECKS exists today; the strip, Tests card, and live region are new, fed by the hub WS. Reduced-motion viewers see the final state.| Test | Suite | Stream | Time | |
|---|---|---|---|---|
| FAIL | computeStreak › gaps | src/lib/streak.test.ts | api-tests | 812ms |
AssertionError: expected 3, got 4 · streak.test.ts:42 · passed locally in agent worktree ⚠ drift | ||||
| PASS | computeStreak › empty history | src/lib/streak.test.ts | api-tests | 3ms |
| PASS | webhook signature verify | src/hooks/verify.test.ts | web-tests | 121ms |
| SKIP | hyperdrive round-trip | src/db/live.test.ts | api-tests | — |
check event type — and the contrast is the animation itself: the v1 tile sits frozen until the phase ends, while the v2 tile ticks per-stream counts, cycles the running file, and pops the failure in the moment it happens, pulsing amber while streaming. Wall mode contract: glanceable at 3m. Ops mode expands the phase bar into the full per-test table (same component as mockup B).Bob is not a future consumer — it's the migration source. bob-check v1 shipped with cockpit V2: shim in every agent worktree, worktree-watch.ts tails .bob/check-events.ndjson, the ws-gateway fans check session events, tiles render per-phase bars. fg-check replaces the shim in place; everything downstream keeps working, then gets richer:
./.bob/bin/bob-check (name kept so session prompts and SKILL.md stay valid). v2 events flow through the existing check SessionEventType untouched — the gateway forwards event types it doesn't inspect.test 57/58 ✗ from scraped counts. With CTRF-shaped test_finished events the tile can show the failing test names live, and ops mode can expand a phase bar into the per-test list with messages — no new transport, just richer payloads on events the cockpit already subscribes to.check included) for tablet session views — per the cockpit plan's rule: shared contracts, never sibling endpoints.check-events-demo) exposed that "zero-config onboarding" required three hand-set pieces of state, each now a real feature: #447 Forgejo webhook provisioning (repo create Step 2.6 + backfill route — new repos pushed into the void), #441 host_node_id adoption from the app's stages, #439 ci_provider auto-enroll when forge-ci.toml exists at head. Also landed: #435 pipeline matrix per-test dots + collapsed PREVIEWS panel (live on forgegraf.com), #443 CTRF artifact fetch (forge ci checks --ctrf + node-disk route), #444 release-agent fixes (HOME under set -u; tag-token fallback with loud degradation — secret since re-minted properly). Bob PR #43 (drift markers) awaits Bob's own review loop. CLI 0.3.5 dispatch chained to #443's merge. The end-to-end demo (push → webhook → adopt → enroll → build → stream → ci checks) is armed as an autonomous chain behind #447.agent/internal/fgcheck embeds the built CLI, .fg/bin prepended via sandbox.DefaultCIPath — command = "fg-check test" needs zero installs), 0.1.4 reporters (per-suite stream so multi-suite counts sum; published), forge ci checks + token route, CTRF artifact beside the build log (Go FoldToCtrf), monorepo CI dogfooding (reporter in apps/web + fold-to-job-summary step), docs/check-events.md + onboarding step, the ChecksSummary story, and Bob's cockpit drift markers (cockpit-v2). Remaining after both merge: agent release (branch release/agent-0.1.56 already cut) + first fleet repo on fg-check test..fg/check-events.ndjson, posts ci-progress every 2 s, ships CIReport.tests. Server: ci-report accepts tests → builds.metadata.tests; ci.progress bus event on /api/fg/events; ci/gate + runs/:id/failures expose tests. Web: CI run page Checks section (per-configuration bars, exact/scraped counts, failing tests) polling /api/ci/:id/checks. Bob: bob-check → fg-check launcher, runner persists end-of-run rollups, cockpit reads FG summaries + bridges ci.* SSE. Remaining: agent release so fleet runners emit; Forgejo-runner PATH prep (fg-check on runner PATH); best-practices docs/skill; "local vs CI" diff marker (stretch).| Task | Files | Verification | Status |
|---|---|---|---|
| Write the v2 envelope spec (schema + JSON Schema for the event union; CTRF test object embedded) | new packages/check-events/ in ForgeGraph monorepo (spec + TS types + Go types) | Schema validates the example corpus; v1 bob-check lines parse as legacy | Done |
Extract bob-check into fg-check: zero-dep CLI, detection (package.json → justfile → Makefile → manifest), tee-stdout runner, events file append | packages/check-events/cli/ (published binary), vendored shim build | Smoke: run in a JS repo, events match v1 behavior + v2 fields | Done |
Rung 2–4 adapters: TeamCity service-message parser, TAP parser, scrape (port existing regexes, tag confidence) | same package | Fixture corpus per format (recorded real outputs) → golden event streams | Done |
| Task | Files | Verification | Status |
|---|---|---|---|
Go: go test -json adapter (Package → stream) | packages/check-events/cli/adapters/go.* | Golden stream from ForgeGraph agent's own test suite | Done |
| Rust: nextest libtest-json-plus adapter + env injection; pin nextest version into toolchain evidence | adapters/rust | Fixture from a sample workspace; parallel binaries → distinct streams | Done |
Python: inject --report-log, tail the jsonl; map reportlog $report_type events | adapters/python | pytest suite fixture incl. skips/xfails | Done |
Ruby: ship fg_formatter.rb (RSpec formatter → NDJSON via --out); minitest reporter fallback | adapters/ruby | RSpec fixture; formatter works without Gemfile changes | Done |
JS: first-class vitest + jest reporters — small in-process NDJSON reporter packages injected via CLI flags (--reporter); mocha json-stream / node:test TAP for the rest | adapters/js, packages/check-events/reporters/vitest + /jest | Per-test golden streams from the monorepo's own suites; scrape rung becomes unreachable for vitest/jest repos | Done |
| Task | Files | Verification | Status |
|---|---|---|---|
Manifest CI runner invokes fg-check (when targets detected) or wraps test.command; tails events into hub session events | agent/internal/manifestci/runner.go, new tail (mirror bob's worktree-watch drain) | Runner test: sentinel repo emits events end-to-end | Done |
ci-report gains optional tests field: inline summary (per-configuration counts, duration, top failures) on the builds row; full CTRF report uploaded as a build artifact, fetched on demand by the CI page | apps/web/src/app/api/agent/ci-report/route.ts, artifact upload path, schema migration | Route test; mock-module (not row-insert) per deploy-route mock convention; artifact round-trips the CTRF JSON Schema | Done |
| CI run page: live phase bars (WS) + per-test results table (stored CTRF) alongside toolchain evidence | apps/web/src/app/ci/[runId]/ | Page renders live during a real run; failed tests listed with messages | Done |
| Forgejo-runner jobs: fg-check available on runner PATH via toolchain prep (mise/nix strategies) | agent/internal/miseenv/ hook | Workflow job in monorepo emits events | Done |
| Task | Files | Verification | Status |
|---|---|---|---|
Swap bob-check shim to fg-check build; keep shim name/path; cockpit tiles read v2 test payloads (failing names live, ops-mode per-test expansion) | bob: apps/ooda-runner/src/bob-check-cli.mjs → vendored fg-check; components/cockpit/* | Cockpit renders richer tiles on a live session; v1 sessions unaffected | In review |
| "Local vs CI" diff on the PR pipeline strip (same test failed/passed in both runs) | bob services/cockpit/pipeline.ts | Synthetic divergence fixture renders the drift marker | Stretch |
| Best-practices docs + samples (npm scripts / justfile / Makefile / forge-ci.toml [check]) and fg-onboard-repo skill update | docs/, skills | Onboarding a fresh Rust + Python repo yields live bars with zero extra config | Done |
Every phase above is built and running. The parts a plan cannot predict are below — each one cost a real outage or a real near-miss, so they are recorded as findings rather than as tidy checkmarks.
The documented config was a fork bomb. The plan told repos to write
[test] command = "fg-check test" in forge-ci.toml, and separately
said manifest commands win over detection. Following both is a loop: fg-check read the
manifest, found itself as the test target, and spawned itself without end. It
swap-killed a CI node twice — the first time read as a capacity problem, which is why it
took two incidents to see.
Fixed in check-events 0.1.7 / agent 0.1.59: a manifest command naming
fg-check is a delegation, so the phase resolves through package.json / justfile /
Makefile instead; and FG_CHECK_ACTIVE_PHASES makes a nested fg-check refuse a
phase its parent already owns. The docs now say this explicitly.
Containment, not just a fix. The sandbox had no resource ceiling, so any
runaway build could take its host down. Agent 0.1.60 wraps every CI command in a transient
systemd-run scope: MemoryMax at half of RAM, MemorySwapMax=0
so the kernel OOM-kills inside the cgroup instead of the host thrashing into swap-death, and
TasksMax=512 so a fork bomb hits a wall. Verified in the runner's journal —
Started run-<id>.scope wrapping both the build and the test command — not
merely assumed from the version number.
A thrashing node is not a silent node. The self-healing reaper only reset
nodes silent past 15 minutes. A fork-bombed box still squeezes out a heartbeat every few
minutes, so it never looked dead and had to be reset by hand — twice. The reaper now also
judges cadence: beating slower than every 5 minutes opens a
node_cadence_collapsed alert, and a streak past 15 minutes makes the node a reset
candidate. The firing alert row is itself the "degraded since" timestamp, so this needed no
schema change.
… test running mid-run, then ✓ test passed 3/3.forge ci checks <run> --ctrf fetched the
folded report off node disk — 3 tests, per-test names and durations.1,246 package.json files scanned across the dev tree: the only consumers of
@forgegraph/check-events are ForgeGraph itself (via workspace:*, so
always the fixed source) and Bob (root bob-check plus ooda-runner,
both moved to 0.1.7). Bob's lockfile had pinned 0.1.4 and 0.1.6 exactly — the
vulnerable versions — and now carries a single deduplicated 0.1.7. Nothing vulnerable was ever
installed on a node, so this was caught before it shipped.
| Risk | Mitigation |
|---|---|
| nextest libtest-json is experimental and may change shape | Pin nextest version in toolchain evidence; adapter tolerant of unknown fields; fixture corpus catches drift in CI |
| Interleaved stdout from parallel runners corrupts rung-2/3 parsing | stream discriminator where the format provides one; TAP explicitly documented as unsafe under interleaving → prefer file side-channels |
| Event volume on huge suites (10k+ tests) floods hub WS | Wrapper coalesces test_finished into batched count updates above a threshold; full per-test detail only in the folded CTRF report |
| Scrape rung silently wrong counts | "confidence":"scraped" flag; UI renders scraped counts dimmed/approximate |
| Two checkouts of the contract drift (bob vendored copy vs ForgeGraph package) | Single source: fg-check built in ForgeGraph, vendored into bob by version; envelope spec has a JSON Schema both CIs validate fixtures against |
ci-report. Decided[check] section; fg-check wraps the existing test.command / test.integration / build.command. Unit and e2e/integration run as separate configurations, and lint/typecheck pre-checks are their own configurations so Bob identifies them as streaming checks. Decided