Merge pull request #880 from fabro-sh/petri-integration

Run Fabro workflows on Petri: the cutover (F1 to F4)
This commit is contained in:
Bryan Helmkamp 2026-09-20 14:27:53 -04:00 • committed by GitHub
commit 6d78cc4f47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
727 changed files with 80848 additions and 174872 deletions

View file

@ -7,3 +7,6 @@ t = "test -- --format terse"
# pays ~900ms of system-proxy lookup overhead per process, which pushes tests
# past the 3s nextest kill threshold under parallel load.
FABRO_HTTP_PROXY_POLICY = "disabled"
[net]
git-fetch-with-cli = true

View file

@ -15,6 +15,12 @@ leak-timeout = "500ms"
filter = "package(fabro-workflow)"
slow-timeout = { period = "2s", terminate-after = 3 }
# fabro-petri's adapter tests run whole workflows on the host sandbox
# through the sandbox-driver plugin, and one of them calls the twin.
[[profile.default.overrides]]
filter = "package(fabro-petri)"
slow-timeout = { period = "5s", terminate-after = 4 }
# Real descendant regressions include bounded reaping and process probes.
# Leave room for their own watchdogs to run fail-safe fixture cleanup.
[[profile.default.overrides]]
@ -59,3 +65,7 @@ leak-timeout = "2s"
filter = "package(fabro-workflow)"
slow-timeout = { period = "30s", terminate-after = 4 }
[[profile.ci.overrides]]
filter = "package(fabro-petri)"
slow-timeout = { period = "30s", terminate-after = 4 }

View file

@ -91,6 +91,26 @@ jobs:
fi
- run: cargo +nightly-2026-04-14 clippy --locked --workspace --all-targets -- -D warnings
rustdoc:
name: Rustdoc
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.97.1
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
with:
cache-on-failure: true
# Broken intra-doc links and the other rustdoc lints fail the build.
- run: cargo doc --locked --workspace --no-deps
env:
RUSTDOCFLAGS: -D warnings
generated-docs:
name: Generated Docs
runs-on: ubuntu-24.04-x86-32-cores
@ -124,6 +144,43 @@ jobs:
with:
cache-on-failure: true
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
# Every Petri run takes its scope through a sandbox-driver plugin
# executable that Petri finds on PATH: `sandbox-driver-host` for the
# `local` provider, `sandbox-driver-docker` for `docker`. Installed
# at the rev the workspace pins, so the plugins and the in-process
# driver are one build; a from-source build, so the two executables
# are cached by OS and rev and only rebuilt when the pin moves.
- name: Read the sandbox-driver rev the workspace pins
id: sandbox-driver
run: |
rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)"
test -n "$rev"
echo "rev=$rev" >> "$GITHUB_OUTPUT"
- name: Restore the sandbox-driver plugin executables
id: sandbox-driver-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cargo/bin/sandbox-driver-host
~/.cargo/bin/sandbox-driver-docker
key: sandbox-driver-plugins-${{ runner.os }}-${{ steps.sandbox-driver.outputs.rev }}
- name: Install the sandbox-driver plugin executables
if: steps.sandbox-driver-cache.outputs.cache-hit != 'true'
run: cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "${{ steps.sandbox-driver.outputs.rev }}" sandbox-driver-host sandbox-driver-docker
# The images the suite's Docker tests run. The plugin pulls a missing
# image on first use, but a 1 GiB pull inside a run's wait is a flake,
# so pull them here, where a registry problem reads as one. Most tests
# leave the image to Petri, whose Docker scope runs on its default
# runner image at the pin the checked-out Petri names; the
# fabro-server catalog scenarios name CATALOG_IMAGE in
# lib/apps/fabro-server/tests/it/scenario/petri.rs.
- name: Pull the images the Docker tests run
run: |
backend="$(dirname "$(cargo metadata --locked --format-version 1 | jq -r '.packages[] | select(.name == "petri-executor-sandbox") | .manifest_path')")/src/backend.rs"
pin="$(sed -n 's/^const RUNNER_PIN: &str = "\([0-9a-f]*\)";$/\1/p' "$backend")"
test -n "$pin"
docker pull "ghcr.io/lithoscomputer/ubuntu-24.04:slim-$pin"
docker pull ghcr.io/lithoscomputer/ubuntu-22.04:slim
- run: cargo nextest run --locked --workspace --status-level slow --profile ci
# The twin-mode ignored suites this job once ran belonged to fabro-agent,
# which pebble's coding agent replaced; the agent loop's workflow-level
@ -131,14 +188,14 @@ jobs:
# Re-add a `--run-ignored only -E 'package(...)'` step here when a
# package has ignored suites that are fully green in twin mode.
sandbox-plugins:
name: Sandbox plugins (stdio)
sandbox-docker:
name: Sandbox providers (Docker)
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
env:
# The plugin scenarios skip when an executable or daemon is missing;
# in CI a skip is a failure.
# The Docker scenarios skip when the executable, the daemon or the
# image is missing; in CI a skip is a failure.
FABRO_REQUIRE_SANDBOX_PLUGINS: "1"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -151,25 +208,45 @@ jobs:
with:
cache-on-failure: true
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
# The image the Docker scenarios' environment names, and Petri's
# default runner image, which the fabro-petri Docker test runs.
- run: docker pull buildpack-deps:noble
# The driver's own Host and Docker executables, installed at the rev the
# workspace pins so the plugins and the in-process providers are one
# build; the CLI scenarios find them on PATH and launch them over stdio.
- name: Install the sandbox-driver plugin executables
- name: Pull Petri's default runner image
run: |
backend="$(dirname "$(cargo metadata --locked --format-version 1 | jq -r '.packages[] | select(.name == "petri-executor-sandbox") | .manifest_path')")/src/backend.rs"
pin="$(sed -n 's/^const RUNNER_PIN: &str = "\([0-9a-f]*\)";$/\1/p' "$backend")"
test -n "$pin"
docker pull "ghcr.io/lithoscomputer/ubuntu-24.04:slim-$pin"
# Every Petri run takes its scope through a sandbox-driver plugin
# executable that Petri finds on PATH: `sandbox-driver-host` for the
# `local` provider, `sandbox-driver-docker` for `docker`. Installed
# at the rev the workspace pins, so the plugins and the in-process
# driver are one build; a from-source build, so the two executables
# are cached by OS and rev and only rebuilt when the pin moves.
- name: Read the sandbox-driver rev the workspace pins
id: sandbox-driver
run: |
rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)"
test -n "$rev"
cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "$rev" sandbox-driver-host sandbox-driver-docker
# Host and Docker served as plugins through the workflow scenarios. The
# scenarios are e2e tests (ignored by default); the key-free ones run
# here, the LLM-backed ones self-skip without credentials.
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-cli --test it -E 'test(/host_plugin_|docker_plugin_/)'
# The stdio plugin proof (not ignored: it skips without the executable,
# which the environment above forbids) and the driver-backed Docker
# integration tests.
- run: cargo nextest run --locked --profile ci --status-level slow -p fabro-sandbox --test plugin_provider
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-sandbox --test docker_streaming
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-workflow --test it -E 'test(asset_collection_docker_sandbox)'
echo "rev=$rev" >> "$GITHUB_OUTPUT"
- name: Restore the sandbox-driver plugin executables
id: sandbox-driver-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cargo/bin/sandbox-driver-host
~/.cargo/bin/sandbox-driver-docker
key: sandbox-driver-plugins-${{ runner.os }}-${{ steps.sandbox-driver.outputs.rev }}
- name: Install the sandbox-driver plugin executables
if: steps.sandbox-driver-cache.outputs.cache-hit != 'true'
run: cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "${{ steps.sandbox-driver.outputs.rev }}" sandbox-driver-host sandbox-driver-docker
# The workflow scenarios on the Docker provider. The scenarios are e2e
# tests (ignored by default); the key-free ones run here, the
# LLM-backed ones self-skip without credentials.
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-cli --test it -E 'test(/::docker_/)'
# The Petri runs (not ignored: they skip without the host plugin, which
# the environment above forbids).
- run: cargo nextest run --locked --profile ci --status-level slow -p fabro-petri
test-macos:
name: Test (macOS)
@ -188,4 +265,28 @@ jobs:
with:
cache-on-failure: true
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
# Every Petri run takes its scope through a sandbox-driver plugin
# executable that Petri finds on PATH: `sandbox-driver-host` for the
# `local` provider, `sandbox-driver-docker` for `docker`. Installed
# at the rev the workspace pins, so the plugins and the in-process
# driver are one build; a from-source build, so the two executables
# are cached by OS and rev and only rebuilt when the pin moves.
- name: Read the sandbox-driver rev the workspace pins
id: sandbox-driver
run: |
rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)"
test -n "$rev"
echo "rev=$rev" >> "$GITHUB_OUTPUT"
- name: Restore the sandbox-driver plugin executables
id: sandbox-driver-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
~/.cargo/bin/sandbox-driver-host
~/.cargo/bin/sandbox-driver-docker
key: sandbox-driver-plugins-${{ runner.os }}-${{ steps.sandbox-driver.outputs.rev }}
- name: Install the sandbox-driver plugin executables
if: steps.sandbox-driver-cache.outputs.cache-hit != 'true'
run: cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "${{ steps.sandbox-driver.outputs.rev }}" sandbox-driver-host sandbox-driver-docker
# No Docker daemon on the macOS runner: the Docker tests skip there.
- run: cargo nextest run --locked --workspace --status-level slow --profile ci

148
AGENTS.md
View file

@ -30,21 +30,12 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
### Docker sandbox provider
- Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities).
- The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs.
- Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. For an exact commit, the submitted branch names the working branch and the syntactically valid SHA is requested directly. No layer proves branch/SHA ancestry: a fetchable commit is checked out, an unavailable commit fails setup, and branch HEAD is never substituted.
- The sandbox layer also accepts an optional exact commit for future admitted
runs. An exact commit always requires a non-empty branch. The sandbox driver
performs the pin the same way on every provider: it initializes an empty
repository, fetches the SHA directly at the requested depth, and attaches
the admitted branch to it, so the workspace reports the admitted branch
name. Daytona's native toolbox clone serves plain branch clones only; its
commit pin checks the branch head out first, so the driver does not use
it. A successful clone has the pin checked out; the driver's
conformance suite verifies that on every provider, and fabro does not
re-verify HEAD. Never fall back to a newer branch HEAD, and do not wire
this capability directly from legacy `GitContext.sha`. The sandbox layer
does not verify that the commit is reachable from the branch; admission
owns that check. Current production callers remain branch-only until the
RunIntent admission cutover supplies a validated branch/SHA pair.
- Fabro no longer clones a repository into a sandbox: the engine prepares
every run's checkout. `CloneRequest` still travels beside the sandbox spec
so the run record names the origin and branch; fabro validates it (a pin
needs a branch, a non-GitHub origin needs `skip_clone`) and refuses a
request that asks for a clone. Preflight and `fabro exec` initialize
sandboxes with `CloneRequest::none()`, which creates an empty workspace.
### Release automation
- `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.
@ -119,17 +110,132 @@ Before merging changes that add or move shared test helpers, verify:
## Architecture
Fabro is an AI-powered workflow orchestration platform. Workflows are defined as Graphviz graphs, where each node is a stage (agent, prompt, command, conditional, human, parallel, etc.) executed by the workflow engine.
Fabro is an AI-powered workflow orchestration platform. Workflows are defined as Graphviz graphs, where each node is a stage (agent, prompt, command, conditional, human, parallel, etc.). Petri, the workflow engine, admits a workflow at create and executes every run. Two crates import it: `fabro-petri` (the engine adapters) and `fabro-dot` (Petri's DOT parser, for reading a graph's shape and file references).
### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`)
- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`
- **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, and human-in-the-loop interactions
- **fabro-sandbox** — Local, Docker, and Daytona sandbox providers. `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
- **fabro-dot** — The workflow graph as written, read through Petri's DOT parser: its name, goal, node and edge counts, and the files it references (`import`, `stack.child_workflow`, `@file` prompts, the goal). The bundler and the workflow-version store walk references through it; `fabro-graphviz` re-emits Fabro DOT for Graphviz through it
- **fabro-graphviz** — SVG rendering of workflow graphs through the vendored Graphviz (`graphviz-sys`)
- **fabro-pebble-sandbox** — A `sandbox-driver` handle as the `Environment` pebble's coding agent runs its tools through (`PebbleSandbox`), with Fabro's exec policy, port routes, and secret redactor. Petri creates and owns every run sandbox through the sandbox driver; Fabro attaches to one for Ask Fabro, and `fabro exec` creates a host sandbox of its own. Agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and runs the operator's Docker daemon; daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
- **fabro-api** — Auto-generated Rust types and reqwest HTTP client from OpenAPI spec (build.rs + progenitor)
- **fabro-github** — GitHub App auth (JWT signing, installation tokens, PR creation)
- **fabro-mcp** — Model Context Protocol client/server
- **fabro-mcp-server** — Fabro's own MCP server (`fabro mcp`): the run tools for external agents
- **fabro-slack** — Slack integration (socket mode, blocks API)
- **fabro-checkpoint** — Git checkpoint author identity and commit trailers
- **fabro-telemetry** — CLI analytics (Segment) and crash reporting (Sentry), with anonymous IDs, command sanitization, and detached subprocess delivery
- **fabro-util** — Shared utilities (redaction, terminal formatting)
### TypeScript (fabro-web)
- `cd apps/fabro-web && bun run dev` — rebuild web assets on change for the Rust server; refresh the browser manually
- `cd apps/fabro-web && bun test` — run tests
- `cd apps/fabro-web && bun run typecheck` — type check
- `cd apps/fabro-web && bun run build` — production build (writes to `apps/fabro-web/dist/` only; does NOT update the bundled SPA that ships in the Rust binary)
- `cargo dev build [-- <cargo args>]` — refreshes the embedded SPA assets from the production build, verifies SPA asset budgets, and then runs `cargo build` with forwarded args. The embedded assets are gitignored except for `.gitkeep`; use this when building a Rust binary that should include a populated SPA bundle. `bun run dev` for local development is unchanged because debug builds prefer `apps/fabro-web/dist/` on disk via the server fallback.
### Docker image
- `cargo dev docker-build` — builds the local Docker image from the current tree using the release pipeline's cargo-zigbuild approach. Honors `--arch amd64|arm64`, `--tag <name>` (default `fabro-sh/fabro`), `--compile-only` (stages `tmp/docker-context/<arch>/fabro` without `docker build`), and `--dry-run` (prints the Docker commands without running them). Prefer this over writing a throwaway Dockerfile; the release pipeline, `Dockerfile`, and this command share the same binary layout.
### Docker sandbox provider
- Docker is the default runtime sandbox provider from `defaults.toml`. The Fabro process must have a working Docker client environment (`DOCKER_HOST`, socket access, Docker Desktop behavior, TLS settings, groups/permissions, and any remote daemon policy are operator responsibilities).
- The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs.
- Fabro no longer clones a repository into a sandbox: the engine prepares
every run's checkout. `CloneRequest` still travels beside the sandbox spec
so the run record names the origin and branch; fabro validates it (a pin
needs a branch, a non-GitHub origin needs `skip_clone`) and refuses a
request that asks for a clone. Preflight and `fabro exec` initialize
sandboxes with `CloneRequest::none()`, which creates an empty workspace.
### Release automation
- `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation.
### Marketing site (apps/marketing)
- `cd apps/marketing && bun run dev` — start Astro dev server
- `cd apps/marketing && bun run build` — production build
- `cd apps/marketing && bunx vercel --prod` — deploy to Vercel (project: website, domain: fabro.sh)
### Dev servers
1. `fabro server start` — starts the Rust API server (demo mode is per-request via `X-Fabro-Demo: 1` header)
2. `cd apps/fabro-web && bun run dev` — rebuilds web assets on change; refresh the browser manually
3. Mintlify docs dev server (requires Docker — `mintlify dev` needs Node LTS which may not match the host):
```
docker run --rm -d -p 3333:3333 -v $(pwd)/docs/public:/docs -w /docs --name mintlify-dev node:22-slim \
bash -c "npx mintlify dev --host 0.0.0.0 --port 3333"
```
Then open http://localhost:3333. Stop with `docker stop mintlify-dev`.
## API workflow
The OpenAPI spec at `docs/public/api-reference/fabro-api.yaml` is the source of truth for the fabro-api HTTP interface.
1. Edit `docs/public/api-reference/fabro-api.yaml`
2. `cargo build -p fabro-api` — build.rs regenerates Rust types and client via progenitor
3. Write/update handler in `lib/apps/fabro-server/src/server.rs`, add route to `build_router()`
4. `cargo nextest run -p fabro-server` — conformance test catches spec/router drift
5. `cd lib/packages/fabro-api-client && bun run generate` — regenerates TypeScript Axios client
### API type ownership
- Treat OpenAPI as the source of truth for the wire contract, not as the automatic owner of Rust types.
- Before adding or keeping a generated schema type, search the workspace for an existing hand-written Rust type with the same product meaning.
- If the schema and an existing Rust type have the same semantics and serde shape, reuse the existing type via `lib/foundation/fabro-api/build.rs` `with_replacement(...)` instead of generating a parallel API type.
- If two types are close but not identical, prefer proposing changes that align them into one canonical type rather than accepting small drift. It is usually better to iterate the API now than to create permanently split Rust/API types.
- Keep a separate API DTO only when the API is intentionally a projection, summary, or presentation-specific view of internal state. In that case, give it a distinct API-facing name instead of reusing the internal concept name.
- Treat `ApiFoo` aliases and `foo_to_api` / `foo_from_api` adapters as a smell unless they represent a real semantic boundary. They should not exist only to bridge accidental duplicate types.
- If a type is shared across crates and is part of the core product vocabulary, move it to a shared crate first, then make `fabro-api` reuse it.
- For every new `with_replacement(...)`, add a `fabro-api` test that proves type identity and JSON parity with the OpenAPI schema.
## Test support boundaries
Test-only helpers, fixture constructors, fake credentials, in-memory stores, panic-heavy setup code, and test environment shims must not be exposed from production modules or linked into normal builds.
Put shared test helpers in a dedicated `test_support` module gated behind tests or an explicit feature:
```rust
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
```
If another crate's tests need those helpers, enable the feature only through a dev-dependency using Cargo's dual-listing pattern:
```toml
[dependencies]
fabro-server = { path = "../fabro-server" }
[dev-dependencies]
fabro-server = { path = "../fabro-server", features = ["test-support"] }
```
Do not enable `test-support` in default features, production dependencies, release builds, or binaries.
Use names that make the boundary obvious: `test_app_state`, `test_store_bundle`, `test_auth_mode`, and similar. Avoid production-looking names such as `create_app_state` for test fixtures. `#[doc(hidden)]` is not a substitute for feature-gating; hidden public APIs still compile, link, and can be used accidentally.
Before merging changes that add or move shared test helpers, verify:
- `cargo build --workspace` succeeds without `test-support`
- relevant tests compile and run with `test-support`
- `rg -n "create_app_state|test-only-name"` does not show production call sites
- release/debug artifacts do not contain fake secrets, fixture tokens, or test helper symbols when built without `test-support`
## Architecture
Fabro is an AI-powered workflow orchestration platform. Workflows are defined as Graphviz graphs, where each node is a stage (agent, prompt, command, conditional, human, parallel, etc.). Petri, the workflow engine, admits a workflow at create and executes every run. Two crates import it: `fabro-petri` (the engine adapters) and `fabro-dot` (Petri's DOT parser, for reading a graph's shape and file references).
### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`)
- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
- **fabro-dot** — The workflow graph as written, read through Petri's DOT parser: its name, goal, node and edge counts, and the files it references (`import`, `stack.child_workflow`, `@file` prompts, the goal). The bundler and the workflow-version store walk references through it; `fabro-graphviz` re-emits Fabro DOT for Graphviz through it
- **fabro-graphviz** — SVG rendering of workflow graphs through the vendored Graphviz (`graphviz-sys`)
- **fabro-pebble-sandbox** — A `sandbox-driver` handle as the `Environment` pebble's coding agent runs its tools through (`PebbleSandbox`), with Fabro's exec policy, port routes, and secret redactor. Petri creates and owns every run sandbox through the sandbox driver; Fabro attaches to one for Ask Fabro, and `fabro exec` creates a host sandbox of its own. Agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and runs the operator's Docker daemon; daemon access is host-root-equivalent and assumes trusted callers/payloads.
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
- **fabro-api** — Auto-generated Rust types and reqwest HTTP client from OpenAPI spec (build.rs + progenitor)
- **fabro-github** — GitHub App auth (JWT signing, installation tokens, PR creation)
- **fabro-mcp-server** — Fabro's own MCP server (`fabro mcp`): the run tools for external agents
- **fabro-slack** — Slack integration (socket mode, blocks API)
- **fabro-checkpoint** — Git checkpoint author identity and commit trailers
- **fabro-telemetry** — CLI analytics (Segment) and crash reporting (Sentry), with anonymous IDs, command sanitization, and detached subprocess delivery
@ -140,7 +246,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
- **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec
### Key design patterns
- **RunSandbox** — One concrete sandbox type for local, Docker, and Daytona execution environments, over the `sandbox-driver` facets (exec, filesystem, search, git). There is no fabro-side sandbox trait; tests use `fabro_sandbox::test_support::MockSandbox` over the driver's scripted doubles. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection.
- **Direct sandbox access** — Petri creates every run sandbox through the sandbox driver and records its provider, id and working directory on the run (`RunSandboxInstance`); every Docker and Daytona sandbox carries the `petri.run` label. The server reaches a run's sandbox (the sandbox tab, Run Files, terminal, SSH, preview URLs, VNC, `fabro cp`, Ask Fabro) through `fabro-server/src/sandbox_access.rs`: it connects the record's provider itself, keys ownership on `petri.run`, and works on the driver's `Arc<dyn Sandbox>` facets (exec, filesystem, search, git, pty). Deleting a run deletes its sandboxes through Petri's lease ledger (`fabro_petri::prune`, what `petri sandbox prune` does), not through a provider call of Fabro's own. There is no fabro-side sandbox trait; tests use `fabro_pebble_sandbox::test_support::MockSandbox` over the driver's scripted doubles.
- **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes
- **OpenAPI-first** — `fabro-api.yaml` drives Rust type + client generation (progenitor) and TypeScript client generation (openapi-generator)
- **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed
@ -151,7 +257,7 @@ When working in an area covered by a strategy doc, read the relevant document
**before** making changes:
- **`docs/internal/logging-strategy.md`** — read when adding `tracing` calls (`info!`, `debug!`, `warn!`, `error!`), working on error handling paths, or adding new operations that should be observable
- **`docs/internal/events-strategy.md`** — read when adding or modifying `Event` variants, touching `Emitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types
- **`docs/internal/events-strategy.md`** — read when adding a platform record kind, changing the projection fold or the run stream, or writing a consumer that matches on stream items
- **`docs/internal/testing-strategy.md`** — read when adding or reorganizing tests, choosing between unit vs `tests/it`, deciding whether a test belongs in `cmd` vs `workflow` vs `scenario`, or deciding how to structure snapshots and fixtures
- **`docs/internal/server-secrets-strategy.md`** — read when adding or changing server-level secrets, startup validation, install-time secret persistence, or subprocess env inheritance/scrubbing
- **`docs/internal/migrations-strategy.md`** — read when adding or changing temporary compatibility migrations, startup/file rewrites, migration runners, backups, or removal deadlines

1685
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -14,8 +14,6 @@ version = "0.362.0-nightly.0"
license = "MIT"
[workspace.dependencies]
agent-client-protocol = { version = "0.11.1", features = ["unstable_session_usage"] }
agent-client-protocol-tokio = "0.11.1"
anyhow = "1"
axum = { version = "0.8" }
axum-extra = { version = "0.10", features = ["cookie-private", "query"] }
@ -39,22 +37,6 @@ fs2 = "0.4"
base64 = "0.22"
bytes = "1"
tokio-util = "0.7"
# AWS building blocks for the native Bedrock adapter. Lean stack: request
# signing + credential chain + event-stream decode only. Transport for the
# actual Bedrock inference calls stays on fabro-http; the full
# aws-sdk-bedrockruntime (and its parallel hyper stack) is not pulled in.
# aws-config keeps its DEFAULT features on purpose: `rt-tokio` supplies the
# TokioSleep impl the credential chain's retry requires (without it,
# resolving the default chain panics with "an async sleep implementation is
# required"), and `sso`/`credentials-process` make from_default_chain's
# documented SSO/credential-process support real. `rustls` pins the TLS
# backend for credential-resolution HTTP.
aws-config = { version = "1", features = ["behavior-version-latest", "rustls"] }
aws-credential-types = { version = "1", features = ["hardcoded-credentials"] }
aws-sigv4 = "1"
aws-smithy-eventstream = "0.60"
aws-smithy-runtime-api = "1"
aws-smithy-types = "1"
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4"
jsonschema = { version = "0.42", default-features = false }
@ -130,10 +112,22 @@ sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driv
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "67c9f486dd28f15c04e8d590a91e6f5563f7605d" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "67c9f486dd28f15c04e8d590a91e6f5563f7605d", features = ["mcp", "search-providers"] }
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "67c9f486dd28f15c04e8d590a91e6f5563f7605d" }
# petri: the workflow engine Fabro runs its workflows on. Pinned by rev, the
# same way pebble and sandbox-driver are. Petri pins the same pebble,
# lithos-llm and sandbox-driver revisions as this file, so the workspace links
# one copy of each. Only `fabro-petri` and `fabro-dot` (the DOT parser alone)
# may depend on these packages; the keys carry the `petri_` prefix so the crate
# names say where they come from.
petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-runtime" }
petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-execution" }
petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-store" }
petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-attractor-steps" }
petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-frontend-attractor" }
petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-frontend-fabro" }
petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "9d5171558115d94e867af01ec6d1938e0f636e12", package = "petri-testkit" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"
slatedb = { version = "0.11.2", features = ["zstd"] }
object_store = { version = "0.12.5", features = ["aws"] }
rust-embed = "8"
percent-encoding = "2"

View file

@ -5,7 +5,9 @@ export type DebugCategory =
| "command"
| "lifecycle"
| "human"
| "system";
| "system"
| "petri"
| "platform";
export const DEBUG_CATEGORIES: readonly DebugCategory[] = [
"agent",
@ -13,6 +15,8 @@ export const DEBUG_CATEGORIES: readonly DebugCategory[] = [
"lifecycle",
"human",
"system",
"petri",
"platform",
] as const;
const PREFIX_TO_CATEGORY: Record<string, DebugCategory> = {
@ -34,6 +38,8 @@ const CATEGORY_LABEL: Record<DebugCategory, string> = {
lifecycle: "Lifecycle",
human: "Human",
system: "System",
petri: "Petri",
platform: "Platform",
};
const CATEGORY_TONE: Record<DebugCategory, string> = {
@ -42,6 +48,8 @@ const CATEGORY_TONE: Record<DebugCategory, string> = {
lifecycle: "bg-amber/15 text-amber",
human: "bg-coral/15 text-coral",
system: "bg-overlay-strong text-fg-3",
petri: "bg-teal-500/15 text-teal-500",
platform: "bg-amber/15 text-amber",
};
const CATEGORY_COLOR: Record<DebugCategory, string> = {
@ -50,6 +58,8 @@ const CATEGORY_COLOR: Record<DebugCategory, string> = {
lifecycle: "var(--color-amber)",
human: "var(--color-coral)",
system: "var(--color-ice-300)",
petri: "var(--color-teal-500)",
platform: "var(--color-amber)",
};
export function debugCategory(eventName: string | null | undefined): DebugCategory {

View file

@ -12,12 +12,10 @@ import {
FunnelIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/16/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { Tooltip } from "./ui";
import { formatAbsoluteTs } from "../lib/format";
import {
debugCategory,
debugCategoryColor,
debugCategoryLabel,
debugCategoryTone,
@ -28,19 +26,35 @@ import {
import { FloatingTooltip } from "./floating-tooltip";
import { useWindowEvent } from "../hooks/effects";
/**
* What a debug row needs of a run stream item, as `debugRowsFromStream`
* shapes it: the `stream_seq`, the item's name, when it was recorded, and
* its category (`petri` or `platform`).
*/
export interface DebugRowLike {
seq: number;
event?: string | null;
ts: string;
category: DebugCategory;
}
export function debugRowCategory(row: DebugRowLike): DebugCategory {
return row.category;
}
export function DebugEventRow({
event,
runStart,
selected,
onSelect,
}: {
event: EventEnvelope;
event: DebugRowLike;
runStart: string | undefined;
selected: boolean;
onSelect: () => void;
}) {
const eventName = event.event ?? "";
const category = debugCategory(eventName);
const category = debugRowCategory(event);
return (
<button
type="button"
@ -291,7 +305,7 @@ export function DebugDnaStrip({
onSelect,
runStart,
}: {
events: EventEnvelope[];
events: DebugRowLike[];
selectedSeq: number | null;
onSelect: (seq: number) => void;
runStart: string | undefined;
@ -351,7 +365,7 @@ export function DebugDnaStrip({
const ms = Date.parse(event.ts);
if (Number.isNaN(ms)) return null;
const pct = ((ms - range.start) / range.duration) * 100;
const category = debugCategory(event.event);
const category = debugRowCategory(event);
const color = debugCategoryColor(category);
const isSelected = event.seq === selectedSeq;
const isHovered = hover?.seq === event.seq;
@ -411,14 +425,14 @@ function DnaPopover({
anchorRect,
runStart,
}: {
event: EventEnvelope;
event: DebugRowLike;
anchorRect: DOMRect;
runStart: string | undefined;
}) {
const category = debugCategory(event.event);
const category = debugRowCategory(event);
return (
<FloatingTooltip rect={anchorRect} placement="top">
{`${debugCategoryLabel(category)} · ${friendlyEventName(event.event)} · ${formatElapsed(event.ts, runStart)}`}
{`${debugCategoryLabel(category)} · ${friendlyEventName(event.event ?? "")} · ${formatElapsed(event.ts, runStart)}`}
</FloatingTooltip>
);
}

View file

@ -191,7 +191,7 @@ describe("InterviewDock", () => {
expect(buttons.Revise).toBeDefined();
});
test("multiple choice renders option descriptions as display text", () => {
test("multiple choice renders option descriptions and previews as display text", () => {
const question = makeQuestion({
question_type: QuestionType.MULTIPLE_CHOICE,
options: [
@ -201,6 +201,7 @@ describe("InterviewDock", () => {
description: "Deploy the current patch",
preview: "<b>not rendered specially</b>",
},
{ key: "R", label: "[R] Revise" },
],
});
const tree = render(
@ -209,7 +210,13 @@ describe("InterviewDock", () => {
const text = textContent(tree.toJSON());
expect(text).toContain("Approve");
expect(text).toContain("Deploy the current patch");
expect(text).not.toContain("<b>not rendered specially</b>");
// The preview is shown as the text it is, never parsed as markup.
expect(text).toContain("<b>not rendered specially</b>");
const previews = tree.root.findAll(
(node) => node.props["data-testid"] === "interview-option-preview",
);
expect(previews).toHaveLength(1);
expect(shouldStackOptions(question.options ?? [])).toBe(true);
});
test("freeform question renders a textarea and disables send when empty", () => {

View file

@ -309,7 +309,9 @@ function ConfirmationBody({
export function shouldStackOptions(options: InterviewOption[]): boolean {
return options.some(
(option) =>
option.label.length > STACK_LABEL_LENGTH || Boolean(option.description),
option.label.length > STACK_LABEL_LENGTH ||
Boolean(option.description) ||
Boolean(option.preview),
);
}
@ -465,6 +467,14 @@ function OptionLabel({ option }: { option: InterviewOption }) {
{option.description}
</span>
)}
{option.preview && (
<span
data-testid="interview-option-preview"
className="mt-1 block whitespace-pre-wrap rounded bg-overlay-strong px-2 py-1 font-mono text-[11px]/4 font-normal text-fg-3"
>
{option.preview}
</span>
)}
</span>
);
}

View file

@ -0,0 +1,93 @@
import { useMemo } from "react";
import type { RunProjection } from "@qltysh/fabro-api-client";
import { formatAbsoluteTs } from "../lib/format";
import {
platformRecordsOf,
type PlatformRecordEntry,
} from "../lib/petri-stream";
import { useRunState, useRunStream } from "../lib/queries";
const KIND_LABEL: Record<string, string> = {
"checkpoint": "Checkpoint",
"pull_request.created": "Pull request",
"run.notice": "Notice",
"run.title": "Title",
"run.branch": "Run branch",
};
function kindLabel(kind: string): string {
return KIND_LABEL[kind] ?? kind;
}
/**
* The platform records of a Petri run: Fabro's own facts beside the engine's
* events (a checkpoint with its commit, a pull request, a notice), each with
* the stage it belongs to when it belongs to one.
*/
export function PlatformRecordsPanelView({
records,
projection,
}: {
records: PlatformRecordEntry[];
projection: RunProjection | null | undefined;
}) {
const pullRequest = projection?.pull_request ?? null;
if (records.length === 0 && !pullRequest) return null;
return (
<section
aria-label="Platform records"
className="rounded-md border border-line bg-panel/60 px-6 py-4"
>
<h3 className="text-[10px] font-medium uppercase tracking-[0.08em] text-fg-muted">
Platform records
</h3>
<ul className="mt-2 space-y-1 text-sm">
{pullRequest && (
<li className="flex items-baseline gap-3">
<span className="w-28 shrink-0 text-fg-muted">Pull request</span>
<a
href={pullRequest.html_url}
target="_blank"
rel="noreferrer"
className="truncate font-mono text-teal-500 hover:text-teal-300"
>
#{pullRequest.number}
</a>
</li>
)}
{records.map((record) => (
<li
key={record.streamSeq}
data-kind={record.kind}
className="flex items-baseline gap-3"
>
<span className="w-28 shrink-0 text-fg-muted">{kindLabel(record.kind)}</span>
<span className="min-w-0 flex-1 truncate font-mono text-fg-2">
{record.detail ?? "—"}
</span>
{record.stageKey && (
<span className="shrink-0 font-mono text-xs text-fg-muted">
stage {record.stageKey}
</span>
)}
<span className="shrink-0 font-mono text-xs tabular-nums text-fg-muted">
{formatAbsoluteTs(record.ts)}
</span>
</li>
))}
</ul>
</section>
);
}
/** The panel for a run: the platform records on its stream. */
export function PlatformRecordsPanel({ runId }: { runId: string }) {
const runStateQuery = useRunState(runId);
const streamQuery = useRunStream(runId);
const records = useMemo(
() => (streamQuery.data ? platformRecordsOf(streamQuery.data) : []),
[streamQuery.data],
);
return <PlatformRecordsPanelView records={records} projection={runStateQuery.data} />;
}

View file

@ -10,13 +10,13 @@ import {
stageStatusLabel,
stageStatusTone,
} from "../lib/stage-sidebar";
import { deriveRunPhases, type RunPhase } from "../lib/run-phases";
import type { RunPhase } from "../lib/run-phases";
import { useTickingNow } from "../lib/time";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
interface WaterfallProps {
runId: string;
events: EventEnvelope[];
/** The run's phases from its platform lifecycle records (`deriveRunPhasesFromStream`). */
phases: RunPhase[];
stages: RunStage[];
createdAtIso: string;
completedAtIso: string | null;
@ -157,18 +157,16 @@ function stageRow(runId: string, stage: RunStage, nowMs: number): Row | null {
function buildRows({
runId,
events,
phases: givenPhases,
stages,
createdAtIso,
nowMs,
}: {
runId: string;
events: EventEnvelope[];
phases: RunPhase[];
stages: RunStage[];
createdAtIso: string;
nowMs: number;
}): Row[] {
const phases = deriveRunPhases(events, createdAtIso).map((p) => phaseRow(p, nowMs));
const phases = givenPhases.map((p) => phaseRow(p, nowMs));
const stageRows: Row[] = [];
for (const stage of stages) {
if (!isVisibleStage(stage.node_id)) continue;
@ -181,15 +179,15 @@ function buildRows({
export function RunWaterfall({
runId,
events,
phases,
stages,
createdAtIso,
completedAtIso,
}: WaterfallProps) {
const nowMs = useTickingNow(true, 1000);
const rows = useMemo(
() => buildRows({ runId, events, stages, createdAtIso, nowMs }),
[runId, events, stages, createdAtIso, nowMs],
() => buildRows({ runId, phases, stages, nowMs }),
[runId, phases, stages, nowMs],
);
const createdMs = Date.parse(createdAtIso);

View file

@ -20,7 +20,6 @@ import {
denyRun,
isCancellationPendingState,
mapError,
retryRun,
unarchiveRun,
} from "../../lib/run-actions";
import type { LifecycleAction } from "../../lib/run-actions";
@ -44,7 +43,6 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) {
const status = run.lifecycleStatus;
const showApprove = run.pendingApproval === true;
const showDeny = run.pendingApproval === true;
const showRetry = status === "failed" || status === "dead";
const showArchive = canArchive(status);
const showUnarchive = canUnarchive(status);
const showCancel = canCancel(status);
@ -56,7 +54,7 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) {
);
const pending = pendingAction !== null || cancellationPending;
const hasLifecycle = showRetry || showArchive || showUnarchive;
const hasLifecycle = showArchive || showUnarchive;
const hasDestructive = showDeny || showCancel || showDelete;
async function runAction<T>(
@ -162,20 +160,6 @@ export function RowActionsMenu({ run }: { run: RunWithStatus }) {
</button>
</MenuItem>
)}
{showRetry && (
<MenuItem>
<button
type="button"
onClick={() =>
void runAction("retry", () => retryRun(run.id), "Retried run.")
}
disabled={pending}
className={MENU_ITEM_CLASS}
>
Retry
</button>
</MenuItem>
)}
{showArchive && (
<MenuItem>
<button

View file

@ -1,77 +0,0 @@
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import {
getArray,
getNumber,
getObject,
getString,
} from "../lib/unknown";
export interface StageSummary {
attempt?: number;
maxAttempts?: number;
failureMessage?: string;
notes?: string;
inputTokens?: number;
outputTokens?: number;
filesTouchedCount?: number;
systemActor?: string;
exitCode?: number;
}
export function deriveStageSummary(events: EventEnvelope[]): StageSummary {
const summary: StageSummary = {};
for (const e of events) {
const props = e.properties ?? {};
switch (e.event) {
case "stage.started": {
const attempt = getNumber(props, "attempt");
const max = getNumber(props, "max_attempts");
if (attempt !== undefined) summary.attempt = attempt;
if (max !== undefined) summary.maxAttempts = max;
break;
}
case "stage.completed": {
readFailure(summary, getObject(props, "failure"));
readUsage(summary, getObject(props, "usage"));
readTermination(summary, getObject(props, "termination"));
const notes = getString(props, "notes");
if (notes !== undefined) summary.notes = notes;
const files = getArray(props, "files_touched");
if (files !== undefined) summary.filesTouchedCount = files.length;
break;
}
case "stage.failed": {
readFailure(summary, getObject(props, "failure"));
readUsage(summary, getObject(props, "usage"));
break;
}
}
}
return summary;
}
function readFailure(summary: StageSummary, failure: unknown) {
if (!failure) return;
const message = getString(failure, "message");
if (message !== undefined) summary.failureMessage = message;
const actor = getString(failure, "system_actor");
if (actor !== undefined) summary.systemActor = actor;
}
/** `stage.completed.usage` is a `ModelUsage`: the model, then the usage. */
function readUsage(summary: StageSummary, modelUsage: unknown) {
if (!modelUsage) return;
const tokens = getObject(getObject(modelUsage, "usage"), "tokens");
if (!tokens) return;
const input = getNumber(tokens, "input");
const output = getNumber(tokens, "output");
if (input !== undefined) summary.inputTokens = input;
if (output !== undefined) summary.outputTokens = output;
}
function readTermination(summary: StageSummary, termination: unknown) {
if (!termination) return;
const exitCode = getNumber(termination, "exit_code");
if (exitCode !== undefined) summary.exitCode = exitCode;
}

View file

@ -2,26 +2,12 @@ import { describe, expect, test } from "bun:test";
import type { ReactNode } from "react";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { SWRConfig } from "swr";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { StagePopover } from "./stage-popover";
import { deriveStageSummary } from "./stage-popover-summary";
import type { Stage } from "../lib/stage-sidebar";
import { generatedAxios } from "../lib/api-client";
import { makeUsage } from "../lib/test-fixtures";
import { makeStage as baseMakeStage } from "../lib/test-utils";
function makeEvent(overrides: Partial<EventEnvelope>): EventEnvelope {
return {
id: "evt-1",
ts: "2026-05-24T12:00:00Z",
run_id: "run-1",
event: "stage.started",
seq: 1,
...overrides,
} as EventEnvelope;
}
function makeStage(overrides: Partial<Stage> = {}): Stage {
return baseMakeStage({
status: "succeeded",
@ -32,112 +18,11 @@ function makeStage(overrides: Partial<Stage> = {}): Stage {
});
}
describe("deriveStageSummary", () => {
test("returns empty summary for no events", () => {
expect(deriveStageSummary([])).toEqual({});
});
test("captures attempt and max_attempts from latest stage.started", () => {
const summary = deriveStageSummary([
makeEvent({ event: "stage.started", seq: 1, properties: { attempt: 1, max_attempts: 3 } }),
makeEvent({ event: "stage.failed", seq: 2, properties: { failure: { message: "boom" } } }),
makeEvent({ event: "stage.started", seq: 3, properties: { attempt: 2, max_attempts: 3 } }),
]);
expect(summary.attempt).toBe(2);
expect(summary.maxAttempts).toBe(3);
});
test("captures failure message from stage.failed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.failed",
properties: { failure: { message: "verify failed: 3 tests failing", system_actor: "agent" } },
}),
]);
expect(summary.failureMessage).toBe("verify failed: 3 tests failing");
expect(summary.systemActor).toBe("agent");
});
test("captures usage tokens from stage.completed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.completed",
properties: {
usage: {
model: { provider: "anthropic", model_id: "claude-sonnet-4-6" },
usage: { tokens: { input: 12400, output: 3120 } },
},
},
}),
]);
expect(summary.inputTokens).toBe(12400);
expect(summary.outputTokens).toBe(3120);
});
test("captures notes from stage.completed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.completed",
properties: { notes: "skipped because input was empty" },
}),
]);
expect(summary.notes).toBe("skipped because input was empty");
});
test("captures files_touched count from stage.completed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.completed",
properties: { files_touched: ["a.rs", "b.rs", "c.rs"] },
}),
]);
expect(summary.filesTouchedCount).toBe(3);
});
test("captures termination exit_code from stage.completed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.completed",
properties: { termination: { exit_code: 137 } },
}),
]);
expect(summary.exitCode).toBe(137);
});
test("later events overwrite earlier ones (latest attempt wins)", () => {
const summary = deriveStageSummary([
makeEvent({ event: "stage.failed", seq: 1, properties: { failure: { message: "old" } } }),
makeEvent({ event: "stage.failed", seq: 2, properties: { failure: { message: "newer" } } }),
]);
expect(summary.failureMessage).toBe("newer");
});
test("ignores non-lifecycle events", () => {
const summary = deriveStageSummary([
makeEvent({ event: "agent.tool.completed", properties: { tool_name: "Bash" } }),
makeEvent({ event: "stage.started", properties: { attempt: 1, max_attempts: 1 } }),
]);
expect(summary.attempt).toBe(1);
});
test("tolerates missing or non-numeric properties", () => {
const summary = deriveStageSummary([
makeEvent({ event: "stage.started", properties: {} }),
makeEvent({ event: "stage.completed", properties: { usage: null } }),
]);
expect(summary).toEqual({});
});
});
function render(node: ReactNode): TestRenderer.ReactTestRenderer {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let tree!: TestRenderer.ReactTestRenderer;
act(() => {
tree = TestRenderer.create(
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
{node}
</SWRConfig>,
);
tree = TestRenderer.create(node);
});
return tree;
}
@ -152,122 +37,40 @@ function textOf(tree: TestRenderer.ReactTestRenderer): string {
return collect(tree.toJSON());
}
interface MockEventsResponse {
data: EventEnvelope[];
meta: { has_more: boolean };
}
function withMockedStageEvents<T>(
events: EventEnvelope[],
body: () => Promise<T>,
): Promise<T> {
const response: MockEventsResponse = { data: events, meta: { has_more: false } };
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => ({
data: response,
status: 200,
statusText: "OK",
headers: {},
config,
});
return body().finally(() => {
generatedAxios.defaults.adapter = originalAdapter;
});
}
describe("StagePopover rendering", () => {
test("succeeded stage shows model, tokens, and files touched", async () => {
await withMockedStageEvents(
[
makeEvent({
event: "stage.completed",
properties: {
usage: {
model: { provider: "anthropic", model_id: "claude-sonnet-4-6" },
usage: { tokens: { input: 12400, output: 3120 } },
},
files_touched: ["a.rs", "b.rs"],
},
}),
],
async () => {
const stage = makeStage({ status: "succeeded" });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="1m 30s" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("implement");
expect(text).toContain("Succeeded");
expect(text).toContain("agent");
expect(text).toContain("claude-opus-4-7");
expect(text).toContain("12.4k in");
expect(text).toContain("3.1k out");
expect(text).toContain("Files touched");
},
);
test("succeeded stage shows the model and the projection's tokens", () => {
const stage = makeStage({
status: "succeeded",
usage: makeUsage({ input: 12400, output: 3120 }),
});
const tree = render(<StagePopover runId="run-1" stage={stage} duration="1m 30s" />);
const text = textOf(tree);
expect(text).toContain("implement");
expect(text).toContain("Succeeded");
expect(text).toContain("agent");
expect(text).toContain("claude-opus-4-7");
expect(text).toContain("12.4k in");
expect(text).toContain("3.1k out");
});
test("failed stage shows truncated reason", async () => {
const longMessage = "x".repeat(500);
await withMockedStageEvents(
[makeEvent({ event: "stage.failed", properties: { failure: { message: longMessage } } })],
async () => {
const stage = makeStage({ status: "failed", duration: "12s" });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="12s" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("Reason");
expect(text).toContain("…");
// Truncated to ≤240 chars (plus the ellipsis we appended).
const reasonMatch = text.match(/x+/);
expect(reasonMatch).not.toBeNull();
expect(reasonMatch![0].length).toBeLessThanOrEqual(240);
},
);
test("failed stage shows the model and nothing it must load", () => {
const stage = makeStage({ status: "failed", duration: "12s" });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="12s" />);
const text = textOf(tree);
expect(text).toContain("Failed");
expect(text).toContain("claude-opus-4-7");
expect(text).not.toContain("Reason");
expect(text).not.toContain("Loading");
});
test("retrying stage shows attempt and previous failure", async () => {
await withMockedStageEvents(
[
makeEvent({ event: "stage.started", seq: 1, properties: { attempt: 1, max_attempts: 3 } }),
makeEvent({
event: "stage.failed",
seq: 2,
properties: { failure: { message: "transient infra error" }, will_retry: true },
}),
],
async () => {
const stage = makeStage({ status: "retrying" });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="--" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("Attempt");
expect(text).toContain("1 of 3");
expect(text).toContain("Previous failure");
expect(text).toContain("transient infra error");
},
);
});
test("skipped stage shows skip reason from notes", async () => {
await withMockedStageEvents(
[makeEvent({ event: "stage.completed", properties: { notes: "no work to do" } })],
async () => {
const stage = makeStage({ status: "skipped", duration: "--" });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="--" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("Reason");
expect(text).toContain("no work to do");
},
);
test("failed command stage shows neither a model nor an exit code", () => {
const stage = makeStage({ status: "failed", handler: "command", providerUsed: null });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="3s" />);
const text = textOf(tree);
expect(text).toContain("Failed");
expect(text).toContain("command");
expect(text).not.toContain("Model");
expect(text).not.toContain("Exit code");
});
test("pending stage renders minimal shell without status tail", () => {
@ -309,33 +112,4 @@ describe("StagePopover rendering", () => {
expect(text).not.toContain("Resumed from");
expect(text).not.toContain("Graph visit");
});
test("failed command stage shows exit code instead of model", async () => {
await withMockedStageEvents(
[
makeEvent({
event: "stage.failed",
properties: { failure: { message: "exit 2" } },
}),
makeEvent({
event: "stage.completed",
properties: { termination: { exit_code: 2 } },
}),
],
async () => {
const stage = makeStage({
status: "failed",
handler: "command",
providerUsed: null,
});
const tree = render(<StagePopover runId="run-1" stage={stage} duration="3s" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("Exit code");
expect(text).toContain("2");
},
);
});
});

View file

@ -1,9 +1,7 @@
import { useMemo } from "react";
import { Link } from "react-router";
import type { StageState } from "@qltysh/fabro-api-client";
import { formatTokenCount } from "../lib/format";
import { useRunStageEvents } from "../lib/queries";
import {
formatStageLabel,
stageStatusLabel,
@ -12,39 +10,6 @@ import {
} from "../lib/stage-sidebar";
import { timeAgo } from "../lib/time";
import { PopoverHeader, PopoverRow, PopoverRows } from "./ui";
import {
deriveStageSummary,
type StageSummary,
} from "./stage-popover-summary";
const REASON_MAX_CHARS = 240;
/** Trim, collapse blank-line runs, and cap to ~240 chars with an ellipsis. */
function truncateReason(text: string): { display: string; truncated: boolean } {
const collapsed = text
.replace(/\r\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (collapsed.length <= REASON_MAX_CHARS) {
return { display: collapsed, truncated: false };
}
return {
display: `${collapsed.slice(0, REASON_MAX_CHARS - 1).trimEnd()}…`,
truncated: true,
};
}
function TruncatedReason({ text }: { text: string }) {
const { display, truncated } = truncateReason(text);
return (
<span
className="line-clamp-3 break-words text-fg-2"
title={truncated ? text : undefined}
>
{display}
</span>
);
}
function StatusPill({ status }: { status: StageState }) {
return (
@ -68,20 +33,12 @@ function ModelRow({ providerUsed }: { providerUsed: Stage["providerUsed"] }) {
);
}
function AttemptRow({ summary }: { summary: StageSummary }) {
if (summary.attempt === undefined) return null;
const max = summary.maxAttempts;
return (
<PopoverRow label="Attempt">
{max && max > 1 ? `${summary.attempt} of ${max}` : `${summary.attempt}`}
</PopoverRow>
);
}
function TokensRow({ summary }: { summary: StageSummary }) {
if (summary.inputTokens === undefined && summary.outputTokens === undefined) return null;
const inLabel = formatTokenCount(summary.inputTokens ?? 0, { compactDecimal: true });
const outLabel = formatTokenCount(summary.outputTokens ?? 0, { compactDecimal: true });
/** The stage's tokens from its projection usage, once any were counted. */
function TokensRow({ usage }: { usage: Stage["usage"] }) {
const { input, output } = usage.tokens;
if (input === undefined && output === undefined) return null;
const inLabel = formatTokenCount(input ?? 0, { compactDecimal: true });
const outLabel = formatTokenCount(output ?? 0, { compactDecimal: true });
return (
<PopoverRow label="Tokens">
<span className="font-mono tabular-nums">
@ -91,105 +48,29 @@ function TokensRow({ summary }: { summary: StageSummary }) {
);
}
function StatusTail({
stage,
summary,
loading,
}: {
stage: Stage;
summary: StageSummary;
loading: boolean;
}) {
/**
* What the popover shows below the timing rows, by state: the model while
* the stage runs, the model and tokens once it finished. The projection
* carries no failure reason, attempt count or exit code for the popover.
*/
function StatusTail({ stage }: { stage: Stage }) {
switch (stage.status) {
case "pending":
case "cancelled":
return summary.systemActor ? (
<PopoverRow label="Cancelled by">{summary.systemActor}</PopoverRow>
) : null;
case "running":
return (
<>
<AttemptRow summary={summary} />
<ModelRow providerUsed={stage.providerUsed} />
</>
);
case "retrying":
return (
<>
<AttemptRow summary={summary} />
{summary.failureMessage && (
<PopoverRow label="Previous failure">
<TruncatedReason text={summary.failureMessage} />
</PopoverRow>
)}
</>
);
case "failed":
return <ModelRow providerUsed={stage.providerUsed} />;
case "succeeded":
return (
<>
<ModelRow providerUsed={stage.providerUsed} />
<TokensRow summary={summary} />
{summary.filesTouchedCount !== undefined && summary.filesTouchedCount > 0 && (
<PopoverRow label="Files touched">{summary.filesTouchedCount}</PopoverRow>
)}
</>
);
case "partially_succeeded":
return (
<>
{summary.notes && (
<PopoverRow label="Notes">
<TruncatedReason text={summary.notes} />
</PopoverRow>
)}
<ModelRow providerUsed={stage.providerUsed} />
<TokensRow summary={summary} />
<TokensRow usage={stage.usage} />
</>
);
case "failed": {
const isCommand = stage.handler === "command";
return (
<>
{summary.failureMessage ? (
<PopoverRow label="Reason">
<TruncatedReason text={summary.failureMessage} />
</PopoverRow>
) : loading ? (
<LoadingRow />
) : null}
<AttemptRow summary={summary} />
{isCommand && summary.exitCode !== undefined ? (
<PopoverRow label="Exit code">
<span className="font-mono tabular-nums">{summary.exitCode}</span>
</PopoverRow>
) : (
<ModelRow providerUsed={stage.providerUsed} />
)}
</>
);
}
case "skipped":
return summary.notes ? (
<PopoverRow label="Reason">
<TruncatedReason text={summary.notes} />
</PopoverRow>
) : loading ? (
<LoadingRow />
) : null;
default:
return null;
}
}
function LoadingRow() {
return (
<>
<dt className="text-fg-3" />
<dd className="text-fg-muted italic">Loading details…</dd>
</>
);
}
interface StagePopoverProps {
runId: string;
stage: Stage;
@ -198,10 +79,6 @@ interface StagePopoverProps {
}
export function StagePopover({ runId, stage, duration }: StagePopoverProps) {
const { data: events } = useRunStageEvents(runId, stage.id);
const summary = useMemo(() => deriveStageSummary(events ?? []), [events]);
const loading = events === undefined && stage.status !== "pending";
return (
<div className="min-w-[14rem]">
<PopoverHeader>
@ -241,7 +118,7 @@ export function StagePopover({ runId, stage, duration }: StagePopoverProps) {
<span className="font-mono tabular-nums">{stage.graphVisit}</span>
</PopoverRow>
)}
<StatusTail stage={stage} summary={summary} loading={loading} />
<StatusTail stage={stage} />
</PopoverRows>
</div>
);

View file

@ -4,11 +4,10 @@ import {
ArrowLongRightIcon,
ArrowsRightLeftIcon,
} from "@heroicons/react/20/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { StageMetaBar } from "./meta-bar";
import { findEdgeForNode } from "./helpers";
import type { EdgeSelection } from "./helpers";
const REASON_LABEL: Record<string, string> = {
condition: "Matched condition",
@ -23,19 +22,16 @@ function reasonLabel(reason: string): string {
export function ConditionalDecision({
stage,
runEvents,
edge,
allStages,
runId,
}: {
stage: Stage;
runEvents: EventEnvelope[];
/** The edge the stage took (its `route.applied`), or null while undecided. */
edge: EdgeSelection | null;
allStages: Stage[];
runId: string;
}) {
const edge = useMemo(
() => findEdgeForNode(runEvents, stage.nodeId),
[runEvents, stage.nodeId],
);
const targetStage = useMemo(() => {
if (!edge) return null;
let pick: Stage | null = null;

View file

@ -1,11 +1,11 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils";
import { setupReactTestEnv } from "../../lib/test-utils";
import { makeUsage } from "../../lib/test-fixtures";
import type { Stage } from "../stage-sidebar";
import { FanInResults } from "./fan-in-results";
import type { ReducerTranscript } from "./helpers";
let teardown: () => void;
beforeEach(() => {
@ -26,22 +26,18 @@ const fanInStage: Stage = {
usage: makeUsage(),
};
function event(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
return makeEventEnvelope(seq, { stage_id: "join@1", ...partial });
}
function renderFanIn(events: EventEnvelope[]): string {
function renderFanIn(reducer: ReducerTranscript | null): string {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(<FanInResults stage={fanInStage} events={events} />);
renderer = TestRenderer.create(<FanInResults stage={fanInStage} reducer={reducer} />);
});
return JSON.stringify(renderer.toJSON());
}
describe("FanInResults", () => {
test("renders a neutral joined state without best-branch selection UI", () => {
const rendered = renderFanIn([]);
const rendered = renderFanIn(null);
expect(rendered).toContain("Joined");
expect(rendered).not.toContain("Selected branch");
@ -50,23 +46,13 @@ describe("FanInResults", () => {
});
test("optionally renders the standard reducer transcript", () => {
const rendered = renderFanIn([
event(1, {
event: "stage.prompt",
properties: {
mode: "prompt",
text: "Combine the useful findings.",
model: "claude-sonnet-4-6",
},
}),
event(2, {
event: "prompt.completed",
properties: {
response: "All branch findings are now available.",
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1200, output: 340 } } },
},
}),
]);
const rendered = renderFanIn({
prompt: "Combine the useful findings.",
response: "All branch findings are now available.",
model: "claude-sonnet-4-6",
inputTokens: 1200,
outputTokens: 340,
});
expect(rendered).toContain("Reducer transcript");
expect(rendered).toContain("Combine the useful findings.");

View file

@ -1,25 +1,22 @@
import { useMemo } from "react";
import {
CheckCircleIcon,
CpuChipIcon,
} from "@heroicons/react/20/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { formatTokenCount } from "../../lib/format";
import { Markdown } from "./primitives";
import { StageMetaBar } from "./meta-bar";
import { parseReducerTranscript } from "./helpers";
import type { ReducerTranscript } from "./helpers";
export function FanInResults({
stage,
events,
reducer,
}: {
stage: Stage;
events: EventEnvelope[];
/** The reducer's transcript from the projection, or null when none ran. */
reducer: ReducerTranscript | null;
}) {
const reducer = useMemo(() => parseReducerTranscript(events), [events]);
return (
<div className="space-y-6 pl-3 pr-4 sm:pr-6 lg:pr-8">
<StageMetaBar stage={stage}>

View file

@ -1,362 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { makeEventEnvelope } from "../../lib/test-utils";
import {
extractStageContext,
parseHumanInterviewPairs,
parseParallelOverview,
parseReducerTranscript,
} from "./helpers";
describe("parseHumanInterviewPairs", () => {
test("pairs interview.started with interview.completed by question_id", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "interview.started",
properties: {
question_id: "q-1",
question: "Approve PR?",
question_type: "yes_no",
options: [
{ key: "y", label: "Yes" },
{ key: "n", label: "No" },
],
allow_freeform: false,
},
}),
makeEventEnvelope(2, {
event: "interview.completed",
properties: {
question_id: "q-1",
question: "Approve PR?",
answer: "y",
duration_ms: 4200,
actor: { kind: "user", email: "alice@example.com" },
},
}),
];
const pairs = parseHumanInterviewPairs(events);
expect(pairs).toHaveLength(1);
expect(pairs[0].question.questionType).toBe("yes_no");
expect(pairs[0].question.options).toEqual([
{ key: "y", label: "Yes" },
{ key: "n", label: "No" },
]);
const resolution = pairs[0].resolution;
expect(resolution).not.toBeNull();
if (resolution?.kind === "answered") {
expect(resolution.answer).toBe("y");
expect(resolution.actor).toBe("alice@example.com");
expect(resolution.durationMs).toBe(4200);
}
});
test("leaves resolution null for unanswered (still pending) questions", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "interview.started",
properties: {
question_id: "q-1",
question: "Pick a branch",
question_type: "multiple_choice",
},
}),
];
const pairs = parseHumanInterviewPairs(events);
expect(pairs[0].resolution).toBeNull();
});
test("preserves option description and preview metadata from started events", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "interview.started",
properties: {
question_id: "q-1",
question: "Pick a path",
question_type: "multiple_choice",
options: [
{
key: "ship",
label: "Ship",
description: "Deploy the current patch",
preview: "diff preview",
},
],
},
}),
];
const pairs = parseHumanInterviewPairs(events);
expect(pairs[0].question.options[0]).toEqual({
key: "ship",
label: "Ship",
description: "Deploy the current patch",
preview: "diff preview",
});
});
test("preserves a typed review target from started events", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "interview.started",
properties: {
question_id: "q-1",
question:
"Review the Quarry review exercise document, then choose the next action.",
question_type: "multiple_choice",
review_target: {
label: "Quarry review exercise",
url: "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef",
kind: "document",
},
},
}),
];
const pairs = parseHumanInterviewPairs(events);
expect(pairs[0].question.reviewTarget).toEqual({
label: "Quarry review exercise",
url: "https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef",
kind: "document",
});
});
test("captures timeout and interrupted resolutions", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "interview.started",
properties: { question_id: "q-1", question: "?", question_type: "freeform" },
}),
makeEventEnvelope(2, {
event: "interview.timeout",
properties: { question_id: "q-1", duration_ms: 30000 },
}),
makeEventEnvelope(3, {
event: "interview.started",
properties: { question_id: "q-2", question: "?", question_type: "freeform" },
}),
makeEventEnvelope(4, {
event: "interview.interrupted",
properties: {
question_id: "q-2",
reason: "user cancelled",
duration_ms: 1200,
actor: { kind: "user", email: "bob@example.com" },
},
}),
];
const pairs = parseHumanInterviewPairs(events);
expect(pairs[0].resolution?.kind).toBe("timeout");
expect(pairs[1].resolution?.kind).toBe("interrupted");
if (pairs[1].resolution?.kind === "interrupted") {
expect(pairs[1].resolution.reason).toBe("user cancelled");
expect(pairs[1].resolution.actor).toBe("bob@example.com");
}
});
});
describe("parseParallelOverview", () => {
test("rolls up branch_count and status-only results", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "parallel.started",
properties: { branch_count: 3 },
}),
makeEventEnvelope(2, {
event: "parallel.completed",
properties: {
duration_ms: 12000,
success_count: 2,
failure_count: 1,
results: [
{
id: "branch-a",
status: "succeeded",
context_updates: { "response.branch-a": "A" },
},
{
id: "branch-b",
status: "succeeded",
context_updates: { "command.output": { stdout: "B" } },
},
{
id: "branch-c",
status: "failed",
context_updates: { "response.branch-c": "C" },
},
],
},
}),
];
const overview = parseParallelOverview(events);
expect(overview).toEqual({
branchCount: 3,
results: [
{ id: "branch-a", index: null, itemLabel: null, status: "succeeded" },
{ id: "branch-b", index: null, itemLabel: null, status: "succeeded" },
{ id: "branch-c", index: null, itemLabel: null, status: "failed" },
],
});
});
test("parses dynamic item identity from results", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "parallel.completed",
properties: {
duration_ms: 20,
success_count: 2,
failure_count: 0,
results: [
{
id: "reviewer",
index: 0,
item_label: "auth",
status: "succeeded",
context_updates: {},
},
{
id: "reviewer",
index: 1,
item_label: "api",
status: "succeeded",
context_updates: {},
},
],
},
}),
];
expect(parseParallelOverview(events).results).toEqual([
{ id: "reviewer", index: 0, itemLabel: "auth", status: "succeeded" },
{ id: "reviewer", index: 1, itemLabel: "api", status: "succeeded" },
]);
});
test("reports in-flight when only the started event is present", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "parallel.started",
properties: { branch_count: 4 },
}),
];
const overview = parseParallelOverview(events);
expect(overview.branchCount).toBe(4);
expect(overview.results).toEqual([]);
});
});
describe("parseReducerTranscript", () => {
test("returns null when fan-in joins without a reducer", () => {
expect(parseReducerTranscript([])).toBeNull();
});
test("parses the standard prompt transcript when a reducer ran", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "stage.prompt",
properties: {
mode: "prompt",
text: "Combine the branch results.",
model: "claude-sonnet-4-6",
},
}),
makeEventEnvelope(2, {
event: "prompt.completed",
properties: {
response: "The branch results are joined.",
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1200, output: 340 } } },
},
}),
];
expect(parseReducerTranscript(events)).toEqual({
prompt: "Combine the branch results.",
response: "The branch results are joined.",
model: "claude-sonnet-4-6",
inputTokens: 1200,
outputTokens: 340,
});
});
test("uses normal prompt mode for the reducer transcript", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "stage.prompt",
properties: { mode: "prompt", text: "Standard reducer" },
}),
makeEventEnvelope(2, {
event: "prompt.completed",
properties: { response: "Standard response" },
}),
];
expect(parseReducerTranscript(events)?.response).toBe("Standard response");
});
});
describe("extractStageContext", () => {
test("keeps author-set keys and drops engine bookkeeping keys", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "stage.completed",
properties: {
context_updates: {
"plan.summary": "ship the thing",
review_score: 8,
last_stage: "implement",
last_response: "done",
"response.implement": "full text",
"internal.run_id": "run-1",
"current.preamble": "...",
"command.output": "blob:abc",
"human.gate.selected": "A",
"parallel.results": [],
},
},
}),
];
const ctx = extractStageContext(events);
expect(ctx).not.toBeNull();
expect(ctx?.updates).toEqual({
"plan.summary": "ship the thing",
review_score: 8,
});
});
test("extracts routing hints from preferred_label and suggested_next_ids", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "stage.completed",
properties: {
preferred_label: "approve",
suggested_next_ids: ["review", "merge", 7],
},
}),
];
const ctx = extractStageContext(events);
expect(ctx?.routing.preferredLabel).toBe("approve");
expect(ctx?.routing.suggestedNextIds).toEqual(["review", "merge"]);
expect(ctx?.updates).toEqual({});
});
test("returns null when the stage only wrote engine keys", () => {
const events: EventEnvelope[] = [
makeEventEnvelope(1, {
event: "stage.completed",
properties: {
context_updates: { last_stage: "implement", "command.output": "blob:x" },
},
}),
];
expect(extractStageContext(events)).toBeNull();
});
test("returns null when the stage has not completed", () => {
expect(extractStageContext([])).toBeNull();
});
});

View file

@ -1,20 +1,10 @@
import { ReviewTargetKind, StageOutcome } from "@qltysh/fabro-api-client";
import type { EventEnvelope, ReviewTarget } from "@qltysh/fabro-api-client";
/**
* The shapes the stage renderers show, and the helpers they share. Each is
* derived from a run's stream or projection in `lib/petri-stream.ts`.
*/
import type { ReviewTarget, StageOutcome } from "@qltysh/fabro-api-client";
import {
getArray,
getNumber,
getObject,
getString,
isRecord,
type UnknownRecord,
} from "../../lib/unknown";
const STAGE_OUTCOMES: ReadonlySet<string> = new Set(Object.values(StageOutcome));
function asStageOutcome(value: string | undefined): StageOutcome | null {
return value !== undefined && STAGE_OUTCOMES.has(value) ? (value as StageOutcome) : null;
}
import { getString, type UnknownRecord } from "../../lib/unknown";
export interface InterviewOption {
key: string;
@ -45,7 +35,7 @@ export interface HumanInterviewPair {
resolution: HumanResolution | null;
}
function principalLabel(actor: unknown): string | null {
export function principalLabel(actor: unknown): string | null {
if (!actor || typeof actor !== "object") return null;
const record = actor as UnknownRecord;
const kind = getString(record, "kind") ?? "";
@ -63,106 +53,7 @@ function principalLabel(actor: unknown): string | null {
return kind || null;
}
function parseInterviewOptions(value: unknown): InterviewOption[] {
if (!Array.isArray(value)) return [];
const out: InterviewOption[] = [];
for (const item of value) {
if (!item || typeof item !== "object") continue;
const record = item as UnknownRecord;
const key = getString(record, "key");
const label = getString(record, "label");
if (key && label) {
const option: InterviewOption = { key, label };
const description = getString(record, "description");
const preview = getString(record, "preview");
if (description !== null) option.description = description;
if (preview !== null) option.preview = preview;
out.push(option);
}
}
return out;
}
function parseReviewTarget(value: unknown): ReviewTarget | null {
if (!isRecord(value)) return null;
const label = getString(value, "label");
const url = getString(value, "url");
const kind = getString(value, "kind");
if (!label || !url || kind !== ReviewTargetKind.DOCUMENT) return null;
return { label, url, kind };
}
/**
* Pair `interview.started` events with the matching `interview.completed`,
* `.timeout`, or `.interrupted` resolution by `question_id`. Unanswered
* questions return with `resolution: null` so the UI can show pending state.
*/
export function parseHumanInterviewPairs(events: EventEnvelope[]): HumanInterviewPair[] {
const pairs = new Map<string, HumanInterviewPair>();
for (const event of events) {
const props: UnknownRecord = event.properties ?? {};
if (event.event === "interview.started") {
const questionId = getString(props, "question_id");
if (!questionId) continue;
pairs.set(questionId, {
question: {
ts: event.ts,
questionId,
question: getString(props, "question") ?? "",
questionType: getString(props, "question_type") ?? "freeform",
options: parseInterviewOptions(props.options),
allowFreeform: props.allow_freeform === true,
timeoutSeconds: getNumber(props, "timeout_seconds") ?? null,
contextDisplay: getString(props, "context_display") ?? null,
reviewTarget: parseReviewTarget(props.review_target),
},
resolution: null,
});
continue;
}
if (event.event === "interview.completed") {
const questionId = getString(props, "question_id");
const pair = questionId ? pairs.get(questionId) : undefined;
if (!pair) continue;
pair.resolution = {
kind: "answered",
ts: event.ts,
answer: getString(props, "answer") ?? "",
durationMs: getNumber(props, "duration_ms") ?? 0,
actor: principalLabel(props.actor),
};
continue;
}
if (event.event === "interview.timeout") {
const questionId = getString(props, "question_id");
const pair = questionId ? pairs.get(questionId) : undefined;
if (!pair) continue;
pair.resolution = {
kind: "timeout",
ts: event.ts,
durationMs: getNumber(props, "duration_ms") ?? 0,
};
continue;
}
if (event.event === "interview.interrupted") {
const questionId = getString(props, "question_id");
const pair = questionId ? pairs.get(questionId) : undefined;
if (!pair) continue;
pair.resolution = {
kind: "interrupted",
ts: event.ts,
reason: getString(props, "reason") ?? "interrupted",
durationMs: getNumber(props, "duration_ms") ?? 0,
actor: principalLabel(props.actor),
};
}
}
return Array.from(pairs.values()).sort((a, b) => a.question.ts.localeCompare(b.question.ts));
}
/** Identity and outcome of one branch, parsed from `parallel.completed`. */
/** Identity and outcome of one branch of a parallel stage. */
export interface ParallelBranchSummary {
id: string;
index: number | null;
@ -175,45 +66,6 @@ export interface ParallelOverview {
results: ParallelBranchSummary[];
}
/**
* Roll up the `parallel.started` (announces branch count) and
* `parallel.completed` (carries the per-branch results) events for a parallel
* stage. Pre-completion, only the announce data is available.
*
* Only branch identity is parsed. The event's own `success_count`,
* `failure_count` and `duration_ms` rollups are deliberately ignored: the
* renderer counts the branch rows it actually draws, and duration comes from
* the stage record via `StageMetaBar`.
*/
export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview {
let branchCount: number | null = null;
let results: ParallelBranchSummary[] = [];
for (const event of events) {
const props: UnknownRecord = event.properties ?? {};
if (event.event === "parallel.started") {
branchCount = getNumber(props, "branch_count") ?? branchCount;
} else if (event.event === "parallel.completed") {
const rawResults = getArray(props, "results") ?? [];
results = rawResults
.map((entry) => {
const record = entry && typeof entry === "object" ? (entry as UnknownRecord) : null;
if (!record) return null;
const id = getString(record, "id");
const index = getNumber(record, "index") ?? null;
const itemLabel = getString(record, "item_label") ?? null;
const status = asStageOutcome(getString(record, "status"));
if (!id || !status) return null;
return { id, index, itemLabel, status } satisfies ParallelBranchSummary;
})
.filter((r): r is ParallelBranchSummary => r != null);
if (branchCount == null) branchCount = results.length;
}
}
return { branchCount, results };
}
export interface ReducerTranscript {
prompt: string;
response: string;
@ -222,91 +74,12 @@ export interface ReducerTranscript {
outputTokens: number;
}
/** Extract the standard prompt/response transcript emitted by an optional fan-in reducer. */
export function parseReducerTranscript(events: EventEnvelope[]): ReducerTranscript | null {
let prompt = "";
let response = "";
let model: string | null = null;
let inputTokens = 0;
let outputTokens = 0;
let hasReducer = false;
for (const event of events) {
const props: UnknownRecord = event.properties ?? {};
if (event.event === "stage.prompt") {
prompt = getString(props, "text") ?? prompt;
model = getString(props, "model") ?? model;
hasReducer = true;
} else if (event.event === "prompt.completed" && hasReducer) {
response = getString(props, "response") ?? response;
model = getString(props, "model") ?? model;
// `prompt.completed.usage` is a `ModelUsage`: the model, then the usage.
const tokens = getObject(getObject(getObject(props, "usage"), "usage"), "tokens") ?? {};
inputTokens = getNumber(tokens, "input") ?? inputTokens;
outputTokens = getNumber(tokens, "output") ?? outputTokens;
}
}
return hasReducer ? { prompt, response, model, inputTokens, outputTokens } : null;
}
export interface StageContextData {
routing: { preferredLabel: string | null; suggestedNextIds: string[] };
/** `context_updates` keys the workflow deliberately set (engine keys removed). */
updates: Record<string, unknown>;
}
// Engine/auto-populated context keys. These are bookkeeping or already shown in
// a stage's primary tab (command output, human answers, fan-in results), so the
// Context tab hides them and surfaces only what the workflow deliberately wrote.
const ENGINE_CONTEXT_KEYS = new Set(["last_stage", "last_response", "command.output"]);
const ENGINE_CONTEXT_PREFIXES = [
"response.",
"internal.",
"current.",
"human.gate.",
"parallel.",
];
function isEngineContextKey(key: string): boolean {
if (ENGINE_CONTEXT_KEYS.has(key)) return true;
return ENGINE_CONTEXT_PREFIXES.some((prefix) => key.startsWith(prefix));
}
/**
* Extract the workflow's deliberate outputs from the `stage.completed` event:
* author-set `context_updates` (minus engine keys) plus the routing hints
* (`preferred_label`, `suggested_next_ids`). Returns null when the stage hasn't
* finished or produced nothing worth showing — which hides the Context tab.
*/
export function extractStageContext(events: EventEnvelope[]): StageContextData | null {
for (const event of events) {
if (event.event !== "stage.completed") continue;
const props: UnknownRecord = event.properties ?? {};
const rawUpdates = getObject(props, "context_updates") ?? {};
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawUpdates)) {
if (!isEngineContextKey(key)) updates[key] = value;
}
const preferredLabel = getString(props, "preferred_label") ?? null;
const suggestedNextIds = (getArray(props, "suggested_next_ids") ?? []).filter(
(v): v is string => typeof v === "string",
);
if (
Object.keys(updates).length === 0 &&
!preferredLabel &&
suggestedNextIds.length === 0
) {
return null;
}
return { routing: { preferredLabel, suggestedNextIds }, updates };
}
return null;
}
export interface EdgeSelection {
fromNode: string;
toNode: string;
@ -315,35 +88,5 @@ export interface EdgeSelection {
isJump: boolean;
}
/**
* Find the `edge.selected` event whose `from_node` matches this conditional
* stage's node. Edge events are run-scoped (no stage_id) so callers must pass
* the full run events list, not the per-stage events.
*
* When the stage runs multiple times, the most recent matching event wins —
* for now we just take the last one. Sufficient until we surface visit data.
*/
export function findEdgeForNode(
runEvents: EventEnvelope[],
nodeId: string,
): EdgeSelection | null {
let latest: EdgeSelection | null = null;
for (const event of runEvents) {
if (event.event !== "edge.selected") continue;
const props = event.properties ?? {};
const fromNode = getString(props, "from_node");
if (fromNode !== nodeId) continue;
const toNode = getString(props, "to_node") ?? "";
latest = {
fromNode: nodeId,
toNode,
reason: getString(props, "reason") ?? "",
condition: getString(props, "condition") ?? null,
isJump: props.is_jump === true,
};
}
return latest;
}
// Re-export helper used by renderers that need to read nested properties.
export { getString };

View file

@ -1,4 +1,3 @@
import { useMemo } from "react";
import {
ArrowPathIcon,
ChatBubbleLeftEllipsisIcon,
@ -7,7 +6,6 @@ import {
ExclamationTriangleIcon,
NoSymbolIcon,
} from "@heroicons/react/20/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import {
@ -19,11 +17,10 @@ import { formatAbsoluteTs, formatDurationMs } from "../../lib/format";
import { ACTIVE_STAGE_STATES } from "../../lib/stage-sidebar";
import { Markdown } from "./primitives";
import { StageMetaBar } from "./meta-bar";
import {
parseHumanInterviewPairs,
type HumanInterviewPair,
type HumanResolution,
type InterviewOption,
import type {
HumanInterviewPair,
HumanResolution,
InterviewOption,
} from "./helpers";
function questionTypeLabel(type: string): string {
@ -203,6 +200,11 @@ function QuestionBlock({
{option.description}
</span>
)}
{option.preview && (
<span className="mt-1 block whitespace-pre-wrap rounded bg-overlay-strong px-2 py-1 font-mono text-[11px]/4 text-fg-3">
{option.preview}
</span>
)}
</span>
</li>
))}
@ -224,12 +226,12 @@ function QuestionBlock({
export function HumanQA({
stage,
events,
pairs,
}: {
stage: Stage;
events: EventEnvelope[];
/** The stage's questions and their answers (`parsePetriInterviewPairs`). */
pairs: HumanInterviewPair[];
}) {
const pairs = useMemo(() => parseHumanInterviewPairs(events), [events]);
const stageActive = ACTIVE_STAGE_STATES.has(stage.status);
const pendingCount = pairs.filter((p) => p.resolution == null).length;

View file

@ -1,16 +1,15 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { StageOutcome, StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import {
makeEventEnvelope,
makeStage as baseMakeStage,
setupReactTestEnv,
textContent,
} from "../../lib/test-utils";
import type { Stage } from "../stage-sidebar";
import type { ParallelBranchSummary, ParallelOverview } from "./helpers";
import { ParallelChildren } from "./parallel-children";
let teardown: () => void;
@ -57,38 +56,21 @@ function branchStage(
});
}
function event(partial: Partial<EventEnvelope>): EventEnvelope {
return makeEventEnvelope(partial.seq ?? 1, {
event: "parallel.completed",
stage_id: "fork@1",
...partial,
});
}
function startedEvent(branchCount: number): EventEnvelope {
return event({
event: "parallel.started",
properties: { branch_count: branchCount },
});
}
function completedEvent(results: Array<{ id: string; status: StageOutcome }>): EventEnvelope {
const countOf = (status: StageOutcome) =>
results.filter((result) => result.status === status).length;
return event({
seq: 2,
event: "parallel.completed",
properties: {
duration_ms: 12000,
success_count: countOf(StageOutcome.SUCCEEDED),
failure_count: countOf(StageOutcome.FAILED),
results: results.map((result) => ({ ...result, context_updates: {} })),
},
});
/** The fork's overview: the announced branch count and the results so far. */
function overview(
branchCount: number | null,
results: Array<{ id: string; status: StageOutcome }> = [],
): ParallelOverview {
return {
branchCount,
results: results.map(
(result): ParallelBranchSummary => ({ ...result, index: null, itemLabel: null }),
),
};
}
function renderParallel(
events: EventEnvelope[],
forkOverview: ParallelOverview,
allStages: Stage[],
stage = parallelStage,
): TestRenderer.ReactTestRenderer {
@ -98,7 +80,7 @@ function renderParallel(
<MemoryRouter>
<ParallelChildren
stage={stage}
events={events}
overview={forkOverview}
runId="run-1"
allStages={allStages}
/>
@ -123,7 +105,7 @@ function statValue(renderer: TestRenderer.ReactTestRenderer, label: string): str
describe("ParallelChildren", () => {
test("renders live branch names, statuses, counts, and stage links", () => {
const renderer = renderParallel(
[startedEvent(2)],
overview(2),
[
branchStage("review_glm", 0, StageState.SUCCEEDED),
branchStage("review_opus", 1, StageState.RUNNING),
@ -146,7 +128,7 @@ describe("ParallelChildren", () => {
test("shows the recorded stage duration when cancellation interrupts the fan-out", () => {
const renderer = renderParallel(
[startedEvent(2)],
overview(2),
[],
makeStage({
id: "fork@1",
@ -165,7 +147,7 @@ describe("ParallelChildren", () => {
test("keeps looped fork links scoped to the selected fork visit", () => {
const renderer = renderParallel(
[startedEvent(1)],
overview(1),
[
branchStage("review_glm", 0, StageState.SUCCEEDED, "fork@1", 1),
branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2),
@ -179,7 +161,7 @@ describe("ParallelChildren", () => {
// Branches queued behind `max_parallel` reserve no stage identity, so the
// observed indexes are sparse. Sizing the list by entry count would drop
// the only running branch.
const renderer = renderParallel([], [branchStage("review_opus", 2, StageState.RUNNING)]);
const renderer = renderParallel(overview(null), [branchStage("review_opus", 2, StageState.RUNNING)]);
expect(branchRowText(renderer)).toEqual([
"PendingBranch 1",
@ -191,7 +173,7 @@ describe("ParallelChildren", () => {
});
test("renders branches with no stage or result yet as pending placeholders", () => {
const renderer = renderParallel([startedEvent(3)], [branchStage("review_glm", 0, StageState.RUNNING)]);
const renderer = renderParallel(overview(3), [branchStage("review_glm", 0, StageState.RUNNING)]);
expect(branchRowText(renderer)).toEqual([
"Runningreview_glm",
@ -204,7 +186,7 @@ describe("ParallelChildren", () => {
test("labels a re-entered branch with its visit, matching the sidebar", () => {
const renderer = renderParallel(
[startedEvent(1)],
overview(1),
[branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2)],
makeStage({ id: "fork@2", name: "fork", handler: "parallel", visit: 2 }),
);
@ -215,13 +197,10 @@ describe("ParallelChildren", () => {
test("keeps duplicate branch targets in index order and only links recorded stages", () => {
const renderer = renderParallel(
[
startedEvent(2),
completedEvent([
overview(2, [
{ id: "review", status: StageOutcome.FAILED },
{ id: "review", status: StageOutcome.FAILED },
]),
],
[branchStage("review", 0, StageState.SUCCEEDED)],
);
@ -234,10 +213,7 @@ describe("ParallelChildren", () => {
test("renders a completed result without a matching stage as an unlinked row", () => {
const renderer = renderParallel(
[
startedEvent(1),
completedEvent([{ id: "legacy_branch", status: StageOutcome.SUCCEEDED }]),
],
overview(1, [{ id: "legacy_branch", status: StageOutcome.SUCCEEDED }]),
[],
);
@ -250,15 +226,12 @@ describe("ParallelChildren", () => {
branchStage("partial", 0, StageState.PARTIALLY_SUCCEEDED),
branchStage("skipped", 1, StageState.SKIPPED),
];
const running = renderParallel([startedEvent(2)], allStages);
const running = renderParallel(overview(2), allStages);
const completed = renderParallel(
[
startedEvent(2),
completedEvent([
overview(2, [
{ id: "partial", status: StageOutcome.PARTIALLY_SUCCEEDED },
{ id: "skipped", status: StageOutcome.SKIPPED },
]),
],
allStages,
);
@ -279,31 +252,13 @@ describe("ParallelChildren", () => {
<MemoryRouter>
<ParallelChildren
stage={parallelStage}
events={[
event({
properties: {
duration_ms: 100,
success_count: 2,
failure_count: 0,
results: [
{
id: "reviewer",
index: 0,
item_label: "auth",
status: "succeeded",
context_updates: {},
},
{
id: "reviewer",
index: 1,
item_label: "api",
status: "succeeded",
context_updates: {},
},
],
},
}),
]}
overview={{
branchCount: 2,
results: [
{ id: "reviewer", index: 0, itemLabel: "auth", status: StageOutcome.SUCCEEDED },
{ id: "reviewer", index: 1, itemLabel: "api", status: StageOutcome.SUCCEEDED },
],
}}
runId="run-1"
allStages={[
{

View file

@ -2,13 +2,11 @@ import { useMemo } from "react";
import { Link } from "react-router";
import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
import { StageState } from "@qltysh/fabro-api-client";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar";
import { StageMetaBar } from "./meta-bar";
import { parseParallelOverview } from "./helpers";
import type { ParallelBranchSummary } from "./helpers";
import type { ParallelBranchSummary, ParallelOverview } from "./helpers";
/** Branch row view state sourced from a live branch stage or completed result. */
interface BranchRow {
@ -98,17 +96,16 @@ function ChildRow({
export function ParallelChildren({
stage,
events,
overview,
runId,
allStages,
}: {
stage: Stage;
events: EventEnvelope[];
/** The fork's branch count and results from the projection. */
overview: ParallelOverview;
runId: string;
allStages: Stage[];
}) {
const overview = useMemo(() => parseParallelOverview(events), [events]);
const stagesByBranchIndex = useMemo(() => {
const byIndex = new Map<number, Stage>();
for (const candidate of allStages) {

View file

@ -1,25 +1,25 @@
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import {
debugCategory,
debugCategoryLabel,
debugCategoryTone,
type DebugCategory,
} from "../event-debug-helpers";
import { StageMetaBar } from "./meta-bar";
interface CategoryCount {
export interface CategoryCount {
category: DebugCategory;
count: number;
}
function summarizeEventCategories(events: EventEnvelope[]): CategoryCount[] {
/** What the summary counts: a stream row with its category. */
export interface CategorizedEvent {
category: DebugCategory;
}
export function summarizeEventCategories(events: CategorizedEvent[]): CategoryCount[] {
const counts = new Map<DebugCategory, number>();
for (const event of events) {
if (!event.event) continue;
const cat = debugCategory(event.event);
counts.set(cat, (counts.get(cat) ?? 0) + 1);
counts.set(event.category, (counts.get(event.category) ?? 0) + 1);
}
return Array.from(counts.entries())
.map(([category, count]) => ({ category, count }))
@ -31,7 +31,7 @@ export function StageSummary({
events,
}: {
stage: Stage;
events: EventEnvelope[];
events: CategorizedEvent[];
}) {
const categories = summarizeEventCategories(events);

View file

@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";
import { makePetriItem, makePlatformItem } from "../lib/test-utils";
import { steeringToastMessage } from "./use-run-toasts";
const stage = { name: "code" };
function control(seq: number, ctl: Record<string, unknown>, deliverable?: boolean) {
return makePetriItem(
seq,
{ event: "control.requested", firing: 1, ctl },
{ stage, derived: deliverable === undefined ? undefined : { deliverable } },
);
}
describe("steeringToastMessage", () => {
test("a delivered steer, a queued steer and an interrupt each earn a toast", () => {
expect(steeringToastMessage(control(1, { deliver: { $steer: "try again" } }, true))).toBe(
"Steer delivered.",
);
expect(steeringToastMessage(control(2, { deliver: { $steer: "try again" } }, false))).toBe(
"Steer queued — will apply when an agent stage runs.",
);
expect(steeringToastMessage(control(3, { cancel: { reason: "interrupt" } }))).toBe(
"Agent interrupted.",
);
});
test("a refused steer shows the worker's notice", () => {
expect(
steeringToastMessage(
makePlatformItem(4, {
kind: "run.notice",
code: "steer_refused",
message: "No agent stage is running.",
}),
),
).toBe("No agent stage is running.");
});
test("an answer delivery and every other item earn none", () => {
expect(
steeringToastMessage(control(5, { deliver: { $answer: { question: "gate#2", choice: "N" } } }, true)),
).toBeNull();
expect(steeringToastMessage(makePetriItem(6, { event: "step.finished", firing: 1 }, { stage }))).toBeNull();
expect(
steeringToastMessage(makePlatformItem(7, { kind: "run.notice", code: "other", message: "x" })),
).toBeNull();
expect(steeringToastMessage(makePlatformItem(8, { kind: "run.lifecycle", transition: "running" }))).toBeNull();
});
});

View file

@ -1,8 +1,16 @@
import { useEffect, useRef } from "react";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import { useToast } from "../components/toast";
import { subscribeToRunEvents, type RunEventPayload } from "../lib/run-events";
import { eventDedupeKey } from "../lib/cross-tab-sse";
import {
petriBody,
petriEventName,
platformRecordKind,
} from "../lib/petri-stream";
import { subscribeToRunEvents } from "../lib/run-events";
import type { MutateFn } from "../lib/sse";
import { getBool, getObject, getString } from "../lib/unknown";
const NOOP_MUTATE = (() => undefined) as MutateFn;
const DEDUPE_WINDOW = 256;
@ -13,19 +21,19 @@ const DEDUPE_WINDOW = 256;
*/
export function useRunToasts(runId: string | undefined) {
const { push } = useToast();
const seenEventIdsRef = useRef(new Set<string>());
const seenItemKeysRef = useRef(new Set<string>());
useEffect(() => {
if (!runId) return;
const seen = new Set<string>();
seenEventIdsRef.current = seen;
seenItemKeysRef.current = seen;
return subscribeToRunEvents(runId, NOOP_MUTATE, undefined, {
onEvent: (payload) => {
const dedupeId = eventDedupeId(payload);
if (dedupeId) {
if (seen.has(dedupeId)) return;
seen.add(dedupeId);
onItem: (item) => {
const dedupeKey = eventDedupeKey(item);
if (dedupeKey) {
if (seen.has(dedupeKey)) return;
seen.add(dedupeKey);
if (seen.size > DEDUPE_WINDOW) {
// Set iteration order is insertion order; drop the oldest.
const oldest = seen.values().next().value;
@ -33,7 +41,7 @@ export function useRunToasts(runId: string | undefined) {
}
}
const message = steeringToastMessage(payload);
const message = steeringToastMessage(item);
if (message) {
push({ message });
}
@ -42,35 +50,27 @@ export function useRunToasts(runId: string | undefined) {
}, [push, runId]);
}
function eventDedupeId(payload: RunEventPayload): string | null {
if (typeof payload.id === "string") return payload.id;
if (typeof payload.seq === "number") return `seq:${payload.seq}`;
/**
* The toast a steering item earns: a `control.requested` that delivers a
* `$steer` (queued until an agent stage runs when Petri says it is not
* deliverable) or cancels the firing (an interrupt), and the `run.notice`
* the worker records when it refuses a steer.
*/
export function steeringToastMessage(item: RunStreamItem): string | null {
if (platformRecordKind(item) === "run.notice") {
const record = getObject(item.item, "record");
if (getString(record, "code") !== "steer_refused") return null;
return getString(record, "message") ?? "Steer refused: no agent stage is running.";
}
if (petriEventName(item) !== "control.requested") return null;
const ctl = getObject(petriBody(item), "ctl");
if (!ctl) return null;
if (getObject(ctl, "deliver")?.$steer !== undefined) {
const deliverable = getBool(getObject(item.item, "derived"), "deliverable");
return deliverable === false
? "Steer queued — will apply when an agent stage runs."
: "Steer delivered.";
}
if (ctl.cancel !== undefined) return "Agent interrupted.";
return null;
}
function steeringToastMessage(payload: RunEventPayload): string | null {
const props = payload.properties ?? {};
switch (payload.event) {
case "run.interrupt":
return "Agent interrupted.";
case "run.steer":
return "Steer accepted.";
case "agent.steering.injected":
return "Steer delivered.";
case "agent.steer.buffered":
return "Steer queued — will apply when an agent stage runs.";
case "agent.steer.dropped": {
const reason = props.reason;
if (reason === "queue_full") {
return "Steer rate limit reached; oldest queued steer dropped.";
}
if (reason === "run_ended") {
return "Run ended before queued steer(s) could apply.";
}
return null;
}
default:
return null;
}
}

View file

@ -555,7 +555,7 @@ describe("InstallApp", () => {
expect(text).toContain("fabro-data");
expect(text).toContain("us-east-1");
expect(text).toContain("Access key");
expect(text).toContain("slatedb/, artifacts/");
expect(text).toContain("artifacts/");
});
await act(async () => {

View file

@ -773,7 +773,7 @@ function ObjectStoreStep({
return (
<StepPanel
title="Choose the shared object store"
description="This configures the shared backend for both SlateDB and run artifacts. Fabro still keeps its local storage root on disk."
description="This configures the backend for run artifacts. Fabro still keeps its local storage root on disk."
error={saveError}
submitting={submitting}
submittingLabel={
@ -969,7 +969,7 @@ function ObjectStoreStep({
<div className="space-y-3">
<Field
label="Local directory"
hint="Shared root for SlateDB and run artifacts."
hint="Root for run artifacts."
>
<input
ref={localRootInputRef}
@ -989,7 +989,7 @@ function ObjectStoreStep({
/>
</Field>
<p className="rounded-lg bg-overlay px-4 py-3 text-sm/6 text-fg-3 outline-1 -outline-offset-1 outline-white/10">
Fabro will store SlateDB and run artifacts under this directory.
Fabro will store run artifacts under this directory.
</p>
</div>
)}
@ -1556,7 +1556,7 @@ function WelcomeScreen() {
["Server URL", "Confirm where operators will reach Fabro."],
[
"Object store",
"Choose local disk or AWS S3 for SlateDB and artifacts.",
"Choose local disk or AWS S3 for artifacts.",
],
["Sandbox", "Choose Docker or Daytona for workflow execution."],
["LLMs", "Validate API keys for Anthropic, OpenAI, or Gemini."],
@ -1869,12 +1869,12 @@ const OBJECT_STORE_PROVIDER_OPTIONS: ReadonlyArray<CardOption<ObjectStoreProvide
{
id: "local",
title: "Local disk",
body: "Uses the host filesystem for SlateDB and run artifacts.",
body: "Uses the host filesystem for run artifacts.",
},
{
id: "s3",
title: "AWS S3",
body: "Uses one S3 bucket with fixed slatedb/ and artifacts/ prefixes.",
body: "Uses one S3 bucket with the fixed artifacts/ prefix.",
},
];
@ -2394,7 +2394,7 @@ function ObjectStoreSummaryRows({
: "Runtime credentials"
}
/>
<SummaryRow label="Prefixes" value="slatedb/, artifacts/" mono />
<SummaryRow label="Prefix" value="artifacts/" mono />
</>
);
}

View file

@ -360,50 +360,6 @@ export async function fetchAllPages<TItem, TExtra extends object = {}>(
}
}
export async function fetchAllStageEvents<TItem extends { seq: number }>(
label: string,
loadPage: (sinceSeq: number, limit: number) => Promise<PaginatedEnvelope<TItem>>,
): Promise<TItem[]> {
const PAGE_LIMIT = 1000;
const MAX_PAGES = 50;
const data: TItem[] = [];
let sinceSeq = 1;
let pagesLoaded = 0;
while (true) {
const page = await loadPage(sinceSeq, PAGE_LIMIT);
pagesLoaded += 1;
if (page.data.length === 0) {
if (page.meta.has_more) {
console.warn(
`Stage events fetch for ${label} returned an empty page with has_more=true; stopping at ${data.length} items to avoid spinning.`,
);
}
return data;
}
data.push(...page.data);
if (!page.meta.has_more) return data;
if (pagesLoaded >= MAX_PAGES) {
console.warn(
`Stopped stage events fetch for ${label} after ${pagesLoaded} pages and ${data.length} items because the safety cap was reached.`,
);
return data;
}
const highestSeq = page.data.reduce((max, event) => Math.max(max, event.seq), sinceSeq - 1);
if (highestSeq < sinceSeq) {
console.warn(
`Stage events fetch for ${label} returned a non-advancing page at since_seq=${sinceSeq}; stopping at ${data.length} items to avoid spinning.`,
);
return data;
}
sinceSeq = highestSeq + 1;
}
}
export function requestSignalOptions(request?: Request): RawAxiosRequestConfig {
return request?.signal ? { signal: request.signal } : {};
}

View file

@ -8,43 +8,16 @@ import type { SessionStreamEvent } from "./session-stream";
function event(name: string, properties: Record<string, unknown>): SessionStreamEvent {
return {
seq: 0,
event: { event: name, properties },
} as unknown as SessionStreamEvent;
}
function flattenedEvent(
name: string,
properties: Record<string, unknown>,
): SessionStreamEvent {
return {
seq: 0,
id: "evt_1",
ts: "2026-05-22T16:25:34.940200Z",
seq: 1,
session_id: "01HZX6M0P7SE4VJ9Y3X2B8E9QF",
run_id: "run_1",
ts: "2026-05-22T16:25:34.940200Z",
event: name,
properties,
} as unknown as SessionStreamEvent;
}
describe("applyTurnEvent", () => {
test("appends assistant deltas from flattened SSE event envelopes", () => {
const acc = {
activeTextIndex: null,
parts: [],
toolCallIndex: new Map(),
} as Parameters<typeof applyTurnEvent>[0];
expect(
applyTurnEvent(
acc,
flattenedEvent("run.session.assistant_delta", { delta: "Hello" }),
),
).toBe(true);
expect(acc.parts).toEqual([{ type: "text", text: "Hello" }]);
});
test("appends assistant deltas into a single streaming text part", () => {
const acc = {
activeTextIndex: null,

View file

@ -91,43 +91,22 @@ function snapshot(acc: TurnAccumulator): ChatModelRunResult {
return { content: acc.parts.slice() };
}
/**
* Apply a single `EventEnvelope` to the accumulator. Returns true if the
* accumulator changed and a fresh `ChatModelRunResult` should be yielded.
*/
interface NestedRunEvent {
event?: string;
properties?: Record<string, unknown>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function eventPayload(envelope: SessionStreamEvent): {
eventName: string;
props: Record<string, unknown>;
} {
const raw = envelope as unknown as Record<string, unknown>;
if (typeof raw.event === "string") {
return {
eventName: raw.event,
props: isRecord(raw.properties) ? raw.properties : {},
};
}
const nested = isRecord(raw.event) ? (raw.event as NestedRunEvent) : {};
return {
eventName: nested.event ?? "",
props: isRecord(nested.properties) ? nested.properties : {},
};
}
/**
* Apply a single `SessionEvent` to the accumulator. Returns true if the
* accumulator changed and a fresh `ChatModelRunResult` should be yielded.
*/
export function applyTurnEvent(
acc: TurnAccumulator,
envelope: SessionStreamEvent,
event: SessionStreamEvent,
): boolean {
const { eventName, props } = eventPayload(envelope);
const eventName = event.event;
const props: Record<string, unknown> = isRecord(event.properties)
? event.properties
: {};
if (eventName === "run.session.assistant_delta") {
const delta = typeof props.delta === "string" ? props.delta : "";

View file

@ -4,6 +4,7 @@ import {
shouldRefreshBoardForEvent,
subscribeToBoardEvents,
} from "./board-events";
import { makePlatformItem } from "./test-utils";
import {
createCrossTabSseCoordinator,
type BroadcastChannelLike,
@ -35,12 +36,20 @@ class FakeBroadcastChannel implements BroadcastChannelLike {
}
describe("shouldRefreshBoardForEvent", () => {
test("refreshes board for run and interview status changes only", () => {
expect(shouldRefreshBoardForEvent("run.running")).toBe(true);
expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true);
expect(shouldRefreshBoardForEvent("run.cancel.requested")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true);
expect(shouldRefreshBoardForEvent("checkpoint.completed")).toBe(false);
test("refreshes the board for the stream items that change a run's row", () => {
// Fabro's lifecycle records, before and after Petri runs the run.
expect(shouldRefreshBoardForEvent("run.lifecycle")).toBe(true);
expect(shouldRefreshBoardForEvent("run.title")).toBe(true);
expect(shouldRefreshBoardForEvent("run.archived")).toBe(true);
expect(shouldRefreshBoardForEvent("pull_request.created")).toBe(true);
// Petri's run events while it does, and the question that blocks it.
expect(shouldRefreshBoardForEvent("run.started")).toBe(true);
expect(shouldRefreshBoardForEvent("run.finished")).toBe(true);
expect(shouldRefreshBoardForEvent("wait.state.changed")).toBe(true);
expect(shouldRefreshBoardForEvent("control.requested")).toBe(true);
// A stage's own progress changes no row.
expect(shouldRefreshBoardForEvent("step.finished")).toBe(false);
expect(shouldRefreshBoardForEvent("checkpoint")).toBe(false);
});
});
@ -68,7 +77,7 @@ describe("subscribeToBoardEvents", () => {
await waitFor(() => created.length === 1);
keys.length = 0;
source.emit({ event: "run.running" });
source.emit(makePlatformItem(1, { kind: "run.lifecycle", transition: "running" }));
expect(created).toEqual(["/api/v1/attach"]);
expect(keys).toHaveLength(1);
@ -104,7 +113,7 @@ describe("subscribeToBoardEvents", () => {
throw new Error("source should be reused");
}, { debounceMs: 0, coordinator });
source.emit({ event: "run.running" });
source.emit(makePlatformItem(1, { kind: "run.lifecycle", transition: "running" }));
expect(created).toEqual(["/api/v1/attach"]);
expect(keys).toHaveLength(1);

View file

@ -6,6 +6,7 @@ import {
type CrossTabSseCoordinator,
} from "./cross-tab-sse";
import { runListCacheMatchers } from "./board-cache";
import { isStreamItemPayload, streamItemName } from "./petri-stream";
import { queryKeys } from "./query-keys";
import {
createBrowserEventSource,
@ -21,35 +22,31 @@ interface BoardEventOptions {
coordinator?: CrossTabSseCoordinator;
}
// The stream items that change what the board shows of a run: its status
// (Fabro's `run.lifecycle` records before and after Petri runs it, Petri's
// own run events while it does), its title, its archive state, its parent,
// its pull request, and the questions that block it (`wait.state.changed`
// turns the status to blocked, `control.requested` delivers the answer).
// Named as `streamItemName` names them: a platform record by its `kind`, a
// Petri event by its `<subject>.<verb>`.
const BOARD_STATUS_EVENTS = new Set([
"run.submitted",
"run.start_requested",
"run.pending",
"run.approved",
"run.denied",
"run.runnable",
"run.starting",
"run.running",
"run.removing",
"run.paused",
"run.unpaused",
"run.blocked",
"run.unblocked",
"run.cancel.requested",
"run.pause.requested",
"run.unpause.requested",
"run.completed",
"run.failed",
"run.created",
"run.lifecycle",
"run.title",
"run.parent",
"run.archived",
"run.unarchived",
"run.title.updated",
"interview.started",
"interview.completed",
"interview.timeout",
"interview.interrupted",
"run.superseded",
"pull_request.created",
"pull_request.linked",
"pull_request.unlinked",
"interview.answered",
"run.started",
"run.finished",
"run.paused",
"run.unpaused",
"run.stalled",
"invocation.cancel.requested",
"wait.state.changed",
"control.requested",
]);
const subscriptions = new Map<string, SharedEventSubscription>();
@ -86,9 +83,10 @@ export function subscribeToBoardEvents(
function boardInvalidation(payload: EventPayload) {
return {
keys: payload.event && shouldRefreshBoardForEvent(payload.event)
? boardRunKeys()
: [],
keys:
isStreamItemPayload(payload) && shouldRefreshBoardForEvent(streamItemName(payload))
? boardRunKeys()
: [],
};
}

View file

@ -8,6 +8,8 @@ import {
type CrossTabSseCoordinator,
type CrossTabSseMessage,
} from "./cross-tab-sse";
import { isStreamItemPayload, platformRecordKind } from "./petri-stream";
import { makePlatformItem } from "./test-utils";
import type { EventPayload, MutateFn } from "./sse";
type MessageHandler = ((event: { data: string }) => void) | null;
@ -190,7 +192,7 @@ describe("subscribeToCrossTabSse", () => {
subscriptionKey: "board",
keys: boardKeys,
resolveInvalidation: (payload) => ({
keys: payload.event === "run.running" ? ["board"] : [],
keys: isLifecycleItem(payload) ? ["board"] : [],
}),
resyncKeys: () => ["board-resync"],
});
@ -198,7 +200,7 @@ describe("subscribeToCrossTabSse", () => {
subscriptionKey: "run:run-1",
keys: runKeys,
resolveInvalidation: (payload) => ({
keys: payload.event === "run.running" && payload.run_id === "run-1" ? ["run"] : [],
keys: isLifecycleItem(payload) && payload.run_id === "run-1" ? ["run"] : [],
}),
resyncKeys: () => ["run-resync"],
});
@ -634,7 +636,7 @@ function subscribeForRunEvent(coordinator: CrossTabSseCoordinator, keys: string[
subscriptionKey: "run-feed",
keys,
resolveInvalidation: (payload) => ({
keys: payload.event === "run.running" ? ["event"] : [],
keys: isLifecycleItem(payload) ? ["event"] : [],
}),
resyncKeys: () => ["resync"],
});
@ -698,6 +700,7 @@ function candidateGenerations(coordinator: CrossTabSseCoordinator): number[] {
return [...inspectable.candidates.values()].map((candidate) => candidate.candidateGeneration);
}
/** A run's `run.lifecycle` stream item: `seq` is its delivery sequence. */
function runEvent({
id,
runId,
@ -707,13 +710,15 @@ function runEvent({
runId: string;
seq: number;
}) {
return {
id,
return makePlatformItem(
seq,
run_id: runId,
event: "run.running",
ts: "2026-05-04T12:00:00.000Z",
};
{ kind: "run.lifecycle", transition: "running" },
{ run_id: runId, id },
);
}
function isLifecycleItem(payload: EventPayload): boolean {
return isStreamItemPayload(payload) && platformRecordKind(payload) === "run.lifecycle";
}
async function waitFor(condition: () => boolean, timeoutMs = 500) {

View file

@ -1162,16 +1162,16 @@ function candidateKey(candidate: CandidateMessage): string {
return `${candidate.candidateGeneration}:${candidate.candidateId}`;
}
export function eventDedupeKey(payload: EventPayload): string | undefined {
if (typeof payload.id === "string" && payload.id.length > 0) {
return payload.id;
}
const runId = typeof payload.run_id === "string" ? payload.run_id : undefined;
const seq = typeof payload.seq === "number" ? payload.seq : undefined;
const event = typeof payload.event === "string" ? payload.event : undefined;
if (runId && seq != null && event) {
return `${runId}:${seq}:${event}`;
/** The key that dedupes a stream item across tabs and sources, from a parsed frame or a typed item. */
export function eventDedupeKey(payload: {
run_id?: unknown;
stream_seq?: unknown;
}): string | undefined {
// A run stream item's `id` is the item's own identity within its run (a
// Petri `EventId` or a platform record seq), so two runs share ids; the
// run and the delivery sequence together are unique.
if (typeof payload.stream_seq === "number" && typeof payload.run_id === "string") {
return `${payload.run_id}:stream:${payload.stream_seq}`;
}
return undefined;
}

View file

@ -1,10 +1,12 @@
import { describe, expect, test } from "bun:test";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import {
createCrossTabSseCoordinator,
type BroadcastChannelLike,
} from "./cross-tab-sse";
import { subscribeToLiveEvents, type LiveEventPayload } from "./live-events";
import { subscribeToLiveEvents } from "./live-events";
import { makePetriItem, makePlatformItem } from "./test-utils";
import type { EventSourceLike } from "./sse";
type MessageHandler = ((event: { data: string }) => void) | null;
@ -34,7 +36,7 @@ describe("subscribeToLiveEvents", () => {
test("coordinated mode opens /api/v1/attach and forwards every payload", async () => {
const source = new FakeEventSource();
const created: string[] = [];
const seen: LiveEventPayload[] = [];
const seen: RunStreamItem[] = [];
const coordinator = createCoordinator((url) => {
created.push(url);
return source;
@ -50,8 +52,10 @@ describe("subscribeToLiveEvents", () => {
await waitFor(() => created.length === 1);
source.emit({ id: "evt-1", event: "stage.started", run_id: "run-a" });
source.emit({ id: "evt-2", event: "agent.message", run_id: "run-b" });
source.emit(makePetriItem(1, { event: "step.started", firing: 1 }, { run_id: "run-a" }));
source.emit(makePlatformItem(1, { kind: "checkpoint" }, { run_id: "run-b" }));
// A frame that is not a stream item is dropped.
source.emit({ event: "stage.started", run_id: "run-c" });
expect(created).toEqual(["/api/v1/attach"]);
expect(seen.map((p) => p.run_id)).toEqual(["run-a", "run-b"]);
@ -63,7 +67,7 @@ describe("subscribeToLiveEvents", () => {
test("fallback mode opens /api/v1/attach (not a per-run URL) and forwards payloads", () => {
const source = new FakeEventSource();
const created: string[] = [];
const seen: LiveEventPayload[] = [];
const seen: RunStreamItem[] = [];
const coordinator = createFallbackCoordinator();
const cleanup = subscribeToLiveEvents(
@ -75,8 +79,12 @@ describe("subscribeToLiveEvents", () => {
{ coordinator },
);
source.emit({ id: "evt-1", event: "run.completed", run_id: "run-a" });
source.emit({ id: "evt-2", event: "run.failed", run_id: "run-b" });
source.emit(
makePlatformItem(9, { kind: "run.lifecycle", transition: "succeeded" }, { run_id: "run-a" }),
);
source.emit(
makePlatformItem(9, { kind: "run.lifecycle", transition: "failed" }, { run_id: "run-b" }),
);
expect(created).toEqual(["/api/v1/attach"]);
expect(seen.map((p) => p.run_id)).toEqual(["run-a", "run-b"]);

View file

@ -1,10 +1,12 @@
import { useEffect, useRef } from "react";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import type { Key } from "swr";
import {
subscribeToCrossTabSse,
type CrossTabSseCoordinator,
} from "./cross-tab-sse";
import { isStreamItemPayload } from "./petri-stream";
import { queryKeys } from "./query-keys";
import {
createBrowserEventSource,
@ -14,17 +16,6 @@ import {
type SharedEventSubscription,
} from "./sse";
export interface LiveEventPayload extends EventPayload {
id?: string;
seq?: number;
event?: string;
ts?: string;
run_id?: string;
node_id?: string;
stage_id?: string;
properties?: Record<string, unknown>;
}
interface LiveEventOptions {
coordinator?: CrossTabSseCoordinator;
}
@ -34,48 +25,50 @@ const SUBSCRIPTION_KEY = "live-events";
const NO_KEYS: Key[] = [];
const NOOP_MUTATE = () => Promise.resolve();
/**
* Every run stream item the global attach stream (`GET /api/v1/attach`)
* delivers, across all runs. A frame that is not a stream item is dropped.
*/
export function subscribeToLiveEvents(
onEvent: (payload: LiveEventPayload) => void,
onItem: (item: RunStreamItem) => void,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ coordinator }: LiveEventOptions = {},
): () => void {
return subscribeToCrossTabSse<LiveEventPayload>({
const forward = (payload: EventPayload) => {
if (isStreamItemPayload(payload)) onItem(payload);
return { keys: NO_KEYS };
};
return subscribeToCrossTabSse<EventPayload>({
coordinator,
subscriptionKey: SUBSCRIPTION_KEY,
mutate: NOOP_MUTATE,
debounceMs: 0,
resyncKeys: () => NO_KEYS,
resolveInvalidation: (payload) => {
onEvent(payload);
return { keys: NO_KEYS };
},
resolveInvalidation: forward,
fallbackSubscribe: () =>
subscribeToSharedEventSource<LiveEventPayload>({
subscribeToSharedEventSource<EventPayload>({
subscriptions,
subscriptionKey: SUBSCRIPTION_KEY,
url: queryKeys.system.attachUrl(),
mutate: NOOP_MUTATE,
eventSourceFactory,
debounceMs: 0,
resolveInvalidation: (payload) => {
onEvent(payload);
return { keys: NO_KEYS };
},
resolveInvalidation: forward,
}),
});
}
/**
* Synchronizes React with the shared live-events SSE stream. The subscription is
* closed before resubscribe and on unmount; `onEvent` sees the latest render.
* closed before resubscribe and on unmount; `onItem` sees the latest render.
*/
export function useLiveEventsSubscription(
onEvent: (payload: LiveEventPayload) => void,
onItem: (item: RunStreamItem) => void,
) {
const onEventRef = useRef(onEvent);
onEventRef.current = onEvent;
const onItemRef = useRef(onItem);
onItemRef.current = onItem;
useEffect(() => {
return subscribeToLiveEvents((payload) => onEventRef.current(payload));
return subscribeToLiveEvents((item) => onItemRef.current(item));
}, []);
}

View file

@ -23,7 +23,6 @@ import {
cancelRun,
denyRun,
isLifecycleActionError,
retryRun,
unarchiveRun,
} from "./run-actions";
@ -82,15 +81,6 @@ export function useUnarchiveRun(id: string | undefined) {
return useLifecycleMutation(id, "unarchive", unarchiveRun);
}
export function useRetryRun(id: string | undefined) {
return useLifecycleMutation(id, "retry", retryRun, (run, mutate) => {
void mutate(queryKeys.runs.detail(run.id), run, { revalidate: false });
if (run.parent_id) {
mutateRunListCaches(mutate);
}
});
}
function useLifecycleMutation(
id: string | undefined,
intent: LifecycleAction,
@ -118,12 +108,10 @@ function useLifecycleMutation(
{
onSuccess: (result) => {
if (!id || !result.ok) return;
if (intent !== "retry") {
// Keep the returned lifecycle state visible while revalidation
// observes the durable follow-up event (notably a 202 cancel).
void mutate(queryKeys.runs.detail(id), result.run, { revalidate: true });
void mutate(queryKeys.runs.usage(id));
}
// Keep the returned lifecycle state visible while revalidation
// observes the durable follow-up event (notably a 202 cancel).
void mutate(queryKeys.runs.detail(id), result.run, { revalidate: true });
void mutate(queryKeys.runs.usage(id));
mutateRunListCaches(mutate);
onSuccessExtra?.(result.run, mutate);
},

View file

@ -0,0 +1,25 @@
/**
* The Petri scenario fixtures the server tests capture
* (`lib/apps/fabro-server/tests/it/scenario/petri_stream.rs` under
* `FABRO_CAPTURE_PETRI_FIXTURES`): a settled run's projection and its whole
* stream. Test-only; `tsc` excludes the tests that import this module.
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { RunProjection, RunStreamItem } from "@qltysh/fabro-api-client";
export type PetriFixtureName = "hello" | "command" | "parallel" | "gate";
export interface PetriFixture {
run_id: string;
projection: RunProjection;
stream: RunStreamItem[];
}
const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "test-fixtures", "petri");
export function loadPetriFixture(name: PetriFixtureName): PetriFixture {
const text = readFileSync(join(FIXTURES_DIR, `${name}.json`), "utf8");
return JSON.parse(text) as PetriFixture;
}

View file

@ -0,0 +1,326 @@
import { describe, expect, test } from "bun:test";
import { loadPetriFixture } from "./petri-fixtures";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import {
agentEnvelopesOf,
commandOutcomeOf,
commandScriptOf,
debugRowsFromStream,
deriveRunPhasesFromStream,
extractPetriStageContext,
findPetriEdgeForStage,
isStreamItemPayload,
isTerminalLifecycleItem,
itemsForStage,
parallelOverviewFromProjection,
matchedCondition,
outputLossNote,
parsePetriInterviewPairs,
petriEventName,
petriStageLabel,
platformRecordKind,
platformRecordsOf,
reducerTranscriptFromProjection,
stagesFromProjection,
streamItemName,
} from "./petri-stream";
const hello = loadPetriFixture("hello");
const command = loadPetriFixture("command");
const parallel = loadPetriFixture("parallel");
const gate = loadPetriFixture("gate");
describe("stream items", () => {
test("a fixture run executes on Petri and its stream is dense", () => {
for (const fixture of [hello, command, parallel, gate]) {
const seqs = fixture.stream.map((item) => item.stream_seq);
expect(seqs).toEqual(seqs.map((_, index) => index + 1));
for (const item of fixture.stream) {
expect(isStreamItemPayload(item)).toBe(true);
expect(item.run_id).toBe(fixture.run_id);
}
}
expect(isStreamItemPayload({ event: "run.completed", seq: 3 })).toBe(false);
});
test("a Petri item is named by its recorded event and a platform item by its kind", () => {
const names = command.stream.map(streamItemName);
expect(names[0]).toBe("run.created");
expect(names).toContain("run.started");
expect(names).toContain("visit.started");
expect(names).toContain("step.finished");
expect(names[names.length - 2]).toBe("run.finished");
expect(names[names.length - 1]).toBe("run.lifecycle");
const created = command.stream[0];
expect(platformRecordKind(created)).toBe("run.created");
expect(petriEventName(created)).toBeUndefined();
});
test("the stage label is the subject's node@visit and skips the fork's delegates", () => {
const labels = new Set(
parallel.stream.map(petriStageLabel).filter((label): label is string => label != null),
);
expect(labels).toEqual(new Set(["start@1", "fork@1", "a@1", "b@1", "merge@1", "exit@1"]));
// The parent execution holds a `parallel.branch` delegate named after
// each branch; only the child execution's own node is the stage.
const starts = parallel.stream.filter(
(item) => petriEventName(item) === "visit.started" && petriStageLabel(item) === "a@1",
);
expect(starts).toHaveLength(1);
expect(itemsForStage(command.stream, "say@1").map(petriEventName)).toEqual([
"visit.started",
"wait.state.changed",
"admission.decided",
"step.started",
"wait.state.changed",
"step.progress.recorded",
// The command's log line, then the checkpoint hook's note.
"step.progress.recorded",
"step.finished",
"visit.completed",
"routing.resolved",
"route.applied",
"token.emitted",
]);
});
});
describe("questions", () => {
test("a gate's question pairs with its delivered answer and the answering principal", () => {
const pairs = parsePetriInterviewPairs(itemsForStage(gate.stream, "gate@1").concat(
gate.stream.filter((item) => platformRecordKind(item) === "interview.answered"),
));
expect(pairs).toHaveLength(1);
const [pair] = pairs;
expect(pair.question.questionId).toBe("gate#2");
expect(pair.question.question).toBe("Go?");
expect(pair.question.questionType).toBe("yes_no");
expect(pair.question.options.map((option) => option.key)).toEqual(["Y", "N"]);
expect(pair.question.allowFreeform).toBe(false);
expect(pair.resolution).toMatchObject({ kind: "answered", answer: "N", actor: "dev" });
expect(pair.resolution?.kind === "answered" && pair.resolution.durationMs).toBeGreaterThan(0);
});
test("a run without a gate asks nothing", () => {
expect(parsePetriInterviewPairs(command.stream)).toEqual([]);
});
});
describe("run phases", () => {
test("the phases come from the platform lifecycle records", () => {
const createdAt = parallel.projection.status_updated_at;
const created = parallel.stream[0];
const phases = deriveRunPhasesFromStream(
parallel.stream,
new Date(created.recorded_at).toISOString(),
);
expect(phases.map((phase) => phase.kind)).toEqual(["submitted", "runnable", "initializing"]);
for (const phase of phases) {
expect(phase.endMs).not.toBeNull();
expect(phase.startMs).toBeLessThanOrEqual(phase.endMs!);
}
expect(createdAt).toBeDefined();
const terminal = parallel.stream.filter(isTerminalLifecycleItem);
expect(terminal).toHaveLength(1);
expect(terminal[0]).toBe(parallel.stream[parallel.stream.length - 1]);
});
});
describe("platform records", () => {
test("a notice recorded between the branches lists with its message", () => {
const records = platformRecordsOf(parallel.stream);
const notices = records.filter((record) => record.kind === "run.notice");
expect(notices).toHaveLength(1);
expect(notices[0].detail).toBe("recorded while both branches ran");
expect(notices[0].stageKey).toBeNull();
});
test("each stage's checkpoint lists with its commit and its stage", () => {
const records = platformRecordsOf(parallel.stream);
const checkpoints = records.filter((record) => record.kind === "checkpoint");
// start, fork, a, b, merge, exit, and the two branch delegates.
expect(checkpoints.length).toBeGreaterThanOrEqual(6);
for (const checkpoint of checkpoints) {
expect(checkpoint.detail).toMatch(/^[0-9a-f]{12}$/);
expect(checkpoint.stageKey).not.toBeNull();
}
const order = records.map((record) => record.kind);
expect(order.indexOf("checkpoint")).toBeLessThan(order.indexOf("run.notice"));
});
test("the debug rows name every item and carry its stage", () => {
const rows = debugRowsFromStream(parallel.stream);
expect(rows).toHaveLength(parallel.stream.length);
const notice = rows.find((row) => row.event === "run.notice");
expect(notice?.category).toBe("platform");
const started = rows.find((row) => row.event === "visit.started" && row.stageLabel === "b@1");
expect(started?.category).toBe("petri");
expect(rows.every((row) => !Number.isNaN(Date.parse(row.ts)))).toBe(true);
});
});
describe("stage renderers", () => {
test("the fork's branches and results come from the projection", () => {
const fork = parallel.projection.stages["fork@1"];
const overview = parallelOverviewFromProjection(fork);
expect(overview.branchCount).toBe(2);
expect(overview.results.map((result) => [result.id, result.index, result.status])).toEqual([
["a", 0, "succeeded"],
["b", 1, "succeeded"],
]);
const stages = stagesFromProjection(parallel.projection);
const branches = stages.filter((stage) => stage.parallelGroupId === "fork@1");
expect(branches.map((stage) => [stage.id, stage.parallelBranchIndex])).toEqual([
["a@1", 0],
["b@1", 1],
]);
expect(stages.map((stage) => stage.id)).toEqual([
"start@1",
"fork@1",
"a@1",
"b@1",
"merge@1",
"exit@1",
]);
});
test("the fan-in with no reducer has no transcript", () => {
expect(reducerTranscriptFromProjection(parallel.projection.stages["merge@1"])).toBeNull();
const greet = reducerTranscriptFromProjection(hello.projection.stages["greet@1"]);
expect(greet?.response).toBe("A haiku, added.");
});
test("the edge a stage took is its route.applied target", () => {
expect(findPetriEdgeForStage(gate.stream, "gate@1")).toEqual({
fromNode: "gate",
toNode: "no",
reason: "condition",
condition: null,
isJump: false,
});
expect(findPetriEdgeForStage(gate.stream, "exit@1")).toBeNull();
});
test("a command stage's outcome is read from its final step.finished", () => {
const say = itemsForStage(command.stream, "say@1");
expect(commandOutcomeOf(say).exitCode).toBe(0);
expect(commandOutcomeOf(say).outputLoss).toBeNull();
expect(extractPetriStageContext(say)).toBeNull();
});
test("an agent stage's projection lists the tools its session was offered", () => {
const names = (hello.projection.stages["greet@1"]?.agent_tools ?? []).map((tool) => tool.name);
expect(names).toContain("read_file");
expect(names).toContain("shell");
expect(names).toContain("request_user_input");
expect(hello.projection.stages["start@1"]?.agent_tools ?? []).toEqual([]);
});
test("a command stage's script rides on its node's meta", () => {
const say = itemsForStage(command.stream, "say@1");
expect(commandScriptOf(say)).toBe("echo hello from petri");
expect(commandScriptOf(itemsForStage(command.stream, "start@1"))).toBeNull();
});
test("the condition an edge matched is read from the node's edge table", () => {
const applied = (edge: number): RunStreamItem => ({
run_id: "run",
stream_seq: 9,
kind: "petri",
id: "9",
recorded_at: 1_789_706_579_000,
item: {
id: { log: "execution", execution: 0, seq: 9, index: 0 },
origin: "core",
context: { invocation: 0, execution: 0 },
subject: {
node: {
id: 2,
name: "build",
kind: "attractor/command",
meta: {
kind: "command",
edges: {
"0": { to: "ok", label: null, condition: "outcome=succeeded" },
"1": { to: "bad", label: null },
},
},
},
firing: 2,
visit: 1,
attempt: 1,
generation: 0,
branch: { role: "none" },
},
record: {
seq: 9,
body: { event: "route.applied", kind: "edge", firing: 2, group: 0, edge },
},
derived: { target: { name: edge === 0 ? "ok" : "bad" }, transition: "Continue", back: false },
},
});
expect(matchedCondition(applied(0))).toBe("outcome=succeeded");
expect(matchedCondition(applied(1))).toBeUndefined();
expect(findPetriEdgeForStage([applied(0)], "build@1")).toEqual({
fromNode: "build",
toNode: "ok",
reason: "condition",
condition: "outcome=succeeded",
isJump: false,
});
});
test("a command's output loss is read from its metrics and worded for the view", () => {
const finished = (custom: Record<string, unknown>): RunStreamItem => ({
run_id: "run",
stream_seq: 5,
kind: "petri",
id: "5",
recorded_at: 1_789_706_579_000,
item: {
id: { log: "execution", execution: 0, seq: 5, index: 0 },
origin: "external",
context: { invocation: 0, execution: 0 },
subject: { node: { id: 2, name: "say", kind: "attractor/command", meta: { kind: "command" } }, firing: 2, visit: 1, attempt: 1, generation: 0, branch: { role: "none" } },
record: {
seq: 5,
body: {
event: "step.finished",
firing: 2,
attempt: 1,
outcome: {
status: "success",
output: { stdout: "x", exit_status: 0 },
metrics: { duration_ms: 3, exit_code: 0, custom },
},
},
},
derived: { final: true, exhausted: false },
},
});
expect(commandOutcomeOf([finished({})]).outputLoss).toBeNull();
const cut = commandOutcomeOf([
finished({ "output.dropped_bytes": 2048, "output.truncated_lines": 1 }),
]).outputLoss;
expect(cut).toEqual({ droppedBytes: 2048, truncatedLines: 1, incomplete: false });
expect(outputLossNote(cut)).toBe("Output truncated: 2,048 bytes dropped, 1 line cut");
const silent = commandOutcomeOf([finished({ "output.incomplete": true })]).outputLoss;
expect(outputLossNote(silent)).toBe(
"Output may be incomplete: the capture ended on silence, so the tail may be missing",
);
expect(outputLossNote(null)).toBeNull();
});
test("an agent stage's Pebble envelopes are read with their variant and session", () => {
const envelopes = agentEnvelopesOf(itemsForStage(hello.stream, "greet@1"));
expect(envelopes.length).toBeGreaterThan(0);
expect(envelopes[0].variant).toBe("SessionStarted");
expect(envelopes[0].payload).toEqual({ provider: "openai", model: "gpt-5.4" });
expect(envelopes.every((envelope) => envelope.sessionId?.startsWith("ses_"))).toBe(true);
const message = envelopes.find((envelope) => envelope.variant === "AssistantMessage");
expect(message?.payload.text).toBe("A haiku, added.");
expect(agentEnvelopesOf(itemsForStage(command.stream, "say@1"))).toEqual([]);
});
});

View file

@ -0,0 +1,781 @@
/**
* Pure helpers over a Petri run's stream: the `RunStreamItem`s
* `GET /runs/{id}/events` serves for a run that executes on Petri. Each item
* is a Petri `RunEvent` (Petri's event contract, passed through as JSON) or a
* stored platform record (Fabro's own fact about the run), in one envelope
* keyed by `stream_seq`. The mapping from items to views follows
* `lib/components/fabro-petri/VIEWS.md`.
*/
import { StageOutcome, StageState } from "@qltysh/fabro-api-client";
import type {
RunProjection,
RunStreamItem,
StageProjection,
} from "@qltysh/fabro-api-client";
import type {
EdgeSelection,
HumanInterviewPair,
HumanResolution,
InterviewOption,
ParallelOverview,
ReducerTranscript,
StageContextData,
} from "../components/stage-renderers/helpers";
import { principalLabel } from "../components/stage-renderers/helpers";
import { principalDisplay } from "./principal-display";
import type { Stage } from "./stage-sidebar";
import type { RunPhase, RunPhaseKind } from "./run-phases";
import { formatDurationMs } from "./format";
import {
getArray,
getBool,
getNumber,
getObject,
getString,
isRecord,
type UnknownRecord,
} from "./unknown";
export type PetriStream = ReadonlyArray<RunStreamItem>;
export function isPetriItem(item: RunStreamItem): boolean {
return item.kind === "petri";
}
export function isPlatformItem(item: RunStreamItem): boolean {
return item.kind === "platform";
}
/** Whether an SSE frame, parsed, is a run stream item. */
export function isStreamItemPayload(
payload: unknown,
): payload is RunStreamItem {
return (
isRecord(payload) &&
typeof payload.stream_seq === "number" &&
(payload.kind === "petri" || payload.kind === "platform")
);
}
function record(item: RunStreamItem): UnknownRecord | undefined {
return getObject(item.item, "record");
}
function derived(item: RunStreamItem): UnknownRecord | undefined {
return getObject(item.item, "derived");
}
/** The stored platform record's `kind`, for a platform item. */
export function platformRecordKind(item: RunStreamItem): string | undefined {
if (!isPlatformItem(item)) return undefined;
return getString(record(item), "kind");
}
/**
* The `<subject>.<verb>` name of a Petri event: the recorded body's `event`
* tag, or a view event's tag under `derived`.
*/
export function petriEventName(item: RunStreamItem): string | undefined {
if (!isPetriItem(item)) return undefined;
return (
getString(getObject(record(item), "body"), "event") ??
getString(derived(item), "event")
);
}
/** The name a listing shows: the Petri event name or the platform kind. */
export function streamItemName(item: RunStreamItem): string {
return petriEventName(item) ?? platformRecordKind(item) ?? item.kind;
}
/** When the item's record was appended, as an ISO timestamp. */
export function streamItemTs(item: RunStreamItem): string {
return new Date(item.recorded_at).toISOString();
}
/** Petri's reading of a `step.progress.recorded` payload (`derived.parsed`). */
export function petriParsed(item: RunStreamItem): UnknownRecord | undefined {
return getObject(derived(item), "parsed");
}
/** The recorded event body of a Petri item (`record.body`). */
export function petriBody(item: RunStreamItem): UnknownRecord | undefined {
return getObject(record(item), "body");
}
function subject(item: RunStreamItem): UnknownRecord | undefined {
return getObject(item.item, "subject");
}
function subjectNode(item: RunStreamItem): UnknownRecord | undefined {
return getObject(subject(item), "node");
}
/**
* Whether the subject's node is a stage of its own. A lowering node (the
* `parallel.branch` delegate the fork's execution holds for each branch, a
* synthetic fan-in placeholder) shares a name with a real stage and is not
* one.
*/
function isShownNode(node: UnknownRecord | undefined): boolean {
if (!node) return false;
const meta = getObject(node, "meta");
if (getBool(meta, "synthetic") === true) return false;
return getString(meta, "kind") !== "parallel.branch";
}
/**
* The stage label (`node@visit`) of a Petri item's subject, or `undefined`
* for an item with no subject or one whose node is a lowering node.
*/
export function petriStageLabel(item: RunStreamItem): string | undefined {
const node = subjectNode(item);
if (!isShownNode(node)) return undefined;
const name = getString(node, "name");
if (!name) return undefined;
const visit = getNumber(subject(item), "visit") ?? 1;
return `${name}@${visit}`;
}
/** The subject's stage key, `(execution, firing)`, for an item under a firing. */
export function petriStageKey(item: RunStreamItem): string | undefined {
const firing = getNumber(subject(item), "firing");
const execution = getNumber(getObject(item.item, "context"), "execution");
if (firing === undefined || execution === undefined) return undefined;
return `${execution}:${firing}`;
}
/** The items whose subject is the stage with this label, in stream order. */
export function itemsForStage(
stream: PetriStream,
stageLabel: string,
): RunStreamItem[] {
return stream.filter((item) => petriStageLabel(item) === stageLabel);
}
// ── Questions ───────────────────────────────────────────────────────────
function parseOptions(value: unknown): InterviewOption[] {
if (!Array.isArray(value)) return [];
const out: InterviewOption[] = [];
for (const entry of value) {
const key = getString(entry, "key");
const label = getString(entry, "label");
if (!key || !label) continue;
const option: InterviewOption = { key, label };
const description = getString(entry, "description");
const preview = getString(entry, "preview");
if (description !== undefined) option.description = description;
if (preview !== undefined) option.preview = preview;
out.push(option);
}
return out;
}
/**
* Who answered, from the `interview.answered` record's `Principal`: the
* user's login, or the legacy label for an actor shaped as the old events
* carried it.
*/
function answeringPrincipalLabel(principal: unknown): string | null {
if (!isRecord(principal)) return null;
const kind = getString(principal, "kind");
if (kind === "user" && getString(principal, "login")) {
return principalDisplay(principal as unknown as Parameters<typeof principalDisplay>[0]).label;
}
return principalLabel(principal);
}
function answerText(answer: UnknownRecord): string {
const choice = getString(answer, "choice");
if (choice) return choice;
const text = getString(answer, "text");
if (text) return text;
const choices = getArray(answer, "choices");
if (choices) return choices.filter((c): c is string => typeof c === "string").join(", ");
if (getBool(answer, "cancelled") === true) return "";
if (getBool(answer, "confirmed") !== undefined) {
return getBool(answer, "confirmed") ? "yes" : "no";
}
for (const value of Object.values(answer)) {
if (typeof value === "string") return value;
}
return "";
}
/**
* Pair each question a stage asked (`step.progress.recorded` with
* `derived.parsed.kind === "question"`) with what resolved it: the delivered
* `control.requested` answer, a `question_expired` reading, or a cancelled
* answer. The answering principal comes from the `interview.answered`
* platform record keyed on Petri's question id.
*/
export function parsePetriInterviewPairs(stream: PetriStream): HumanInterviewPair[] {
const pairs = new Map<string, HumanInterviewPair>();
const actors = new Map<string, string | null>();
const askedAt = new Map<string, number>();
for (const item of stream) {
if (isPlatformItem(item)) {
const rec = record(item);
if (getString(rec, "kind") === "interview.answered") {
const question = getString(rec, "question");
if (question) actors.set(question, answeringPrincipalLabel(rec?.principal));
}
continue;
}
const name = petriEventName(item);
const parsed = petriParsed(item);
if (name === "step.progress.recorded" && getString(parsed, "kind") === "question") {
const question = getObject(parsed, "question");
const id = getString(question, "id");
if (!id) continue;
const timeoutMs = getNumber(question, "timeout_ms");
askedAt.set(id, item.recorded_at);
pairs.set(id, {
question: {
ts: streamItemTs(item),
questionId: id,
question: getString(question, "text") ?? "",
questionType: getString(question, "kind") ?? "freeform",
options: parseOptions(question?.options),
allowFreeform: getBool(question, "freeform") === true,
timeoutSeconds: timeoutMs !== undefined ? Math.round(timeoutMs / 1000) : null,
contextDisplay: getString(question, "context") ?? null,
reviewTarget: null,
},
resolution: null,
});
continue;
}
if (name === "step.progress.recorded" && getString(parsed, "kind") === "question_expired") {
const expired = getObject(parsed, "expired");
const id = getString(expired, "question");
const pair = id ? pairs.get(id) : undefined;
if (!pair || !id) continue;
pair.resolution = {
kind: "timeout",
ts: streamItemTs(item),
durationMs: getNumber(expired, "waited_ms") ?? item.recorded_at - (askedAt.get(id) ?? item.recorded_at),
};
continue;
}
if (name === "control.requested") {
const d = derived(item);
const answer = getObject(d, "answer");
const id = getString(answer, "question");
const pair = id ? pairs.get(id) : undefined;
if (!pair || !id || !answer) continue;
if (getBool(d, "deliverable") === false) continue;
const durationMs = item.recorded_at - (askedAt.get(id) ?? item.recorded_at);
const resolution: HumanResolution =
getBool(answer, "cancelled") === true
? {
kind: "interrupted",
ts: streamItemTs(item),
reason: "cancelled",
durationMs,
actor: null,
}
: {
kind: "answered",
ts: streamItemTs(item),
answer: answerText(answer),
durationMs,
actor: null,
};
pair.resolution = resolution;
}
}
for (const pair of pairs.values()) {
const resolution = pair.resolution;
if (resolution && resolution.kind !== "timeout") {
resolution.actor = actors.get(pair.question.questionId) ?? null;
}
}
return Array.from(pairs.values()).sort((a, b) => a.question.ts.localeCompare(b.question.ts));
}
// ── Run phases ──────────────────────────────────────────────────────────
const PHASE_LABEL: Record<RunPhaseKind, string> = {
submitted: "Submitted",
pending: "Pending",
runnable: "Runnable",
initializing: "Initializing",
};
const TERMINAL_TRANSITIONS: ReadonlySet<string> = new Set(["succeeded", "failed", "dead"]);
/** Whether a platform item is the run's terminal lifecycle record. */
export function isTerminalLifecycleItem(item: RunStreamItem): boolean {
if (platformRecordKind(item) !== "run.lifecycle") return false;
const transition = getString(record(item), "transition");
return transition !== undefined && TERMINAL_TRANSITIONS.has(transition);
}
/**
* The run's phases before its stages own the timeline, from the platform
* `run.lifecycle` records.
*/
export function deriveRunPhasesFromStream(
stream: PetriStream,
createdAtIso: string,
): RunPhase[] {
const createdMs = Date.parse(createdAtIso);
if (Number.isNaN(createdMs)) return [];
let startRequestedMs: number | null = null;
let pendingMs: number | null = null;
let runnableMs: number | null = null;
let startingMs: number | null = null;
let runningMs: number | null = null;
let terminalMs: number | null = null;
for (const item of stream) {
if (platformRecordKind(item) !== "run.lifecycle") continue;
const transition = getString(record(item), "transition");
const ms = item.recorded_at;
switch (transition) {
case "start_requested":
startRequestedMs ??= ms;
break;
case "pending":
pendingMs ??= ms;
break;
case "runnable":
runnableMs ??= ms;
break;
case "starting":
startingMs ??= ms;
break;
case "running":
runningMs ??= ms;
break;
case "succeeded":
case "failed":
case "dead":
terminalMs ??= ms;
break;
default:
break;
}
}
const phases: RunPhase[] = [];
phases.push({
kind: "submitted",
label: PHASE_LABEL.submitted,
startMs: createdMs,
endMs: startRequestedMs ?? pendingMs ?? runnableMs ?? startingMs ?? runningMs ?? terminalMs,
});
if (pendingMs != null) {
phases.push({
kind: "pending",
label: PHASE_LABEL.pending,
startMs: pendingMs,
endMs: runnableMs ?? startingMs ?? runningMs ?? terminalMs,
});
}
if (runnableMs != null) {
phases.push({
kind: "runnable",
label: PHASE_LABEL.runnable,
startMs: runnableMs,
endMs: startingMs ?? runningMs ?? terminalMs,
});
}
if (startingMs != null) {
phases.push({
kind: "initializing",
label: PHASE_LABEL.initializing,
startMs: startingMs,
endMs: runningMs ?? terminalMs,
});
}
return phases;
}
// ── Platform records ────────────────────────────────────────────────────
export interface PlatformRecordEntry {
streamSeq: number;
kind: string;
ts: string;
/** The stage the record belongs to, as `execution:firing`, if any. */
stageKey: string | null;
/** A one-line summary: the commit sha, the pull request url, the notice. */
detail: string | null;
}
/** The platform records on the stream that name a Fabro fact worth a row. */
export function platformRecordsOf(stream: PetriStream): PlatformRecordEntry[] {
const out: PlatformRecordEntry[] = [];
for (const item of stream) {
const kind = platformRecordKind(item);
if (!kind) continue;
const rec = record(item) ?? {};
const position = getObject(item.item, "position");
const execution = getNumber(position, "execution") ?? getNumber(rec, "execution");
const firing = getNumber(position, "firing") ?? getNumber(rec, "firing");
const stageKey =
execution !== undefined && firing !== undefined ? `${execution}:${firing}` : null;
let detail: string | null = null;
switch (kind) {
case "checkpoint":
detail = getString(rec, "git_commit_sha")?.slice(0, 12) ?? null;
break;
case "pull_request.created":
detail = getString(rec, "html_url") ?? getString(rec, "url") ?? null;
break;
case "run.notice":
detail = getString(rec, "message") ?? getString(rec, "code") ?? null;
break;
case "run.title":
detail = getString(rec, "title") ?? null;
break;
case "run.branch":
detail = getString(rec, "run_branch") ?? null;
break;
default:
continue;
}
out.push({ streamSeq: item.stream_seq, kind, ts: streamItemTs(item), stageKey, detail });
}
return out;
}
// ── Debug rows ──────────────────────────────────────────────────────────
/** A stream item as the events listing and the stage debug tab show it. */
export interface DebugRow {
/** The `stream_seq`: the row's key and the cursor. */
seq: number;
/** The `<subject>.<verb>` name or the platform record kind. */
event: string;
ts: string;
category: "petri" | "platform";
stageLabel: string | null;
/** The raw item, for the details panel. */
item: RunStreamItem;
}
export function debugRowsFromStream(stream: PetriStream): DebugRow[] {
return stream.map((item) => ({
seq: item.stream_seq,
event: streamItemName(item),
ts: streamItemTs(item),
category: isPlatformItem(item) ? "platform" : "petri",
stageLabel: petriStageLabel(item) ?? null,
item,
}));
}
/** The text a search box matches a row against. */
export function debugRowSearchText(row: DebugRow): string {
const body = isPlatformItem(row.item)
? record(row.item)
: { ...(petriBody(row.item) ?? {}), derived: derived(row.item) ?? {} };
return `${row.event} ${row.stageLabel ?? ""} ${JSON.stringify(body ?? {})}`.toLowerCase();
}
// ── Stage renderers ─────────────────────────────────────────────────────
/**
* The edge a stage's firing took, from its `route.applied` record:
* `derived.target` is the node Petri resolved, `kind` says whether the edge
* was followed or jumped to.
*/
export function findPetriEdgeForStage(
stream: PetriStream,
stageLabel: string,
): EdgeSelection | null {
let latest: EdgeSelection | null = null;
for (const item of stream) {
if (petriEventName(item) !== "route.applied") continue;
if (petriStageLabel(item) !== stageLabel) continue;
const target = getString(getObject(derived(item), "target"), "name");
if (!target) continue;
const body = petriBody(item);
const kind = getString(body, "kind") ?? "edge";
latest = {
fromNode: getString(subjectNode(item), "name") ?? stageLabel,
toNode: target,
reason: kind === "jump" ? "jump" : "condition",
condition: matchedCondition(item) ?? null,
isJump: kind === "jump",
};
}
return latest;
}
const STAGE_OUTCOMES: ReadonlySet<string> = new Set(Object.values(StageOutcome));
/** The fork's branches as the projection carries them (`parallel_results`). */
export function parallelOverviewFromProjection(
stage: StageProjection | undefined,
): ParallelOverview {
const results = (stage?.parallel_results ?? [])
.map((result) => {
const status = STAGE_OUTCOMES.has(result.status) ? (result.status as StageOutcome) : null;
if (!status) return null;
return {
id: result.id,
index: result.index ?? null,
itemLabel: result.item_label ?? null,
status,
};
})
.filter((r): r is NonNullable<typeof r> => r != null);
return { branchCount: results.length > 0 ? results.length : null, results };
}
/** The fan-in's reducer prompt and response, from the projection. */
export function reducerTranscriptFromProjection(
stage: StageProjection | undefined,
): ReducerTranscript | null {
if (!stage?.prompt && !stage?.response) return null;
const tokens = stage.usage?.tokens;
return {
prompt: stage.prompt ?? "",
response: stage.response ?? "",
model: stage.provider_used?.model ?? stage.model?.model_id ?? null,
inputTokens: tokens?.input ?? 0,
outputTokens: tokens?.output ?? 0,
};
}
// The command step's own bookkeeping (`command.output`, `failure_class`)
// joins the engine keys the Context tab hides.
const ENGINE_CONTEXT_KEYS = new Set([
"last_stage",
"last_response",
"command.output",
"failure_class",
]);
const ENGINE_CONTEXT_PREFIXES = ["response.", "internal.", "current.", "human.gate.", "parallel."];
function isEngineContextKey(key: string): boolean {
if (ENGINE_CONTEXT_KEYS.has(key)) return true;
return ENGINE_CONTEXT_PREFIXES.some((prefix) => key.startsWith(prefix));
}
/**
* The workflow's deliberate outputs from the stage's final `step.finished`:
* its `outcome.context_updates` minus the engine's keys.
*/
export function extractPetriStageContext(items: PetriStream): StageContextData | null {
let latest: StageContextData | null = null;
for (const item of items) {
if (petriEventName(item) !== "step.finished") continue;
if (getBool(derived(item), "final") === false) continue;
const outcome = getObject(petriBody(item), "outcome");
const rawUpdates = getObject(outcome, "context_updates") ?? {};
const updates: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawUpdates)) {
if (!isEngineContextKey(key)) updates[key] = value;
}
if (Object.keys(updates).length === 0) {
latest = null;
continue;
}
latest = { routing: { preferredLabel: null, suggestedNextIds: [] }, updates };
}
return latest;
}
/** A Pebble `CodingAgentEvent` envelope a stage's step recorded. */
export interface PetriAgentEnvelope {
ts: string;
streamSeq: number;
/** The Pebble variant name, e.g. `AssistantMessage`. */
variant: string;
/** The variant's fields. */
payload: UnknownRecord;
sessionId: string | null;
parentSessionId: string | null;
}
/**
* The backend envelopes among a stage's items: a `step.progress.recorded`
* whose custom payload carries a string `kind` (the backend) and an `event`
* object, Pebble's `CodingAgentEvent` as recorded: `{seq, stream_id,
* session_id, parent_session_id?, timestamp, event: {Variant: {...}}}`.
*/
export function agentEnvelopesOf(items: PetriStream): PetriAgentEnvelope[] {
const out: PetriAgentEnvelope[] = [];
for (const item of items) {
if (petriEventName(item) !== "step.progress.recorded") continue;
const custom = getObject(getObject(petriBody(item), "ev"), "custom");
const envelope = getObject(custom, "event");
if (!custom || !envelope || !getString(custom, "kind")) continue;
const event = getObject(envelope, "event");
if (!event) continue;
let variant: string | null = null;
let payload: UnknownRecord = {};
for (const [key, value] of Object.entries(event)) {
variant = key;
payload = isRecord(value) ? value : {};
break;
}
if (!variant) continue;
out.push({
ts: getString(envelope, "timestamp") ?? streamItemTs(item),
streamSeq: item.stream_seq,
variant,
payload,
sessionId: getString(envelope, "session_id") ?? null,
parentSessionId: getString(envelope, "parent_session_id") ?? null,
});
}
return out;
}
/**
* The condition a `route.applied` item's edge matched, as written: the
* record's `edge` keys the subject node's `meta.edges`, whose entry carries
* the edge's `condition` when it has one (EVENTS.md "Source metadata").
*/
export function matchedCondition(item: RunStreamItem): string | undefined {
const edge = getNumber(petriBody(item), "edge");
if (edge === undefined) return undefined;
const edges = getObject(getObject(subjectNode(item), "meta"), "edges");
return getString(getObject(edges, String(edge)), "condition");
}
/**
* A command stage's script: the text the step runs rides on the node's
* `meta.script`, on every event of the stage (EVENTS.md "Source metadata").
*/
export function commandScriptOf(items: PetriStream): string | null {
for (const item of items) {
const script = getString(getObject(subjectNode(item), "meta"), "script");
if (script) return script;
}
return null;
}
/**
* What a command's output capture did not keep, from the final
* `step.finished` metrics: `output.dropped_bytes` and
* `output.truncated_lines` count what the caps cut; `output.incomplete`
* says the capture ended on silence, so the tail may be missing by an
* amount nobody counted. Absent when the output is whole.
*/
export interface CommandOutputLoss {
droppedBytes: number;
truncatedLines: number;
incomplete: boolean;
}
/**
* The exit code, duration and output loss of the stage's final
* `step.finished`: the command step's output carries `exit_status`, its
* metrics the duration and the loss counters under `custom`.
*/
export function commandOutcomeOf(items: PetriStream): {
exitCode: number | null;
durationMs: number;
outputLoss: CommandOutputLoss | null;
} {
let exitCode: number | null = null;
let durationMs = 0;
let outputLoss: CommandOutputLoss | null = null;
for (const item of items) {
if (petriEventName(item) !== "step.finished") continue;
const outcome = getObject(petriBody(item), "outcome");
const output = getObject(outcome, "output");
const metrics = getObject(outcome, "metrics");
exitCode =
getNumber(output, "exit_status") ?? getNumber(metrics, "exit_code") ?? exitCode;
durationMs = getNumber(metrics, "duration_ms") ?? durationMs;
const custom = getObject(metrics, "custom");
const droppedBytes = getNumber(custom, "output.dropped_bytes") ?? 0;
const truncatedLines = getNumber(custom, "output.truncated_lines") ?? 0;
const incomplete = getBool(custom, "output.incomplete") === true;
outputLoss =
droppedBytes > 0 || truncatedLines > 0 || incomplete
? { droppedBytes, truncatedLines, incomplete }
: null;
}
return { exitCode, durationMs, outputLoss };
}
/** The one-line note the stage view shows beside output that is not whole. */
export function outputLossNote(loss: CommandOutputLoss | null): string | null {
if (!loss) return null;
const parts: string[] = [];
if (loss.droppedBytes > 0) {
parts.push(`${loss.droppedBytes.toLocaleString()} bytes dropped`);
}
if (loss.truncatedLines > 0) {
parts.push(`${loss.truncatedLines} ${loss.truncatedLines === 1 ? "line" : "lines"} cut`);
}
const counted = parts.length > 0 ? `Output truncated: ${parts.join(", ")}` : null;
if (!loss.incomplete) return counted;
const tail = "the capture ended on silence, so the tail may be missing";
return counted ? `${counted}; ${tail}` : `Output may be incomplete: ${tail}`;
}
// ── Stages from the projection ──────────────────────────────────────────
const STAGE_STATES: ReadonlySet<string> = new Set(Object.values(StageState));
/**
* The sidebar stages a projection describes, sorted by their first event.
* The API serves the same rows through `/runs/{id}/stages`; this derivation
* lets a view (and a test) build them from the projection alone.
*/
export function stagesFromProjection(projection: RunProjection): Stage[] {
const stages: Stage[] = [];
for (const [id, stage] of Object.entries(projection.stages ?? {})) {
const at = id.lastIndexOf("@");
const name = at > 0 ? id.slice(0, at) : id;
const visit = at > 0 ? Number.parseInt(id.slice(at + 1), 10) || 1 : 1;
const branch = stage.parallel_branch_id ?? null;
const branchAt = branch ? branch.lastIndexOf(":") : -1;
const status = STAGE_STATES.has(stage.state) ? stage.state : StageState.PENDING;
stages.push({
id,
name,
handler: (stage as { handler?: Stage["handler"] }).handler ?? "agent",
nodeId: name,
visit,
graphVisit: (stage as { graph_visit?: number | null }).graph_visit ?? null,
resumedFromStageId: null,
parallelGroupId: branch && branchAt > 0 ? branch.slice(0, branchAt) : null,
parallelBranchIndex:
branch && branchAt > 0 ? Number.parseInt(branch.slice(branchAt + 1), 10) : null,
status,
duration:
stage.timing?.wall_time_ms != null ? formatDurationMs(stage.timing.wall_time_ms) : "--",
startedAt: stage.started_at ?? null,
providerUsed: stage.provider_used ?? null,
usage: stage.usage,
firstEventSeq: stage.first_event_seq,
} as Stage & { firstEventSeq: number });
}
// Stages list in the order they started. The branches of one fork start
// concurrently, so among themselves they list by branch index, anchored
// at the first of them to start.
const seqOf = (stage: Stage) => (stage as Stage & { firstEventSeq?: number }).firstEventSeq ?? 0;
const groupAnchor = new Map<string, number>();
for (const stage of stages) {
if (stage.parallelGroupId == null) continue;
const anchor = groupAnchor.get(stage.parallelGroupId);
if (anchor == null || seqOf(stage) < anchor) groupAnchor.set(stage.parallelGroupId, seqOf(stage));
}
const sortKey = (stage: Stage): [number, number] =>
stage.parallelGroupId == null
? [seqOf(stage), -1]
: [groupAnchor.get(stage.parallelGroupId) ?? seqOf(stage), stage.parallelBranchIndex ?? -1];
stages.sort((a, b) => {
const [aSeq, aIndex] = sortKey(a);
const [bSeq, bIndex] = sortKey(b);
return aSeq - bSeq || aIndex - bIndex;
});
return stages.map(({ firstEventSeq: _, ...stage }: Stage & { firstEventSeq?: number }) => stage);
}

View file

@ -11,7 +11,6 @@ import type {
CommandLogResponse,
Environment,
EnvironmentListResponse,
EventEnvelope,
ListRunsDirectionEnum,
ListRunsSortEnum,
McpServer,
@ -27,6 +26,7 @@ import type {
RunArtifactListResponse,
RunProjection,
Run,
RunStreamItem,
RunUsage,
SandboxDetails,
SecretListResponse,
@ -52,7 +52,6 @@ import {
automationsApi,
environmentsApi,
fetchAllPages,
fetchAllStageEvents,
generatedAxios,
humanInTheLoopApi,
insightsApi,
@ -367,16 +366,6 @@ export function useRunPullRequest(id: string | undefined) {
);
}
export function useRunStageEvents(id: string | undefined, stageId: string | undefined) {
return useSWR<EventEnvelope[]>(
id && stageId ? queryKeys.runs.stageEvents(id, stageId) : null,
() =>
fetchAllStageEvents(`run ${id} stage ${stageId}`, (sinceSeq, limit) =>
apiData(() => runInternalsApi.listStageEvents(id!, stageId!, sinceSeq, limit)),
),
);
}
export function useRunStageContextWindow(
id: string | undefined,
stageId: string | undefined,
@ -387,13 +376,37 @@ export function useRunStageContextWindow(
);
}
export function useRunEventsList(id: string | undefined) {
return useSWR<EventEnvelope[]>(
id ? queryKeys.runs.events(id, 1000) : null,
() =>
fetchAllStageEvents(`run ${id} events`, (sinceSeq, limit) =>
apiData(() => runInternalsApi.listRunEvents(id!, sinceSeq, limit)),
),
const STREAM_PAGE_LIMIT = 1000;
const STREAM_MAX_PAGES = 50;
/**
* Every item of a run's stream (`GET /runs/{id}/events`), paged by `after`
* (the last `stream_seq` seen).
*/
async function fetchRunStream(id: string): Promise<RunStreamItem[]> {
const items: RunStreamItem[] = [];
let after = 0;
for (let pages = 0; pages < STREAM_MAX_PAGES; pages += 1) {
const page = await apiData(() =>
runInternalsApi.listRunEvents(id, STREAM_PAGE_LIMIT, after),
);
if (page.data.length === 0) return items;
items.push(...page.data);
const last = page.data[page.data.length - 1];
if (!page.meta.has_more || last.stream_seq <= after) return items;
after = last.stream_seq;
}
console.warn(
`Stopped run stream fetch for ${id} after ${STREAM_MAX_PAGES} pages and ${items.length} items because the safety cap was reached.`,
);
return items;
}
/** A run's stream: Petri's events and Fabro's platform records, in order. */
export function useRunStream(id: string | undefined) {
return useSWR<RunStreamItem[]>(
id ? queryKeys.runs.stream(id) : null,
() => fetchRunStream(id!),
);
}

View file

@ -60,7 +60,8 @@ export const queryKeys = {
usage: (id: string) => ["runs", "usage", id] as const,
questions: (id: string, limit = 1, offset = 0) =>
["runs", "questions", id, limit, offset] as const,
events: (id: string, limit = 1000) => ["runs", "events", id, limit] as const,
/** A Petri run's stream: every `RunStreamItem` in `stream_seq` order. */
stream: (id: string) => ["runs", "stream", id] as const,
stageEvents: (id: string, stageId: string) =>
["runs", "stage-events", id, stageId] as const,
stageContextWindow: (id: string, stageId: string) =>
@ -79,7 +80,6 @@ export const queryKeys = {
cancel: (id: string) => ["runs", "cancel", id] as const,
approve: (id: string) => ["runs", "approve", id] as const,
deny: (id: string) => ["runs", "deny", id] as const,
retry: (id: string) => ["runs", "retry", id] as const,
archive: (id: string) => ["runs", "archive", id] as const,
unarchive: (id: string) => ["runs", "unarchive", id] as const,
updateTitle: (id: string) => ["runs", "update-title", id] as const,

View file

@ -13,7 +13,6 @@ import {
canArchive,
canApprove,
canCancel,
canRetry,
canUnarchive,
cancellationActionLabel,
cancellationSuccessMessage,
@ -24,7 +23,6 @@ import {
isCancellationPending,
isCancellationPendingState,
mapError,
retryRun,
unarchiveRun,
unarchiveRuns,
} from "./run-actions";
@ -350,22 +348,6 @@ describe("run lifecycle actions", () => {
});
});
test("retryRun parses a 201 response", async () => {
stubGeneratedAxiosOnce({
status: 201,
body: {
...makeRun({ kind: "submitted" }),
id: "run-2",
retried_from: "run-1",
},
});
const result = await retryRun("run-1");
expect(result.id).toBe("run-2");
expect(result.retried_from).toBe("run-1");
expect(result.lifecycle.status.kind).toBe("submitted");
});
test("404 and 409 preserve the parsed error envelope", async () => {
stubGeneratedAxiosOnce({
status: 404,
@ -449,16 +431,6 @@ describe("run lifecycle actions", () => {
expect(canApprove(makeRun({ kind: "runnable" }))).toBe(false);
});
test("canRetry allows terminal runs except archived runs", () => {
expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }))).toBe(true);
expect(canRetry(makeRun({ kind: "dead" }))).toBe(true);
expect(canRetry(makeRun({ kind: "failed", reason: "cancelled" }))).toBe(true);
expect(canRetry(makeRun({ kind: "succeeded", reason: "completed" }))).toBe(true);
expect(canRetry(makeRun({ kind: "running" }))).toBe(false);
expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }, true))).toBe(false);
expect(canRetry(makeRun({ kind: "succeeded", reason: "completed" }, true))).toBe(false);
});
test("isTerminalCancelledRun distinguishes immediate cancel success from in-flight cancellation", () => {
expect(
isTerminalCancelledRun(makeRun({ kind: "failed", reason: "cancelled" })),

View file

@ -23,8 +23,7 @@ export type LifecycleAction =
| "approve"
| "deny"
| "archive"
| "unarchive"
| "retry";
| "unarchive";
export interface LifecycleActionError {
status: number;
@ -118,10 +117,6 @@ export async function deleteRuns(
}
}
export async function retryRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "retry", request);
}
export async function deleteRun(id: string, request?: Request): Promise<void> {
try {
await apiResponse(() => runsApi.deleteRun(id, undefined, requestSignalOptions(request)));
@ -147,11 +142,6 @@ export function canUnarchive(status: string | null | undefined): boolean {
return status === "archived";
}
export function canRetry(run: Pick<Run, "lifecycle"> | null | undefined): boolean {
if (!run || run.lifecycle.archived) return false;
return isTerminalRunStatus(run.lifecycle.status.kind);
}
export function isTerminalRunStatus(
status: string | null | undefined,
): boolean {
@ -223,8 +213,6 @@ export function mapError(error: unknown, action: LifecycleAction): string {
return "Only terminal runs can be archived.";
case "unarchive":
return "Active runs can't be unarchived.";
case "retry":
return "This run can no longer be retried.";
}
}
@ -245,8 +233,6 @@ export function mapError(error: unknown, action: LifecycleAction): string {
return "Couldn't archive the run right now. Try again.";
case "unarchive":
return "Couldn't unarchive the run right now. Try again.";
case "retry":
return "Couldn't retry the run right now. Try again.";
}
}
@ -267,8 +253,6 @@ async function runLifecycleAction(
return await apiData(() => runsApi.archiveRun(id, requestSignalOptions(request)));
case "unarchive":
return await apiData(() => runsApi.unarchiveRun(id, requestSignalOptions(request)));
case "retry":
return await apiData(() => runsApi.retryRun(id, requestSignalOptions(request)));
}
} catch (error) {
throw lifecycleActionErrorFromError(error);

View file

@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test";
import type { Key } from "swr";
import {
queryKeysForRunEvent,
subscribeToRunEvents,
} from "./run-events";
import { loadPetriFixture } from "./petri-fixtures";
import { streamItemName } from "./petri-stream";
import { queryKeysForStreamItem, subscribeToRunEvents } from "./run-events";
import { makePetriItem, makePlatformItem } from "./test-utils";
import {
createCrossTabSseCoordinator,
type BroadcastChannelLike,
@ -39,191 +39,100 @@ class FakeBroadcastChannel implements BroadcastChannelLike {
close() {}
}
describe("queryKeysForRunEvent", () => {
test("terminal events invalidate run-scoped resources", () => {
expect(queryKeysForRunEvent("run-1", "run.completed")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
...queryKeys.runs.filesAllScopes("run-1"),
queryKeys.runs.commits("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stages("run-1"),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
describe("queryKeysForStreamItem", () => {
const parallel = loadPetriFixture("parallel");
const gate = loadPetriFixture("gate");
const runId = "run-petri";
const named = (name: string, stage?: string) =>
parallel.stream.find((item) => {
const body = (item.item as { record?: { body?: { event?: string } } }).record?.body;
const derived = (item.item as { derived?: { event?: string } }).derived;
const subject = (item.item as { subject?: { node?: { name?: string } } }).subject;
return (
(body?.event ?? derived?.event) === name &&
(stage === undefined || subject?.node?.name === stage)
);
})!;
test("a stage's visit invalidates the stage list, the state, the stream and its stage keys", () => {
const { keys, immediate } = queryKeysForStreamItem(runId, named("visit.started", "merge"));
expect(immediate).toBe(false);
expect(keys).toEqual([
queryKeys.runs.stages(runId),
queryKeys.runs.state(runId),
queryKeys.runs.detail(runId),
queryKeys.runs.stream(runId),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
queryKeys.runs.stageEvents(runId, "merge@1"),
queryKeys.runs.stageContextWindow(runId, "merge@1"),
]);
});
test("stage.retrying invalidates stage-scoped and run-scoped resources", () => {
expect(queryKeysForRunEvent("run-1", "stage.retrying", "verify@2")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.stageEvents("run-1", "verify@2"),
queryKeys.runs.stageContextWindow("run-1", "verify@2"),
test("a platform notice refreshes the run summary; the terminal lifecycle record is immediate", () => {
const notice = parallel.stream.find(
(item) => item.kind === "platform" && (item.item as { record: { kind: string } }).record.kind === "run.notice",
)!;
expect(queryKeysForStreamItem(runId, notice)).toEqual({
keys: [queryKeys.runs.detail(runId), queryKeys.runs.state(runId), queryKeys.runs.stream(runId)],
immediate: false,
});
const terminal = parallel.stream[parallel.stream.length - 1];
const result = queryKeysForStreamItem(runId, terminal);
expect(result.immediate).toBe(true);
expect(result.keys).toContainEqual(queryKeys.runs.usage(runId));
expect(result.keys).toContainEqual(queryKeys.runs.stream(runId));
});
test("a question and its answer refresh the questions list", () => {
const question = gate.stream.find(
(item) => (item.item as { derived?: { parsed?: { kind?: string } } }).derived?.parsed?.kind === "question",
)!;
expect(queryKeysForStreamItem(runId, question).keys[0]).toEqual(
queryKeys.runs.questions(runId, 25, 0),
);
const answer = gate.stream.find(
(item) => (item.item as { record?: { body?: { event?: string } } }).record?.body?.event === "control.requested",
)!;
expect(queryKeysForStreamItem(runId, answer).keys).toContainEqual(
queryKeys.runs.stageEvents(runId, "gate@1"),
);
});
test("a run stream item on the attach stream is invalidated by its own rules", async () => {
const source = new FakeEventSource();
const keys: Key[] = [];
// The coordinated stream carries every run, so the item's `run_id` is
// what keeps another run's item from invalidating this one.
const coordinator = createCoordinator(() => source);
const cleanup = subscribeToRunEvents(
runId,
(key) => {
keys.push(key);
return Promise.resolve();
},
() => {
throw new Error("source should be created by coordinator");
},
{ debounceMs: 0, coordinator },
);
await waitFor(() => source.onmessage !== null);
keys.length = 0;
source.emit({ ...named("step.finished", "a"), run_id: runId });
expect(keys).toEqual([
queryKeys.runs.state(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.detail(runId),
queryKeys.runs.stream(runId),
queryKeys.runs.stageEvents(runId, "a@1"),
queryKeys.runs.stageContextWindow(runId, "a@1"),
]);
});
test("stage-scoped steering events invalidate run events and stage-scoped resources", () => {
expect(queryKeysForRunEvent("run-1", "agent.session.activated", "agent@1")).toEqual([
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "agent@1"),
queryKeys.runs.stageContextWindow("run-1", "agent@1"),
]);
});
test("stage-scoped interrupt injection invalidates run events and stage-scoped resources", () => {
expect(queryKeysForRunEvent("run-1", "agent.interrupt.injected", "nap@1")).toEqual([
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "nap@1"),
queryKeys.runs.stageContextWindow("run-1", "nap@1"),
]);
});
test("interrupt settlement invalidates projected control state and stage activity", () => {
expect(queryKeysForRunEvent("run-1", "agent.round.interrupted", "nap@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "nap@1"),
queryKeys.runs.stageContextWindow("run-1", "nap@1"),
]);
});
test("parallel branch lifecycle invalidates the stages list backing live branch rows", () => {
// Branches bypass stage.started/stage.completed, so these events are the
// only signal that a branch row's status changed.
expect(queryKeysForRunEvent("run-1", "parallel.branch.started", "review_glm@1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.stageEvents("run-1", "review_glm@1"),
]);
expect(queryKeysForRunEvent("run-1", "parallel.branch.completed", "review_glm@1")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
queryKeys.runs.stageEvents("run-1", "review_glm@1"),
]);
});
test("fork lifecycle invalidates run-scoped resources without a stage id", () => {
for (const event of ["parallel.started", "parallel.completed"]) {
expect(queryKeysForRunEvent("run-1", event)).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
]);
}
});
test("cancel requests invalidate the durable run summary", () => {
expect(queryKeysForRunEvent("run-1", "run.cancel.requested")).toEqual([
queryKeys.runs.detail("run-1"),
]);
});
test("pair messages invalidate stage-scoped resources", () => {
expect(queryKeysForRunEvent("run-1", "agent.pair.user_message", "nap@1")).toEqual([
queryKeys.runs.stageEvents("run-1", "nap@1"),
queryKeys.runs.stageContextWindow("run-1", "nap@1"),
]);
expect(queryKeysForRunEvent("run-1", "agent.pair.system_message", "nap@1")).toEqual([
queryKeys.runs.stageEvents("run-1", "nap@1"),
queryKeys.runs.stageContextWindow("run-1", "nap@1"),
]);
});
test("todo events invalidate run state and run events", () => {
for (const event of ["todo.created", "todo.updated", "todo.deleted"]) {
expect(queryKeysForRunEvent("run-1", event)).toEqual([
queryKeys.runs.state("run-1"),
queryKeys.runs.events("run-1", 1000),
]);
}
});
test("todo events with a stage id also invalidate that stage's events", () => {
expect(queryKeysForRunEvent("run-1", "todo.created", "code@1")).toEqual([
queryKeys.runs.state("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
});
test("every inference projection transition invalidates live run state", () => {
for (const event of [
"agent.llm.started",
"agent.error",
]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
}
for (const event of ["agent.llm.first_output", "agent.llm.retry"]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.state("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
}
expect(
queryKeysForRunEvent("run-1", "agent.message", "code@1"),
).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
queryKeys.runs.stageContextWindow("run-1", "code@1"),
]);
expect(queryKeysForRunEvent("run-1", "agent.session.ended")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.usage("run-1"),
]);
});
test("ACP timing events invalidate live summaries and stage events", () => {
for (const event of [
"agent.acp.started",
"agent.acp.completed",
"agent.acp.cancelled",
"agent.acp.timed_out",
]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
}
});
test("tool timing events invalidate live summaries and stage resources", () => {
for (const event of ["agent.tool.started", "agent.tool.completed"]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
queryKeys.runs.stageContextWindow("run-1", "code@1"),
]);
}
});
test("watchdog timeout refreshes the stage events for that stage", () => {
expect(
queryKeysForRunEvent("run-1", "watchdog.timeout", "code@1"),
).toEqual([queryKeys.runs.stageEvents("run-1", "code@1")]);
keys.length = 0;
source.emit({ ...named("step.finished", "a"), run_id: "another-run" });
expect(keys).toEqual([]);
cleanup();
coordinator.close();
});
});
@ -252,13 +161,15 @@ describe("subscribeToRunEvents", () => {
await waitFor(() => created.length === 1);
keys.length = 0;
source.emit({ event: "checkpoint.completed", run_id: "other-run" });
source.emit({ event: "checkpoint.completed", run_id: "run-coordinated" });
source.emit(makePlatformItem(1, { kind: "checkpoint" }, { run_id: "other-run" }));
source.emit(makePlatformItem(1, { kind: "checkpoint" }, { run_id: "run-coordinated" }));
expect(created).toEqual(["/api/v1/attach"]);
expect(keys).toEqual([
...queryKeys.runs.filesAllScopes("run-coordinated"),
queryKeys.runs.commits("run-coordinated"),
queryKeys.runs.state("run-coordinated"),
queryKeys.runs.stream("run-coordinated"),
]);
cleanup();
@ -282,15 +193,25 @@ describe("subscribeToRunEvents", () => {
await waitFor(() => source.onmessage !== null);
keys.length = 0;
source.emit({ event: "run.failed", run_id: "run-terminal" });
source.emit(
makePlatformItem(
1,
{ kind: "run.lifecycle", transition: "failed", status: { kind: "failed", reason: "error" } },
{ run_id: "run-terminal" },
),
);
expect(source.closed).toBe(false);
expect(keys).toContainEqual(queryKeys.runs.files("run-terminal"));
expect(keys).toContainEqual(queryKeys.runs.usage("run-terminal"));
keys.length = 0;
source.emit({ event: "run.archived", run_id: "run-terminal" });
source.emit(makePlatformItem(2, { kind: "run.archived" }, { run_id: "run-terminal" }));
expect(source.closed).toBe(false);
expect(keys).toEqual([queryKeys.runs.detail("run-terminal")]);
expect(keys).toEqual([
queryKeys.runs.detail("run-terminal"),
queryKeys.runs.state("run-terminal"),
queryKeys.runs.stream("run-terminal"),
]);
cleanup();
coordinator.close();
@ -317,12 +238,14 @@ describe("subscribeToRunEvents", () => {
expect(created).toEqual(["/api/v1/runs/run-refcount/attach"]);
firstCleanup();
source.emit({ event: "checkpoint.completed" });
source.emit(makePlatformItem(1, { kind: "checkpoint" }, { run_id: "run-refcount" }));
expect(source.closed).toBe(false);
expect(keys).toEqual([
...queryKeys.runs.filesAllScopes("run-refcount"),
queryKeys.runs.commits("run-refcount"),
queryKeys.runs.state("run-refcount"),
queryKeys.runs.stream("run-refcount"),
]);
secondCleanup();
@ -350,15 +273,21 @@ describe("subscribeToRunEvents", () => {
}, {
debounceMs: 0,
coordinator,
onEvent: (payload) => {
if (payload.event) seen.push(payload.event);
onItem: (item) => {
seen.push(streamItemName(item));
},
});
source.emit({ id: "evt-1", event: "agent.steer.buffered", properties: {} });
source.emit(
makePlatformItem(1, { kind: "run.notice", code: "steer_refused" }, { run_id: "run-shared-payload" }),
);
expect(seen).toEqual(["agent.steer.buffered"]);
expect(keys).toEqual([queryKeys.runs.events("run-shared-payload", 1000)]);
expect(seen).toEqual(["run.notice"]);
expect(keys).toEqual([
queryKeys.runs.detail("run-shared-payload"),
queryKeys.runs.state("run-shared-payload"),
queryKeys.runs.stream("run-shared-payload"),
]);
firstCleanup();
secondCleanup();
@ -379,7 +308,19 @@ describe("subscribeToRunEvents", () => {
{ debounceMs: 0, coordinator },
);
// A frame that is not a stream item invalidates nothing and keeps the
// source open.
source.emit({ event: "run.failed" });
expect(source.closed).toBe(false);
expect(keys).toEqual([]);
source.emit(
makePlatformItem(
1,
{ kind: "run.lifecycle", transition: "dead", status: { kind: "dead", reason: "lease_lost" } },
{ run_id: "run-terminal" },
),
);
expect(source.closed).toBe(true);
expect(keys).toContainEqual(queryKeys.runs.files("run-terminal"));
@ -389,7 +330,7 @@ describe("subscribeToRunEvents", () => {
coordinator.close();
});
test("envelope with suffixed stage_id invalidates stageEvents(runId, stageId)", async () => {
test("a stage's step.finished on the run's own stream invalidates its stage keys", async () => {
const source = new FakeEventSource();
const keys: Key[] = [];
const coordinator = createCoordinator(() => source);
@ -404,43 +345,19 @@ describe("subscribeToRunEvents", () => {
);
await waitFor(() => source.onmessage !== null);
source.emit({
event: "stage.retrying",
run_id: "run-stage",
stage_id: "verify@2",
node_id: "verify",
});
expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify@2"));
expect(keys).toContainEqual(queryKeys.runs.stages("run-stage"));
expect(keys).toContainEqual(queryKeys.runs.events("run-stage", 1000));
expect(keys).toContainEqual(queryKeys.runs.graph("run-stage", "LR"));
expect(keys).toContainEqual(queryKeys.runs.detail("run-stage"));
expect(keys).not.toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify"));
cleanup();
coordinator.close();
});
test("falls back to node_id when an event has no stage_id", async () => {
const source = new FakeEventSource();
const keys: Key[] = [];
const coordinator = createCoordinator(() => source);
const cleanup = subscribeToRunEvents(
"run-stage-node",
(key) => {
keys.push(key);
return Promise.resolve();
},
() => source,
{ debounceMs: 0, coordinator },
source.emit(
makePetriItem(
1,
{ event: "step.finished", firing: 2 },
{ stage: { name: "verify", visit: 2 }, run_id: "run-stage" },
),
);
await waitFor(() => source.onmessage !== null);
source.emit({ event: "stage.started", run_id: "run-stage-node", node_id: "verify" });
expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage-node", "verify"));
expect(keys).toContainEqual(queryKeys.runs.stages("run-stage-node"));
expect(keys).toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify@2"));
expect(keys).toContainEqual(queryKeys.runs.stageContextWindow("run-stage", "verify@2"));
expect(keys).toContainEqual(queryKeys.runs.stages("run-stage"));
expect(keys).toContainEqual(queryKeys.runs.stream("run-stage"));
expect(keys).not.toContainEqual(queryKeys.runs.stageEvents("run-stage", "verify@1"));
cleanup();
coordinator.close();
@ -514,27 +431,3 @@ async function waitFor(condition: () => boolean, timeoutMs = 200) {
}
throw new Error("condition did not become true before timeout");
}
describe("agent session events", () => {
test("refresh the run state the stage sidebar reads its agent facts from", () => {
for (const event of [
"agent.route.failover",
"agent.route.failover.stopped",
"agent.mcp.server.ready",
"agent.mcp.server.failed",
"agent.mcp.server.disconnected",
"agent.skills.discovered",
"agent.skill.activated",
"agent.sub.spawned",
"agent.sub.completed",
"agent.sub.failed",
"agent.compaction.completed",
]) {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.state("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
}
});
});

View file

@ -1,11 +1,21 @@
import { useEffect } from "react";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import { useSWRConfig, type Key } from "swr";
import {
subscribeToCrossTabSse,
type CrossTabSseCoordinator,
} from "./cross-tab-sse";
import {
isStreamItemPayload,
isTerminalLifecycleItem,
petriEventName,
petriParsed,
petriStageLabel,
platformRecordKind,
} from "./petri-stream";
import { queryKeys } from "./query-keys";
import { getString } from "./unknown";
import {
createBrowserEventSource,
subscribeToSharedEventSource,
@ -15,318 +25,178 @@ import {
type SharedEventSubscription,
} from "./sse";
/**
* A frame of a run's attach stream as parsed JSON: a `RunStreamItem`, its
* fields optional until `isStreamItemPayload` has checked the shape.
*/
export interface RunEventPayload extends EventPayload {
id?: string;
seq?: number;
event?: string;
run_id?: string;
node_id?: string;
stage_id?: string;
properties?: Record<string, unknown>;
stream_seq?: number;
kind?: string;
id?: string;
recorded_at?: number;
item?: unknown;
}
interface RunEventOptions {
debounceMs?: number;
coordinator?: CrossTabSseCoordinator;
onEvent?: (payload: RunEventPayload) => void;
/** Called with every well-formed stream item of the run, before invalidation. */
onItem?: (item: RunStreamItem) => void;
}
const subscriptions = new Map<string, SharedEventSubscription>();
const TERMINAL_EVENTS = new Set(["run.completed", "run.failed"]);
const RUN_SUMMARY_EVENTS = new Set([
"run.submitted",
"run.start_requested",
"run.pending",
"run.approved",
"run.denied",
"run.runnable",
"run.starting",
"run.running",
"run.paused",
"run.unpaused",
"run.blocked",
"run.unblocked",
"run.cancel.requested",
"run.pause.requested",
"run.unpause.requested",
"run.archived",
"run.unarchived",
"run.title.updated",
"pull_request.created",
"pull_request.linked",
"pull_request.unlinked",
]);
const STAGE_EVENTS = new Set([
"stage.started",
"stage.completed",
"stage.failed",
"stage.retrying",
]);
// Single source of truth: every event type the `eventsToActivity` reducer in
// `routes/run-stages.tsx` consumes. When any of these arrive for a stage we
// currently view, the stage-events SWR key for that stage must be invalidated
// so the panel refetches. The reducer imports this list so the switch stays
// in sync with the invalidation set; if the reducer grows a new case, this
// list is the single edit point.
//
// The lifecycle `STAGE_EVENTS` set is kept separate because it also fans out
// to run-scoped invalidations (stages list, graph, detail).
export const STAGE_ACTIVITY_EVENT_TYPES = [
"stage.prompt",
"prompt.completed",
"agent.message",
"agent.tool.started",
"agent.tool.completed",
"agent.steering.injected",
"agent.interrupt.injected",
"agent.round.interrupted",
"agent.pair.user_message",
"agent.pair.system_message",
"command.started",
"command.completed",
] as const;
export type StageActivityEventType = (typeof STAGE_ACTIVITY_EVENT_TYPES)[number];
const STAGE_ACTIVITY_EVENTS = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
// Parallel branches bypass the engine's `stage.started` / `stage.completed`
// lifecycle (the parallel handler dispatches each branch directly), so
// `STAGE_EVENTS` never fires for them. Without this set the stages list never
// refetches while a fork runs and branch rows stay frozen at their first
// observed state.
const PARALLEL_EVENTS = new Set([
"parallel.started",
"parallel.branch.started",
"parallel.branch.completed",
"parallel.completed",
]);
const INTERVIEW_EVENTS = new Set([
"interview.started",
"interview.completed",
"interview.timeout",
"interview.interrupted",
]);
const STEERING_EVENTS = new Set([
"run.interrupt",
"run.steer",
"agent.steering.injected",
"agent.interrupt.injected",
"agent.round.interrupted",
"agent.session.activated",
"agent.session.deactivated",
"agent.steer.buffered",
"agent.steer.dropped",
]);
const AGENT_CONTROL_STATE_EVENTS = new Set([
"agent.round.interrupted",
"agent.steering.injected",
"agent.session.deactivated",
]);
const INFERENCE_EVENTS = new Set([
"agent.llm.started",
"agent.llm.first_output",
"agent.llm.retry",
"agent.message",
"agent.error",
"agent.session.ended",
]);
const INFERENCE_TIMING_EVENTS = new Set([
"agent.llm.started",
"agent.message",
"agent.error",
"agent.session.ended",
]);
const TOOL_TIMING_EVENTS = new Set([
"agent.tool.started",
"agent.tool.completed",
]);
const ACP_TIMING_EVENTS = new Set([
"agent.acp.started",
"agent.acp.completed",
"agent.acp.cancelled",
"agent.acp.timed_out",
]);
// Todo / task mutation events refresh `getRunState` consumers (so per-stage
// todo projections update live) and the run events list.
const TODO_EVENTS = new Set([
"todo.created",
"todo.updated",
"todo.deleted",
]);
// The coding agent's own events whose facts the stage sidebar reads from
// `StageProjection.agent`: route moves, MCP server outcomes, skills,
// subagent lifecycle, and compactions. Each refreshes the run state the
// sidebar renders from, like a todo mutation does. Files the tree wrote
// arrive with `agent.tool.completed`, which already refreshes it.
const AGENT_SESSION_EVENTS = new Set([
"agent.route.failover",
"agent.route.failover.stopped",
"agent.mcp.server.ready",
"agent.mcp.server.failed",
"agent.mcp.server.disconnected",
"agent.skills.discovered",
"agent.skill.activated",
"agent.sub.spawned",
"agent.sub.turn.started",
"agent.sub.completed",
"agent.sub.failed",
"agent.sub.closed",
"agent.compaction.completed",
]);
/**
* The SWR keys a run stream item invalidates. Petri's events are
* named `<subject>.<verb>`; a platform record by its `kind`. The stage
* keys use the subject's `node@visit` label, which is the stage id the
* projection keys stages by.
*/
export function queryKeysForStreamItem(
runId: string,
item: RunStreamItem,
): { keys: Key[]; immediate: boolean } {
const stageId = petriStageLabel(item);
const stageKeys: Key[] = stageId
? [queryKeys.runs.stageEvents(runId, stageId), queryKeys.runs.stageContextWindow(runId, stageId)]
: [];
const stream = queryKeys.runs.stream(runId);
function liveTimingKeys(runId: string): Key[] {
if (item.kind === "platform") {
const kind = platformRecordKind(item);
if (isTerminalLifecycleItem(item)) {
return { keys: terminalKeys(runId, stream), immediate: true };
}
switch (kind) {
case "checkpoint":
return {
keys: [
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.state(runId),
stream,
],
immediate: false,
};
case "interview.answered":
return {
keys: [queryKeys.runs.questions(runId, 25, 0), queryKeys.runs.detail(runId), stream],
immediate: false,
};
default:
return { keys: [queryKeys.runs.detail(runId), queryKeys.runs.state(runId), stream], immediate: false };
}
}
const name = petriEventName(item);
switch (name) {
case "run.finished":
return { keys: terminalKeys(runId, stream), immediate: false };
case "run.started":
case "run.paused":
case "run.unpaused":
case "invocation.finished":
case "invocation.cancel.requested":
case "run.stalled":
return { keys: [queryKeys.runs.detail(runId), queryKeys.runs.state(runId), stream], immediate: false };
case "visit.started":
case "visit.completed":
case "retry.scheduled":
case "wait.state.changed":
case "admission.decided":
return {
keys: [
queryKeys.runs.stages(runId),
queryKeys.runs.state(runId),
queryKeys.runs.detail(runId),
stream,
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
...stageKeys,
],
immediate: false,
};
case "step.progress.recorded": {
const parsed = getString(petriParsed(item), "kind");
if (parsed === "question" || parsed === "question_expired") {
return {
keys: [
queryKeys.runs.questions(runId, 25, 0),
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
stream,
...stageKeys,
],
immediate: false,
};
}
return { keys: [queryKeys.runs.state(runId), stream, ...stageKeys], immediate: false };
}
case "control.requested":
return {
keys: [
queryKeys.runs.questions(runId, 25, 0),
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
stream,
...stageKeys,
],
immediate: false,
};
case "step.finished":
return {
keys: [
queryKeys.runs.state(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.detail(runId),
stream,
...stageKeys,
],
immediate: false,
};
case "fork.started":
case "branch.completed":
case "fork.completed":
case "node.expanded":
return {
keys: [
queryKeys.runs.stages(runId),
queryKeys.runs.state(runId),
stream,
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
],
immediate: false,
};
case "routing.resolved":
case "route.applied":
return { keys: [stream, ...stageKeys], immediate: false };
default:
return { keys: [stream], immediate: false };
}
}
function terminalKeys(runId: string, stream: Key): Key[] {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
stream,
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
}
export function queryKeysForRunEvent(
runId: string,
event: string,
stageId?: string,
): Key[] {
if (event === "checkpoint.completed") {
return [
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
];
}
if (TERMINAL_EVENTS.has(event)) {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
}
if (RUN_SUMMARY_EVENTS.has(event)) {
return [queryKeys.runs.detail(runId)];
}
if (INTERVIEW_EVENTS.has(event)) {
return [
queryKeys.runs.questions(runId, 25, 0),
queryKeys.runs.detail(runId),
];
}
if (STAGE_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.stages(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
}
return keys;
}
if (PARALLEL_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (STEERING_EVENTS.has(event)) {
const keys: Key[] = [queryKeys.runs.events(runId, 1000)];
if (AGENT_CONTROL_STATE_EVENTS.has(event)) {
keys.unshift(queryKeys.runs.state(runId));
}
if (event === "agent.round.interrupted") {
keys.unshift(
queryKeys.runs.detail(runId),
queryKeys.runs.usage(runId),
);
}
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
}
return keys;
}
if (INFERENCE_EVENTS.has(event)) {
const keys = INFERENCE_TIMING_EVENTS.has(event)
? liveTimingKeys(runId)
: [queryKeys.runs.state(runId)];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
if (event === "agent.message") {
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
}
}
return keys;
}
if (TOOL_TIMING_EVENTS.has(event)) {
const keys = liveTimingKeys(runId);
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
keys.push(queryKeys.runs.stageContextWindow(runId, stageId));
}
return keys;
}
if (ACP_TIMING_EVENTS.has(event)) {
const keys = liveTimingKeys(runId);
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
if (event === "watchdog.timeout") {
return stageId ? [queryKeys.runs.stageEvents(runId, stageId)] : [];
}
if (STAGE_ACTIVITY_EVENTS.has(event)) {
return stageId
? [
queryKeys.runs.stageEvents(runId, stageId),
queryKeys.runs.stageContextWindow(runId, stageId),
]
: [];
}
if (TODO_EVENTS.has(event) || AGENT_SESSION_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.state(runId),
queryKeys.runs.events(runId, 1000),
];
if (stageId) {
keys.push(queryKeys.runs.stageEvents(runId, stageId));
}
return keys;
}
return [];
}
export function subscribeToRunEvents(
runId: string,
mutate: MutateFn,
eventSourceFactory: (url: string) => EventSourceLike = createBrowserEventSource,
{ debounceMs = 300, coordinator, onEvent }: RunEventOptions = {},
{ debounceMs = 300, coordinator, onItem }: RunEventOptions = {},
): () => void {
return subscribeToCrossTabSse<RunEventPayload>({
coordinator,
@ -336,8 +206,7 @@ export function subscribeToRunEvents(
resyncKeys: () => resyncKeysForRun(runId),
resolveInvalidation: (payload) => {
if (payload.run_id !== runId) return { keys: [] };
onEvent?.(payload);
return runInvalidation(runId, payload);
return runInvalidation(runId, payload, onItem);
},
fallbackSubscribe: () =>
subscribeToSharedEventSource<RunEventPayload>({
@ -348,22 +217,22 @@ export function subscribeToRunEvents(
eventSourceFactory,
debounceMs,
resolveInvalidation: (payload) => {
onEvent?.(payload);
const result = runInvalidation(runId, payload);
const result = runInvalidation(runId, payload, onItem);
return { ...result, close: result.immediate };
},
}),
});
}
function runInvalidation(runId: string, payload: RunEventPayload) {
const event = payload.event;
if (!event) return { keys: [], immediate: false };
const stageId = stageIdFromPayload(payload);
const keys = queryKeysForRunEvent(runId, event, stageId);
const terminal = TERMINAL_EVENTS.has(event);
return { keys, immediate: terminal };
/** A frame that is not a stream item (a malformed frame) invalidates nothing. */
function runInvalidation(
runId: string,
payload: RunEventPayload,
onItem: RunEventOptions["onItem"],
) {
if (!isStreamItemPayload(payload)) return { keys: [], immediate: false };
onItem?.(payload);
return queryKeysForStreamItem(runId, payload);
}
function resyncKeysForRun(runId: string) {
@ -374,20 +243,13 @@ function resyncKeysForRun(runId: string) {
queryKeys.runs.commits(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.stream(runId),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
queryKeys.runs.questions(runId, 25, 0),
];
}
function stageIdFromPayload(payload: RunEventPayload): string | undefined {
if (typeof payload.stage_id === "string") return payload.stage_id;
if (typeof payload.node_id === "string") return payload.node_id;
const nodeId = payload.properties?.node_id;
return typeof nodeId === "string" ? nodeId : undefined;
}
/**
* Synchronizes React/SWR with a run-scoped SSE stream. Changing `runId`
* resubscribes, and the active subscription is closed on unmount.

View file

@ -1,175 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { deriveRunPhases } from "./run-phases";
const CREATED = "2026-05-23T12:00:00.000Z";
const T_REQUESTED = "2026-05-23T12:00:01.000Z";
const T_PENDING = "2026-05-23T12:00:02.000Z";
const T_RUNNABLE = "2026-05-23T12:00:03.000Z";
const T_STARTING = "2026-05-23T12:00:04.000Z";
const T_RUNNING = "2026-05-23T12:00:10.000Z";
const T_FAILED = "2026-05-23T12:00:12.000Z";
function makeEvent(name: string, ts: string, seq: number): EventEnvelope {
return {
id: `evt-${seq}`,
seq,
ts,
run_id: "run-1",
event: name,
} as EventEnvelope;
}
describe("deriveRunPhases", () => {
test("returns empty for an unparseable created_at", () => {
expect(deriveRunPhases([], "not-a-date")).toEqual([]);
});
test("submitted phase is open-ended when no transitions have fired", () => {
const phases = deriveRunPhases([], CREATED);
expect(phases).toEqual([
{
kind: "submitted",
label: "Submitted",
startMs: Date.parse(CREATED),
endMs: null,
},
]);
});
test("closes submitted at run.start_requested and opens pending when approval is required", () => {
const phases = deriveRunPhases(
[
makeEvent("run.start_requested", T_REQUESTED, 1),
makeEvent("run.pending", T_PENDING, 2),
],
CREATED,
);
expect(phases).toEqual([
{
kind: "submitted",
label: "Submitted",
startMs: Date.parse(CREATED),
endMs: Date.parse(T_REQUESTED),
},
{
kind: "pending",
label: "Pending",
startMs: Date.parse(T_PENDING),
endMs: null,
},
]);
});
test("emits submitted, pending, runnable, and initializing through run.running", () => {
const phases = deriveRunPhases(
[
makeEvent("run.start_requested", T_REQUESTED, 1),
makeEvent("run.pending", T_PENDING, 2),
makeEvent("run.runnable", T_RUNNABLE, 3),
makeEvent("run.starting", T_STARTING, 4),
makeEvent("run.running", T_RUNNING, 5),
],
CREATED,
);
expect(phases).toEqual([
{
kind: "submitted",
label: "Submitted",
startMs: Date.parse(CREATED),
endMs: Date.parse(T_REQUESTED),
},
{
kind: "pending",
label: "Pending",
startMs: Date.parse(T_PENDING),
endMs: Date.parse(T_RUNNABLE),
},
{
kind: "runnable",
label: "Runnable",
startMs: Date.parse(T_RUNNABLE),
endMs: Date.parse(T_STARTING),
},
{
kind: "initializing",
label: "Initializing",
startMs: Date.parse(T_STARTING),
endMs: Date.parse(T_RUNNING),
},
]);
});
test("skips pending and runnable phases when those events are missing", () => {
const phases = deriveRunPhases(
[
makeEvent("run.starting", T_STARTING, 1),
makeEvent("run.running", T_RUNNING, 2),
],
CREATED,
);
expect(phases.map((p) => p.kind)).toEqual(["submitted", "initializing"]);
expect(phases[0]!.endMs).toBe(Date.parse(T_STARTING));
expect(phases[1]!.startMs).toBe(Date.parse(T_STARTING));
expect(phases[1]!.endMs).toBe(Date.parse(T_RUNNING));
});
test("closes initializing at run.failed when the run never reaches running", () => {
const phases = deriveRunPhases(
[
makeEvent("run.start_requested", T_REQUESTED, 1),
makeEvent("run.runnable", T_RUNNABLE, 2),
makeEvent("run.starting", T_STARTING, 3),
makeEvent("run.failed", T_FAILED, 4),
],
CREATED,
);
expect(phases).toEqual([
{
kind: "submitted",
label: "Submitted",
startMs: Date.parse(CREATED),
endMs: Date.parse(T_REQUESTED),
},
{
kind: "runnable",
label: "Runnable",
startMs: Date.parse(T_RUNNABLE),
endMs: Date.parse(T_STARTING),
},
{
kind: "initializing",
label: "Initializing",
startMs: Date.parse(T_STARTING),
endMs: Date.parse(T_FAILED),
},
]);
});
test("uses run.starting as fallback end for submitted when pre-execution events are missing", () => {
const phases = deriveRunPhases(
[makeEvent("run.starting", T_STARTING, 1)],
CREATED,
);
expect(phases[0]!.endMs).toBe(Date.parse(T_STARTING));
});
test("ignores unrelated events", () => {
const phases = deriveRunPhases(
[
makeEvent("agent.message", T_REQUESTED, 1),
makeEvent("stage.started", T_STARTING, 2),
],
CREATED,
);
expect(phases).toEqual([
{
kind: "submitted",
label: "Submitted",
startMs: Date.parse(CREATED),
endMs: null,
},
]);
});
});

View file

@ -1,110 +1,12 @@
import type { EventEnvelope } from "@qltysh/fabro-api-client";
export type RunPhaseKind = "submitted" | "pending" | "runnable" | "initializing";
/**
* A slice of the run's timeline before its stages own it, from the platform
* `run.lifecycle` records (`deriveRunPhasesFromStream`).
*/
export interface RunPhase {
kind: RunPhaseKind;
label: string;
startMs: number;
endMs: number | null;
}
const PHASE_LABEL: Record<RunPhaseKind, string> = {
submitted: "Submitted",
pending: "Pending",
runnable: "Runnable",
initializing: "Initializing",
};
// Stages own the timeline once `run.running` fires, so we stop slicing there.
export function deriveRunPhases(
events: ReadonlyArray<EventEnvelope> | undefined,
createdAtIso: string,
): RunPhase[] {
const createdMs = Date.parse(createdAtIso);
if (Number.isNaN(createdMs)) return [];
let startRequestedMs: number | null = null;
let pendingMs: number | null = null;
let runnableMs: number | null = null;
let startingMs: number | null = null;
let runningMs: number | null = null;
let terminalMs: number | null = null;
let remaining = 6;
for (const event of events ?? []) {
if (remaining === 0) break;
let target: "startRequested" | "pending" | "runnable" | "starting" | "running" | "terminal" | null = null;
switch (event.event) {
case "run.start_requested":
if (startRequestedMs == null) target = "startRequested";
break;
case "run.pending":
if (pendingMs == null) target = "pending";
break;
case "run.runnable":
if (runnableMs == null) target = "runnable";
break;
case "run.starting":
if (startingMs == null) target = "starting";
break;
case "run.running":
if (runningMs == null) target = "running";
break;
case "run.completed":
case "run.failed":
if (terminalMs == null) target = "terminal";
break;
}
if (target == null) continue;
const ms = Date.parse(event.ts);
if (Number.isNaN(ms)) continue;
switch (target) {
case "startRequested": startRequestedMs = ms; break;
case "pending": pendingMs = ms; break;
case "runnable": runnableMs = ms; break;
case "starting": startingMs = ms; break;
case "running": runningMs = ms; break;
case "terminal": terminalMs = ms; break;
}
remaining -= 1;
}
const phases: RunPhase[] = [];
phases.push({
kind: "submitted",
label: PHASE_LABEL.submitted,
startMs: createdMs,
endMs: startRequestedMs ?? pendingMs ?? runnableMs ?? startingMs ?? runningMs ?? terminalMs,
});
if (pendingMs != null) {
phases.push({
kind: "pending",
label: PHASE_LABEL.pending,
startMs: pendingMs,
endMs: runnableMs ?? startingMs ?? runningMs ?? terminalMs,
});
}
if (runnableMs != null) {
phases.push({
kind: "runnable",
label: PHASE_LABEL.runnable,
startMs: runnableMs,
endMs: startingMs ?? runningMs ?? terminalMs,
});
}
if (startingMs != null) {
phases.push({
kind: "initializing",
label: PHASE_LABEL.initializing,
startMs: startingMs,
endMs: runningMs ?? terminalMs,
});
}
return phases;
}

View file

@ -1,6 +1,6 @@
import {
SessionsApiAxiosParamCreator,
type EventEnvelope,
type SessionEvent,
type SubmitTurnRequest,
} from "@qltysh/fabro-api-client";
@ -9,7 +9,7 @@ import {
generatedApiConfiguration,
} from "./api-client";
export type SessionStreamEvent = EventEnvelope;
export type SessionStreamEvent = SessionEvent;
type FetchLike = (
input: string,

View file

@ -4,8 +4,8 @@ export type KeyMatcher = (key: Key) => boolean;
export type KeyOrMatcher = Key | KeyMatcher;
export type MutateFn = (key: KeyOrMatcher) => ReturnType<MutatorCallback>;
/** A parsed SSE frame: a run stream item, read through `isStreamItemPayload`. */
export interface EventPayload {
event?: string;
[key: string]: unknown;
}

View file

@ -1,9 +1,10 @@
import { createElement, type ReactNode } from "react";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import type { Stage } from "./stage-sidebar";
import { makeUsage } from "./test-fixtures";
import type { UnknownRecord } from "./unknown";
const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const;
@ -43,19 +44,113 @@ export function setupReactTestEnv(): () => void {
};
}
/** Build an event envelope fixture; override any field via `partial`. */
export function makeEventEnvelope(
const STREAM_EPOCH_MS = Date.parse("2026-04-09T12:00:00Z");
/** The stage a Petri item's subject names: a node, its visit and its firing. */
export interface StreamStage {
name: string;
visit?: number;
firing?: number;
/** The node's `meta.kind`; `agent` unless given. */
kind?: string;
}
/**
* A platform record item of a run's stream, as `GET /runs/{id}/events`
* serves it: `record.kind` names the fact. Recorded one second per `seq`
* after the stream epoch unless `recorded_at` is overridden.
*/
export function makePlatformItem(
seq: number,
partial: Partial<EventEnvelope>,
): EventEnvelope {
record: UnknownRecord & { kind: string },
overrides: Partial<RunStreamItem> = {},
): RunStreamItem {
const recordedAt = overrides.recorded_at ?? STREAM_EPOCH_MS + seq * 1000;
return {
seq,
id: `evt-${seq}`,
ts: `2026-04-09T12:00:0${seq}Z`,
run_id: "run-1",
event: "stage.prompt",
...partial,
} as EventEnvelope;
stream_seq: seq,
kind: "platform",
id: String(seq),
recorded_at: recordedAt,
item: { seq, recorded_at: recordedAt, record },
...overrides,
};
}
/**
* A Petri event item of a run's stream: the recorded `body` (named by its
* `event`) under the stage `subject`, with Petri's `derived` view beside it.
*/
export function makePetriItem(
seq: number,
body: UnknownRecord & { event: string },
{
stage,
derived,
...overrides
}: { stage?: StreamStage; derived?: UnknownRecord } & Partial<RunStreamItem> = {},
): RunStreamItem {
const recordedAt = overrides.recorded_at ?? STREAM_EPOCH_MS + seq * 1000;
const subject = stage
? {
node: { id: 0, name: stage.name, kind: "attractor/stage", meta: { kind: stage.kind ?? "agent" } },
firing: stage.firing ?? stage.visit ?? 1,
visit: stage.visit ?? 1,
attempt: 1,
}
: undefined;
return {
run_id: "run-1",
stream_seq: seq,
kind: "petri",
id: `execution 0/${seq}/0`,
recorded_at: recordedAt,
item: {
id: { log: "execution", execution: 0, seq, index: 0 },
origin: "external",
context: { invocation: 0, execution: 0 },
...(subject ? { subject } : {}),
recorded_at: recordedAt,
record: { seq, origin: "external", recorded_at: recordedAt, body },
...(derived ? { derived } : {}),
},
...overrides,
};
}
/**
* A stage's `step.progress.recorded` carrying one Pebble `CodingAgentEvent`
* envelope: the `variant` (`AssistantMessage`, `ToolCallStarted`, ...) with
* its `payload`, stamped `ts`.
*/
export function makePebbleItem(
seq: number,
ts: string,
stage: StreamStage,
variant: string,
payload: UnknownRecord,
): RunStreamItem {
return makePetriItem(
seq,
{
event: "step.progress.recorded",
firing: stage.firing ?? stage.visit ?? 1,
ev: {
custom: {
kind: "pebble",
node: stage.name,
event: {
seq,
stream_id: "ses_1",
session_id: "ses_1",
timestamp: ts,
event: { [variant]: payload },
},
},
},
},
{ stage, recorded_at: Date.parse(ts) },
);
}
/** Flatten a rendered subtree to its visible text. */

View file

@ -181,7 +181,6 @@ mock.module("../lib/mutations", () => ({
useDenyRun: mutationState,
useInterruptRun: mutationState,
usePreviewRun: mutationState,
useRetryRun: mutationState,
useSteerRun: mutationState,
useSubmitInterviewAnswer: submitAnswerHookMock,
useUpdateRunTitle: mutationState,
@ -550,7 +549,6 @@ describe("handleLifecycleToastResult", () => {
deny: null,
archive: null,
unarchive: null,
retry: null,
},
};
@ -623,7 +621,6 @@ describe("handleLifecycleToastResult", () => {
deny: null,
archive: null,
unarchive: null,
retry: null,
},
};
@ -746,63 +743,6 @@ describe("RunDetail full-height child routes", () => {
expect(badges.map((badge) => badge.children.join(""))).toContain("7");
});
test("successful retry result navigates to the new run once", () => {
const pushed: Array<{ message: string; tone?: string }> = [];
const navigated: string[] = [];
const result: RunDetailActionResult = {
intent: "retry",
ok: true,
run: {
...makeRunSummary({ status: "runnable" }),
id: "run_retry",
retried_from: "run_1",
},
};
const initialState: LifecycleToastState = {
activeArchiveToastId: null,
lastProcessed: {
cancel: null,
approve: null,
deny: null,
archive: null,
unarchive: null,
retry: null,
},
};
const next = handleLifecycleToastResult(
"retry",
result,
initialState,
{
push: (toast) => {
pushed.push(toast);
return "toast-1";
},
dismiss: () => undefined,
},
(path) => navigated.push(path),
);
const replay = handleLifecycleToastResult(
"retry",
result,
next,
{
push: (toast) => {
pushed.push(toast);
return "toast-2";
},
dismiss: () => undefined,
},
(path) => navigated.push(path),
);
expect(next.lastProcessed.retry).toBe(result);
expect(replay).toBe(next);
expect(pushed).toEqual([{ message: "Retry started." }]);
expect(navigated).toEqual(["/runs/run_retry"]);
});
test("hides the Sandbox tab for a planned sandbox without an instance", async () => {
currentRunState = {
sandbox: {

View file

@ -28,7 +28,6 @@ import {
useDenyRun,
useInterruptRun,
usePreviewRun,
useRetryRun,
useUnarchiveRun,
type LifecycleMutationResult,
} from "../lib/mutations";
@ -37,7 +36,6 @@ import { useRunToasts } from "../hooks/use-run-toasts";
import { useRun, useRunQuestions, useRunState } from "../lib/queries";
import {
canApprove,
canRetry,
deleteErrorMessage,
deleteRun,
isCancellationPending,
@ -113,7 +111,6 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const denyMutation = useDenyRun(params.id);
const archiveMutation = useArchiveRun(params.id);
const unarchiveMutation = useUnarchiveRun(params.id);
const retryMutation = useRetryRun(params.id);
const interruptMutation = useInterruptRun(params.id);
const navigate = useNavigate();
const { mutate } = useSWRConfig();
@ -160,10 +157,9 @@ export default function RunDetail({ params }: { params: { id: string } }) {
result,
lifecycleToastStateRef,
{ push, dismiss },
intent === "retry" ? navigate : undefined,
);
},
[dismiss, navigate, push],
[dismiss, push],
);
const triggerLifecycleAction = useCallback(
async (intent: LifecycleAction, trigger: LifecycleTrigger) => {
@ -205,7 +201,6 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const denyPending = denyMutation.isMutating;
const archivePending = archiveMutation.isMutating;
const unarchivePending = unarchiveMutation.isMutating;
const retryPending = retryMutation.isMutating;
const handlePreview = async () => {
const previewWindow = window.open("about:blank", "_blank");
try {
@ -284,15 +279,6 @@ export default function RunDetail({ params }: { params: { id: string } }) {
},
],
lifecycle: [
...(!demoMode && canRetry(summary)
? [{
key: "retry",
label: "Retry",
pendingLabel: "Retrying…",
pending: retryPending,
onSelect: () => void triggerLifecycleAction("retry", retryMutation.trigger),
}]
: []),
...(visibility.showArchive
? [{
key: "archive",

View file

@ -34,7 +34,6 @@ export function createLifecycleToastState(): LifecycleToastState {
deny: null,
archive: null,
unarchive: null,
retry: null,
},
};
}
@ -44,14 +43,12 @@ export function updateLifecycleToastState(
result: RunDetailActionResult | undefined,
stateRef: { current: LifecycleToastState },
toastApi: ToastApi,
navigate?: (path: string) => void,
) {
stateRef.current = handleLifecycleToastResult(
intent,
result,
stateRef.current,
toastApi,
navigate,
);
}
@ -75,7 +72,6 @@ export function handleLifecycleToastResult(
result: RunDetailActionResult | undefined,
state: LifecycleToastState,
toastApi: ToastApi,
navigate?: (path: string) => void,
): LifecycleToastState {
if (!result || result.intent !== intent) return state;
if (state.lastProcessed[intent] === result) return state;
@ -107,12 +103,6 @@ export function handleLifecycleToastResult(
return nextState;
}
if (intent === "retry") {
toastApi.push({ message: "Retry started." });
navigate?.(`/runs/${result.run.id}`);
return nextState;
}
if (state.activeArchiveToastId) {
toastApi.dismiss(state.activeArchiveToastId);
}

View file

@ -1,6 +1,5 @@
import { useMemo, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import {
DebugEventDetailsPanel,
@ -8,27 +7,44 @@ import {
EventSearchInput,
MultiSelectFilter,
} from "../components/event-debug";
import {
debugCategory,
debugCategoryLabel,
} from "../components/event-debug-helpers";
import { debugCategoryLabel } from "../components/event-debug-helpers";
import { RunWaterfall } from "../components/run-waterfall";
import type { RunPhase } from "../lib/run-phases";
import { StageSidebar } from "../components/stage-sidebar";
import { EmptyState, ErrorState, LoadingState } from "../components/state";
import { useRun, useRunEventsList, useRunStages } from "../lib/queries";
import {
debugRowSearchText,
debugRowsFromStream,
deriveRunPhasesFromStream,
type DebugRow,
} from "../lib/petri-stream";
import { useRun, useRunStages, useRunStream } from "../lib/queries";
import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar";
export const handle = { wide: true, fullHeight: true };
type ViewMode = "waterfall" | "events";
const EMPTY_EVENTS: EventEnvelope[] = [];
const EMPTY_ROWS: DebugRow[] = [];
export default function RunEvents() {
const { id } = useParams();
const runQuery = useRun(id);
const stagesQuery = useRunStages(id);
const eventsQuery = useRunEventsList(id);
// The run's events are its stream: Petri's events and the platform
// records, which the waterfall's phases and the events list derive from.
const streamQuery = useRunStream(id);
const streamPhases = useMemo(
() =>
streamQuery.data && runQuery.data
? deriveRunPhasesFromStream(streamQuery.data, runQuery.data.timestamps.created_at)
: undefined,
[streamQuery.data, runQuery.data],
);
const streamRows = useMemo(
() => (streamQuery.data ? debugRowsFromStream(streamQuery.data) : undefined),
[streamQuery.data],
);
const [searchParams, setSearchParams] = useSearchParams();
const view: ViewMode = searchParams.get("view") === "events" ? "events" : "waterfall";
const setView = (next: ViewMode) => {
@ -63,24 +79,24 @@ export default function RunEvents() {
{view === "waterfall" ? (
<WaterfallPane
runId={id!}
events={eventsQuery.data}
eventsError={eventsQuery.error}
phases={streamPhases}
eventsError={streamQuery.error}
stagesData={stagesQuery.data}
stagesError={stagesQuery.error}
createdAt={runQuery.data?.timestamps.created_at}
completedAt={runQuery.data?.timestamps.completed_at ?? null}
onRetry={() => {
void eventsQuery.mutate();
void streamQuery.mutate();
void stagesQuery.mutate();
}}
view={view}
onChangeView={setView}
/>
) : (
<EventsView
events={eventsQuery.data}
error={eventsQuery.error}
onRetry={() => void eventsQuery.mutate()}
<StreamEventsView
rows={streamRows}
error={streamQuery.error}
onRetry={() => void streamQuery.mutate()}
runStart={
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at
@ -130,7 +146,7 @@ function ViewToggle({
function WaterfallPane({
runId,
events,
phases,
eventsError,
stagesData,
stagesError,
@ -141,7 +157,8 @@ function WaterfallPane({
onChangeView,
}: {
runId: string;
events: EventEnvelope[] | undefined;
/** The run's phases once its stream has loaded. */
phases: RunPhase[] | undefined;
eventsError: unknown;
stagesData: ReturnType<typeof useRunStages>["data"];
stagesError: unknown;
@ -153,7 +170,7 @@ function WaterfallPane({
}) {
const error = eventsError ?? stagesError;
const ready =
events !== undefined && stagesData !== undefined && createdAt !== undefined;
phases !== undefined && stagesData !== undefined && createdAt !== undefined;
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col pt-3">
@ -177,7 +194,7 @@ function WaterfallPane({
) : (
<RunWaterfall
runId={runId}
events={events!}
phases={phases!}
stages={stagesData!.data ?? []}
createdAtIso={createdAt!}
completedAtIso={completedAt}
@ -187,15 +204,20 @@ function WaterfallPane({
);
}
function EventsView({
events,
/**
* The events list of a run: one row per stream item, named by the
* Petri event (`<subject>.<verb>`) or the platform record kind, with the
* raw item in the details panel.
*/
export function StreamEventsView({
rows,
error,
onRetry,
runStart,
view,
onChangeView,
}: {
events: EventEnvelope[] | undefined;
rows: DebugRow[] | undefined;
error: unknown;
onRetry: () => void;
runStart: string | undefined;
@ -206,35 +228,43 @@ function EventsView({
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [search, setSearch] = useState("");
const all = events ?? EMPTY_EVENTS;
const all = rows ?? EMPTY_ROWS;
const availableCategories = useMemo<string[]>(() => {
const set = new Set<string>();
for (const event of all) {
if (event.event) set.add(debugCategory(event.event));
}
for (const row of all) set.add(row.category);
return Array.from(set).sort();
}, [all]);
const filtered = useMemo<EventEnvelope[]>(() => {
const filtered = useMemo<DebugRow[]>(() => {
const useCategoryFilter = selectedCategories.length > 0;
const cats = new Set(selectedCategories);
const needle = search.toLowerCase();
return all.filter((event) => {
const name = event.event ?? "";
if (useCategoryFilter && !cats.has(debugCategory(name))) return false;
if (needle) {
const blob = `${name} ${JSON.stringify(event.properties ?? {})}`.toLowerCase();
if (!blob.includes(needle)) return false;
}
return all.filter((row) => {
if (useCategoryFilter && !cats.has(row.category)) return false;
if (needle && !debugRowSearchText(row).includes(needle)) return false;
return true;
});
}, [all, selectedCategories, search]);
const openEvent = useMemo<EventEnvelope | null>(
() => (openSeq != null ? all.find((e) => e.seq === openSeq) ?? null : null),
const openRow = useMemo<DebugRow | null>(
() => (openSeq != null ? all.find((row) => row.seq === openSeq) ?? null : null),
[all, openSeq],
);
const openPayload = useMemo(
() =>
openRow
? {
event: openRow.event,
stream_seq: openRow.seq,
kind: openRow.item.kind,
stage: openRow.stageLabel,
recorded_at: openRow.ts,
item: openRow.item.item,
}
: null,
[openRow],
);
const allCategoriesSelected =
selectedCategories.length === 0 ||
@ -257,7 +287,7 @@ function EventsView({
</div>
);
}
if (events === undefined) {
if (rows === undefined) {
return (
<div className="min-w-0 flex-1 pt-3">
<LoadingState label="Loading events…" />
@ -294,8 +324,8 @@ function EventsView({
{all.length > 0 && (
<span className="text-xs tabular-nums text-fg-muted">
{isFiltering
? `${filtered.length.toLocaleString()} of ${all.length.toLocaleString()} events`
: `${all.length.toLocaleString()} events`}
? `${filtered.length.toLocaleString()} of ${all.length.toLocaleString()} items`
: `${all.length.toLocaleString()} items`}
</span>
)}
</div>
@ -314,24 +344,56 @@ function EventsView({
No events match these filters.
</div>
) : (
filtered.map((event) => (
<DebugEventRow
key={`event-${event.seq}`}
event={event}
filtered.map((row) => (
<StreamEventRow
key={`stream-${row.seq}`}
row={row}
runStart={runStart}
selected={openSeq === event.seq}
onSelect={() => setOpenSeq(event.seq)}
selected={openSeq === row.seq}
onSelect={() => setOpenSeq(row.seq)}
/>
))
)}
</div>
</div>
<DebugEventDetailsPanel event={openEvent} onClose={() => setOpenSeq(null)} />
<DebugEventDetailsPanel event={openPayload} onClose={() => setOpenSeq(null)} />
</>
);
}
/** A debug row with the stage the item belongs to beside its name. */
function StreamEventRow({
row,
runStart,
selected,
onSelect,
}: {
row: DebugRow;
runStart: string | undefined;
selected: boolean;
onSelect: () => void;
}) {
return (
<div className="grid grid-cols-[1fr_auto] items-center">
<DebugEventRow
event={row}
runStart={runStart}
selected={selected}
onSelect={onSelect}
/>
{row.stageLabel && (
<span
data-stage={row.stageLabel}
className="pr-5 font-mono text-[11px] text-fg-muted"
>
{row.stageLabel}
</span>
)}
</div>
);
}
function errorMessage(error: unknown): string | undefined {
return error instanceof Error ? error.message : undefined;
}

View file

@ -21,7 +21,8 @@ mock.module("../lib/queries", () => ({
mutate: graphMutateMock,
}),
useRunGraphSource: () => ({ data: undefined }),
useRunStageEvents: () => ({ data: [] }),
useRunState: () => ({ data: undefined }),
useRunStream: () => ({ data: undefined }),
}));
mock.module("../components/run-summary-panel", () => ({

View file

@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router";
import { ApiError } from "../lib/api-client";
import { useRun, useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries";
import { FloatingTooltip } from "../components/floating-tooltip";
import { PlatformRecordsPanel } from "../components/platform-records-panel";
import { RunSummaryPanel } from "../components/run-summary-panel";
import { StagePopover } from "../components/stage-popover";
import { StageSidebar } from "../components/stage-sidebar";
@ -150,8 +151,9 @@ export default function RunOverview() {
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-4 pb-[var(--fabro-interview-dock-clearance,0px)]">
<div className="shrink-0">
<div className="shrink-0 space-y-4">
<RunSummaryPanel runId={id!} />
<PlatformRecordsPanel runId={id!} />
</div>
{graphSvg === undefined && graphQuery.isLoading ? (
<div className="flex-1" />

View file

@ -0,0 +1,285 @@
/**
* The run detail's views over a Petri run, rendered from the projection and
* the stream the server tests captured (`test-fixtures/petri/*.json`): one
* scenario per fixture, every view `VIEWS.md` lists that the web app draws
* from those two sources.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { ReactElement } from "react";
import type { RunStage } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { PlatformRecordsPanelView } from "../components/platform-records-panel";
import { RunWaterfall } from "../components/run-waterfall";
import { StageSidebar } from "../components/stage-sidebar";
import { FanInResults } from "../components/stage-renderers/fan-in-results";
import { HumanQA } from "../components/stage-renderers/human-qa";
import { ParallelChildren } from "../components/stage-renderers/parallel-children";
import { ConditionalDecision } from "../components/stage-renderers/conditional-decision";
import { loadPetriFixture, type PetriFixture } from "../lib/petri-fixtures";
import {
debugRowsFromStream,
deriveRunPhasesFromStream,
findPetriEdgeForStage,
itemsForStage,
parallelOverviewFromProjection,
parsePetriInterviewPairs,
platformRecordsOf,
reducerTranscriptFromProjection,
stagesFromProjection,
} from "../lib/petri-stream";
import { setupReactTestEnv } from "../lib/test-utils";
import { StreamEventsView } from "./run-events";
import { StageChatView, buildPetriStageActivity } from "./run-stages";
let teardown: () => void;
beforeEach(() => {
teardown = setupReactTestEnv();
});
afterEach(() => teardown());
function render(element: ReactElement): string {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<MemoryRouter initialEntries={["/runs/run-1"]}>{element}</MemoryRouter>,
);
});
const json = JSON.stringify(renderer.toJSON());
act(() => renderer.unmount());
return json;
}
function runStages(fixture: PetriFixture): RunStage[] {
return stagesFromProjection(fixture.projection).map((stage) => ({
id: stage.id,
name: stage.name,
handler: stage.handler,
status: stage.status,
node_id: stage.nodeId,
visit: stage.visit,
started_at: stage.startedAt,
wall_time_ms: fixture.projection.stages[stage.id]?.timing?.wall_time_ms,
usage: stage.usage,
parallel_group_id: stage.parallelGroupId ?? undefined,
parallel_branch_index: stage.parallelBranchIndex ?? undefined,
}));
}
function createdAt(fixture: PetriFixture): string {
return new Date(fixture.stream[0].recorded_at).toISOString();
}
describe("a command-only run", () => {
const fixture = loadPetriFixture("command");
const stages = stagesFromProjection(fixture.projection);
test("the stage list shows every stage with its state", () => {
expect(stages.map((stage) => [stage.id, stage.status])).toEqual([
["start@1", "succeeded"],
["say@1", "succeeded"],
["exit@1", "succeeded"],
]);
const html = render(<StageSidebar stages={stages} runId="run-1" />);
for (const name of ["start", "say", "exit"]) expect(html).toContain(name);
// The sidebar shows a stage's state as its icon's tone: mint is succeeded.
expect((html.match(/text-mint/g) ?? []).length).toBe(3);
expect(html).not.toContain("animate-pulse");
});
test("the command stage is one command turn with its exit status and output size", () => {
const say = fixture.projection.stages["say@1"];
const activity = buildPetriStageActivity(
itemsForStage(fixture.stream, "say@1"),
say,
"command",
);
expect(activity.turns).toHaveLength(1);
expect(activity.turns[0]).toMatchObject({
kind: "command",
running: false,
exitCode: 0,
outputBytes: say.output_bytes,
});
});
test("the waterfall's phases come from the lifecycle records", () => {
const html = render(
<RunWaterfall
runId="run-1"
phases={deriveRunPhasesFromStream(fixture.stream, createdAt(fixture))}
stages={runStages(fixture)}
createdAtIso={createdAt(fixture)}
completedAtIso={fixture.projection.conclusion?.timestamp ?? null}
/>,
);
for (const label of ["Submitted", "Runnable", "Initializing", "say"]) {
expect(html).toContain(label);
}
});
});
describe("the hello run on the twin", () => {
const fixture = loadPetriFixture("hello");
const stages = stagesFromProjection(fixture.projection);
test("the agent stage's chat shows the prompt and the agent's response", () => {
const greet = stages.find((stage) => stage.id === "greet@1")!;
expect(greet.handler).toBe("agent");
const activity = buildPetriStageActivity(
itemsForStage(fixture.stream, "greet@1"),
fixture.projection.stages["greet@1"],
"agent",
);
expect(activity.turns.map((turn) => turn.kind)).toEqual(["system", "assistant"]);
expect(activity.turns[0]).toMatchObject({ kind: "system" });
expect((activity.turns[0] as { content: string }).content).toContain("Add a haiku");
expect(activity.turns[1]).toMatchObject({
kind: "assistant",
content: "A haiku, added.",
inputTokens: 1,
outputTokens: 5,
});
const html = render(
<StageChatView
turns={activity.turns}
pendingTools={activity.pendingTools}
stage={greet}
/>,
);
expect(html).toContain("A haiku, added.");
});
test("the stage list shows the agent stage succeeded", () => {
const greet = stages.find((stage) => stage.id === "greet@1")!;
expect(greet.status).toBe("succeeded");
expect(greet.providerUsed?.model).toBe("gpt-5.4");
const html = render(<StageSidebar stages={stages} runId="run-1" />);
expect(html).toContain("greet");
expect(html).toContain("text-mint");
});
});
describe("a two-branch parallel run", () => {
const fixture = loadPetriFixture("parallel");
const stages = stagesFromProjection(fixture.projection);
test("the fork lists both branches under it with their outcomes", () => {
const fork = stages.find((stage) => stage.id === "fork@1")!;
const html = render(
<ParallelChildren
stage={fork}
events={[]}
overview={parallelOverviewFromProjection(fixture.projection.stages["fork@1"])}
runId="run-1"
allStages={stages}
/>,
);
expect(html).toContain("Branches");
expect(html).toContain("/runs/run-1/stages/a@1");
expect(html).toContain("/runs/run-1/stages/b@1");
expect((html.match(/Succeeded/g) ?? []).length).toBeGreaterThanOrEqual(2);
});
test("the fan-in joined the branches", () => {
const merge = stages.find((stage) => stage.id === "merge@1")!;
const html = render(
<FanInResults
stage={merge}
events={[]}
reducer={reducerTranscriptFromProjection(fixture.projection.stages["merge@1"])}
/>,
);
expect(html).toContain("Joined");
expect(html).not.toContain("Reducer transcript");
});
test("the events view lists Petri events by name and the platform notice", () => {
const html = render(
<StreamEventsView
rows={debugRowsFromStream(fixture.stream)}
error={undefined}
onRetry={() => {}}
runStart={createdAt(fixture)}
view="events"
onChangeView={() => {}}
/>,
);
for (const name of ["run.started", "visit.started", "fork.completed", "run.finished"]) {
expect(html).toContain(name);
}
expect(html).toContain("run.notice");
expect(html).toContain('"data-stage":"a@1"');
expect(html).toContain(`${fixture.stream.length} items`);
});
test("the overview lists the platform records", () => {
const html = render(
<PlatformRecordsPanelView
records={platformRecordsOf(fixture.stream)}
projection={fixture.projection}
/>,
);
expect(html).toContain("Platform records");
expect(html).toContain("Notice");
expect(html).toContain("recorded while both branches ran");
// The checkpoint hook wrote one record per stage, with its commit.
expect(html).toContain("Checkpoint");
const checkpoint = fixture.stream.find(
(item) => item.kind === "platform" && item.item.record?.kind === "checkpoint",
);
expect(checkpoint).toBeDefined();
expect(html).toContain(String(checkpoint!.item.record.git_commit_sha).slice(0, 12));
});
test("the sidebar groups the branches under the fork", () => {
const branches = stages.filter((stage) => stage.parallelGroupId === "fork@1");
expect(branches.map((stage) => stage.id)).toEqual(["a@1", "b@1"]);
const html = render(<StageSidebar stages={stages} runId="run-1" />);
for (const name of ["fork", "a", "b", "merge"]) expect(html).toContain(name);
});
});
describe("a human gate answered through the API", () => {
const fixture = loadPetriFixture("gate");
const stages = stagesFromProjection(fixture.projection);
test("the Q&A shows the question, its options and the answer with who gave it", () => {
const gate = stages.find((stage) => stage.id === "gate@1")!;
expect(gate.handler).toBe("human");
const html = render(
<HumanQA
stage={gate}
events={[]}
pairs={parsePetriInterviewPairs(fixture.stream)}
/>,
);
expect(html).toContain("Go?");
expect(html).toContain("[Y] Yes");
expect(html).toContain("[N] No");
expect(html).toContain("dev");
expect(html).not.toContain("pending");
});
test("the gate's decision took the no edge", () => {
const gate = stages.find((stage) => stage.id === "gate@1")!;
const edge = findPetriEdgeForStage(fixture.stream, "gate@1");
const html = render(
<ConditionalDecision
stage={gate}
runEvents={[]}
edge={edge}
allStages={stages}
runId="run-1"
/>,
);
expect(html).toContain("/runs/run-1/stages/no@1");
expect(html).not.toContain("No target");
});
test("no question is left pending in the projection", () => {
expect(Object.keys(fixture.projection.pending_interviews)).toEqual([]);
expect(stages.map((stage) => stage.id)).toEqual(["start@1", "gate@1", "no@1", "exit@1"]);
});
});

View file

@ -217,12 +217,14 @@ describe("RunSandbox route", () => {
test("renders panels for a fully populated sandbox", () => {
currentDetails = sandboxDetails({
sandbox: {
provider: "docker",
image: "ghcr.io/fabro/sandbox:latest",
runtime: {
provider: "docker",
image: "ghcr.io/fabro/sandbox:latest",
runtime: {
id: "abcdef123456",
working_directory: "/workspace",
},
ready_duration_ms: 1500,
retained: true,
},
status: {
state: "running",
@ -248,6 +250,10 @@ describe("RunSandbox route", () => {
const copy = textContent(renderer);
expect(copy).toContain("Allow all");
expect(copy).toContain("4 GiB");
expect(copy).toContain("Ready in");
expect(copy).toContain("1.5s");
expect(copy).toContain("Retained");
expect(copy).toContain("Yes");
});
test("links to the provider dashboard when a sandbox web URL is present", () => {

View file

@ -9,6 +9,7 @@ import {
formatAbsoluteTs,
formatBytesAsMemory,
formatCpuCores,
formatDurationMs,
} from "../lib/format";
import { useRun, useRunSandboxDetails, useRunState } from "../lib/queries";
import {
@ -57,6 +58,17 @@ function nullableTimestamp(value: string | null | undefined): string {
return value ? formatAbsoluteTs(value) : EMPTY_VALUE;
}
function nullableDuration(ms: number | null | undefined): string {
return ms == null ? EMPTY_VALUE : formatDurationMs(ms);
}
/// The release outcome: "Yes" when the sandbox still exists after the run
/// released it, "No" when it was removed, and empty before the release.
function nullableRetained(retained: boolean | null | undefined): string {
if (retained == null) return EMPTY_VALUE;
return retained ? "Yes" : "No";
}
function nullableMegabytes(megabytes: number | null | undefined): string {
return megabytes != null ? formatBytesAsMemory(megabytes * 1024 * 1024) : EMPTY_VALUE;
}
@ -203,6 +215,8 @@ function OverviewPanel({ details }: { details: SandboxDetails }) {
value={nullable(status.image ?? status.snapshot ?? sandbox.image ?? sandbox.snapshot)}
/>
{status.sandbox_kind && <Row label="Kind" value={status.sandbox_kind} />}
<Row label="Ready in" value={nullableDuration(sandbox.ready_duration_ms)} />
<Row label="Retained" value={nullableRetained(sandbox.retained)} />
{status.web_url && (
<LinkRow
label="Provider"

View file

@ -1,13 +1,12 @@
import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { RunStreamItem, StageProjection } from "@qltysh/fabro-api-client";
import {
buildChatItems,
buildStageActivity,
buildPetriStageActivity,
buildThreadDnaItems,
EVENT_KINDS,
eventsTabLabel,
eventsToActivity,
filterDisplayItems,
filterThreadDnaItems,
formatStageModelUsageLabel,
@ -20,16 +19,20 @@ import {
type EventKind,
} from "./run-stages";
import { threadSelectionId } from "../components/event-debug";
import { makeUsage } from "../lib/test-fixtures";
import { makePebbleItem } from "../lib/test-utils";
import type { UnknownRecord } from "../lib/unknown";
function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
return {
seq,
id: `evt-${seq}`,
ts: "2026-04-09T12:00:00Z",
run_id: "run-1",
event: "stage.prompt",
...partial,
} as EventEnvelope;
const CODE_STAGE = { name: "code", visit: 1 };
const TS = "2026-04-09T12:00:00Z";
/** One Pebble envelope the `code@1` stage recorded. */
function pebble(seq: number, variant: string, payload: UnknownRecord, ts = TS): RunStreamItem {
return makePebbleItem(seq, ts, CODE_STAGE, variant, payload);
}
function agentTurns(items: RunStreamItem[]) {
return buildPetriStageActivity(items, undefined, "agent").turns;
}
function toolTurn(opts: {
@ -71,461 +74,8 @@ function expectSingleItem(
return item;
}
describe("eventsToActivity", () => {
test("filters events by stage_id (verify@1 vs verify@2 do not cross-contaminate)", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "stage.prompt",
stage_id: "verify@1",
node_id: "verify",
properties: { text: "first visit prompt" },
}),
envelope(2, {
event: "stage.prompt",
stage_id: "verify@2",
node_id: "verify",
properties: { text: "second visit prompt" },
}),
envelope(3, {
event: "agent.message",
stage_id: "verify@1",
node_id: "verify",
properties: {
event: { AssistantMessage: { text: "first visit reply" } },
},
}),
envelope(4, {
event: "agent.message",
stage_id: "verify@2",
node_id: "verify",
properties: {
event: { AssistantMessage: { text: "second visit reply" } },
},
}),
];
const firstVisit = eventsToActivity(events, "verify@1");
expect(firstVisit).toEqual([
{
kind: "system",
ts: "2026-04-09T12:00:00Z",
content: "first visit prompt",
},
{
kind: "assistant",
ts: "2026-04-09T12:00:00Z",
content: "first visit reply",
inputTokens: 0,
outputTokens: 0,
toolCallCount: null,
reasoning: null,
},
]);
const secondVisit = eventsToActivity(events, "verify@2");
expect(secondVisit).toEqual([
{
kind: "system",
ts: "2026-04-09T12:00:00Z",
content: "second visit prompt",
},
{
kind: "assistant",
ts: "2026-04-09T12:00:00Z",
content: "second visit reply",
inputTokens: 0,
outputTokens: 0,
toolCallCount: null,
reasoning: null,
},
]);
});
test("pairs command.started + command.completed into a single command turn", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "command.started",
node_id: "fmt",
properties: { script: "cargo fmt", language: "shell" },
}),
envelope(2, {
event: "command.completed",
node_id: "fmt",
properties: {
output: "blob://sha256/abc",
output_bytes: 42,
exit_code: 0,
duration_ms: 12,
termination: "exited",
},
}),
];
const turns = eventsToActivity(events, "fmt");
expect(turns).toHaveLength(1);
expect(turns[0]).toMatchObject({
kind: "command",
script: "cargo fmt",
running: false,
outputBytes: 42,
});
});
test("command turn carries the requested stage_id, no @1 fallback", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "command.started",
stage_id: "verify@2",
node_id: "verify",
properties: { script: "echo hi", language: "shell" },
}),
envelope(2, {
event: "command.completed",
stage_id: "verify@2",
node_id: "verify",
properties: {
output: "hi",
exit_code: 0,
duration_ms: 5,
termination: "exited",
},
}),
];
const turns = eventsToActivity(events, "verify@2");
expect(turns).toHaveLength(1);
const turn = turns[0];
expect(turn.kind).toBe("command");
if (turn.kind === "command") {
expect(turn.script).toBe("echo hi");
expect(turn.running).toBe(false);
}
});
test("pairs agent.tool.started + agent.tool.completed into a single tool turn", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.tool.started",
node_id: "detect-drift",
properties: {
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "read_file",
arguments: { path: "config.toml" } } },
},
}),
envelope(2, {
event: "agent.tool.completed",
node_id: "detect-drift",
properties: {
event: { ToolCallCompleted: { tool_call_id: "call-1",
tool_name: "read_file",
output: "[redis]",
is_error: false } },
},
}),
];
const turns = eventsToActivity(events, "detect-drift");
expect(turns).toHaveLength(1);
expect(turns[0].kind).toBe("tool");
if (turns[0].kind === "tool") {
expect(turns[0]).toMatchObject({
toolName: "read_file",
isError: false,
});
}
});
test("renders injected steering as a transcript turn for the matching stage", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "run.steer",
properties: { text: "say hello" },
}),
envelope(2, {
event: "agent.steering.injected",
stage_id: "nap@1",
node_id: "nap",
properties: {
event: { SteeringInjected: { text: "say hello" } },
},
}),
envelope(3, {
event: "agent.steering.injected",
stage_id: "other@1",
node_id: "other",
properties: {
event: { SteeringInjected: { text: "wrong stage" } },
},
}),
];
expect(eventsToActivity(events, "nap@1")).toEqual([
{
kind: "steer",
ts: "2026-04-09T12:00:00Z",
content: "say hello",
},
]);
});
test("renders injected interrupt as a transcript turn for the matching stage", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "run.interrupt",
properties: {},
}),
envelope(2, {
event: "agent.interrupt.injected",
stage_id: "nap@1",
node_id: "nap",
properties: { visit: 1 },
}),
envelope(3, {
event: "agent.interrupt.injected",
stage_id: "other@1",
node_id: "other",
properties: { visit: 1 },
}),
];
expect(eventsToActivity(events, "nap@1")).toEqual([
{
kind: "interrupt",
ts: "2026-04-09T12:00:00Z",
content: "Agent interrupted",
},
]);
});
test("renders settled interrupt as waiting for steering", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.round.interrupted",
stage_id: "nap@1",
node_id: "nap",
properties: { generation: 1, visit: 1 },
}),
envelope(2, {
event: "agent.round.interrupted",
stage_id: "other@1",
node_id: "other",
properties: { generation: 1, visit: 1 },
}),
];
expect(eventsToActivity(events, "nap@1")).toEqual([
{
kind: "interrupt",
ts: "2026-04-09T12:00:00Z",
content: "Interrupted — waiting for steering",
},
]);
});
test("renders pair messages as transcript turns for the matching stage", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.pair.system_message",
ts: "2026-04-09T12:00:00Z",
stage_id: "nap@1",
node_id: "nap",
properties: {
text: "A human has joined this workflow run for live pairing.",
kind: "human_joined",
visit: 1,
},
}),
envelope(2, {
event: "agent.pair.user_message",
ts: "2026-04-09T12:00:05Z",
stage_id: "nap@1",
node_id: "nap",
properties: { text: "try a smaller diff", visit: 1 },
}),
envelope(3, {
event: "agent.pair.user_message",
ts: "2026-04-09T12:00:06Z",
stage_id: "other@1",
node_id: "other",
properties: { text: "wrong stage", visit: 1 },
}),
];
expect(eventsToActivity(events, "nap@1")).toEqual([
{
kind: "pair_system",
ts: "2026-04-09T12:00:00Z",
content: "A human has joined this workflow run for live pairing.",
},
{
kind: "pair_user",
ts: "2026-04-09T12:00:05Z",
content: "try a smaller diff",
},
]);
});
test("renders prompt.completed as an assistant turn for prompt-shape stages", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "stage.prompt",
stage_id: "summarize@1",
node_id: "summarize",
properties: { text: "summarize the diff" },
}),
envelope(2, {
event: "prompt.completed",
stage_id: "summarize@1",
node_id: "summarize",
properties: {
response: "Refactored auth module",
model: "claude-sonnet-4-6",
provider: "anthropic",
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 120, output: 30 } } },
},
}),
];
expect(eventsToActivity(events, "summarize@1")).toEqual([
{
kind: "system",
ts: "2026-04-09T12:00:00Z",
content: "summarize the diff",
},
{
kind: "assistant",
ts: "2026-04-09T12:00:00Z",
content: "Refactored auth module",
inputTokens: 120,
outputTokens: 30,
toolCallCount: null,
reasoning: null,
},
]);
});
test("does not duplicate the assistant turn when prompt.completed follows agent.message", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "stage.prompt",
stage_id: "simplify@1",
node_id: "simplify",
properties: { text: "simplify" },
}),
envelope(2, {
event: "agent.message",
stage_id: "simplify@1",
node_id: "simplify",
properties: {
event: { AssistantMessage: { text: "Done.",
usage: { input: 10, output: 5 } } },
},
}),
envelope(3, {
event: "prompt.completed",
stage_id: "simplify@1",
node_id: "simplify",
properties: {
response: "Done.",
model: "claude-sonnet-4-6",
provider: "anthropic",
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 10, output: 5 } } },
},
}),
];
const turns = eventsToActivity(events, "simplify@1");
expect(turns).toEqual([
{
kind: "system",
ts: "2026-04-09T12:00:00Z",
content: "simplify",
},
{
kind: "assistant",
ts: "2026-04-09T12:00:00Z",
content: "Done.",
inputTokens: 10,
outputTokens: 5,
toolCallCount: null,
reasoning: null,
},
]);
});
test("renders prompt.completed even with no preceding stage.prompt", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "prompt.completed",
stage_id: "summarize@1",
node_id: "summarize",
properties: {
response: "All clear.",
model: "claude-sonnet-4-6",
provider: "anthropic",
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 0, output: 4 } } },
},
}),
];
expect(eventsToActivity(events, "summarize@1")).toEqual([
{
kind: "assistant",
ts: "2026-04-09T12:00:00Z",
content: "All clear.",
inputTokens: 0,
outputTokens: 4,
toolCallCount: null,
reasoning: null,
},
]);
});
test("reads disclosed reasoning off agent.message", () => {
function reasoningOf(properties: Record<string, unknown>) {
const turns = eventsToActivity(
[
envelope(1, {
event: "agent.message",
stage_id: "plan@1",
node_id: "plan",
properties: { event: { AssistantMessage: properties } },
}),
],
"plan@1",
);
expect(turns[0].kind).toBe("assistant");
return turns[0].kind === "assistant" ? turns[0].reasoning : undefined;
}
expect(
reasoningOf({
text: "Done.",
reasoning: { summary: "Checked the config", trace: "step one…" },
}),
).toEqual({ summary: "Checked the config", trace: "step one…" });
// Anthropic thinking arrives as a trace with no summary.
expect(
reasoningOf({ text: "Done.", reasoning: { trace: "step one…" } }),
).toEqual({ trace: "step one…" });
expect(
reasoningOf({
text: "Done.",
reasoning: { summary: "Checked the config" },
}),
).toEqual({ summary: "Checked the config" });
expect(reasoningOf({ text: "Done." })).toBe(null);
// A provider that sends the key but nothing usable reads as "none".
expect(reasoningOf({ text: "Done.", reasoning: {} })).toBe(null);
expect(
reasoningOf({ text: "Done.", reasoning: { summary: "", trace: "" } }),
).toBe(null);
});
test("formatStageModelUsageLabel includes reasoning effort when present", () => {
describe("formatStageModelUsageLabel", () => {
test("includes reasoning effort when present", () => {
expect(
formatStageModelUsageLabel({
mode: "agent",
@ -537,7 +87,7 @@ describe("eventsToActivity", () => {
).toBe("gpt-5.5[high]");
});
test("formatStageModelUsageLabel returns null when the projection has no model", () => {
test("returns null when the projection has no model", () => {
expect(
formatStageModelUsageLabel({
mode: "acp",
@ -546,41 +96,6 @@ describe("eventsToActivity", () => {
}),
).toBe(null);
});
test("ignores unknown event types and events for other stages", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "stage.started",
node_id: "detect-drift",
properties: {},
}),
envelope(2, {
event: "agent.message",
node_id: "detect-drift",
properties: {
event: { AssistantMessage: { text: "signal" } },
},
}),
envelope(3, {
event: "run.running",
node_id: "detect-drift",
properties: {},
}),
envelope(4, {
event: "agent.message",
node_id: "other-stage",
properties: {
event: { AssistantMessage: { text: "wrong stage" } },
},
}),
];
const turns = eventsToActivity(events, "detect-drift");
expect(turns).toHaveLength(1);
if (turns[0].kind === "assistant") {
expect(turns[0].content).toBe("signal");
}
});
});
describe("groupConsecutiveTools", () => {
@ -946,39 +461,22 @@ describe("buildChatItems", () => {
});
});
describe("buildStageActivity pending tools", () => {
describe("buildPetriStageActivity pending tools", () => {
test("returns started-but-not-completed calls for the stage", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.tool.started",
stage_id: "plan@1",
node_id: "plan",
properties: {
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "shell",
arguments: { command: "cargo build" } } },
},
const items = [
pebble(1, "ToolCallStarted", {
tool_call_id: "call-1",
tool_name: "shell",
arguments: { command: "cargo build" },
}),
envelope(2, {
event: "agent.tool.started",
stage_id: "plan@1",
node_id: "plan",
properties: {
event: { ToolCallStarted: { tool_call_id: "call-2",
tool_name: "read_file",
arguments: { file_path: "/tmp/x" } } },
},
}),
envelope(3, {
event: "agent.tool.completed",
stage_id: "plan@1",
node_id: "plan",
properties: {
event: { ToolCallCompleted: { tool_call_id: "call-1", output: "ok" } },
},
pebble(2, "ToolCallStarted", {
tool_call_id: "call-2",
tool_name: "read_file",
arguments: { file_path: "/tmp/x" },
}),
pebble(3, "ToolCallCompleted", { tool_call_id: "call-1", output: "ok" }),
];
expect(buildStageActivity(events, "plan@1").pendingTools).toEqual([
expect(buildPetriStageActivity(items, undefined, "agent").pendingTools).toEqual([
{
toolCallId: "call-2",
toolName: "read_file",
@ -987,45 +485,21 @@ describe("buildStageActivity pending tools", () => {
]);
});
test("ignores events from other stage visits", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.tool.started",
stage_id: "plan@2",
node_id: "plan",
properties: {
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "shell",
arguments: {} } },
},
}),
];
expect(buildStageActivity(events, "plan@1").pendingTools).toEqual([]);
});
test("keeps stable identities for simultaneous calls with the same tool name", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.tool.started",
stage_id: "plan@1",
properties: {
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "shell",
arguments: { command: "cargo build" } } },
},
const items = [
pebble(1, "ToolCallStarted", {
tool_call_id: "call-1",
tool_name: "shell",
arguments: { command: "cargo build" },
}),
envelope(2, {
event: "agent.tool.started",
stage_id: "plan@1",
properties: {
event: { ToolCallStarted: { tool_call_id: "call-2",
tool_name: "shell",
arguments: { command: "cargo test" } } },
},
pebble(2, "ToolCallStarted", {
tool_call_id: "call-2",
tool_name: "shell",
arguments: { command: "cargo test" },
}),
];
expect(buildStageActivity(events, "plan@1").pendingTools).toEqual([
expect(buildPetriStageActivity(items, undefined, "agent").pendingTools).toEqual([
{
toolCallId: "call-1",
toolName: "shell",
@ -1039,34 +513,18 @@ describe("buildStageActivity pending tools", () => {
]);
});
test("ignores malformed tool events without a call id", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.tool.started",
stage_id: "plan@1",
properties: {
event: { ToolCallStarted: { tool_name: "shell", arguments: { command: "ignored" } } },
},
}),
envelope(2, {
event: "agent.tool.started",
stage_id: "plan@1",
properties: {
event: { ToolCallStarted: { tool_call_id: "call-1",
tool_name: "shell",
arguments: { command: "kept" } } },
},
}),
envelope(3, {
event: "agent.tool.completed",
stage_id: "plan@1",
properties: {
event: { ToolCallCompleted: { output: "must not clear call-1" } },
},
test("ignores malformed tool envelopes without a call id", () => {
const items = [
pebble(1, "ToolCallStarted", { tool_name: "shell", arguments: { command: "ignored" } }),
pebble(2, "ToolCallStarted", {
tool_call_id: "call-1",
tool_name: "shell",
arguments: { command: "kept" },
}),
pebble(3, "ToolCallCompleted", { output: "must not clear call-1" }),
];
const activity = buildStageActivity(events, "plan@1");
const activity = buildPetriStageActivity(items, undefined, "agent");
expect(activity.turns).toEqual([]);
expect(activity.pendingTools).toEqual([
{
@ -1263,22 +721,17 @@ describe("buildThreadDnaItems", () => {
});
describe("tool-call-only agent responses", () => {
test("retains an empty agent.message with its timestamp, usage, and tool-call count", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.message",
ts: "2026-04-09T12:00:42Z",
stage_id: "code@1",
node_id: "code",
properties: {
event: { AssistantMessage: { text: "",
usage: { input: 4200, output: 96 },
tool_call_count: 2 } },
},
}),
test("retains an empty assistant message with its timestamp, usage, and tool-call count", () => {
const items = [
pebble(
1,
"AssistantMessage",
{ text: "", usage: { input: 4200, output: 96 }, tool_call_count: 2 },
"2026-04-09T12:00:42Z",
),
];
expect(eventsToActivity(events, "code@1")).toEqual([
expect(agentTurns(items)).toEqual([
{
kind: "assistant",
ts: "2026-04-09T12:00:42Z",
@ -1291,28 +744,16 @@ describe("tool-call-only agent responses", () => {
]);
});
test("does not synthesize a prompt.completed turn after an empty agent.message", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.message",
stage_id: "code@1",
node_id: "code",
properties: {
event: { AssistantMessage: { text: "", tool_call_count: 1 } },
},
}),
envelope(2, {
event: "prompt.completed",
stage_id: "code@1",
node_id: "code",
properties: {
response: "",
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1, output: 2 } } },
},
}),
];
test("does not add the projection's response after an empty assistant message", () => {
const items = [pebble(1, "AssistantMessage", { text: "", tool_call_count: 1 })];
const stage: StageProjection = {
first_event_seq: 1,
state: "succeeded",
usage: makeUsage({ input: 1, output: 2 }),
response: "",
};
const turns = eventsToActivity(events, "code@1");
const turns = buildPetriStageActivity(items, stage, "agent").turns;
expect(turns).toHaveLength(1);
expect(turns[0]).toMatchObject({ kind: "assistant", toolCallCount: 1 });
});
@ -1346,7 +787,6 @@ describe("tool-call-only agent responses", () => {
});
describe("tool batch boundaries", () => {
const STAGE = "code@1";
const RUN_START = "2026-04-09T12:00:00Z";
function modelResponse(
@ -1354,18 +794,13 @@ describe("tool batch boundaries", () => {
ts: string,
toolCallCount: number,
text = "",
): EventEnvelope {
return envelope(seq, {
event: "agent.message",
): RunStreamItem {
return pebble(
seq,
"AssistantMessage",
{ text, usage: { input: 1000, output: 20 }, tool_call_count: toolCallCount },
ts,
stage_id: STAGE,
node_id: "code",
properties: {
event: { AssistantMessage: { text,
usage: { input: 1000, output: 20 },
tool_call_count: toolCallCount } },
},
});
);
}
function shellCall(
@ -1374,42 +809,28 @@ describe("tool batch boundaries", () => {
startTs: string,
endTs: string,
command: string,
): EventEnvelope[] {
): RunStreamItem[] {
return [
envelope(seq, {
event: "agent.tool.started",
ts: startTs,
stage_id: STAGE,
node_id: "code",
properties: {
event: { ToolCallStarted: { tool_call_id: callId,
tool_name: "shell",
arguments: { command } } },
},
}),
envelope(seq + 1, {
event: "agent.tool.completed",
ts: endTs,
stage_id: STAGE,
node_id: "code",
properties: {
event: { ToolCallCompleted: { tool_call_id: callId, tool_name: "shell", output: "ok" } },
},
}),
pebble(
seq,
"ToolCallStarted",
{ tool_call_id: callId, tool_name: "shell", arguments: { command } },
startTs,
),
pebble(
seq + 1,
"ToolCallCompleted",
{ tool_call_id: callId, tool_name: "shell", output: "ok" },
endTs,
),
];
}
// Anonymized reproduction: eight sub-100ms shell calls issued across five
// model responses, each response separated by a minute or more of model
// time and carrying no text of its own.
const REPRO_EVENTS: EventEnvelope[] = [
envelope(1, {
event: "stage.prompt",
ts: RUN_START,
stage_id: STAGE,
node_id: "code",
properties: { text: "investigate the failure" },
}),
const REPRO_EVENTS: RunStreamItem[] = [
pebble(1, "UserInput", { text: "investigate the failure" }, RUN_START),
modelResponse(2, "2026-04-09T12:00:30Z", 2),
...shellCall(
3,
@ -1475,7 +896,7 @@ describe("tool batch boundaries", () => {
];
function reproItems(): DisplayItem[] {
const turns = eventsToActivity(REPRO_EVENTS, STAGE);
const turns = agentTurns(REPRO_EVENTS);
return groupConsecutiveTools(turns.map((turn, index) => ({ turn, index })));
}

View file

@ -23,6 +23,7 @@ import {
EventSearchInput,
MultiSelectFilter,
ThreadDnaStrip,
debugRowCategory,
threadSelectionId,
threadSelectionsEqual,
} from "../components/event-debug";
@ -33,6 +34,7 @@ import {
type DebugCategory,
} from "../components/event-debug-helpers";
import type {
EventDisplayPayload,
ThreadDnaItem,
ThreadDnaSelection,
} from "../components/event-debug";
@ -51,7 +53,13 @@ import {
} from "../components/ui";
import { ConditionalDecision } from "../components/stage-renderers/conditional-decision";
import { FanInResults } from "../components/stage-renderers/fan-in-results";
import { extractStageContext } from "../components/stage-renderers/helpers";
import type {
EdgeSelection,
HumanInterviewPair,
ParallelOverview,
ReducerTranscript,
StageContextData,
} from "../components/stage-renderers/helpers";
import { HumanQA } from "../components/stage-renderers/human-qa";
import { ParallelChildren } from "../components/stage-renderers/parallel-children";
import {
@ -71,19 +79,30 @@ import {
} from "../lib/format";
import { costSourceTag, hasUsage, usageTokenBuckets } from "../lib/usage";
import { plural } from "../lib/plural";
import {
agentEnvelopesOf,
commandOutcomeOf,
commandScriptOf,
outputLossNote,
debugRowSearchText,
debugRowsFromStream,
extractPetriStageContext,
findPetriEdgeForStage,
itemsForStage,
parallelOverviewFromProjection,
parsePetriInterviewPairs,
reducerTranscriptFromProjection,
type CommandOutputLoss,
type DebugRow,
} from "../lib/petri-stream";
import {
useRun,
useRunEventsList,
useRunStageContextWindow,
useRunStageEvents,
useRunStageLog,
useRunStages,
useRunState,
useRunStream,
} from "../lib/queries";
import {
STAGE_ACTIVITY_EVENT_TYPES,
type StageActivityEventType,
} from "../lib/run-events";
import {
ACTIVE_STAGE_STATES,
mapRunStagesToSidebarStages,
@ -96,8 +115,10 @@ import {
type UnknownRecord,
} from "../lib/unknown";
import type {
EventEnvelope,
ReasoningOutput,
RunProjection,
RunStreamItem,
StageProjection,
StageHandler,
StageModelUsage,
Usage,
@ -137,6 +158,8 @@ type TurnType =
exitCode: number | null;
durationMs: number;
outputBytes: number;
/** What the capture did not keep, or null when the output is whole. */
outputLoss: CommandOutputLoss | null;
};
type CommandTurn = Extract<TurnType, { kind: "command" }>;
@ -153,8 +176,6 @@ export type StageRenderer =
type PanelSelection = ThreadDnaSelection;
const STAGE_ACTIVITY_EVENT_SET = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
export const EVENT_KINDS = [
"system",
"steer",
@ -240,10 +261,6 @@ export function eventsTabLabel(
return PRIMARY_TAB_LABEL[renderer];
}
function assertNever(value: never): never {
throw new Error(`Unhandled stage activity event type: ${value}`);
}
export function selectStageRenderer(handler: StageHandler): StageRenderer {
switch (handler) {
case "agent":
@ -266,12 +283,6 @@ export function selectStageRenderer(handler: StageHandler): StageRenderer {
}
}
function activityEventStageId(event: EventEnvelope): string | undefined {
if (typeof event.stage_id === "string") return event.stage_id;
if (typeof event.node_id === "string") return event.node_id;
return getString(event.properties ?? {}, "node_id");
}
interface PendingTool {
ts: string;
toolName: string;
@ -294,20 +305,6 @@ interface PendingCommand {
script: string;
}
/**
* The coding agent's own payload inside an `agent.*` event: `properties.event`
* is externally tagged, `{ AssistantMessage: {...} }`, so the variant's fields
* live one level down. An event with no such payload reads as empty.
*/
function agentEventPayload(props: UnknownRecord): UnknownRecord {
const event = getObject(props, "event");
if (!event) return {};
for (const value of Object.values(event)) {
if (isRecord(value)) return value;
}
return {};
}
function readTurnReasoning(props: UnknownRecord): ReasoningOutput | null {
const reasoning = getObject(props, "reasoning");
if (!reasoning) return null;
@ -319,174 +316,132 @@ function readTurnReasoning(props: UnknownRecord): ReasoningOutput | null {
return trace ? { trace } : null;
}
export function buildStageActivity(
events: EventEnvelope[],
stageId: string,
/** The stream and projection of a Petri run, threaded into the stage views. */
export interface PetriRunData {
stream: RunStreamItem[];
projection: RunProjection;
}
/**
* The turns of a stage that ran on Petri: the prompt the projection holds,
* the Pebble envelopes the stage's step recorded (assistant messages, tool
* calls, interrupts), and, when no envelope carried the answer, the
* projection's response as the one assistant turn. A command stage is one
* command turn from its `step.started` and final `step.finished`.
*/
export function buildPetriStageActivity(
items: RunStreamItem[],
stage: StageProjection | undefined,
renderer: StageRenderer,
): StageActivity {
const turns: TurnType[] = [];
const pendingTools = new Map<string, PendingTool>();
let pendingCommand: PendingCommand | undefined;
let sawAssistantMessage = false;
const firstTs = items[0]
? new Date(items[0].recorded_at).toISOString()
: (stage?.started_at ?? new Date(0).toISOString());
const startTs = stage?.started_at ?? firstTs;
for (const e of events) {
const eventName = e.event;
if (activityEventStageId(e) !== stageId) {
continue;
if (renderer === "command") {
const started = items.some(
(item) => item.kind === "petri" && getString(getObject(getObject(item.item, "record"), "body"), "event") === "step.started",
);
if (started || stage) {
const outcome = commandOutcomeOf(items);
const running =
stage?.state === "running" || stage?.state === "retrying";
turns.push({
kind: "command",
ts: startTs,
script: commandScriptOf(items) ?? "",
running,
exitCode: outcome.exitCode,
durationMs: outcome.durationMs || (stage?.timing?.wall_time_ms ?? 0),
outputBytes: stage?.output_bytes ?? 0,
outputLoss: outcome.outputLoss,
});
}
if (
!eventName ||
!STAGE_ACTIVITY_EVENT_SET.has(eventName)
) {
continue;
}
const eventType = eventName as StageActivityEventType;
const props: UnknownRecord = e.properties ?? {};
switch (eventType) {
case "stage.prompt":
turns.push({
kind: "system",
ts: e.ts,
content: getString(props, "text") ?? e.text ?? "",
});
return { turns, pendingTools: [] };
}
const envelopes = agentEnvelopesOf(items);
// The prompt is the session's `UserInput`; the projection's `prompt` stands
// in for a stage whose session recorded none (a prompt node).
if (stage?.prompt && !envelopes.some((envelope) => envelope.variant === "UserInput")) {
turns.push({ kind: "system", ts: startTs, content: stage.prompt });
}
let sawAssistantMessage = false;
for (const envelope of envelopes) {
const { payload } = envelope;
switch (envelope.variant) {
case "UserInput": {
const text = getString(payload, "text") ?? "";
if (text) turns.push({ kind: "system", ts: envelope.ts, content: text });
break;
case "agent.message": {
}
case "AssistantMessage": {
sawAssistantMessage = true;
// A text-free message still marks the end of a model response — it is
// the boundary between two batches of tool calls. Dropping it would
// splice unrelated batches into one tool group.
const message = agentEventPayload(props);
const usage = getObject(message, "usage") ?? {};
const tokens = getObject(getObject(payload, "usage"), "tokens") ?? getObject(payload, "usage") ?? {};
turns.push({
kind: "assistant",
ts: e.ts,
content: getString(message, "text") ?? "",
inputTokens: getNumber(usage, "input") ?? 0,
outputTokens:
(getNumber(usage, "output") ?? 0) + (getNumber(usage, "reasoning") ?? 0),
toolCallCount: getNumber(message, "tool_call_count") ?? null,
reasoning: readTurnReasoning(message),
ts: envelope.ts,
content: getString(payload, "text") ?? "",
inputTokens: getNumber(tokens, "input") ?? 0,
outputTokens: (getNumber(tokens, "output") ?? 0) + (getNumber(tokens, "reasoning") ?? 0),
toolCallCount: getNumber(payload, "tool_call_count") ?? null,
reasoning: readTurnReasoning(payload),
});
break;
}
case "prompt.completed": {
if (!sawAssistantMessage) {
// `prompt.completed.usage` is a `ModelUsage`: the model, then the usage.
const tokens =
getObject(getObject(getObject(props, "usage"), "usage"), "tokens") ?? {};
turns.push({
kind: "assistant",
ts: e.ts,
content: getString(props, "response") ?? "",
inputTokens: getNumber(tokens, "input") ?? 0,
outputTokens: getNumber(tokens, "output") ?? 0,
toolCallCount: null,
// Only agent.message carries reasoning; prompt stages have none.
reasoning: null,
});
}
break;
}
case "agent.steering.injected": {
const text = getString(agentEventPayload(props), "text") ?? "";
if (text) {
turns.push({ kind: "steer", ts: e.ts, content: text });
}
break;
}
case "agent.interrupt.injected":
turns.push({
kind: "interrupt",
ts: e.ts,
content: "Agent interrupted",
});
break;
case "agent.round.interrupted":
turns.push({
kind: "interrupt",
ts: e.ts,
content: "Interrupted — waiting for steering",
});
break;
case "agent.pair.user_message": {
const text = getString(props, "text") ?? e.text ?? "";
if (text) {
turns.push({ kind: "pair_user", ts: e.ts, content: text });
}
break;
}
case "agent.pair.system_message": {
const text = getString(props, "text") ?? e.text ?? "";
if (text) {
turns.push({ kind: "pair_system", ts: e.ts, content: text });
}
break;
}
case "agent.tool.started": {
const call = agentEventPayload(props);
const callId = getString(call, "tool_call_id") ?? e.tool_call_id ?? "";
case "ToolCallStarted": {
const callId = getString(payload, "tool_call_id");
if (!callId) break;
const args = call.arguments;
const args = payload.arguments;
pendingTools.set(callId, {
ts: e.ts,
toolName: getString(call, "tool_name") ?? "",
ts: envelope.ts,
toolName: getString(payload, "tool_name") ?? "",
input: typeof args === "string" ? args : JSON.stringify(args ?? ""),
});
break;
}
case "agent.tool.completed": {
const call = agentEventPayload(props);
const callId = getString(call, "tool_call_id") ?? e.tool_call_id ?? "";
case "ToolCallCompleted": {
const callId = getString(payload, "tool_call_id");
if (!callId) break;
const started = pendingTools.get(callId);
pendingTools.delete(callId);
const output = call.output ?? "";
const result =
typeof output === "string" ? output : JSON.stringify(output, null, 2);
const output = payload.output ?? "";
turns.push({
kind: "tool",
ts: started?.ts ?? e.ts,
toolName: started?.toolName ?? getString(call, "tool_name") ?? "",
ts: started?.ts ?? envelope.ts,
toolName: started?.toolName ?? getString(payload, "tool_name") ?? "",
input: started?.input ?? "",
result,
isError: call.is_error === true,
durationMs: durationBetween(started?.ts, e.ts),
result: typeof output === "string" ? output : JSON.stringify(output, null, 2),
isError: payload.is_error === true,
durationMs: durationBetween(started?.ts, envelope.ts),
});
break;
}
case "command.started": {
pendingCommand = {
ts: e.ts,
script: getString(props, "script") ?? "",
};
case "SteeringInjected": {
const text = getString(payload, "text") ?? "";
if (text) turns.push({ kind: "steer", ts: envelope.ts, content: text });
break;
}
case "command.completed": {
turns.push({
kind: "command",
ts: pendingCommand?.ts ?? e.ts,
script: pendingCommand?.script ?? "",
running: false,
exitCode: getNumber(props, "exit_code") ?? null,
durationMs: getNumber(props, "duration_ms") ?? 0,
outputBytes: getNumber(props, "output_bytes") ?? 0,
});
pendingCommand = undefined;
case "RoundInterrupted":
turns.push({ kind: "interrupt", ts: envelope.ts, content: "Interrupted — waiting for steering" });
break;
}
default:
assertNever(eventType);
break;
}
}
if (pendingCommand) {
if (!sawAssistantMessage && stage?.response) {
const tokens = stage.usage?.tokens;
turns.push({
kind: "command",
ts: pendingCommand.ts,
script: pendingCommand.script,
running: true,
exitCode: null,
durationMs: 0,
outputBytes: 0,
kind: "assistant",
ts: stage.completion?.timestamp ?? firstTs,
content: stage.response,
inputTokens: tokens?.input ?? 0,
outputTokens: tokens?.output ?? 0,
toolCallCount: null,
reasoning: null,
});
}
@ -500,11 +455,26 @@ export function buildStageActivity(
};
}
export function eventsToActivity(
events: EventEnvelope[],
stageId: string,
): TurnType[] {
return buildStageActivity(events, stageId).turns;
const EMPTY_STREAM: RunStreamItem[] = [];
/** What the details panel shows for a debug row. */
function debugItemPayload(item: DebugRow): EventDisplayPayload {
return {
event: item.event,
stream_seq: item.seq,
kind: item.item.kind,
stage: item.stageLabel,
recorded_at: item.ts,
item: item.item.item,
};
}
/** What the Petri renderers show for one stage, derived once per stage. */
interface PetriStageViews {
pairs: HumanInterviewPair[];
edge: EdgeSelection | null;
overview: ParallelOverview;
reducer: ReducerTranscript | null;
}
type ToolTurn = Extract<TurnType, { kind: "tool" }>;
@ -1545,6 +1515,14 @@ function CommandLogs({
byteCount={turn.outputBytes}
enabled={!turn.running}
/>
{outputLossNote(turn.outputLoss) && (
<p
data-testid="command-output-loss"
className="text-xs text-amber"
>
{outputLossNote(turn.outputLoss)}
</p>
)}
</div>
);
}
@ -1887,7 +1865,7 @@ function EventExportActions({
stageId,
className,
}: {
events: EventEnvelope[];
events: RunStreamItem[];
runId: string;
stageId: string;
className?: string;
@ -1980,7 +1958,7 @@ function EventsToolbar({
totalCount: number;
providerUsed: StageModelUsage | null;
usage: Usage;
events: EventEnvelope[];
events: RunStreamItem[];
runId: string;
stageId: string;
}) {
@ -2094,8 +2072,8 @@ function StageActivityBody({
openDebugSeq,
onDebugSeqChange,
contextData,
runEvents,
stages,
petri,
}: {
effectiveTab: EventsTab;
renderer: StageRenderer;
@ -2107,13 +2085,14 @@ function StageActivityBody({
runId: string;
selectedStage: Stage;
commandTurn: CommandTurn | null;
debugEvents: EventEnvelope[];
filteredDebugEvents: EventEnvelope[];
debugEvents: DebugRow[];
filteredDebugEvents: DebugRow[];
openDebugSeq: number | null;
onDebugSeqChange: (seq: number | null) => void;
contextData: ReturnType<typeof extractStageContext>;
runEvents: EventEnvelope[];
contextData: StageContextData | null;
stages: Stage[];
/** What the stage's renderers show, derived from the stream and projection. */
petri: PetriStageViews;
}) {
const { turns, pendingTools } = activity;
return (
@ -2169,23 +2148,23 @@ function StageActivityBody({
turn={commandTurn}
/>
) : renderer === "human" ? (
<HumanQA stage={selectedStage} events={debugEvents} />
<HumanQA stage={selectedStage} pairs={petri.pairs} />
) : renderer === "conditional" ? (
<ConditionalDecision
stage={selectedStage}
runEvents={runEvents}
edge={petri.edge}
allStages={stages}
runId={runId}
/>
) : renderer === "parallel" ? (
<ParallelChildren
stage={selectedStage}
events={debugEvents}
overview={petri.overview}
runId={runId}
allStages={stages}
/>
) : renderer === "fan_in" ? (
<FanInResults stage={selectedStage} events={debugEvents} />
<FanInResults stage={selectedStage} reducer={petri.reducer} />
) : renderer === "wait" ? (
<WaitStatus stage={selectedStage} />
) : (
@ -2227,6 +2206,7 @@ function RunStageActivityStage({
onKindsChange,
onDebugCategoriesChange,
onSearchChange,
petri,
}: {
runId: string;
selectedStage: Stage;
@ -2240,25 +2220,43 @@ function RunStageActivityStage({
onKindsChange: (kinds: EventKind[]) => void;
onDebugCategoriesChange: (categories: DebugCategory[]) => void;
onSearchChange: (search: string) => void;
/** The run's stream and projection when it executes on Petri. */
petri?: PetriRunData;
}) {
const selectedStageId = selectedStage.id;
const stageEventsQuery = useRunStageEvents(runId, selectedStageId);
const renderer: StageRenderer = selectStageRenderer(selectedStage.handler);
// The stage's views read the run's stream and projection; until those
// load, the stage shows as empty.
const stream = petri?.stream ?? EMPTY_STREAM;
const stageItems = useMemo<RunStreamItem[]>(
() => itemsForStage(stream, selectedStageId),
[stream, selectedStageId],
);
const stageProjection: StageProjection | undefined =
petri?.projection.stages[selectedStageId];
const activity = useMemo(
() => buildStageActivity(stageEventsQuery.data ?? [], selectedStageId),
[stageEventsQuery.data, selectedStageId],
() => buildPetriStageActivity(stageItems, stageProjection, renderer),
[stageItems, stageProjection, renderer],
);
const { turns } = activity;
const renderer: StageRenderer = selectStageRenderer(selectedStage.handler);
const debugEvents = useMemo<EventEnvelope[]>(() => {
return (stageEventsQuery.data ?? []).filter(
(event) => activityEventStageId(event) === selectedStageId,
);
}, [stageEventsQuery.data, selectedStageId]);
const debugEvents = useMemo<DebugRow[]>(
() => debugRowsFromStream(stageItems),
[stageItems],
);
const petriViews = useMemo<PetriStageViews>(
() => ({
pairs: parsePetriInterviewPairs(stageItems),
edge: findPetriEdgeForStage(stream, selectedStageId),
overview: parallelOverviewFromProjection(stageProjection),
reducer: reducerTranscriptFromProjection(stageProjection),
}),
[stream, stageItems, stageProjection, selectedStageId],
);
// The Context tab surfaces the workflow's deliberate per-visit outputs. It
// only exists when the stage completed and actually wrote something.
const contextData = useMemo(
() => extractStageContext(debugEvents),
[debugEvents],
() => extractPetriStageContext(stageItems),
[stageItems],
);
const availableTabs = useMemo<EventsTab[]>(
() =>
@ -2273,11 +2271,6 @@ function RunStageActivityStage({
const isPrimaryAgent = effectiveTab === "primary" && renderer === "agent";
const isDebug = effectiveTab === "debug";
// Some renderers need run-scoped events (e.g. conditional renders the
// engine-level edge.selected event, which has no stage_id). Only fetch when
// the active renderer actually needs it to keep this off the hot path.
const needsRunEvents = renderer === "conditional";
const runEventsQuery = useRunEventsList(needsRunEvents ? runId : undefined);
const commandTurn = useMemo<CommandTurn | null>(() => {
if (effectiveTab !== "primary" || renderer !== "command") return null;
for (let i = turns.length - 1; i >= 0; i -= 1) {
@ -2347,34 +2340,27 @@ function RunStageActivityStage({
}
return null;
}, [isPrimaryAgent, displayItems, panelSelection]);
const openDebugEvent = useMemo<EventEnvelope | null>(
() =>
isDebug && openDebugSeq != null
? (debugEvents.find((e) => e.seq === openDebugSeq) ?? null)
: null,
[isDebug, debugEvents, openDebugSeq],
);
const openDebugEvent = useMemo<EventDisplayPayload | null>(() => {
if (!isDebug || openDebugSeq == null) return null;
const item = debugEvents.find((e) => e.seq === openDebugSeq);
return item ? debugItemPayload(item) : null;
}, [isDebug, debugEvents, openDebugSeq]);
const availableDebugCategories = useMemo<DebugCategory[]>(() => {
if (!isDebug) return [];
const set = new Set<DebugCategory>();
for (const event of debugEvents) {
if (event.event) set.add(debugCategory(event.event));
if (event.event) set.add(debugRowCategory(event));
}
return Array.from(set).sort();
}, [isDebug, debugEvents]);
const filteredDebugEvents = useMemo<EventEnvelope[]>(() => {
const filteredDebugEvents = useMemo<DebugRow[]>(() => {
if (!isDebug) return [];
const useCategoryFilter = selectedDebugCategories.length > 0;
const cats = new Set(selectedDebugCategories);
const needle = search.toLowerCase();
return debugEvents.filter((event) => {
const name = event.event ?? "";
if (useCategoryFilter && !cats.has(debugCategory(name))) return false;
if (needle) {
const blob =
`${name} ${JSON.stringify(event.properties ?? {})}`.toLowerCase();
if (!blob.includes(needle)) return false;
}
if (useCategoryFilter && !cats.has(debugRowCategory(event))) return false;
if (needle && !debugRowSearchText(event).includes(needle)) return false;
return true;
});
}, [isDebug, debugEvents, selectedDebugCategories, search]);
@ -2418,7 +2404,7 @@ function RunStageActivityStage({
}
providerUsed={selectedStage.providerUsed}
usage={selectedStage.usage}
events={stageEventsQuery.data ?? []}
events={stageItems}
runId={runId}
stageId={selectedStageId}
/>
@ -2459,8 +2445,8 @@ function RunStageActivityStage({
openDebugSeq={openDebugSeq}
onDebugSeqChange={setOpenDebugSeq}
contextData={contextData}
runEvents={runEventsQuery.data ?? []}
stages={stages}
petri={petriViews}
/>
</div>
@ -2493,11 +2479,13 @@ function RunStageActivity({
selectedStage,
stages,
runStart,
petri,
}: {
runId: string;
selectedStage: Stage;
stages: Stage[];
runStart: string | undefined;
petri?: PetriRunData;
}) {
const [activityState, dispatchActivity] = useReducer(
stageActivityReducer,
@ -2532,6 +2520,7 @@ function RunStageActivity({
onSearchChange={(nextSearch) =>
dispatchActivity({ type: "searchChanged", search: nextSearch })
}
petri={petri}
/>
);
}
@ -2552,10 +2541,19 @@ export default function RunStages() {
selectedStage?.startedAt ??
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at;
// Insights sidebar only renders for agent stages; fetch projection + context
// window only when the user is on one to keep the hot path lean.
// The stage views read the projection and the run's stream; the
// projection also feeds the insights sidebar of an agent stage, and the
// context window is fetched only for one.
const isAgentStage = selectedStage?.handler === "agent";
const runStateQuery = useRunState(isAgentStage ? id : undefined);
const runStateQuery = useRunState(id);
const streamQuery = useRunStream(id);
const petriData = useMemo<PetriRunData | undefined>(
() =>
runStateQuery.data && streamQuery.data
? { stream: streamQuery.data, projection: runStateQuery.data }
: undefined,
[runStateQuery.data, streamQuery.data],
);
const contextWindowQuery = useRunStageContextWindow(
isAgentStage ? id : undefined,
isAgentStage ? selectedStageId : undefined,
@ -2615,6 +2613,7 @@ export default function RunStages() {
selectedStage={selectedStage}
stages={stages}
runStart={runStart}
petri={petriData}
/>
</div>
);

View file

@ -148,20 +148,16 @@ describe("runs route board mapping", () => {
]);
});
test("refreshes for blocked status and interview events", () => {
expect(shouldRefreshBoardForEvent("run.pending")).toBe(true);
expect(shouldRefreshBoardForEvent("run.runnable")).toBe(true);
expect(shouldRefreshBoardForEvent("run.approved")).toBe(true);
expect(shouldRefreshBoardForEvent("run.denied")).toBe(true);
expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true);
expect(shouldRefreshBoardForEvent("run.unblocked")).toBe(true);
expect(shouldRefreshBoardForEvent("run.cancel.requested")).toBe(true);
test("refreshes for the lifecycle, the blocking question and the answer", () => {
expect(shouldRefreshBoardForEvent("run.created")).toBe(true);
expect(shouldRefreshBoardForEvent("run.lifecycle")).toBe(true);
expect(shouldRefreshBoardForEvent("invocation.cancel.requested")).toBe(true);
expect(shouldRefreshBoardForEvent("run.archived")).toBe(true);
expect(shouldRefreshBoardForEvent("run.unarchived")).toBe(true);
expect(shouldRefreshBoardForEvent("run.title.updated")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.started")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true);
expect(shouldRefreshBoardForEvent("run.created")).toBe(false);
expect(shouldRefreshBoardForEvent("run.title")).toBe(true);
expect(shouldRefreshBoardForEvent("wait.state.changed")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.answered")).toBe(true);
expect(shouldRefreshBoardForEvent("step.progress.recorded")).toBe(false);
});
test("includes the configured server argument for GitHub-auth quick starts", () => {

View file

@ -2,20 +2,22 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter, Route, Routes } from "react-router";
import type { LiveEventPayload } from "../lib/live-events";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
let capturedOnEvent: ((payload: LiveEventPayload) => void) | null = null;
import { makePetriItem, makePlatformItem } from "../lib/test-utils";
let capturedOnEvent: ((payload: RunStreamItem) => void) | null = null;
mock.module("../lib/live-events", () => ({
subscribeToLiveEvents: (
onEvent: (payload: LiveEventPayload) => void,
onEvent: (payload: RunStreamItem) => void,
) => {
capturedOnEvent = onEvent;
return () => {
if (capturedOnEvent === onEvent) capturedOnEvent = null;
};
},
useLiveEventsSubscription: (onEvent: (payload: LiveEventPayload) => void) => {
useLiveEventsSubscription: (onEvent: (payload: RunStreamItem) => void) => {
capturedOnEvent = onEvent;
},
}));
@ -43,7 +45,7 @@ function renderSettingsLiveEvents() {
return renderer!;
}
function pushEvent(payload: LiveEventPayload) {
function pushEvent(payload: RunStreamItem) {
act(() => {
capturedOnEvent?.(payload);
});
@ -63,46 +65,30 @@ function rowsByEventName(renderer: TestRenderer.ReactTestRenderer): string[] {
}
describe("appendLiveEvent", () => {
test("prepends new events newest-first", () => {
const a: LiveEventPayload = { id: "a", event: "x" };
const b: LiveEventPayload = { id: "b", event: "y" };
const result = appendLiveEvent(appendLiveEvent([], a), b);
expect(result.map((e) => e.id)).toEqual(["b", "a"]);
const lifecycle = (seq: number, runId = "run-1") =>
makePlatformItem(seq, { kind: "run.lifecycle", transition: "running" }, { run_id: runId });
test("prepends new items newest-first", () => {
const result = appendLiveEvent(appendLiveEvent([], lifecycle(1)), lifecycle(2));
expect(result.map((item) => item.stream_seq)).toEqual([2, 1]);
});
test("dedupes by id when present", () => {
const a: LiveEventPayload = { id: "a", event: "x" };
const result = appendLiveEvent([a], { id: "a", event: "x" });
test("dedupes an item by its run and delivery sequence", () => {
const result = appendLiveEvent([lifecycle(7)], lifecycle(7));
expect(result).toHaveLength(1);
});
test("dedupes by run_id:seq:event when id is missing", () => {
const a: LiveEventPayload = { run_id: "run-1", seq: 7, event: "x" };
const result = appendLiveEvent([a], { run_id: "run-1", seq: 7, event: "x" });
expect(result).toHaveLength(1);
});
test("keeps different event names with the same run_id and seq", () => {
const a: LiveEventPayload = { run_id: "run-1", seq: 7, event: "x" };
const result = appendLiveEvent([a], { run_id: "run-1", seq: 7, event: "y" });
expect(result).toHaveLength(2);
});
test("treats events with neither id nor seq as distinct", () => {
const a: LiveEventPayload = { event: "x" };
const result = appendLiveEvent([a], { event: "x" });
test("keeps the same delivery sequence of two runs apart", () => {
const result = appendLiveEvent([lifecycle(7, "run-1")], lifecycle(7, "run-2"));
expect(result).toHaveLength(2);
});
test("caps the buffer at MAX_EVENTS", () => {
const seed = Array.from({ length: MAX_EVENTS }, (_, i) => ({
id: `seed-${i}`,
event: "x",
}));
const result = appendLiveEvent(seed, { id: "fresh", event: "x" });
const seed = Array.from({ length: MAX_EVENTS }, (_, i) => lifecycle(i + 1));
const result = appendLiveEvent(seed, lifecycle(MAX_EVENTS + 1));
expect(result).toHaveLength(MAX_EVENTS);
expect(result[0]?.id).toBe("fresh");
expect(result[result.length - 1]?.id).toBe(`seed-${MAX_EVENTS - 2}`);
expect(result[0]?.stream_seq).toBe(MAX_EVENTS + 1);
expect(result[result.length - 1]?.stream_seq).toBe(MAX_EVENTS - 1);
});
});
@ -124,49 +110,57 @@ describe("SettingsLiveEvents route", () => {
expect(text).toContain("only shows events that arrive after it's opened");
});
test("appends incoming events newest first", () => {
const renderer = renderSettingsLiveEvents();
pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" });
pushEvent({ id: "b", event: "agent.message", run_id: "run-2", ts: "2026-05-10T10:00:01Z" });
const stage = { name: "code" };
const started = (seq: number, runId: string) =>
makePetriItem(seq, { event: "step.started", firing: 1 }, { stage, run_id: runId });
const finished = (seq: number, runId: string) =>
makePetriItem(seq, { event: "step.finished", firing: 1 }, { stage, run_id: runId });
expect(rowsByEventName(renderer)).toEqual(["agent.message", "stage.started"]);
test("appends incoming items newest first", () => {
const renderer = renderSettingsLiveEvents();
pushEvent(started(1, "run-1"));
pushEvent(finished(1, "run-2"));
expect(rowsByEventName(renderer)).toEqual(["step.finished", "step.started"]);
});
test("ignores duplicate event ids", () => {
test("ignores an item delivered twice", () => {
const renderer = renderSettingsLiveEvents();
pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" });
pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" });
pushEvent(started(1, "run-1"));
pushEvent(started(1, "run-1"));
expect(rowsByEventName(renderer)).toEqual(["stage.started"]);
expect(rowsByEventName(renderer)).toEqual(["step.started"]);
});
test("links the run_id cell to the run detail page", () => {
test("links the run cell to the run detail page", () => {
const renderer = renderSettingsLiveEvents();
pushEvent({ id: "a", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" });
pushEvent(started(1, "run-1"));
const links = renderer.root.findAllByProps({ to: "/runs/run-1" });
expect(links.length).toBeGreaterThan(0);
});
test("filters events by category and search", () => {
test("names a platform record by its kind and filters items by search", () => {
const renderer = renderSettingsLiveEvents();
pushEvent({ id: "1", event: "stage.started", run_id: "run-1", ts: "2026-05-10T10:00:00Z" });
pushEvent({ id: "2", event: "agent.message", run_id: "run-2", ts: "2026-05-10T10:00:01Z" });
pushEvent({ id: "3", event: "command.started", run_id: "run-3", ts: "2026-05-10T10:00:02Z" });
pushEvent(started(1, "run-1"));
pushEvent(finished(2, "run-2"));
pushEvent(
makePlatformItem(3, { kind: "run.lifecycle", transition: "succeeded" }, { run_id: "run-3" }),
);
expect(rowsByEventName(renderer)).toEqual([
"command.started",
"agent.message",
"stage.started",
"run.lifecycle",
"step.finished",
"step.started",
]);
const searchInput = renderer.root.findByProps({ name: "event-search" });
act(() => {
(searchInput.props.onChange as (e: { target: { value: string } }) => void)({
target: { value: "agent" },
target: { value: "finished" },
});
});
expect(rowsByEventName(renderer)).toEqual(["agent.message"]);
expect(rowsByEventName(renderer)).toEqual(["step.finished"]);
});
});

View file

@ -1,5 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { Link } from "react-router";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import {
DebugEventDetailsPanel,
@ -8,7 +9,6 @@ import {
} from "../components/event-debug";
import {
DEBUG_CATEGORIES,
debugCategory,
debugCategoryLabel,
debugCategoryTone,
type DebugCategory,
@ -17,10 +17,12 @@ import { EmptyState } from "../components/state";
import { Tooltip } from "../components/ui";
import { eventDedupeKey } from "../lib/cross-tab-sse";
import { formatAbsoluteTs } from "../lib/format";
import { useLiveEventsSubscription } from "../lib/live-events";
import {
useLiveEventsSubscription,
type LiveEventPayload,
} from "../lib/live-events";
debugRowSearchText,
debugRowsFromStream,
type DebugRow,
} from "../lib/petri-stream";
export function meta() {
return [{ title: "Live Events — Fabro" }];
@ -31,46 +33,62 @@ export const handle = { wide: true, fullHeight: true };
export const MAX_EVENTS = 1000;
export function appendLiveEvent(
buffer: LiveEventPayload[],
payload: LiveEventPayload,
): LiveEventPayload[] {
const key = eventDedupeKey(payload);
if (key != null && buffer.some((event) => eventDedupeKey(event) === key)) {
buffer: RunStreamItem[],
item: RunStreamItem,
): RunStreamItem[] {
const key = rowKey(item);
if (buffer.some((event) => rowKey(event) === key)) {
return buffer;
}
const next = [payload, ...buffer];
const next = [item, ...buffer];
if (next.length > MAX_EVENTS) next.length = MAX_EVENTS;
return next;
}
export default function SettingsLiveEvents() {
const [events, setEvents] = useState<LiveEventPayload[]>([]);
const [events, setEvents] = useState<RunStreamItem[]>([]);
const [openKey, setOpenKey] = useState<string | null>(null);
const [selectedCategories, setSelectedCategories] = useState<DebugCategory[]>([]);
const [search, setSearch] = useState("");
useLiveEventsSubscription((payload) => {
setEvents((prev) => appendLiveEvent(prev, payload));
useLiveEventsSubscription((item) => {
setEvents((prev) => appendLiveEvent(prev, item));
});
const filtered = useMemo<LiveEventPayload[]>(() => {
const rows = useMemo(() => debugRowsFromStream(events), [events]);
const filtered = useMemo<DebugRow[]>(() => {
const useCategoryFilter = selectedCategories.length > 0;
const cats = new Set<DebugCategory>(selectedCategories);
const needle = search.toLowerCase();
return events.filter((event) => {
const name = event.event ?? "";
if (useCategoryFilter && !cats.has(debugCategory(name))) return false;
return rows.filter((row) => {
if (useCategoryFilter && !cats.has(row.category)) return false;
if (needle) {
const blob = `${name} ${event.run_id ?? ""} ${event.stage_id ?? ""} ${event.node_id ?? ""} ${JSON.stringify(event.properties ?? {})}`.toLowerCase();
const blob = `${debugRowSearchText(row)} ${row.item.run_id.toLowerCase()}`;
if (!blob.includes(needle)) return false;
}
return true;
});
}, [events, selectedCategories, search]);
}, [rows, selectedCategories, search]);
const openEvent = useMemo<LiveEventPayload | null>(
() => (openKey != null ? events.find((e) => rowKey(e) === openKey) ?? null : null),
[events, openKey],
const openRow = useMemo<DebugRow | null>(
() => (openKey != null ? rows.find((row) => rowKey(row.item) === openKey) ?? null : null),
[rows, openKey],
);
const openPayload = useMemo(
() =>
openRow
? {
event: openRow.event,
run_id: openRow.item.run_id,
stream_seq: openRow.seq,
kind: openRow.item.kind,
stage: openRow.stageLabel,
recorded_at: openRow.ts,
item: openRow.item.item,
}
: null,
[openRow],
);
const isFiltering = selectedCategories.length > 0 || search.length > 0;
@ -131,42 +149,41 @@ export default function SettingsLiveEvents() {
No events match these filters.
</div>
) : (
filtered.map((event) => (
filtered.map((row) => (
<LiveEventRow
key={rowKey(event)}
event={event}
selected={openKey === rowKey(event)}
onSelect={() => setOpenKey(rowKey(event))}
key={rowKey(row.item)}
row={row}
selected={openKey === rowKey(row.item)}
onSelect={() => setOpenKey(rowKey(row.item))}
/>
))
)}
</div>
</div>
<DebugEventDetailsPanel event={openEvent} onClose={() => setOpenKey(null)} />
<DebugEventDetailsPanel event={openPayload} onClose={() => setOpenKey(null)} />
</div>
);
}
function rowKey(event: LiveEventPayload): string {
return (
eventDedupeKey(event) ??
`${event.run_id ?? "?"}:${event.event ?? ""}:${event.ts ?? ""}`
);
/** The run and the delivery sequence identify an item across every run. */
function rowKey(item: RunStreamItem): string {
return eventDedupeKey(item) ?? `${item.run_id}:stream:${item.stream_seq}`;
}
function LiveEventRow({
event,
row,
selected,
onSelect,
}: {
event: LiveEventPayload;
row: DebugRow;
selected: boolean;
onSelect: () => void;
}) {
const eventName = event.event ?? "";
const category = debugCategory(eventName);
const stage = event.stage_id ?? event.node_id ?? null;
const eventName = row.event;
const { category } = row;
const runId = row.item.run_id;
const stage = row.stageLabel;
function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key === "Enter" || e.key === " ") {
@ -196,30 +213,22 @@ function LiveEventRow({
{eventName}
</span>
<span className="min-w-0 truncate font-mono text-xs">
{event.run_id ? (
<Link
to={`/runs/${event.run_id}`}
onClick={(e) => e.stopPropagation()}
className="text-fg-3 hover:text-fg hover:underline"
>
{event.run_id}
</Link>
) : (
<span className="text-fg-muted">No run</span>
)}
<Link
to={`/runs/${runId}`}
onClick={(e) => e.stopPropagation()}
className="text-fg-3 hover:text-fg hover:underline"
>
{runId}
</Link>
</span>
<span className="min-w-0 truncate font-mono text-xs text-fg-muted">
{stage ?? ""}
</span>
{event.ts ? (
<Tooltip label={formatAbsoluteTs(event.ts)}>
<span className="font-mono text-xs tabular-nums text-fg-muted">
{formatAbsoluteTs(event.ts)}
</span>
</Tooltip>
) : (
<span className="font-mono text-xs text-fg-muted">No time</span>
)}
<Tooltip label={formatAbsoluteTs(row.ts)}>
<span className="font-mono text-xs tabular-nums text-fg-muted">
{formatAbsoluteTs(row.ts)}
</span>
</Tooltip>
</div>
);
}

View file

@ -38,14 +38,12 @@ export default function SettingsStorage() {
{settings && resources ? (
<>
<StorageRootPanel settings={settings} disk={resources.disk} />
<SlateDbPanel settings={settings} />
<ArtifactsPanel settings={settings} />
</>
) : (
<>
<PanelSkeleton />
<PanelSkeleton />
<PanelSkeleton />
</>
)}
</div>
@ -75,15 +73,6 @@ function StorageRootPanel({
);
}
function SlateDbPanel({ settings }: { settings: ServerSettings }) {
const { slatedb } = settings.server;
return (
<Panel title="SlateDB">
<ObjectStoreRows store={slatedb.store} prefix={slatedb.prefix} />
</Panel>
);
}
function ArtifactsPanel({ settings }: { settings: ServerSettings }) {
const { artifacts } = settings.server;
return (

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,224 +1,107 @@
# Fabro Events Strategy
# Fabro Run Stream Strategy
Fabro emits structured **workflow run events** during execution for observability. Events are the durable audit trail for a run: they drive the run store, SSE streaming, CLI progress rendering, and optional JSONL sinks.
A run's history is two logs, and its public event API is one ordered stream
over both:
Events are distinct from tracing logs. Tracing is developer diagnostics; events are product-facing state transitions and activity records that other systems consume.
- **Petri's records.** The engine writes every fact about execution: the run
starting and finishing, each step's firing, progress, output and outcome, a
question asked and answered, a scope acquired. Fabro stores them unchanged
in `petri_records` through `fabro-petri`'s `SqliteRunStore`, and reads them
through Petri's event contract (`RunEvent`, with its `derived` view).
- **Platform records.** Facts Fabro knows and Petri does not: the lifecycle
before and after the engine (`run.created`, `run.lifecycle`, `run.title`,
`run.parent`, `run.archived`, `run.superseded`, `run.notice`), who answered
a question (`interview.answered`), the branch and git identity a run works
under, a checkpoint commit with its diff, a collected artifact
(`artifact.collected`), the run's diff (`run.diff`), the pull request
requests and outcomes, a notification sent, a pairing. They are
`PlatformRecord` values in
`fabro-store::platform_records`, stored in `platform_records` with a
per-run `seq`.
Detached runs rely on this distinction. If something needs to be visible after reattach, emit a `Event` rather than only logging to stderr or `detach.log`.
The **projector** (`fabro-petri::projection`) folds both logs into the run's
`RunProjection`, the view `GET /runs/{id}/state` serves, and assigns each
record it consumes a `stream_seq` in `petri_stream`. That stream is what
`GET /runs/{id}/events` and the attach stream serve, item by item, as
`RunStreamItem`: `{run_id, stream_seq, kind: petri|platform, id, recorded_at,
item}`. `stream_seq` is the cursor a client resumes from; `id` is the item's
own identity (`<log>/<seq>/<index>` for a Petri event, the record's `seq` for
a platform record) for deduplication.
## Architecture
Tracing logs are separate. Tracing is developer diagnostics; the stream is
the product-facing record other systems consume. If something must be
visible after a reattach, it has to be a record, not a log line.
```text
Engine/Handler -> Event -> Emitter::emit()
|- trace(raw event)
|- canonicalize -> RunEvent
`- on_event(&RunEvent)
|- run store
|- SSE
|- optional JSONL/debug sinks
`- CLI / tests / metrics listeners
```
## Recording a fact
The canonical `RunEvent` is built exactly once in the `fabro-workflow::event` module.
Petri's own facts need nothing from Fabro: the engine records them and the
projector's fold reads them. Add Fabro code only for a fact Petri cannot
know.
- `Event` (in `fabro-workflow`) is the internal typed event emitted by engine and handlers.
- `Emitter` owns an immutable `run_id` and converts `Event` into `RunEvent` via `to_run_event_at()`.
- `RunEvent` (in `fabro-types`) holds envelope metadata plus a typed `body: EventBody`. It has no cached JSON fields; the wire format is produced only during serialization.
- Every listener receives `&RunEvent`, not `&Event`.
- Bypass paths that cannot go through the emitter must call `to_run_event()` once and reuse the same `RunEvent` for every sink.
To record such a fact:
## Canonical Envelope
1. Add a variant to `PlatformRecord` and its kind to `PlatformRecordKind` in
`lib/components/fabro-store/src/platform_records.rs`. The kind is the
`kind` tag on the wire, lowercase dot notation (`pull_request.created`).
A record that belongs to a stage names its `execution` and `firing`.
2. Append it through the server's `run_records` module (or the worker's
client), which commits the record and wakes the projector. Never write
`platform_records` from anywhere else.
3. Fold it in `fabro-petri::projection` when the projection should show it.
A record nobody reads from the projection still reaches the stream.
4. Update the readers that match on record kinds: the CLI's pretty stream
rendering (`petri_stream.rs`), the web app's stream handling, the Slack
service, and the tests or fixtures that name kinds.
Each serialized `RunEvent` uses this canonical envelope:
Do not add a platform record that restates a Petri record. The projection
already carries what the engine knows; read it there.
```json
{
"id": "01960d0c-5d16-7d6e-8f61-9fd6f4a532b5",
"ts": "2026-03-30T12:00:01.000Z",
"run_id": "01JQ...",
"event": "agent.tool.started",
"session_id": "ses_child",
"parent_session_id": "ses_parent",
"node_id": "code",
"node_label": "Code",
"actor": {
"kind": "agent",
"session_id": "ses_child",
"parent_session_id": "ses_parent",
"model": "gpt-5.2"
},
"properties": {
"tool_name": "read_file",
"tool_call_id": "call_1",
"arguments": {"path": "src/main.rs"}
}
}
```
## Reading the stream
Always-present fields:
Servers and workers hold the stream through the projector: `stream_after`
for a page, `subscribe` for live items, `stream_head` for the cursor to
start from. The server's `stream_follower` reads every run's stream once and
fans it out to the in-memory run map and the global broadcast that `/attach`
and the Slack service take their items from.
| Field | Type | Notes |
|---|---|---|
| `id` | string | UUIDv7 event id |
| `ts` | string | UTC timestamp with millisecond precision |
| `run_id` | string | Workflow run id |
| `event` | string | Lowercase dot-notation event name |
Clients read `GET /runs/{id}/events?after=<stream_seq>` for a page and the
attach stream for live items; `fabro-client` exposes `list_run_stream`,
`list_run_stream_until` and `attach_run_stream`.
Optional top-level fields:
When matching items:
| Field | When present |
|---|---|
| `session_id` | Agent/session events |
| `parent_session_id` | Forwarded child-session events |
| `node_id` | Events tied to a graph node or branch |
| `node_label` | Display label for `node_id`; omitted when not applicable |
| `actor` | The principal responsible for the event |
- A Petri event's name is `item.record.body.event` (`run.started`,
`step.started`, `step.progress.recorded`, `step.finished`,
`run.finished`); its parsed meaning is under `item.derived` (a pending
question is `derived.parsed.kind == "question"`).
- A platform record's kind is `item.record.kind`.
- The run has ended when a platform `run.lifecycle` record's `transition`
is `succeeded`, `failed` or `dead`. Petri's `run.finished` precedes it and
carries the engine's own status.
Everything else lives inside `properties`.
Never rebuild an item downstream: pass the `RunStreamItem` through as read.
Important rules:
## Agent events
- Optional envelope fields are omitted, not serialized as `null`.
- Event-specific fields do not get flattened into the top level.
- Actor identity normally lives only in top-level `actor: Principal`; do not duplicate it in
event-specific properties. The exception is `run.created`, whose
`properties.provenance.subject` is the durable run creator stored in `RunSpec`; its envelope
`actor` is derived from the same principal.
- User actors must carry canonical IdP identity through `Principal::User { identity, login, auth_method }`, not a login-only string.
- `EventPayload` validation requires `id`, `ts`, `run_id`, and `event`.
Pebble's `CodingAgentEvent` stream is the agent event contract. Petri stores
each event a coding agent publishes for a step as that step's progress, and
the projector folds them into `StageProjection.agent` with pebble's
`SessionProjection`. Read `StageProjection.agent`, or the stored progress
record itself, instead of folding the stream again. Fabro adds nothing of
its own to this stream.
## Naming
## Ask Fabro sessions
The external event name is lowercase dot notation, for example:
Ask Fabro sessions are not runs. Their events (`run.session.*`) live in
their own log, `run_session_events`, through `RunSessionEventStore`, numbered
per session and served by the sessions API. They never enter a run's stream.
- `run.started`
- `stage.completed`
- `agent.tool.started`
- `sandbox.ready`
- `parallel.branch.completed`
## Persistence guarantees
`event_name()` in the `fabro-workflow::event` module is exhaustive. Do not use wildcard fallthroughs when adding new variants.
A record is committed before it is visible: the projector reads only what
the store has committed, and the stream's `stream_seq` is assigned in the
same transaction as the projection that consumed the record. A client that
resumes from its last `stream_seq` sees every item exactly once.
## Node And Session Metadata
`node_id` is the stable graph identifier. `node_label` is the human-facing display name. Stage events should surface both through the envelope when applicable.
Agent events now use explicit session links:
- `session_id` identifies the session that originally emitted the event.
- `parent_session_id` identifies the immediate parent session for forwarded child events.
- Nested sub-agents preserve immediate parentage across boundaries.
`AgentEvent::SubAgentEvent` no longer exists. Child activity is forwarded as normal agent events with session linkage in the envelope.
## Direct-Write Paths
Most events flow through `Emitter::emit()`. The remaining direct-write paths must use:
1. `to_run_event(run_id, event)`
2. Serialize and redact once
3. Reuse that exact `RunEvent` for every sink
Never build the same `RunEvent` twice if multiple sinks receive it.
## Adding A New Event
### 1. Add the typed event
Add a variant to `Event`, `AgentEvent`, or `SandboxLifecycle` as appropriate. Sandbox
facts come from two places: the pipeline emits `Initializing`, `Ready`, and
`InitializeFailed` around bringing the sandbox up, and the sandbox driver's own events
(operations and their outcome, progress inside a create such as an image pull, snapshot
builds, state observations, notices) are stored whole as `Event::SandboxDriver` by the
`DriverEventRecorder` in the `fabro-workflow::event` module. Their names derive from the
event (`fabro_types::sandbox_driver_event_name`): `<subject>.<action>.<phase>` such as
`sandbox.stop.completed` or `snapshot.create.started`, `<subject>.state`, and
`<subject>.notice`; their `properties` are the driver's event as the driver serializes
it, so the driver's `Event` is part of fabro's stored format. Fabro-sandbox emits no
events of its own.
### 2. Add tracing
Extend `Event::trace()` so the raw event is observable in tracing output.
### 3. Add an external name
Extend `event_name()` with the new lowercase dot-notation string.
### 4. Add the `EventBody` variant
Add a variant to `EventBody` in `fabro-types/src/run_event/mod.rs` with a corresponding props struct. Use `#[serde(rename = "dotted.name")]` matching the external name from step 3.
### 5. Map envelope fields and construct `EventBody`
Update `stored_event_fields()` and `event_body_from_event()` in the `fabro-workflow::event` module:
- Move `node_id`, `node_label`, `session_id`, and `parent_session_id` into the envelope when appropriate.
- Construct the `EventBody` variant directly from the `Event` fields.
- For `Event::Agent` sub-variants, merge `visit` into the inner props and lift `stage` to `node_id`.
- For `Event::Sandbox` sub-variants, unwrap and flatten into the corresponding `EventBody` variant.
### 6. Emit it
Prefer `Emitter::emit(&Event::...)`.
Use `to_run_event()` only for true bypass paths.
For cache-backed lifecycle work, emit slow-path start events only when the operation actually misses cache or waits on remote state. Completion events should represent a real ensure step (inspect, build, pull, or poll), not a configured no-op.
### 7. Update consumers
Check:
- CLI progress parsing
- `fabro events`
- store validation
- tests or fixtures that inspect event names or fields
## Agent Events
Pebble's `CodingAgentEvent` stream is the agent event contract. The worker's
event sink stores every event the coding agent publishes for a stage, except
streaming deltas, verbatim as `EventBody::Agent` under a name derived from
its variant (`fabro_types::coding_event_name`), and the store folds those
events into `StageProjection.agent` with pebble's `SessionProjection`. Do not
add a fabro event that restates a pebble event, and do not add a second fold
of the stream: read `StageProjection.agent`, or the stored pebble event
itself, instead.
Fabro emits an agent event of its own only for a fact pebble cannot know.
Today those are `agent.session.activated`, `agent.session.deactivated`,
`agent.tools.available`, `agent.pair.user_message`,
`agent.pair.system_message`, `agent.interrupt.injected`,
`agent.steer.buffered`, `agent.steer.dropped`, the `agent.acp.*` family, and
`prompt.failover` for a one-shot prompt stage that walks its fallback plan
without pebble. A new fabro agent event needs the same justification: name
the fact pebble does not have.
## Consumer Guidance
When writing Rust consumers (listeners, store projections, CLI progress):
- Match on `event.body` using `EventBody::*` variants. This gives you typed access to event-specific fields. For a pebble event, match `EventBody::Agent(props)` and then `props.coding_event()`.
- For a stage's agent facts (usage, route, MCP servers, skills, todos, subagents, files, failovers, compactions), read `StageProjection.agent` rather than folding the events again.
- Use `event.node_id`, `event.node_label`, `event.session_id`, and `event.parent_session_id` for envelope metadata.
- Only use `event.event_name()` or `event.properties()` for generic/display purposes (logging, forwarding). These involve serialization and should not be used on hot paths.
When writing external JSON consumers (SSE clients, JSONL parsers):
- Match on the `"event"` field for the dot-notation event name.
- Read event-specific data from `"properties"`.
- Read stage/branch identity from `"node_id"` and `"node_label"`.
- Read agent hierarchy from `"session_id"` and `"parent_session_id"`.
Do not rebuild or mutate the `RunEvent` in downstream listeners.
## Bypass And Persistence Guarantees
Any JSONL sink, the run store, and SSE should reflect the same canonical envelope bytes after redaction.
An active workflow treats any run-event sink write failure as fatal. It cancels execution and
attempts to persist `run.failed` through the direct sink path. Persistence-error logs must include
the full source chain so an HTTP status or transport failure remains visible.
`status.json` remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished.
A worker cannot continue past a record it failed to append: the store's
error reaches the engine and fails the run.

File diff suppressed because it is too large Load diff

View file

@ -1,488 +0,0 @@
# Fabro Event Schema V2: Concrete Shape
Date: 2026-04-09
Status: implemented
This document turns the settled design decisions from the event-schema discussion into a concrete wire-contract proposal.
It intentionally supersedes the earlier framing in [fabro-event-schema-v2-proposal.md](/Users/bhelmkamp/p/fabro-sh/fabro/docs-internal/fabro-event-schema-v2-proposal.md) for:
- proposal 1: one canonical persisted log, not two truths
- proposal 2: formalize and generalize the existing `since_seq` replay contract, rather than inventing replay from scratch
## Design Decisions Carried Forward
- one canonical persisted event log
- plain hand-coded Rust structs are the authoritative source of truth for the event contract
- `RunEvent` remains the canonical semantic event type
- `seq` remains outside `RunEvent`, in the store/API envelope
- replay stays built around ordered `since_seq` cursors
- typed Rust consumers matching on `EventBody` remain the primary consumer model
- the envelope widens only modestly for execution topology and tool-call correlation: `stage_id`, `parallel_group_id`, `parallel_branch_id`, `tool_call_id`
- existing durable event families stay broadly intact
- live token/delta noise does not become part of the durable persisted Rust event contract
- snapshots are out of scope for both the durable event contract and the attach API
## Contract Source Of Truth
V2 does not adopt schema generation or a registry-first workflow.
The authoritative source of truth for the event contract should be plain, hand-coded Rust structs and enums that model the public wire shape directly.
Implications:
- the Rust event types are the canonical contract
- this document describes that contract and should stay aligned with the Rust types
- any TypeScript types, JSON Schema, or OpenAPI fragments are secondary artifacts, not the source of truth
- codegen is explicitly out of scope for the initial V2 implementation
## Why Evolve The Current Model
V2 should evolve Fabro's existing event architecture rather than replace it with a generic event platform.
Earlier drafts of this document proposed a generic reducer contract, a larger ontology-first envelope, and a narrower replacement event catalog. V2 walks that back. The current code's boundary between internal workflow events, `RunEvent`, and `EventEnvelope` is stronger and simpler than it first appeared, so evolving that model is cheaper and clearer than replacing it.
The current code already has a strong separation of concerns:
- internal workflow/runtime events in `fabro-workflow`
- one canonical semantic `RunEvent`
- a store/API envelope that carries `seq` outside the event payload
That separation is worth preserving. The main V2 changes should be:
- modest envelope widening for execution topology
- cleanup and clarification of event-family boundaries
- keeping the durable event catalog semantic and typed
V2 should not introduce:
- a generic reducer contract based on `entity_type` / `event_role`
- canonical persisted token deltas
- snapshot events as a second truth layer
## Capability Coverage Decisions
V2 is evolutionary over the current `RunEvent` surface. It keeps the existing durable event families broadly intact rather than replacing them with a new ontology.
The main additions are:
- `stage_id` in the envelope for concrete stage execution identity
- `parallel_group_id` in the envelope for one execution of a parallel node
- `parallel_branch_id` in the envelope for one branch inside a parallel execution
- `tool_call_id` in the envelope for agent tool lifecycle events that need a stable cross-family join key
Everything else should remain in typed `EventBody` props unless there is a strong cross-family reason to promote it. `session_id` already exists in the envelope today and stays as-is. `tool_call_id` is promoted now because `agent.tool.*` events already carry a stable tool-call identity that other durable families can reference when needed. `turn_id` is deferred because Fabro does not yet have a durable turn identity that spans the families that would need to join on it.
## Exact Delta From Current Code
This is the implementation delta from the current Rust codebase, not the full history of how the design was reached.
### Add
- add `stage_id: Option<String>` to `RunEvent`
- add `parallel_group_id: Option<String>` to `RunEvent`
- add `parallel_branch_id: Option<String>` to `RunEvent`
- add `tool_call_id: Option<String>` to `RunEvent`
- add `actor: Option<ActorRef>` to `RunEvent`
- extend envelope extraction in `stored_event_fields()` to populate the new execution-topology fields when known
- extend envelope extraction in `stored_event_fields()` to populate `tool_call_id` on tool-lifecycle events when known
- update `RunEvent` serialization and parsing so the new optional envelope fields round-trip cleanly
### Keep As-Is
- `RunEvent` remains the canonical semantic event type
- `EventBody` remains the typed tagged union of durable event families
- `EventBody::Unknown` remains the compatibility valve for unknown event names on read
- `EventEnvelope` remains the ordered outer wrapper with `seq` outside the event payload
- `EventEnvelope.payload` remains `EventPayload`, not `RunEvent`
- the internal/store `EventEnvelope` Rust type stays wrapped as `{ seq, payload }`
- attach/replay remains exact ordered replay from `since_seq`, followed by live tailing
- current durable event families stay broadly intact
- live token/delta noise remains outside the durable persisted contract
- snapshots remain out of scope
### Do Not Do
- do not inline `seq` into `RunEvent`
- do not introduce `entity_type`, `entity_id`, or `event_role`
- do not replace typed Rust consumers with a generic reducer model
- do not redesign the store envelope
- do not add snapshot events or attach-time synthetic snapshots
- do not persist token deltas or other live UI noise as durable `RunEvent`s
## Canonical Rust Shapes
V2 should model the public contract directly as hand-coded Rust types, following the existing architecture.
```rust
pub struct RunEvent {
pub id: String,
pub ts: DateTime<Utc>,
pub run_id: RunId,
pub node_id: Option<String>,
pub node_label: Option<String>,
pub stage_id: Option<String>,
pub parallel_group_id: Option<String>,
pub parallel_branch_id: Option<String>,
pub session_id: Option<String>,
pub parent_session_id: Option<String>,
pub tool_call_id: Option<String>,
pub actor: Option<ActorRef>,
pub body: EventBody,
}
pub struct EventEnvelope {
pub seq: u32,
pub payload: EventPayload,
}
pub struct ActorRef {
pub kind: ActorKind,
pub id: Option<String>,
pub display: Option<String>,
}
pub enum ActorKind {
User,
Agent,
System,
}
```
`RunEvent` remains the semantic product event. `EventEnvelope` remains the ordered store/API wrapper. The store continues to persist validated JSON `EventPayload`, not typed `RunEvent` structs.
For wire JSON, `EventEnvelope` should serialize in flattened form so clients see:
```json
{
"seq": 4861,
"id": "...",
"ts": "...",
"run_id": "...",
"event": "...",
"properties": { ... }
}
```
That flattening is a wire concern only. It does not move `seq` into `RunEvent`, and it does not change the internal/store Rust shape of `EventEnvelope`.
`EventBody` remains a hand-coded tagged enum serialized as:
```json
{
"event": "stage.completed",
"properties": { "...": "..." }
}
```
V2 should also preserve the current unknown-event fallback shape:
```rust
EventBody::Unknown {
name: String,
properties: serde_json::Value,
}
```
This fallback already exists in the current code and should be kept.
### Envelope Rules
- `id`, `ts`, `run_id`, and `event` are always present on the serialized `RunEvent`.
- `seq` is not part of `RunEvent`. It stays in the outer `EventEnvelope`.
- Optional envelope fields are omitted, never serialized as `null`.
- The existing top-level envelope fields remain:
- `node_id`
- `node_label`
- `session_id`
- `parent_session_id`
- V2 adds only these new optional envelope fields:
- `stage_id`
- `parallel_group_id`
- `parallel_branch_id`
- `tool_call_id`
- Other relationship identifiers stay inside typed `properties`.
- `turn_id` remains in typed `properties`; see the deferral decision in `Capability Coverage Decisions`.
- `actor` is optional. When present, it identifies the primary actor for the event.
- Set `actor` on human- or agent-initiated events where that identity matters to consumers. Example: `run.cancel.requested` should identify the user who initiated the cancel.
- Set `actor` on durable agent output when the producing session identity matters. Example: `agent.message` should identify the agent session.
- Omit `actor` for routine runtime events with no meaningful primary actor. Example: `stage.started`.
### ID Format Conventions
- `run_id` keeps Fabro's current format: an unprefixed ULID string.
- `stage_id` keeps Fabro's current format: `"{node_id}@{visit}"`.
- `node_id` is the stable graph node identifier from the workflow definition.
- `parallel_group_id` should be the durable identity of one execution of a parallel node. The default format should be `"{node_id}@{visit}"`.
- `parallel_branch_id` should be the durable identity of one branch within a parallel execution. The default format should be `"{parallel_group_id}:{index}"`.
- Consumers should otherwise treat IDs as opaque strings.
### Presence Expectations
- `stage_id` is present on events tied to a concrete stage execution.
- `parallel_group_id` is present on `parallel.*` events and on events emitted inside a parallel execution when that scope is known.
- `parallel_branch_id` is present on `parallel.branch.*` events and on nested events emitted inside a specific branch when that scope is known.
- `session_id` and `parent_session_id` keep their current meaning for forwarded agent/session activity.
- `tool_call_id` is present on `agent.tool.*` events and on other durable events that directly describe the same tool call.
- `node_label` remains in the envelope for display-oriented consumers.
- `actor` is expected on control actions and durable agent output when there is a meaningful user or agent identity to expose. It is usually omitted on routine runtime lifecycle events.
## Consumer Model
Rust consumers should keep matching on `RunEvent.body` using typed `EventBody` variants.
This document does not adopt:
- `entity_type`
- `entity_id`
- `event_role`
- a generic reducer contract
External JSON consumers should continue to:
- match on `"event"`
- read event-specific values from `"properties"`
- read `"seq"` from the flattened outer event envelope on API/SSE responses
- use envelope metadata only for cross-cutting context such as stage, session, execution topology, and tool-call correlation
## Replay Contract
Fabro keeps the current replay model:
- ordered events are stored as `EventEnvelope { seq, payload }`
- API/SSE serialization of `EventEnvelope` should flatten `seq` into the top-level JSON object returned to clients
- attach starts from `since_seq`
- the server replays exact persisted envelopes and then tails live envelopes while the run is active
- SSE keepalive comments are transport frames, not events
V2 does not introduce:
- `run.snapshot`
- `session.snapshot`
- API-level attach snapshots
- persisted snapshot events of any kind
The durable model remains simple: replay ordered events, no duplicate truth layer.
## Implementation Checklist
An engineer implementing this proposal should make only these structural changes unless a later section explicitly says otherwise.
1. Update [`RunEvent`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/mod.rs) to add:
- `stage_id`
- `parallel_group_id`
- `parallel_branch_id`
- `tool_call_id`
- `actor`
2. Update `RunEvent::to_value()` and `RunEvent` parsing in [`run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/mod.rs) so the new envelope fields serialize and deserialize.
3. Extend `StoredEventFields` and `stored_event_fields()` in [`event.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/components/fabro-workflow/src/event.rs) to populate:
- `stage_id`
- `parallel_group_id`
- `parallel_branch_id`
- `tool_call_id` on tool-lifecycle events
- `actor` when there is a clear primary actor
These values should come from the emitter's current execution context for stage and parallel scope, and from event-specific payloads for `tool_call_id`.
4. Leave [`EventEnvelope`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/components/fabro-store/src/types.rs) structurally unchanged:
- `seq: u32`
- `payload: EventPayload`
5. Update API/SSE envelope serialization so wire JSON is flattened:
- top-level `seq`
- then the `RunEvent` payload fields alongside it
- no `"payload": { ... }` wrapper in JSON responses
6. Leave the replay/attach flow unchanged in behavior:
- persisted replay from `since_seq`
- live tail after replay
- no snapshots
7. Keep the current `EventBody` family surface unless there is an explicit product reason to change a specific family.
8. Keep streaming-noise agent events out of durable `RunEvent` conversion.
9. Update the HTTP/API schema docs to reflect both:
- new `RunEvent` envelope fields
- flattened JSON serialization of `EventEnvelope`
## EventBody And Property Model
V2 should keep the current hand-coded domain split for prop structs:
- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/run.rs)
- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/stage.rs)
- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/agent.rs)
- infra/setup props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/infra.rs)
- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/foundation/fabro-types/src/run_event/misc.rs)
That split is part of the design quality. V2 should keep adding hand-coded prop structs, not collapse everything into generic maps.
## Durable Event Surface
V2 keeps the current durable family surface broadly intact.
### Run
- `run.created`
- `run.started`
- `run.submitted`
- `run.starting`
- `run.running`
- `run.removing`
- `run.cancel.requested`
- `run.pause.requested`
- `run.unpause.requested`
- `run.paused`
- `run.unpaused`
- `run.rewound`
- `run.completed`
- `run.failed`
- `run.notice`
### Stage And Prompt
- `stage.started`
- `stage.completed`
- `stage.failed`
- `stage.retrying`
- `stage.prompt`
- `prompt.completed`
### Parallel
- `parallel.started`
- `parallel.branch.started`
- `parallel.branch.completed`
- `parallel.completed`
### Interview / Human Input
- `interview.started`
- `interview.completed`
- `interview.timeout`
- `interview.interrupted`
### Checkpoint
- `checkpoint.completed`
- `checkpoint.failed`
### Agent Durable Events
Pebble's events, stored verbatim under the names `fabro_types::CODING_EVENT_NAMES`
lists (every `CodingEvent` variant except the streaming deltas):
- `agent.session.started`, `agent.session.ended`, `agent.processing.end`
- `agent.input`, `agent.message`
- `agent.llm.started`, `agent.llm.first_output`, `agent.llm.retry`
- `agent.tool.started`, `agent.tool.completed`, `agent.tool.process.completed`, `agent.tool.rounds.exhausted`
- `agent.error`, `agent.warning`, `agent.loop.detected`
- `agent.steering.injected`, `agent.round.interrupted`
- `agent.compaction.started`, `agent.compaction.completed`, `agent.compaction.failed`, `agent.compaction.cancelled`
- `agent.route.failover`, `agent.route.failover.stopped`
- `agent.mcp.server.ready`, `agent.mcp.server.failed`, `agent.mcp.server.disconnected`
- `agent.sub.spawned`, `agent.sub.turn.started`, `agent.sub.completed`, `agent.sub.failed`, `agent.sub.closed`
- `agent.memory.loaded`, `agent.skills.discovered`, `agent.skill.activated`
- `todo.created`, `todo.updated`, `todo.deleted`
Fabro's own, for facts pebble cannot know:
- `agent.session.activated`, `agent.session.deactivated`, `agent.tools.available`
- `agent.pair.user_message`, `agent.pair.system_message`
- `agent.interrupt.injected`, `agent.steer.buffered`, `agent.steer.dropped`
- `agent.acp.started`, `agent.acp.completed`, `agent.acp.cancelled`, `agent.acp.timed_out`
- `prompt.failover` (a one-shot prompt stage's move to a fallback route)
The former mirrors `agent.mcp.ready`, `agent.mcp.failed`,
`agent.mcp.disconnected`, and `agent.failover` are no longer emitted; runs
recorded with them read them back as generic events.
### Git
- `git.commit`
- `git.push`
- `git.branch`
- `git.worktree.added`
- `git.worktree.removed`
- `git.fetch`
- `git.reset`
### Infra And Execution
- `sandbox.*`
- `setup.*`
- `cli.ensure.*` (legacy only)
- `command.*`
- `agent.cli.*`
- `pull_request.*`
- `artifact.captured`
- `ssh.ready`
- `subgraph.*`
- `edge.selected`
- `loop.restart`
- `retro.*`
## Explicitly Non-Durable Streaming Noise
The current boundary that keeps live token/delta noise out of `RunEvent` should remain in place.
These stay outside the durable persisted contract:
- `agent.output.replace`
- `agent.text.delta`
- `agent.reasoning.delta`
- `agent.tool.output.delta`
(`agent.skill.expanded` was previously listed here. No such event exists — the
`AgentEvent::SkillExpanded` variant was removed, and slash-skill expansion is
reported through the durable `agent.skill.activated` with `source == "slash"`.)
If Fabro needs those for UI, they belong in a separate transient stream, not in the canonical persisted Rust event contract.
## Example Shapes
### Flattened Wire JSON
```json
{
"seq": 4861,
"id": "evt_01JSE1N7RJD1NW2JSDT3W0YQ92",
"ts": "2026-04-08T16:21:11.106Z",
"run_id": "01JSE1M0Q0P8P6KQW9Q6D58Q0E",
"event": "agent.tool.completed",
"stage_id": "code@1",
"node_id": "code",
"node_label": "Code",
"session_id": "ses_child",
"tool_call_id": "call_1",
"parent_session_id": "ses_parent",
"properties": {
"tool_name": "read_file",
"output": {
"summary": "Read docs-internal/events-strategy.md"
},
"is_error": false,
"visit": 1
}
}
```
In Rust, `EventEnvelope` still remains `{ seq, payload: EventPayload }`. The example above is only the flattened API/SSE JSON form of that envelope.
## Practical Guidance
- Preserve the current one-time canonicalization boundary from internal `Event` to external `RunEvent`.
- Keep `RunEvent` semantic and typed. Do not turn it into a generic reducer envelope.
- Keep `seq` outside the event payload.
- Widen the envelope only modestly: `stage_id`, `parallel_group_id`, `parallel_branch_id`, and `tool_call_id`.
- Keep `session_id` as the existing top-level session field.
- Keep event-specific detail inside typed props.
- Preserve `EventBody::Unknown` as the compatibility valve for unknown event names on read.
- Do not store token deltas or other live UI noise as durable `RunEvent`s.
- Do not add snapshot events or attach-time synthetic snapshots.
- When adding a new durable event, update the current Rust boundary cleanly:
- internal `Event`
- `event_name()`
- envelope extraction
- `EventBody`
- typed props
- affected consumers
## Open Follow-Up
- `correlation_id`-style cross-entity grouping remains deferred until Fabro has a concrete consumer and explicit propagation rules

View file

@ -161,13 +161,6 @@ debug!(node = %id, handler = %handler_type, "Executing pipeline node");
debug!(node = %id, duration_ms = elapsed, "Pipeline node complete");
```
**fabro-mcp:**
```rust
info!(server = %name, tools = tool_count, "MCP server ready");
debug!(server = %name, transport = %transport_type, "Connecting to MCP server");
error!(server = %name, error = %err, "MCP server failed to start");
```
## Cross-Package Guidelines
Every crate that does meaningful work should emit tracing events. The `tracing` dependency is workspace-level — add it to any crate's `Cargo.toml` with:

View file

@ -20,7 +20,7 @@ The crate-local `src/migrations.rs` module is the registry. It imports numbered
Examples:
- `fabro-config` owns settings-file migrations.
- `fabro-server` owns server startup activation migrations for SQLite blob storage and run history.
- `fabro-db` owns the SQL schema migrations under `lib/foundation/fabro-db/migrations/`.
Keep migration APIs `pub(crate)` unless another crate genuinely orchestrates the migration.

View file

@ -122,7 +122,7 @@ Disallowed setup in `fabro-cli/tests/it`:
- writing `run.json` directly
- writing `status.json` directly
- writing `progress.jsonl` directly
- writing Petri records or platform records directly
- writing `conclusion.json` directly
- writing runtime interview files directly
- writing cached workflow files into run dirs directly
@ -175,7 +175,7 @@ Good structured snapshot targets:
- `status.json`
- `inspect` output
- `live.json`
- compacted `progress.jsonl` event sequences
- compacted run stream item sequences
- workflow conclusions and checkpoint summaries
### Keep direct assertions for relational invariants
@ -343,7 +343,7 @@ Before merging a test change, check:
Avoid these patterns in CLI integration tests:
- manually creating fake run directories
- writing `progress.jsonl` lines by hand
- writing run stream items or records by hand
- writing runtime interview files by hand
- writing asset manifests by hand
- scattering the same workflow setup across many files instead of using fixtures

View file

@ -17,7 +17,7 @@ Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.tom
| Scope | Examples |
|---|---|
| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.sandbox]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.sandbox]`, `[server.storage]`, `[server.artifacts]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` |
The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing.
@ -73,15 +73,6 @@ prefix = "artifacts"
bucket = "my-fabro-data"
region = "us-east-1"
[server.slatedb]
provider = "s3"
prefix = "slatedb"
disk_cache = true
[server.slatedb.s3]
bucket = "my-fabro-data"
region = "us-east-1"
[server.scheduler]
max_concurrent_runs = 8
@ -204,10 +195,17 @@ be lowercase ASCII letters, digits, and interior hyphens. The plugin starts with
environment: only `env` and the ambient variables listed in `inherit_env` reach it. Bundled
providers reject these plugin keys.
The same executable serves both sides of a run. The server launches it to reach a run's
sandbox after the fact (the sandbox tab, files, terminal, Ask Fabro), and Petri launches it in
the run's worker to create the sandbox. The server hands the worker `path` and `sha256` as
`PETRI_SANDBOX_<KIND>_PLUGIN` and `PETRI_SANDBOX_<KIND>_SHA256` (the kind uppercased, hyphens
as underscores), and `PETRI_SANDBOX_PLUGIN_DEV=1` when any configured plugin sets `dev`, so a
plugin configured here needs no second configuration for the worker.
```toml title="settings.toml"
[server.sandbox.providers.e2b]
enabled = true
path = "/opt/fabro/plugins/fabro-sandbox-e2b" # default: `fabro-sandbox-<kind>` on PATH
path = "/opt/fabro/plugins/sandbox-driver-e2b" # default: `sandbox-driver-<kind>` on PATH
sha256 = "0123…cdef" # pin the executable; `dev = true` skips it
args = []
inherit_env = ["PATH"]
@ -219,47 +217,26 @@ E2B_API_URL = "https://api.e2b.example"
| Key | Description | Default |
|---|---|---|
| `enabled` | Whether runs may select this provider | `true` |
| `path` | Plugin executable path | `fabro-sandbox-<kind>` on `PATH` |
| `path` | Plugin executable path | `sandbox-driver-<kind>` on `PATH` |
| `sha256` | Pinned SHA-256 of the executable, hex | none |
| `dev` | Allow launching without a checksum | `false` |
| `args` | Arguments passed to the executable | `[]` |
| `env` | Complete environment for the plugin, apart from `inherit_env` | `{}` |
| `inherit_env` | Ambient variables forwarded from the server process | `[]` |
### `[server.slatedb]` section
### Removed: `[server.slatedb]`
Configure the embedded SlateDB key-value store used for the remaining
object-store-backed indexes and as the read-only source for temporary storage
migrations. Run history and content-addressed blobs live in SQLite; artifacts
use `[server.artifacts]`.
Earlier releases kept an embedded SlateDB key-value store beside the
artifact store. Run history and content-addressed blobs now live in SQLite,
so the section has no store behind it. A `settings.toml` that still carries
`[server.slatedb]` is rewritten once at startup: the section is removed, a
backup is written beside the file, and a warning names both. Delete the
section yourself to avoid the rewrite.
| Key | Description | Default |
|---|---|---|
| `provider` | Object store backend: `local` or `s3` | `"local"` |
| `prefix` | Key prefix within the object store | `""` |
| `flush_interval` | How often to flush the write-ahead log | `"1ms"` |
| `disk_cache` | Enable a local disk cache for object store reads | `false` |
When `disk_cache = true`, Fabro creates a cache directory at `<storage_root>/cache/slatedb` and
configures SlateDB to cache object store bytes on local disk (16 GB max, 4 MB parts). This
significantly reduces read latency and costs for S3-backed deployments. A warning is emitted if
enabled with `provider = "local"` since the disk cache adds overhead when the object store is
already local.
```toml title="settings.toml"
[server.slatedb]
provider = "s3"
disk_cache = true
[server.slatedb.s3]
bucket = "fabro-production"
region = "us-east-1"
```
The browser install wizard's `Object store` step manages both `[server.slatedb]` and
`[server.artifacts]` together. `Local disk` uses the detected local object-store root, defaulting
to `<storage_root>/objects`, with fixed prefixes `slatedb` and `artifacts`. `AWS S3` writes one
shared bucket with the same fixed prefixes.
The browser install wizard's `Object store` step manages `[server.artifacts]`.
`Local disk` uses the detected local object-store root, defaulting to
`<storage_root>/objects`, with the fixed prefix `artifacts`. `AWS S3` writes
one bucket with the same fixed prefix.
```toml title="Local disk object store"
[server.artifacts]
@ -268,13 +245,6 @@ prefix = "artifacts"
[server.artifacts.local]
root = "/var/lib/fabro/objects"
[server.slatedb]
provider = "local"
prefix = "slatedb"
[server.slatedb.local]
root = "/var/lib/fabro/objects"
```
The wizard only covers AWS S3 bucket/region plus one of:
@ -284,16 +254,14 @@ The wizard only covers AWS S3 bucket/region plus one of:
Advanced S3-compatible settings such as custom `endpoint` or `path_style` remain a manual
configuration path. If you need MinIO, R2, or another S3-compatible backend, configure
`[server.slatedb]` and `[server.artifacts]` directly in `settings.toml`. The runtime still
honors those hand-edited values even though the browser wizard does not manage them.
`[server.artifacts]` directly in `settings.toml`. The runtime still honors those hand-edited
values even though the browser wizard does not manage them.
### SQLite state and migration backups
Shared relational state, including run events and current run rows,
Shared relational state, including run records and current run rows,
content-addressed blobs, vault entries, server-managed definitions, and CLI
auth sessions, lives at `<storage_root>/db/fabro.sqlite3`. The
`[server.slatedb]` object store remains configured for compatibility imports
and session-to-run reverse indexes during the storage transition.
auth sessions, lives at `<storage_root>/db/fabro.sqlite3`.
CLI auth sessions are stored as an `auth_sessions` row per signed-in CLI, with the rotating refresh tokens for that session in `refresh_tokens`. Pending browser-to-CLI handoffs live briefly in `oauth_authorization_codes`; the table contains a SHA-256 hash of each one-time code, never the raw bearer value. Revoking a session from **Settings → Sessions**, or with `DELETE /api/v1/auth/sessions/{id}`, deletes the session row and its tokens together.
@ -476,7 +444,7 @@ Fabro resolves these from `process env -> server.env`.
| Variable | Description |
|---|---|
| `AWS_ACCESS_KEY_ID` | Static AWS access key ID for S3-backed `[server.slatedb]` / `[server.artifacts]` |
| `AWS_ACCESS_KEY_ID` | Static AWS access key ID for an S3-backed `[server.artifacts]` |
| `AWS_SECRET_ACCESS_KEY` | Matching static AWS secret access key |
| `AWS_SESSION_TOKEN` | Optional matching AWS session token for temporary static credentials |
@ -484,8 +452,8 @@ The browser install wizard can write these into `server.env` for the AWS S3 manu
path. It does not support manual STS/session-token input; use runtime credentials instead for ECS,
EC2 instance profiles, IRSA, or web-identity flows.
For the narrowest production policy, scope access to one bucket and the `slatedb/` and
`artifacts/` prefixes with `s3:ListBucket` plus `s3:GetObject`, `s3:PutObject`, and
For the narrowest production policy, scope access to one bucket and the `artifacts/` prefix
with `s3:ListBucket` plus `s3:GetObject`, `s3:PutObject`, and
`s3:DeleteObject`. Prefer a dedicated IAM user or role for Fabro instead of reusing broad AWS
credentials.

View file

@ -64,7 +64,7 @@ Before execution, `{{ goal }}` becomes `Add a /health endpoint to the API server
| `{{ goal }}` | The graph-level `goal` attribute |
| `{{ inputs.name }}` | A value from `[run.inputs]` |
Undefined prompt variables render as empty text and produce a `template_undefined_variable` diagnostic. `fabro validate` reports that diagnostic as a warning; run-style commands promote it to an error before proceeding. Environment variables are not available in prompt templates.
A prompt variable that nothing binds is a diagnostic from the workflow compile. `fabro validate` reports it as a warning (`attractor.unbound_input`) and leaves the text unrendered; run-style commands and `fabro preflight` refuse the workflow with `unsupported.template.unbound_input` before a run is created. Environment variables are not available in prompt templates.
Prompt and goal templates can use static MiniJinja includes to share partials:

File diff suppressed because it is too large Load diff

View file

@ -237,7 +237,6 @@
"pages": [
"GET /api/v1/runs/{id}/checkpoint",
"GET /api/v1/runs/{id}/stages",
"GET /api/v1/runs/{id}/stages/{stageId}/events",
"GET /api/v1/runs/{id}/settings"
]
}

View file

@ -147,46 +147,6 @@ fabro events 01JKXYZ
fabro dump 01JKXYZ --output ./run-dump
```
## Rewinding to an earlier checkpoint
If a later stage goes off-track, you can rewind a terminal run to an earlier checkpoint and resume from there instead of restarting the entire workflow. Rewind creates a replacement run at the target checkpoint, archives the source run, and prints the new run ID to resume:
```bash
# List the checkpoint timeline
fabro rewind <RUN_ID> --list
# Rewind to a specific checkpoint
fabro rewind <RUN_ID> plan@2
# Resume from the rewound point
fabro resume <NEW_RUN_ID>
```
The source run must already be terminal (`succeeded`, `failed`, or `dead`). If the source is archived, unarchive it first. If archive fails after the replacement run is created, do not retry `fabro rewind`; archive the source run manually.
See [`fabro rewind`](/reference/cli#fabro-rewind) for the full command reference.
## Forking a run
If you want to explore an alternate path from a checkpoint without archiving the original run, use `fabro fork` instead of `fabro rewind`. Fork creates a new independent run branching from the target checkpoint; the original run stays intact.
```bash
# List checkpoints
fabro fork <RUN_ID> --list
# Fork from a specific checkpoint
fabro fork <RUN_ID> plan@2
# Resume the forked run
fabro resume <NEW_RUN_ID>
```
Use **rewind** when a terminal run should be abandoned and replaced from an earlier point. Use **fork** when you want to try a different approach while keeping the original run as a reference.
`fabro rewind --list`, `fabro fork --list`, `fabro rewind`, and `fabro fork` are server-backed. Timeline listing reads checkpoints from the durable run store.
See [`fabro fork`](/reference/cli#fabro-fork) for the full command reference.
## When checkpointing is active
Git checkpointing activates automatically when:

View file

@ -3,11 +3,11 @@ title: "Architecture"
description: "How Fabro's CLI and API modes work under the hood"
---
Fabro provides two interfaces — a CLI for local development and an HTTP API for production use. Both share a common workflow engine.
Fabro provides two interfaces — a CLI for local development and an HTTP API for production use. Both submit runs to the same server, and every run executes on the same engine.
## Shared engine
## The engine
At the core of both modes is the `WorkflowRunEngine`. It parses the Graphviz graph, walks nodes, dispatches to handlers (agent, command, human, etc.), selects edges, and checkpoints after each stage. The engine is parameterized by an `Interviewer` trait that controls how human-in-the-loop questions are presented — terminal prompts in CLI mode, HTTP request/response in API mode.
Every run executes on Petri, the workflow engine. At create time the server hands Petri the workflow bundle and the run's settings; Petri lowers the Graphviz graph, checks it, pins the models its nodes name, and either admits the graph or refuses the run with its diagnostics. At execution a worker process runs the admitted graph: it walks the nodes, dispatches each stage (agent, command, human, etc.), selects edges, and records every step in the run's log, from which the run resumes after an interruption. Human-in-the-loop questions reach the server's questions API; the CLI answers them from the terminal, the web app from the run page.
## CLI mode
@ -15,7 +15,7 @@ At the core of both modes is the `WorkflowRunEngine`. It parses the Graphviz gra
fabro run workflow.fabro --goal "Implement the login feature"
```
The CLI parses the workflow, creates the engine with a `ConsoleInterviewer`, and executes synchronously. Events are printed to stderr, progress is shown with terminal indicators, and human-in-the-loop questions are answered via interactive terminal prompts. When the run finishes, the process exits.
The CLI builds the run manifest, submits it to the server (starting a local one when none is running), and follows the run: events are printed to stderr, progress is shown with terminal indicators, and human-in-the-loop questions are answered via interactive terminal prompts. When the run finishes, the process exits.
CLI mode is ideal for:
- Local development and iteration on workflows
@ -50,7 +50,7 @@ Key server config options:
2. **Start request** — `POST /api/v1/runs/{id}/start` moves normal runs to `runnable`. Parent-generated [child runs](/execution/child-runs) may move to `pending` with `approval_required`.
3. **Approve if needed** — `POST /api/v1/runs/{id}/approve` moves an approval-gated run to `runnable`; `deny` fails it with `approval_denied`.
4. **Schedule** — A background scheduler promotes `runnable` runs to `running` in FIFO order, up to the concurrency limit.
5. **Execute** — The engine walks the graph, streaming events to all subscribers.
5. **Execute** — A worker runs the admitted graph on Petri, streaming events to all subscribers.
6. **Complete** — The run transitions to `succeeded`, `failed`, or `dead`.
### Event streaming

View file

@ -90,7 +90,8 @@ fabro [OPTIONS] [COMMAND]
| `fabro provider` | Provider operations |
| `fabro repo` | Repository commands |
| `fabro resume` | Resume an interrupted workflow run |
| `fabro rewind` | Rewind a workflow run to an earlier checkpoint |
| `fabro retry` | Retry a finished workflow run from its last checkpoint in a new run |
| `fabro rewind` | Rewind a workflow run to an earlier checkpoint, replacing it |
| `fabro rm` | Remove one or more workflow runs |
| `fabro run` | Register a workflow version, create a run, and start it |
| `fabro sandbox` | Sandbox operations (cp, ssh, preview) |
@ -100,6 +101,7 @@ fabro [OPTIONS] [COMMAND]
| `fabro start` | Start a created workflow run on the server |
| `fabro steer` | Steer a running agent mid-execution |
| `fabro system` | System maintenance commands |
| `fabro timeline` | Show the checkpoint timeline of a workflow run |
| `fabro unarchive` | Restore archived runs to their prior terminal status |
| `fabro uninstall` | Uninstall Fabro from this machine |
| `fabro upgrade` | Upgrade fabro to the latest version |
@ -1022,9 +1024,29 @@ fabro resume [OPTIONS] <RUN>
| `-d, --detach` | Run in the background and print the run ID |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
### `fabro retry`
Retry a finished workflow run from its last checkpoint in a new run
```bash
fabro retry [OPTIONS] <RUN_ID>
```
#### Arguments
| Name | Description |
| --- | --- |
| `RUN_ID` | Run ID (or unambiguous prefix) |
#### Options
| Option | Description |
| --- | --- |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
### `fabro rewind`
Rewind a workflow run to an earlier checkpoint
Rewind a workflow run to an earlier checkpoint, replacing it
```bash
fabro rewind [OPTIONS] <RUN_ID> [TARGET]
@ -1406,6 +1428,7 @@ fabro steer [OPTIONS] <RUN> [TEXT]
| --- | --- |
| `--interrupt` | Cancel the in-flight LLM stream / tool calls and deliver the message as the next user turn (default: append to the steering queue) |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
| `--stage <stage>` | Agent stage to steer, as its stage id (node@visit) or node name (default: the run's one live agent stage) |
| `--text-stdin` | Read steer text from stdin instead of a positional arg |
### `fabro system`
@ -1525,6 +1548,26 @@ fabro system repair runs [OPTIONS]
| `--storage-dir <storage_dir>` | Local storage directory (default: ~/.fabro/storage) |
| `--yes` | Actually delete unreadable runs (default is dry-run) |
### `fabro timeline`
Show the checkpoint timeline of a workflow run
```bash
fabro timeline [OPTIONS] <RUN_ID>
```
#### Arguments
| Name | Description |
| --- | --- |
| `RUN_ID` | Run ID (or unambiguous prefix) |
#### Options
| Option | Description |
| --- | --- |
| `--server <server>` | Fabro server target: http(s) URL or absolute Unix socket path |
### `fabro unarchive`
Restore archived runs to their prior terminal status

View file

@ -221,7 +221,7 @@ Fabro stores every one of these as an `agent.*` run event whose properties are t
### Tool middleware
Implement pebble's `ToolMiddleware` to intercept tool calls for approval, logging, or transformation, and install it with the builder's `.tool_middleware(...)`. Fabro's `fabro_hooks::WorkflowToolHookCallback` is one: it runs the workflow's `pre_tool_use` hooks before each call and the `post_tool_use` hooks after.
Implement pebble's `ToolMiddleware` to intercept tool calls for approval, logging, or transformation, and install it with the builder's `.tool_middleware(...)`. Petri's Attractor agent step installs one for Fabro's `pre_tool_use` and `post_tool_use` hooks.
```rust
use async_trait::async_trait;

View file

@ -28,9 +28,9 @@ When Fabro can construct a direct install URL, the token is embedded in the URL
The `Object store` step offers two wizard-managed modes:
- `Local disk` for a host-local object-store root, detected by default and editable before continuing
- `AWS S3` for one shared bucket with fixed `slatedb/` and `artifacts/` prefixes
- `AWS S3` for one bucket with the fixed `artifacts/` prefix
The wizard's manual-credential path stores only `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in `server.env`. It does not collect STS/session tokens or S3-compatible endpoint settings. If you need MinIO, Cloudflare R2, path-style options, or custom endpoints, finish install with local defaults and then edit `[server.slatedb]` / `[server.artifacts]` in `settings.toml` manually.
The wizard's manual-credential path stores only `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in `server.env`. It does not collect STS/session tokens or S3-compatible endpoint settings. If you need MinIO, Cloudflare R2, path-style options, or custom endpoints, finish install with local defaults and then edit `[server.artifacts]` in `settings.toml` manually.
When you finish the wizard, the server writes `~/.fabro/settings.toml` and exits cleanly. Start it again to boot in configured mode:

View file

@ -36,7 +36,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
| CLI-only | `[cli.target]`, `[cli.auth]`, `[cli.exec]`, `[cli.output]`, `[cli.updates]`, `[cli.logging]` |
| Server-side run policy | `[run.model]`, `[run.environment]`, `[environments.<slug>]`, `[run.checkpoint]`, `[run.inputs]`, `[run.prepare]`, `[run.pull_request]`, `[run.integrations.github]`, `[run.hooks]`, `[run.agent.mcps]` |
| Shared LLM catalog | `[llm]`, a lithos-llm catalog overlay: `[llm.providers.<id>]`, `[llm.providers.<id>.models.<id>]`, and the agent harness under `metadata.agent` |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
| Server-only | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
`[cli.*]` and `[server.*]` stanzas are owner-specific: they are only consumed from `~/.fabro/settings.toml` (plus process-local flags and env overrides). The same stanzas in `.fabro/project.toml` or `workflow.toml` remain schema-valid but runtime-inert.

View file

@ -95,7 +95,7 @@ Fabro uses this order:
A `model_stylesheet` on an imported graph is ignored and produces an `imported_model_stylesheet_ignored` warning. Put the stylesheet on the root graph. A root stylesheet can target imported nodes by their generated IDs, classes, or shapes.
If an input or variable is unavailable, `fabro validate` reports `template_undefined_variable`. It skips stylesheet syntax and model checks for that validation pass. Run-style commands treat the same diagnostic as an error before they create or start a run.
If an input or variable is unavailable, `fabro validate` reports `attractor.unbound_input` as a warning and leaves the stylesheet unrendered, so its syntax and model checks wait for the values. Run-style commands refuse the workflow with `unsupported.template.unbound_input` before they create or start a run.
## Selectors

View file

@ -163,7 +163,7 @@ digraph Example {
That prompt becomes `Create a plan for: Implement the login feature`.
A graph goal cannot contain `{{ goal }}` because that would reference itself. `fabro validate` reports `goal_self_reference` as an error; put the reusable text in an input or server-managed variable instead.
A graph goal cannot contain `{{ goal }}` because that would reference itself: the goal is not bound while it renders, so `fabro validate` reports `attractor.unbound_input` and run-style commands refuse the workflow with `unsupported.template.unbound_input`. Put the reusable text in an input or server-managed variable instead.
## Expansion timing
@ -185,9 +185,7 @@ Fabro renders the graph `goal` first and stores the rendered value back onto the
## Undefined variables
Fabro renders undefined workflow variables as empty text and records a `template_undefined_variable` diagnostic. `fabro validate` reports that diagnostic as a warning so you can validate workflow structure before all inputs are known. Offline validation does not read a server's variable store, so `{{ vars.* }}` references also warn there. Run-style commands such as `fabro run`, `fabro create`, and preflight use the server snapshot and promote any still-undefined reference to an error before proceeding.
In a `script`, an undefined value records the same diagnostic but leaves the token in place rather than emptying it, so validation output shows what is unbound.
A workflow variable that nothing binds is a diagnostic from the workflow compile. `fabro validate` reports it as a warning (`attractor.unbound_input`) and leaves the text unrendered, so you can validate workflow structure before all inputs are known. Offline validation does not read a server's variable store, so `{{ vars.* }}` references also warn there. Run-style commands such as `fabro run`, `fabro create`, and preflight use the server snapshot and refuse any still-unbound reference with `unsupported.template.unbound_input` before proceeding.
## Template includes

View file

@ -24,22 +24,19 @@ fabro-environment = { path = "../../components/fabro-environment" }
fabro-llm = { path = "../../components/fabro-llm" }
fabro-oauth = { path = "../../foundation/fabro-oauth" }
fabro-github = { path = "../../components/fabro-github" }
pebble-agent.workspace = true
pebble-coding-agent.workspace = true
pebble-cli-core.workspace = true
sandbox-driver.workspace = true
fabro-dump = { path = "../../components/fabro-dump" }
fabro-hooks = { path = "../../components/fabro-hooks" }
fabro-install = { path = "../../components/fabro-install" }
fabro-interview = { path = "../../components/fabro-interview" }
fabro-mcp = { path = "../../components/fabro-mcp" }
fabro-mcp-server = { path = "../fabro-mcp-server" }
fabro-petri = { path = "../../components/fabro-petri" }
fabro-manifest = { path = "../../components/fabro-manifest" }
fabro-proc = { path = "../../foundation/fabro-proc" }
fabro-sandbox = { path = "../../components/fabro-sandbox" }
fabro-checkpoint = { path = "../../components/fabro-checkpoint" }
fabro-pebble-sandbox = { path = "../../components/fabro-pebble-sandbox" }
sandbox-driver.workspace = true
sandbox-driver-host.workspace = true
fabro-graphviz = { path = "../../components/fabro-graphviz" }
fabro-validate = { path = "../../components/fabro-validate" }
fabro-workflow = { path = "../../components/fabro-workflow" }
fabro-server = { path = "../fabro-server" }
fabro-client = { path = "../../foundation/fabro-client" }
@ -53,7 +50,6 @@ fabro-redact.workspace = true
fabro-util = { path = "../../foundation/fabro-util" }
fabro-http.workspace = true
fabro-static.workspace = true
fabro-template = { path = "../../foundation/fabro-template" }
fabro-tool = { path = "../../components/fabro-tool" }
clap.workspace = true
clap_complete.workspace = true
@ -62,7 +58,6 @@ console.workspace = true
indicatif.workspace = true
anyhow.workspace = true
miette.workspace = true
dotenvy.workspace = true
tokio.workspace = true
tokio-tungstenite.workspace = true
tracing.workspace = true
@ -70,32 +65,25 @@ tracing-subscriber.workspace = true
tracing-appender.workspace = true
chrono = { workspace = true, features = ["serde"] }
dirs.workspace = true
fs2.workspace = true
serde.workspace = true
thiserror.workspace = true
toml.workspace = true
toml_edit.workspace = true
futures.workspace = true
regex.workspace = true
semver.workspace = true
progenitor-client = "0.13"
async-trait.workspace = true
jsonwebtoken.workspace = true
base64.workspace = true
ulid.workspace = true
scopeguard = "1"
rustls = { version = "0.23", default-features = false, features = ["std", "ring"] }
ring = "0.17"
x509-parser = "0.16"
rand.workspace = true
dialoguer.workspace = true
git2.workspace = true
axum.workspace = true
serde_json.workspace = true
serde_yaml = "0.9"
tempfile = "3"
sha2.workspace = true
shlex = "1"
object_store.workspace = true
bytes.workspace = true
tokio-util.workspace = true
@ -116,13 +104,17 @@ fabro-build-support = { path = "../../foundation/build-support" }
chrono = { workspace = true }
[dev-dependencies]
shlex = "1"
ulid.workspace = true
regex.workspace = true
git2.workspace = true
assert_cmd = "2"
fabro-db = { path = "../../foundation/fabro-db" }
walkdir.workspace = true
fabro-acp = { path = "../../components/fabro-acp", features = ["test-support"] }
fabro-mcp = { path = "../../components/fabro-mcp", features = ["test-support"] }
rmcp = { workspace = true, features = ["client", "transport-child-process"] }
fabro-build-support = { path = "../../foundation/build-support" }
fabro-sandbox = { path = "../../components/fabro-sandbox", features = ["test-support"] }
fabro-server = { path = "../fabro-server", features = ["test-support"] }
fabro-petri = { path = "../../components/fabro-petri", features = ["test-support"] }
fabro-workflow = { path = "../../components/fabro-workflow", features = ["test-support"] }
fabro-types = { path = "../../foundation/fabro-types", features = ["clap", "test-support"] }
insta = { workspace = true, features = ["filters"] }

View file

@ -234,6 +234,10 @@ pub(crate) struct RunArgs {
pub(crate) inputs: InputOverrideArgs,
/// Workflow name, path, or OWNER/REPO[@REF]:WORKFLOW
#[allow(
rustdoc::broken_intra_doc_links,
reason = "the help text's `[@REF]` is an optional segment, not a link"
)]
#[arg(required = true)]
pub(crate) workflow: Option<PathBuf>,
@ -545,12 +549,6 @@ pub(crate) struct GraphArgs {
pub(crate) allow_invalid: bool,
}
#[derive(Args)]
pub(crate) struct ParseArgs {
/// Path to the .fabro workflow file
pub(crate) workflow: PathBuf,
}
#[derive(Args)]
pub(crate) struct ArtifactListArgs {
#[command(flatten)]
@ -758,7 +756,33 @@ pub(crate) struct ResumeArgs {
pub(crate) detach: bool,
}
#[derive(Debug, Args)]
#[derive(Args)]
pub(crate) struct RetryArgs {
#[command(flatten)]
pub(crate) server: ServerTargetArgs,
/// Run ID (or unambiguous prefix)
pub(crate) run_id: String,
}
#[derive(Args)]
pub(crate) struct ForkArgs {
#[command(flatten)]
pub(crate) server: ServerTargetArgs,
/// Run ID (or unambiguous prefix)
pub(crate) run_id: String,
/// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from
/// latest)
pub(crate) target: Option<String>,
/// Show the checkpoint timeline instead of forking
#[arg(long)]
pub(crate) list: bool,
}
#[derive(Args)]
pub(crate) struct RewindArgs {
#[command(flatten)]
pub(crate) server: ServerTargetArgs,
@ -774,21 +798,13 @@ pub(crate) struct RewindArgs {
pub(crate) list: bool,
}
#[derive(Debug, Args)]
pub(crate) struct ForkArgs {
#[derive(Args)]
pub(crate) struct TimelineArgs {
#[command(flatten)]
pub(crate) server: ServerTargetArgs,
/// Run ID (or unambiguous prefix)
pub(crate) run_id: String,
/// Target checkpoint: node name, node@visit, or @ordinal (omit to fork from
/// latest)
pub(crate) target: Option<String>,
/// Show the checkpoint timeline instead of forking
#[arg(long)]
pub(crate) list: bool,
}
#[derive(Args)]
@ -827,6 +843,11 @@ pub(crate) struct SteerArgs {
/// as the next user turn (default: append to the steering queue).
#[arg(long)]
pub(crate) interrupt: bool,
/// Agent stage to steer, as its stage id (node@visit) or node name
/// (default: the run's one live agent stage)
#[arg(long, value_name = "STAGE")]
pub(crate) stage: Option<String>,
}
#[derive(Args)]
@ -1089,6 +1110,11 @@ pub(crate) struct RunWorkerArgs {
/// Worker mode
#[arg(long, value_enum)]
pub(crate) mode: RunWorkerMode,
/// The Fabro home the server runs under, for the skills a Petri run's
/// agents read
#[arg(long, hide = true)]
pub(crate) fabro_home: Option<PathBuf>,
}
#[derive(Args, Debug, Clone, Default)]
@ -1295,10 +1321,14 @@ pub(crate) enum RunCommands {
Logs(LogsArgs),
/// Resume an interrupted workflow run
Resume(ResumeArgs),
/// Rewind a workflow run to an earlier checkpoint
Rewind(RewindArgs),
/// Retry a finished workflow run from its last checkpoint in a new run
Retry(RetryArgs),
/// Fork a workflow run from an earlier checkpoint into a new run
Fork(ForkArgs),
/// Rewind a workflow run to an earlier checkpoint, replacing it
Rewind(RewindArgs),
/// Show the checkpoint timeline of a workflow run
Timeline(TimelineArgs),
/// Block until a workflow run completes
Wait(WaitArgs),
/// Steer a running agent mid-execution
@ -1319,10 +1349,12 @@ impl RunCommands {
Self::Events(_) => "events",
Self::Logs(_) => "logs",
Self::Resume(_) => "resume",
Self::Retry(_) => "retry",
Self::Fork(_) => "fork",
Self::Rewind(_) => "rewind",
Self::Timeline(_) => "timeline",
Self::Steer(_) => "steer",
Self::Ask(_) => "ask",
Self::Fork(_) => "fork",
Self::Wait(_) => "wait",
}
}
@ -1404,9 +1436,6 @@ pub(crate) enum Commands {
Validate(ValidateArgs),
/// Render a workflow graph as SVG
Graph(GraphArgs),
/// Parse a DOT file and print its AST
#[command(hide = true)]
Parse(ParseArgs),
/// Inspect and copy run artifacts (screenshots, reports, traces)
Artifact(ArtifactNamespace),
/// Export a run's durable state to a directory
@ -1508,7 +1537,6 @@ impl Commands {
Self::Preflight(_) => "preflight",
Self::Validate(_) => "validate",
Self::Graph(_) => "graph",
Self::Parse(_) => "parse",
Self::RunsCmd(cmd) => cmd.name(),
Self::Model { command } => match command {
Some(ModelsCommand::List(_)) => "model list",

View file

@ -79,8 +79,8 @@ async fn write_run_dump(
state: &RunProjection,
output_dir: &Path,
) -> Result<usize> {
let events = client.list_run_events(run_id, None, None).await?;
let mut dump = RunDump::from_store_state_and_events(state, &events)?;
let items = client.list_run_stream(run_id, 0).await?;
let mut dump = RunDump::from_store_state_and_stream(state, &items)?;
if let Some(log) = client.get_run_logs(run_id).await? {
dump.add_file_bytes("run.log", log);

View file

@ -22,12 +22,10 @@ use fabro_llm::gateway::{GatewayAdapter, GatewayError, GatewayTransport};
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
use fabro_llm::middleware::{Call, Middleware, Next, Output};
use fabro_llm::{Client, ClientOptions, Error as LlmError, ErrorKind};
use fabro_mcp::config::McpServerSettings;
use fabro_mcp::pebble::pebble_servers;
use fabro_sandbox::{RunSandbox, SecretRedactor, local_sandbox};
use fabro_pebble_sandbox::{PebbleSandbox, SecretRedactor};
use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
use fabro_types::settings::run::ResolvedMcpEntry;
use fabro_types::settings::run::{McpServerSettings, ResolvedMcpEntry};
use fabro_util::exit::{self, ErrorExt, ExitClass};
use fabro_util::home::Home;
use fabro_util::terminal::Styles;
@ -40,11 +38,14 @@ use pebble_coding_agent::environment::Environment;
use pebble_coding_agent::subagents::SubagentOptions;
use pebble_coding_agent::tools::{PermissionLevelPolicy, PermissionMiddleware};
use pebble_coding_agent::{CodingAgent, CodingAgentOptions, MemoryDiscovery, SkillDiscovery};
use sandbox_driver::{SandboxProvider as _, SandboxSource, SandboxSpec, WaitOptions};
use sandbox_driver_host::HostProvider;
use tokio::signal;
use tokio_util::sync::CancellationToken;
use crate::args::{AgentArgs, ExecArgs, ExecOutputFormat};
use crate::command_context::CommandContext;
use crate::mcp_servers::pebble_servers;
#[cfg(feature = "sleep_inhibitor")]
use crate::sleep_inhibitor;
use crate::{server_client, user_config};
@ -429,11 +430,11 @@ async fn run_session(
eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}")));
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let sandbox: Arc<RunSandbox> = Arc::new(
local_sandbox(cwd)
.await
.context("failed to create the local sandbox")?,
);
// The provider stays alive beside the sandbox: the session's processes
// are its process groups.
let (_provider, sandbox) = host_sandbox(cwd)
.await
.context("failed to create the local sandbox")?;
let permissions = args.permission_level();
#[expect(
@ -516,6 +517,31 @@ async fn run_session(
.map_err(|error| anyhow::Error::new(SessionError::from(error)))
}
/// The host directory `working_directory` as the sandbox the session runs
/// in, over the sandbox driver's Host provider: designated in place, never
/// removed, brought to `Running` with its Bash verified. The provider is
/// returned beside the sandbox because the session's processes are the
/// provider's process groups; it must outlive the session.
async fn host_sandbox(working_directory: PathBuf) -> AnyResult<(HostProvider, Arc<PebbleSandbox>)> {
let provider = HostProvider::new();
let handle = provider
.create(
&SandboxSpec::new(SandboxSource::HostDirectory)
.working_directory(working_directory.display().to_string()),
None,
)
.await
.with_context(|| format!("failed to designate {}", working_directory.display()))?;
sandbox_driver::activate(handle.as_ref(), &WaitOptions::default())
.await
.context("failed to start the local sandbox")?;
let working_directory = handle.working_directory().to_string();
let sandbox = PebbleSandbox::attach(handle, working_directory)
.await
.context("failed to read the local sandbox's platform")?;
Ok((provider, Arc::new(sandbox)))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
@ -523,6 +549,7 @@ mod tests {
use fabro_llm::test_support::{test_catalog, test_catalog_with_overlay};
use fabro_types::settings::run::{McpServerRef, McpServerSettings, ResolvedMcpEntry};
use lithos_llm::catalog::builtin;
use pebble_coding_agent::environment::{Environment, ExecRequest};
use super::{AgentArgs, resolve_provider_id, run_mcp_servers_for_exec, summarizer_model};
use crate::args::{ExecOutputFormat, PermissionsArg};
@ -603,4 +630,54 @@ mod tests {
assert!(selector.starts_with("anthropic/"), "{selector}");
assert_ne!(selector, "anthropic/claude-opus-4-6");
}
#[tokio::test]
async fn the_session_sandbox_designates_the_directory_on_the_host_provider() {
let directory = tempfile::tempdir().expect("a temporary directory");
let (_provider, sandbox) = super::host_sandbox(directory.path().to_path_buf())
.await
.expect("a host sandbox over the directory");
assert_eq!(
std::path::Path::new(Environment::working_directory(&*sandbox)),
directory.path().canonicalize().expect("canonical path")
);
assert_ne!(Environment::platform(&*sandbox), "unknown");
let outcome = Environment::exec(&*sandbox, ExecRequest {
command: "pwd",
timeout_ms: Some(10_000),
working_dir: None,
env_vars: None,
cancel_token: None,
output_bytes_cap: None,
output_sink: None,
})
.await
.expect("a command runs in the sandbox");
assert_eq!(outcome.result.exit_code, Some(0));
assert_eq!(
std::path::Path::new(outcome.result.stdout.trim())
.canonicalize()
.expect("the reported directory exists"),
directory.path().canonicalize().expect("canonical path")
);
assert!(
directory.path().is_dir(),
"a designated directory is never removed"
);
}
#[tokio::test]
async fn a_missing_directory_is_refused_before_the_session_starts() {
let directory = tempfile::tempdir().expect("a temporary directory");
let missing = directory.path().join("absent");
let error = super::host_sandbox(missing)
.await
.err()
.expect("a directory that does not exist cannot be designated");
assert!(
error.to_string().contains("failed to designate"),
"{error:#}"
);
}
}

View file

@ -14,6 +14,7 @@ use fabro_api::types;
use fabro_config::user::active_settings_path;
use fabro_graphviz::render;
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
use fabro_types::diagnostic::Severity;
use fabro_util::terminal::Styles;
use tracing::debug;
@ -47,7 +48,7 @@ pub(crate) async fn run(
print_diagnostics(&diagnostics, styles, printer);
let has_errors = diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error);
.any(|diagnostic| diagnostic.severity == Severity::Error);
if has_errors && !args.allow_invalid {
bail!("Validation failed");
}

View file

@ -10,7 +10,6 @@ pub(crate) mod install;
pub(crate) mod mcp;
pub(crate) mod model;
pub(crate) mod parent;
pub(crate) mod parse;
pub(crate) mod pr;
pub(crate) mod preflight;
pub(crate) mod provider;

View file

@ -9,7 +9,9 @@ use crate::command_context::CommandContext;
pub(crate) async fn dispatch(ns: ParentNamespace, base_ctx: &CommandContext) -> Result<()> {
match ns.command {
ParentCommand::Link(args) => link::link_command(args, base_ctx).await,
// The link command's future carries several client calls and sits
// past clippy's stack budget; box it once at the call.
ParentCommand::Link(args) => Box::pin(link::link_command(args, base_ctx)).await,
ParentCommand::Unlink(args) => unlink::unlink_command(args, base_ctx).await,
}
}

View file

@ -1,30 +0,0 @@
#![expect(
clippy::disallowed_types,
reason = "sync CLI `parse` command: blocking std::io::Write is the intended output mechanism"
)]
#![expect(
clippy::disallowed_methods,
reason = "sync CLI `parse` command: blocking std::io::stdout is the intended output mechanism"
)]
use std::io::Write;
use fabro_config::project::resolve_workflow;
use fabro_graphviz::parser::parse_ast;
use crate::args::ParseArgs;
use crate::shared::read_workflow_file;
pub(crate) fn run(args: &ParseArgs) -> anyhow::Result<()> {
let stdout = std::io::stdout();
run_to(args, stdout.lock())
}
fn run_to(args: &ParseArgs, mut out: impl Write) -> anyhow::Result<()> {
let dot_path = resolve_workflow(&args.workflow)?;
let source = read_workflow_file(&dot_path)?;
let ast = parse_ast(&source)?;
serde_json::to_writer_pretty(&mut out, &ast)?;
writeln!(out)?;
Ok(())
}

View file

@ -1,6 +1,7 @@
use anyhow::bail;
use fabro_config::user::active_settings_path;
use fabro_manifest::{ManifestBuildInput, build_run_manifest};
use fabro_types::diagnostic::Severity;
use fabro_util::terminal::Styles;
use crate::args::PreflightArgs;
@ -57,7 +58,7 @@ pub(crate) async fn execute(
);
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error)
.any(|diagnostic| diagnostic.severity == Severity::Error)
{
bail!("Validation failed");
}
@ -75,7 +76,7 @@ pub(crate) async fn execute(
if diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == fabro_validate::Severity::Error)
.any(|diagnostic| diagnostic.severity == Severity::Error)
{
bail!("Validation failed");
}

View file

@ -1,6 +1,6 @@
use anyhow::{Result, bail};
use fabro_api::types::CreateRunSessionRequest;
use fabro_store::EventEnvelope;
use fabro_types::{SessionEvent, SessionEventBody};
use crate::args::AskArgs;
use crate::command_context::CommandContext;
@ -24,21 +24,13 @@ pub(crate) async fn run(args: AskArgs, base_ctx: &CommandContext) -> Result<()>
let mut saw_terminal = false;
while let Some(event) = stream.next_event().await? {
render_event(&event, ctx.json_output())?;
match event.event.event_name() {
"run.session.turn.succeeded" | "run.session.turn.interrupted" => {
match &event.body {
SessionEventBody::TurnSucceeded(_) | SessionEventBody::TurnInterrupted(_) => {
saw_terminal = true;
}
"run.session.turn.failed" => {
SessionEventBody::TurnFailed(props) => {
saw_terminal = true;
terminal_error = Some(
event
.event
.properties()?
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("session turn failed")
.to_string(),
);
terminal_error = Some(props.error.clone());
}
_ => {}
}
@ -68,28 +60,18 @@ fn session_title(prompt: &str) -> String {
clippy::print_stdout,
reason = "The ask command streams assistant output and JSON events to stdout."
)]
fn render_event(event: &EventEnvelope, json_output: bool) -> Result<()> {
fn render_event(event: &SessionEvent, json_output: bool) -> Result<()> {
if json_output {
println!("{}", serde_json::to_string(event)?);
return Ok(());
}
match event.event.event_name() {
"run.session.assistant_delta" => {
let properties = event.event.properties()?;
if let Some(delta) = properties.get("delta").and_then(serde_json::Value::as_str) {
print!("{delta}");
}
match &event.body {
SessionEventBody::AssistantDelta(props) => {
print!("{}", props.delta);
}
"run.session.assistant_message" => {
let properties = event.event.properties()?;
if let Some(text) = properties
.get("text")
.and_then(serde_json::Value::as_str)
.filter(|text| !text.is_empty())
{
println!("{text}");
}
SessionEventBody::AssistantMessage(props) if !props.text.is_empty() => {
println!("{}", props.text);
}
_ => {}
}

Some files were not shown because too many files have changed in this diff Show more