mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
Merge main into PR branch, resolve conflicts
Resolved conflicts in args.rs (added --force flag alongside main's --server option), pr_create.rs (merged renamed test helpers with setup_failed_run), and support.rs (adapted setup_failed_run to use main's run_cmd/unique_run_id patterns and event-based store). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
2299d30afd
1447 changed files with 106040 additions and 61523 deletions
|
|
@ -6,7 +6,7 @@
|
|||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "FILE=$(jq -r '.tool_input.file_path') && case \"$FILE\" in *.rs) cargo fmt -- \"$FILE\" ;; esac"
|
||||
"command": "FILE=$(jq -r '.tool_input.file_path') && case \"$FILE\" in *.rs) cargo +nightly fmt -- \"$FILE\" ;; esac"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ed651dcd571699e92162cf657b8eca42d7a6d6b1
|
||||
6c53fc29b5f1309bfd026a883a23006db2bb441c
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1afa8419b53670f95c5d6a041cfd373c4a8c9371
|
||||
6c53fc29b5f1309bfd026a883a23006db2bb441c
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
[profile.default]
|
||||
# Unit tests: flag SLOW after 2s, hard-kill after 4s
|
||||
slow-timeout = { period = "3s", terminate-after = 2 }
|
||||
# Default-profile tests: flag SLOW after 1s, hard-kill after 3s
|
||||
slow-timeout = { period = "1s", terminate-after = 3 }
|
||||
leak-timeout = "500ms"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(fabro-cli) & kind(test)"
|
||||
slow-timeout = { period = "5s", terminate-after = 4 }
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(fabro-cli) & kind(test)"
|
||||
slow-timeout = { period = "3s", terminate-after = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(fabro-server) & kind(test)"
|
||||
slow-timeout = { period = "5s", terminate-after = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(fabro-workflow) & kind(test)"
|
||||
slow-timeout = { period = "2s", terminate-after = 3 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)"
|
||||
slow-timeout = { period = "30s", terminate-after = 1 }
|
||||
|
||||
[profile.e2e]
|
||||
# E2E (ignored) tests: flag SLOW after 10s, hard-kill after 30s
|
||||
slow-timeout = { period = "10s", terminate-after = 3 }
|
||||
leak-timeout = "500ms"
|
||||
|
|
|
|||
|
|
@ -1,29 +1,23 @@
|
|||
version = 1
|
||||
_version = 1
|
||||
|
||||
[fabro]
|
||||
root = "fabro/"
|
||||
|
||||
[features]
|
||||
retros = false
|
||||
|
||||
[pull_request]
|
||||
[run.pull_request]
|
||||
enabled = true
|
||||
draft = false
|
||||
|
||||
[sandbox]
|
||||
[run.sandbox]
|
||||
provider = "daytona"
|
||||
|
||||
[sandbox.daytona]
|
||||
[run.sandbox.daytona]
|
||||
auto_stop_interval = 30
|
||||
|
||||
[sandbox.daytona.labels]
|
||||
[run.sandbox.daytona.labels]
|
||||
repo = "fabro-sh/fabro"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
[run.sandbox.daytona.snapshot]
|
||||
name = "fabro-v6"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
disk = 20
|
||||
memory = "8GB"
|
||||
disk = "20GB"
|
||||
dockerfile = """
|
||||
FROM ubuntu:24.04
|
||||
|
||||
|
|
@ -52,9 +46,10 @@ ENV PATH="/root/.bun/bin:${PATH}"
|
|||
WORKDIR /root
|
||||
"""
|
||||
|
||||
[[hooks]]
|
||||
[[run.hooks]]
|
||||
id = "cargo-fmt"
|
||||
name = "cargo-fmt"
|
||||
event = "post_tool_use"
|
||||
matcher = "write_file|edit_file|apply_patch"
|
||||
command = "cargo fmt"
|
||||
script = "cargo fmt"
|
||||
blocking = true
|
||||
7
.fabro/workflows/gh-triage/workflow.toml
Normal file
7
.fabro/workflows/gh-triage/workflow.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
_version = 1
|
||||
|
||||
[server.integrations.github]
|
||||
|
||||
[server.integrations.github.permissions]
|
||||
pull_requests = "read"
|
||||
issues = "read"
|
||||
7
.fabro/workflows/implement-issue/workflow.toml
Normal file
7
.fabro/workflows/implement-issue/workflow.toml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
_version = 1
|
||||
|
||||
[server.integrations.github]
|
||||
|
||||
[server.integrations.github.permissions]
|
||||
issues = "read"
|
||||
pull_requests = "write"
|
||||
1
.fabro/workflows/implement-plan/workflow.toml
Normal file
1
.fabro/workflows/implement-plan/workflow.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
_version = 1
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
digraph Smoke {
|
||||
graph [goal="Verify the sandbox can lint and test the project"]
|
||||
graph [goal="Verify the sandbox can lint and test the project", retry_target=exit]
|
||||
rankdir=LR
|
||||
|
||||
start [shape=Mdiamond, label="Start"]
|
||||
|
|
@ -8,8 +8,8 @@ digraph Smoke {
|
|||
toolchain [label="Toolchain", shape=parallelogram, script="rustc --version && cargo --version && bun --version 2>&1", goal_gate=true]
|
||||
compile_rust [label="Compile Rust", shape=parallelogram, script="cargo check -q --workspace 2>&1", goal_gate=true]
|
||||
compile_typescript [label="Compile TypeScript", shape=parallelogram, script="cd apps/fabro-web && bun install && bun run typecheck 2>&1", goal_gate=true]
|
||||
lint_rust [label="Lint Rust", shape=parallelogram, script="cargo fmt --check --all 2>&1 && cargo clippy -q --workspace -- -D warnings 2>&1", goal_gate=true]
|
||||
test_rust [label="Test Rust", shape=parallelogram, script="cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true]
|
||||
lint_rust [label="Lint Rust", shape=parallelogram, script="cargo +nightly fmt --check --all 2>&1 && cargo clippy -q --workspace -- -D warnings 2>&1", goal_gate=true]
|
||||
test_rust [label="Test Rust", shape=parallelogram, script="ulimit -n 4096 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true]
|
||||
test_typescript [label="Test TypeScript", shape=parallelogram, script="cd apps/fabro-web && bun test 2>&1", goal_gate=true]
|
||||
|
||||
start -> toolchain
|
||||
1
.fabro/workflows/smoke/workflow.toml
Normal file
1
.fabro/workflows/smoke/workflow.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
_version = 1
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
lib/crates/fabro-spa/assets/** linguist-generated=true -diff
|
||||
43
.github/workflows/release.yml
vendored
43
.github/workflows/release.yml
vendored
|
|
@ -78,3 +78,46 @@ jobs:
|
|||
GH_TOKEN: ${{ github.token }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: gh release create "$TAG_NAME" target/distrib/* --generate-notes
|
||||
|
||||
update-homebrew:
|
||||
name: Update Homebrew Formula
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
path: target/distrib
|
||||
pattern: artifacts-fabro-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate and push formula
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG_NAME#v}"
|
||||
|
||||
SHA_AARCH64_DARWIN=$(awk '{print $1}' target/distrib/fabro-aarch64-apple-darwin.tar.gz.sha256)
|
||||
SHA_X86_64_LINUX=$(awk '{print $1}' target/distrib/fabro-x86_64-unknown-linux-gnu.tar.gz.sha256)
|
||||
SHA_AARCH64_LINUX=$(awk '{print $1}' target/distrib/fabro-aarch64-unknown-linux-gnu.tar.gz.sha256)
|
||||
|
||||
sed \
|
||||
-e "s/{{VERSION}}/$VERSION/g" \
|
||||
-e "s/{{SHA_AARCH64_DARWIN}}/$SHA_AARCH64_DARWIN/g" \
|
||||
-e "s/{{SHA_X86_64_LINUX}}/$SHA_X86_64_LINUX/g" \
|
||||
-e "s/{{SHA_AARCH64_LINUX}}/$SHA_AARCH64_LINUX/g" \
|
||||
installer/fabro.rb.template > fabro.rb
|
||||
|
||||
git clone https://x-access-token:${GH_TOKEN}@github.com/fabro-sh/homebrew-tap.git tap
|
||||
cp fabro.rb tap/Formula/fabro.rb
|
||||
cd tap
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add Formula/fabro.rb
|
||||
git diff --cached --quiet || (git commit -m "Update fabro to ${TAG_NAME}" && git push)
|
||||
|
|
|
|||
11
.github/workflows/rust.yml
vendored
11
.github/workflows/rust.yml
vendored
|
|
@ -5,18 +5,22 @@ on:
|
|||
branches: [main]
|
||||
paths:
|
||||
- "lib/crates/**"
|
||||
- "test/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".cargo/**"
|
||||
- ".config/**"
|
||||
- "openapi/**"
|
||||
- ".github/workflows/rust.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "lib/crates/**"
|
||||
- "test/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- ".cargo/**"
|
||||
- ".config/**"
|
||||
- "openapi/**"
|
||||
- ".github/workflows/rust.yml"
|
||||
workflow_dispatch:
|
||||
|
|
@ -40,7 +44,10 @@ jobs:
|
|||
with:
|
||||
persist-credentials: false
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
- run: cargo fmt --check --all
|
||||
with:
|
||||
toolchain: nightly
|
||||
components: rustfmt
|
||||
- run: cargo +nightly fmt --check --all
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
|
|
@ -53,7 +60,7 @@ jobs:
|
|||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
with:
|
||||
cache-on-failure: true
|
||||
- run: cargo clippy --workspace -- -D warnings
|
||||
- run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
name: Test (Linux)
|
||||
|
|
|
|||
13
.github/workflows/typescript.yml
vendored
13
.github/workflows/typescript.yml
vendored
|
|
@ -5,17 +5,23 @@ on:
|
|||
branches: [main]
|
||||
paths:
|
||||
- "apps/**"
|
||||
- "lib/crates/fabro-spa/**"
|
||||
- "lib/packages/**"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
- ".gitattributes"
|
||||
- "scripts/**"
|
||||
- ".github/workflows/typescript.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "apps/**"
|
||||
- "lib/crates/fabro-spa/**"
|
||||
- "lib/packages/**"
|
||||
- "package.json"
|
||||
- "bun.lock"
|
||||
- ".gitattributes"
|
||||
- "scripts/**"
|
||||
- ".github/workflows/typescript.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
@ -58,4 +64,9 @@ jobs:
|
|||
persist-credentials: false
|
||||
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
|
||||
- run: bun install
|
||||
- run: cd apps/fabro-web && bun run build
|
||||
- run: scripts/refresh-fabro-spa.sh
|
||||
- run: git diff --exit-code -- lib/crates/fabro-spa/assets
|
||||
- run: scripts/check-fabro-spa-budgets.sh
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
- run: cargo build -p fabro-cli --release
|
||||
- run: wc -c < target/release/fabro
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,6 +2,7 @@ target
|
|||
.env
|
||||
.entire
|
||||
node_modules
|
||||
apps/fabro-web/dist/
|
||||
tmp
|
||||
evals/swe-bench/repos/
|
||||
evals/swe-bench/results/
|
||||
|
|
@ -12,3 +13,4 @@ __pycache__
|
|||
.ai/tmp
|
||||
.worktrees/
|
||||
**/*.pending-snap
|
||||
.worktrees
|
||||
|
|
|
|||
24
AGENTS.md
24
AGENTS.md
|
|
@ -11,11 +11,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
- `cargo nextest run -p fabro-workflow -- test_name` — run a single test
|
||||
- `set -a && source .env && set +a && cargo nextest run --workspace --profile e2e --run-ignored only` — run all E2E live tests (requires credentials in `.env`, see `.env.example`)
|
||||
- `set -a && source .env && set +a && cargo nextest run -p fabro-llm --profile e2e --run-ignored only` — run E2E tests for a single crate
|
||||
- `cargo fmt --check --all` — check formatting
|
||||
- `cargo +nightly fmt --check --all` — check formatting (nightly required for rustfmt config)
|
||||
- `cargo +nightly fmt --all` — auto-format
|
||||
- `cargo clippy --workspace -- -D warnings` — lint
|
||||
|
||||
macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)` / `EMFILE`, raise the shell's soft FD limit before running tests, for example `ulimit -n 4096 && cargo nextest run --workspace`. Some terminals and inherited agent sessions start with `ulimit -n 256`, which is too low for the shared CLI test daemon under parallel nextest load.
|
||||
|
||||
### TypeScript (fabro-web)
|
||||
- `cd apps/fabro-web && bun run dev` — start React dev server
|
||||
- `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
|
||||
|
|
@ -27,7 +30,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
|
||||
### 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` — starts the React dev server
|
||||
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:/docs -w /docs --name mintlify-dev node:22-slim \
|
||||
|
|
@ -40,7 +43,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
The OpenAPI spec at `docs/api-reference/fabro-api.yaml` is the source of truth for the fabro-api HTTP interface.
|
||||
|
||||
1. Edit `docs/api-reference/fabro-api.yaml`
|
||||
2. `cargo build -p fabro-api-types` — build.rs regenerates Rust types via typify
|
||||
2. `cargo build -p fabro-api` — build.rs regenerates Rust types and client via progenitor
|
||||
3. Write/update handler in `lib/crates/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
|
||||
|
|
@ -50,14 +53,13 @@ The OpenAPI spec at `docs/api-reference/fabro-api.yaml` is the source of truth f
|
|||
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.
|
||||
|
||||
### Rust crates (`lib/crates/`)
|
||||
- **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune`, `llm`
|
||||
- **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, retros, and human-in-the-loop interactions
|
||||
- **fabro-agent** — AI coding agent with tool use (Bash, Read, Write, Edit, Glob, Grep, WebFetch). `Sandbox` trait abstracts execution environments
|
||||
- **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-types** — Auto-generated Rust types from OpenAPI spec (build.rs + typify)
|
||||
- **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-db** — SQLite with WAL mode, schema migrations
|
||||
- **fabro-mcp** — Model Context Protocol client/server
|
||||
- **fabro-slack** — Slack integration (socket mode, blocks API)
|
||||
- **fabro-devcontainer** — Parses `.devcontainer/devcontainer.json` for container setup
|
||||
|
|
@ -72,7 +74,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
### Key design patterns
|
||||
- **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments
|
||||
- **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes
|
||||
- **OpenAPI-first** — `fabro-api.yaml` drives both Rust type generation (typify) and TypeScript client generation (openapi-generator)
|
||||
- **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
|
||||
|
||||
## Strategy docs
|
||||
|
|
@ -80,7 +82,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
When working on Rust crates, read the relevant strategy doc **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 `WorkflowRunEvent` variants, touching `EventEmitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types
|
||||
- **`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
|
||||
- **`files-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
|
||||
|
||||
## Shell quoting in sandbox code
|
||||
|
|
@ -105,6 +107,8 @@ Never run `cargo insta accept` without first checking what's pending — it acce
|
|||
|
||||
## Testing workflows
|
||||
|
||||
- `fabro run <name>` — run a workflow by name (resolves `fabro/workflows/<name>/workflow.toml`), e.g. `fabro run repl`
|
||||
- `fabro run <name>` — run a workflow by name (resolves `.fabro/workflows/<name>/workflow.toml`), e.g. `fabro run repl`
|
||||
- Use `--no-retro` to skip the retro step and finish faster
|
||||
- `#[e2e_test(twin, live("VAR"))]` — dual-mode test that runs against twin-openai or real API. `#[e2e_test(twin)]` for twin-only tests (e.g., scripted failures). `#[e2e_test(live("VAR"))]` for live-only tests requiring secrets. `#[e2e_test()]` for sandbox tests with no API deps. Behavior is controlled by `FABRO_TEST_MODE` (`live`, `strict`; default is `twin`), and `cargo nextest run --profile e2e ...` implies `strict`. Use `fabro_test::e2e_openai!()` in twin/dual-mode tests to get `(base_url, api_key)`.
|
||||
- Local test HTTP clients must use `.no_proxy()`. Prefer shared helpers like `fabro_test::test_http_client()` or crate-local equivalents instead of `reqwest::Client::new()`, bare `Client::builder().build()`, or `reqwest::get(...)`.
|
||||
- This is not cosmetic: macOS proxy discovery adds hidden startup overhead to repeated localhost reqwest clients and can surface as misleading nextest timeouts.
|
||||
|
|
|
|||
1247
Cargo.lock
generated
1247
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
19
Cargo.toml
19
Cargo.toml
|
|
@ -11,13 +11,15 @@ license = "MIT"
|
|||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
axum = { version = "0.8" }
|
||||
axum-extra = { version = "0.10", features = ["cookie-private"] }
|
||||
cookie = { version = "0.18", features = ["percent-encode", "private", "signed", "key-expansion"] }
|
||||
thiserror = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "query", "form", "multipart"] }
|
||||
ulid = "1"
|
||||
uuid = { version = "1", features = ["v4", "v7"] }
|
||||
uuid = { version = "1", features = ["v4", "v7", "v8"] }
|
||||
rand = "0.8"
|
||||
dotenvy = "0.15"
|
||||
futures = "0.3"
|
||||
|
|
@ -39,12 +41,11 @@ git2 = { version = "0.20", default-features = false }
|
|||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
tracing-appender = "0.2"
|
||||
rmcp = { version = "0.15.0", default-features = false }
|
||||
rmcp = { version = "1.3", default-features = false }
|
||||
walkdir = "2"
|
||||
regex = "1"
|
||||
semver = "1"
|
||||
aho-corasick = "1"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono"] }
|
||||
dirs = "6"
|
||||
mac_address = "1"
|
||||
md5 = "0.7"
|
||||
|
|
@ -52,6 +53,7 @@ mime_guess = "2"
|
|||
indicatif = "0.18"
|
||||
termimad = "0.34"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
jsonwebtoken = { version = "10", features = ["aws_lc_rs"] }
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
|
|
@ -68,10 +70,14 @@ sentry = { version = "0.35", default-features = false, features = ["backtrace",
|
|||
fork = "0.2"
|
||||
exec = "0.3"
|
||||
slatedb = "0.11.2"
|
||||
object_store = "0.12.5"
|
||||
object_store = { version = "0.12.5", features = ["aws"] }
|
||||
rust-embed = "8"
|
||||
percent-encoding = "2"
|
||||
minijinja = "2"
|
||||
fabro-http = { path = "lib/crates/fabro-http" }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "warn"
|
||||
unsafe_code = "deny"
|
||||
unreachable_pub = "warn"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
|
|
@ -94,6 +100,7 @@ print_stderr = "warn"
|
|||
dbg_macro = "warn"
|
||||
empty_drop = "warn"
|
||||
empty_structs_with_brackets = "warn"
|
||||
disallowed_methods = "deny"
|
||||
exit = "warn"
|
||||
get_unwrap = "warn"
|
||||
rc_buffer = "warn"
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
import { apiJson } from "./api-client";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("apiJson", () => {
|
||||
test("returns parsed JSON on 200", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response(JSON.stringify({ id: 1, name: "test" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}))
|
||||
);
|
||||
|
||||
const result = await apiJson<{ id: number; name: string }>("/items/1");
|
||||
|
||||
expect(result).toEqual({ id: 1, name: "test" });
|
||||
});
|
||||
|
||||
test("throws Response with status 404 and null body on not found", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(new Response("Not Found: /items/999", { status: 404 }))
|
||||
);
|
||||
|
||||
try {
|
||||
await apiJson("/items/999");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (thrown) {
|
||||
expect(thrown).toBeInstanceOf(Response);
|
||||
const res = thrown as Response;
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("throws Response with status 500 and null body, stripping sensitive details", async () => {
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
"Internal error: database connection string is postgres://admin:secret@db.internal:5432/prod",
|
||||
{ status: 500 }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
await apiJson("/items/1");
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (thrown) {
|
||||
expect(thrown).toBeInstanceOf(Response);
|
||||
const res = thrown as Response;
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import { importPKCS8, SignJWT } from "jose";
|
||||
import { getAppConfig } from "./lib/config.server";
|
||||
import { isDemoMode } from "./lib/demo-mode.server";
|
||||
import { getUser } from "./lib/session.server";
|
||||
|
||||
const FABRO_JWT_PRIVATE_KEY = process.env.FABRO_JWT_PRIVATE_KEY;
|
||||
|
||||
function decodePemEnv(value: string): string {
|
||||
if (value.startsWith("-----")) return value;
|
||||
return Buffer.from(value, "base64").toString("utf-8");
|
||||
}
|
||||
|
||||
let cachedKey: CryptoKey | null = null;
|
||||
|
||||
async function getSigningKey(): Promise<CryptoKey> {
|
||||
if (cachedKey) return cachedKey;
|
||||
if (!FABRO_JWT_PRIVATE_KEY) {
|
||||
throw new Error("FABRO_JWT_PRIVATE_KEY environment variable is not set");
|
||||
}
|
||||
cachedKey = await importPKCS8(decodePemEnv(FABRO_JWT_PRIVATE_KEY), "EdDSA");
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
async function signToken(sub?: string): Promise<string> {
|
||||
const key = await getSigningKey();
|
||||
return new SignJWT({ iss: "fabro-web", ...(sub ? { sub } : {}) })
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("30s")
|
||||
.sign(key);
|
||||
}
|
||||
|
||||
interface ApiOptions {
|
||||
init?: RequestInit;
|
||||
request?: Request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wrapper that signs requests with a JWT for service-to-service auth.
|
||||
* When a request is provided, the authenticated user's URL is included as
|
||||
* the JWT `sub` claim.
|
||||
*/
|
||||
export async function apiFetch(
|
||||
path: string,
|
||||
options?: ApiOptions
|
||||
): Promise<Response> {
|
||||
const { base_url } = getAppConfig().api;
|
||||
const { init, request } = options ?? {};
|
||||
|
||||
let sub: string | undefined;
|
||||
if (request) {
|
||||
const user = await getUser(request);
|
||||
sub = user?.userUrl;
|
||||
}
|
||||
|
||||
const headers = new Headers(init?.headers);
|
||||
if (FABRO_JWT_PRIVATE_KEY) {
|
||||
const token = await signToken(sub);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
if (request && isDemoMode(request)) {
|
||||
headers.set("X-Fabro-Demo", "1");
|
||||
}
|
||||
|
||||
const url = `${base_url}${path}`;
|
||||
try {
|
||||
return await fetch(url, { ...init, headers });
|
||||
} catch (cause) {
|
||||
throw new Error(`API request to ${url} failed`, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed JSON fetch helper. Calls apiFetch and parses the JSON response.
|
||||
*/
|
||||
export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T> {
|
||||
const res = await apiFetch(path, options);
|
||||
if (!res.ok) throw new Response(null, { status: res.status });
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
16
apps/fabro-web/app/api.test.ts
Normal file
16
apps/fabro-web/app/api.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { isNotImplemented } from "./api";
|
||||
|
||||
describe("isNotImplemented", () => {
|
||||
test("returns true for 501 status", () => {
|
||||
expect(isNotImplemented(501)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for 200 status", () => {
|
||||
expect(isNotImplemented(200)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for 404 status", () => {
|
||||
expect(isNotImplemented(404)).toBe(false);
|
||||
});
|
||||
});
|
||||
79
apps/fabro-web/app/api.ts
Normal file
79
apps/fabro-web/app/api.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
export interface ApiOptions {
|
||||
init?: RequestInit;
|
||||
request?: Request;
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, options?: ApiOptions): Promise<Response> {
|
||||
const { init } = options ?? {};
|
||||
const response = await fetch(`/api/v1${path}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
headers: init?.headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/login";
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function apiJson<T>(path: string, options?: ApiOptions): Promise<T> {
|
||||
const response = await apiFetch(path, options);
|
||||
if (!response.ok) {
|
||||
throw new Response(null, { status: response.status, statusText: response.statusText });
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function isNotImplemented(status: number): boolean {
|
||||
return status === 501;
|
||||
}
|
||||
|
||||
export async function apiJsonOrNull<T>(
|
||||
path: string,
|
||||
options?: ApiOptions,
|
||||
): Promise<T | null> {
|
||||
const response = await apiFetch(path, options);
|
||||
if (isNotImplemented(response.status)) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Response(null, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function getSetupStatus(): Promise<{ configured: boolean }> {
|
||||
const response = await fetch("/api/v1/setup/status", { credentials: "include" });
|
||||
if (!response.ok) {
|
||||
throw new Response(null, { status: response.status, statusText: response.statusText });
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getAuthMe(): Promise<{
|
||||
user: {
|
||||
login: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
userUrl: string;
|
||||
};
|
||||
provider: string;
|
||||
demoMode: boolean;
|
||||
features: { session_sandboxes: boolean; retros: boolean };
|
||||
}> {
|
||||
const response = await fetch("/api/v1/auth/me", { credentials: "include" });
|
||||
if (response.status === 401) {
|
||||
throw new Response(null, { status: 401, statusText: "Unauthorized" });
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Response(null, { status: response.status, statusText: response.statusText });
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { formatDurationSecs } from "../lib/format";
|
||||
|
||||
export type SmoothnessRating = "effortless" | "smooth" | "bumpy" | "struggled" | "failed";
|
||||
|
||||
type LearningCategory = "repo" | "code" | "workflow" | "tool";
|
||||
|
||||
export interface Learning {
|
||||
category: LearningCategory;
|
||||
text: string;
|
||||
}
|
||||
|
||||
type FrictionKind = "retry" | "timeout" | "wrong_approach" | "tool_failure" | "ambiguity";
|
||||
|
||||
export interface FrictionPoint {
|
||||
kind: FrictionKind;
|
||||
description: string;
|
||||
stage_id?: string;
|
||||
}
|
||||
|
||||
type OpenItemKind = "tech_debt" | "follow_up" | "investigation" | "test_gap";
|
||||
|
||||
export interface OpenItem {
|
||||
kind: OpenItemKind;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface StageRetro {
|
||||
stage_id: string;
|
||||
stage_label: string;
|
||||
status: string;
|
||||
duration_ms: number;
|
||||
retries: number;
|
||||
cost?: number;
|
||||
notes?: string;
|
||||
failure_reason?: string;
|
||||
files_touched: string[];
|
||||
}
|
||||
|
||||
export interface AggregateStats {
|
||||
total_duration_ms: number;
|
||||
total_cost?: number;
|
||||
total_retries: number;
|
||||
files_touched: string[];
|
||||
stages_completed: number;
|
||||
stages_failed: number;
|
||||
}
|
||||
|
||||
export interface Retro {
|
||||
run_id: string;
|
||||
workflow_name: string;
|
||||
goal: string;
|
||||
timestamp: string;
|
||||
smoothness?: SmoothnessRating;
|
||||
stages: StageRetro[];
|
||||
stats: AggregateStats;
|
||||
intent?: string;
|
||||
outcome?: string;
|
||||
learnings?: Learning[];
|
||||
friction_points?: FrictionPoint[];
|
||||
open_items?: OpenItem[];
|
||||
}
|
||||
|
||||
export const smoothnessConfig: Record<SmoothnessRating, { label: string; bg: string; text: string; dot: string }> = {
|
||||
effortless: { label: "Effortless", bg: "bg-emerald-500/15", text: "text-emerald-400", dot: "bg-emerald-400" },
|
||||
smooth: { label: "Smooth", bg: "bg-mint/15", text: "text-mint", dot: "bg-mint" },
|
||||
bumpy: { label: "Bumpy", bg: "bg-amber/15", text: "text-amber", dot: "bg-amber" },
|
||||
struggled: { label: "Struggled", bg: "bg-orange-500/15", text: "text-orange-400", dot: "bg-orange-400" },
|
||||
failed: { label: "Failed", bg: "bg-coral/15", text: "text-coral", dot: "bg-coral" },
|
||||
};
|
||||
|
||||
export const learningCategoryConfig: Record<LearningCategory, { label: string; text: string }> = {
|
||||
repo: { label: "Repo", text: "text-teal-400" },
|
||||
code: { label: "Code", text: "text-sky-400" },
|
||||
workflow: { label: "Workflow", text: "text-violet-400" },
|
||||
tool: { label: "Tool", text: "text-amber" },
|
||||
};
|
||||
|
||||
export const frictionKindConfig: Record<FrictionKind, { label: string; text: string }> = {
|
||||
retry: { label: "Retry", text: "text-amber" },
|
||||
timeout: { label: "Timeout", text: "text-coral" },
|
||||
wrong_approach: { label: "Wrong Approach", text: "text-orange-400" },
|
||||
tool_failure: { label: "Tool Failure", text: "text-coral" },
|
||||
ambiguity: { label: "Ambiguity", text: "text-violet-400" },
|
||||
};
|
||||
|
||||
export const openItemKindConfig: Record<OpenItemKind, { label: string; text: string }> = {
|
||||
tech_debt: { label: "Tech Debt", text: "text-orange-400" },
|
||||
follow_up: { label: "Follow-up", text: "text-teal-400" },
|
||||
investigation: { label: "Investigation", text: "text-sky-400" },
|
||||
test_gap: { label: "Test Gap", text: "text-coral" },
|
||||
};
|
||||
|
||||
function formatDurationMs(ms: number): string {
|
||||
return formatDurationSecs(Math.floor(ms / 1000));
|
||||
}
|
||||
|
||||
export { formatDurationMs };
|
||||
49
apps/fabro-web/app/data/runs.test.ts
Normal file
49
apps/fabro-web/app/data/runs.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { mapRunSummaryToRunItem } from "./runs";
|
||||
|
||||
describe("mapRunSummaryToRunItem", () => {
|
||||
test("maps store run summary to RunItem", () => {
|
||||
const summary = {
|
||||
run_id: "01ABC",
|
||||
goal: "Fix the build",
|
||||
workflow_slug: "fix_build",
|
||||
workflow_name: "Fix Build",
|
||||
host_repo_path: "/home/user/myrepo",
|
||||
status: "running",
|
||||
duration_ms: 65000,
|
||||
total_usd_micros: 500000,
|
||||
labels: {},
|
||||
start_time: "2026-04-08T12:00:00Z",
|
||||
status_reason: null,
|
||||
pending_control: null,
|
||||
};
|
||||
const item = mapRunSummaryToRunItem(summary);
|
||||
expect(item.id).toBe("01ABC");
|
||||
expect(item.title).toBe("Fix the build");
|
||||
expect(item.workflow).toBe("fix_build");
|
||||
expect(item.repo).toBe("myrepo");
|
||||
expect(item.elapsed).toBeDefined();
|
||||
});
|
||||
|
||||
test("handles missing optional fields", () => {
|
||||
const summary = {
|
||||
run_id: "01DEF",
|
||||
goal: null,
|
||||
workflow_slug: null,
|
||||
workflow_name: null,
|
||||
host_repo_path: null,
|
||||
status: "submitted",
|
||||
duration_ms: null,
|
||||
total_usd_micros: null,
|
||||
labels: {},
|
||||
start_time: null,
|
||||
status_reason: null,
|
||||
pending_control: null,
|
||||
};
|
||||
const item = mapRunSummaryToRunItem(summary);
|
||||
expect(item.id).toBe("01DEF");
|
||||
expect(item.title).toBe("Untitled run");
|
||||
expect(item.workflow).toBe("unknown");
|
||||
expect(item.repo).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
|
@ -66,6 +66,36 @@ export function mapRunListItem(item: RunListItem): RunItem {
|
|||
};
|
||||
}
|
||||
|
||||
export interface RunSummaryResponse {
|
||||
run_id: string;
|
||||
goal: string | null;
|
||||
workflow_slug: string | null;
|
||||
workflow_name: string | null;
|
||||
host_repo_path: string | null;
|
||||
status: string | null;
|
||||
status_reason: string | null;
|
||||
pending_control: string | null;
|
||||
duration_ms: number | null;
|
||||
total_usd_micros: number | null;
|
||||
labels: Record<string, string>;
|
||||
start_time: string | null;
|
||||
}
|
||||
|
||||
export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem {
|
||||
const repoPath = summary.host_repo_path ?? "";
|
||||
const repoName = repoPath.split("/").pop() || "unknown";
|
||||
return {
|
||||
id: summary.run_id,
|
||||
repo: repoName,
|
||||
title: summary.goal ?? "Untitled run",
|
||||
workflow: summary.workflow_slug ?? "unknown",
|
||||
elapsed:
|
||||
summary.duration_ms != null
|
||||
? formatElapsedSecs(summary.duration_ms / 1000)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveCiStatus(checks: CheckRun[]): CiStatus {
|
||||
if (checks.some((c) => c.status === "failure")) return "failing";
|
||||
if (checks.some((c) => c.status === "pending" || c.status === "queued")) return "pending";
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
export type VerificationResult = "pass" | "fail" | "skip" | "na";
|
||||
|
||||
export type VerificationType = "ai" | "automated" | "analysis" | "ai-analysis";
|
||||
|
||||
export interface Criterion {
|
||||
name: string;
|
||||
description: string;
|
||||
type: VerificationType | null;
|
||||
status: VerificationResult;
|
||||
}
|
||||
|
||||
export interface VerificationCategory {
|
||||
name: string;
|
||||
question: string;
|
||||
status: VerificationResult;
|
||||
criteria: Criterion[];
|
||||
}
|
||||
|
||||
export const statusConfig = {
|
||||
pass: {
|
||||
label: "Pass",
|
||||
color: "text-mint",
|
||||
bg: "bg-mint/15",
|
||||
dot: "bg-mint",
|
||||
border: "border-l-mint/50",
|
||||
},
|
||||
fail: {
|
||||
label: "Fail",
|
||||
color: "text-coral",
|
||||
bg: "bg-coral/15",
|
||||
dot: "bg-coral",
|
||||
border: "border-l-coral/50",
|
||||
},
|
||||
skip: {
|
||||
label: "Skip",
|
||||
color: "text-fg-muted",
|
||||
bg: "bg-overlay",
|
||||
dot: "bg-fg-muted",
|
||||
border: "border-l-fg-muted/50",
|
||||
},
|
||||
na: {
|
||||
label: "N/A",
|
||||
color: "text-fg-muted",
|
||||
bg: "bg-overlay",
|
||||
dot: "bg-fg-muted",
|
||||
border: "border-l-fg-muted/50",
|
||||
},
|
||||
} as const satisfies Record<
|
||||
VerificationResult,
|
||||
{ label: string; color: string; bg: string; dot: string; border: string }
|
||||
>;
|
||||
|
||||
export const typeConfig = {
|
||||
ai: { label: "AI", color: "text-teal-300", bg: "bg-teal-500/10" },
|
||||
automated: { label: "Automated", color: "text-mint", bg: "bg-mint/10" },
|
||||
analysis: { label: "Analysis", color: "text-amber", bg: "bg-amber/10" },
|
||||
"ai-analysis": { label: "AI + Analysis", color: "text-teal-300", bg: "bg-teal-500/10" },
|
||||
} as const satisfies Record<
|
||||
VerificationType,
|
||||
{ label: string; color: string; bg: string }
|
||||
>;
|
||||
|
||||
export type VerificationMode = "active" | "evaluate" | "disabled";
|
||||
|
||||
export interface CriterionPerformance {
|
||||
f1: number | null;
|
||||
passAt1: number | null;
|
||||
mode: VerificationMode;
|
||||
evaluations: VerificationResult[];
|
||||
}
|
||||
|
||||
export const modeConfig = {
|
||||
active: { label: "Active", color: "text-mint", bg: "bg-mint/10" },
|
||||
evaluate: { label: "Evaluate", color: "text-amber", bg: "bg-amber/10" },
|
||||
disabled: { label: "Disabled", color: "text-fg-muted", bg: "bg-overlay" },
|
||||
} as const satisfies Record<
|
||||
VerificationMode,
|
||||
{ label: string; color: string; bg: string }
|
||||
>;
|
||||
|
||||
export function getCriteriaSummary(criteria: readonly Criterion[]) {
|
||||
return {
|
||||
passing: criteria.filter((c) => c.status === "pass").length,
|
||||
failing: criteria.filter((c) => c.status === "fail").length,
|
||||
na: criteria.filter((c) => c.status === "na").length,
|
||||
total: criteria.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
17
apps/fabro-web/app/entry.tsx
Normal file
17
apps/fabro-web/app/entry.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { routes } from "./router";
|
||||
|
||||
const router = createBrowserRouter(routes);
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("Missing #root element");
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
20
apps/fabro-web/app/layouts/app-shell.test.tsx
Normal file
20
apps/fabro-web/app/layouts/app-shell.test.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { getVisibleNavigation } from "./app-shell";
|
||||
|
||||
describe("getVisibleNavigation", () => {
|
||||
test("shows all nav items in demo mode", () => {
|
||||
const items = getVisibleNavigation(true);
|
||||
const names = items.map((i) => i.name);
|
||||
expect(names).toContain("Workflows");
|
||||
expect(names).toContain("Runs");
|
||||
expect(names).toContain("Insights");
|
||||
});
|
||||
|
||||
test("hides Workflows and Insights in production mode", () => {
|
||||
const items = getVisibleNavigation(false);
|
||||
const names = items.map((i) => i.name);
|
||||
expect(names).not.toContain("Workflows");
|
||||
expect(names).not.toContain("Insights");
|
||||
expect(names).toContain("Runs");
|
||||
});
|
||||
});
|
||||
|
|
@ -11,75 +11,42 @@ import {
|
|||
Bars3Icon,
|
||||
BeakerIcon,
|
||||
ChartBarIcon,
|
||||
CheckBadgeIcon,
|
||||
Cog6ToothIcon,
|
||||
LightBulbIcon,
|
||||
MoonIcon,
|
||||
PlayIcon,
|
||||
RectangleStackIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Form, Link, Outlet, redirect, useLocation, useMatches } from "react-router";
|
||||
import { Form, Link, Outlet, redirect, useLocation, useMatches, useRevalidator } from "react-router";
|
||||
import { getAuthMe } from "../api";
|
||||
import { DemoModeProvider } from "../lib/demo-mode";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { isDemoMode, demoCookieHeader } from "../lib/demo-mode.server";
|
||||
import { isGitHubAppConfigured } from "../lib/github.server";
|
||||
import { requireUser } from "../lib/session.server";
|
||||
import type { Route } from "./+types/app-shell";
|
||||
|
||||
const DEMO_USER = {
|
||||
userUrl: "",
|
||||
login: "demo",
|
||||
name: "Demo User",
|
||||
email: "demo@example.com",
|
||||
avatarUrl: "https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png",
|
||||
};
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const config = getAppConfig();
|
||||
const { provider } = config.web.auth;
|
||||
const demoMode = isDemoMode(request);
|
||||
if (provider === "insecure_disabled") {
|
||||
return { user: DEMO_USER, demoMode, features: config.features };
|
||||
}
|
||||
if (provider === "github" && !isGitHubAppConfigured()) {
|
||||
throw redirect("/setup");
|
||||
}
|
||||
const user = await requireUser(request);
|
||||
return { user, provider, demoMode, features: config.features };
|
||||
export async function loader() {
|
||||
return getAuthMe();
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const form = await request.formData();
|
||||
if (form.get("intent") === "toggle-demo") {
|
||||
const enabled = !isDemoMode(request);
|
||||
const referer = request.headers.get("Referer") ?? "/start";
|
||||
const url = new URL(referer);
|
||||
return redirect(url.pathname + url.search, {
|
||||
headers: { "Set-Cookie": demoCookieHeader(enabled) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon },
|
||||
{ name: "Runs", href: "/runs", icon: PlayIcon },
|
||||
{ name: "Verification", href: "/verification/criteria", icon: CheckBadgeIcon },
|
||||
{ name: "Retros", href: "/retros", icon: LightBulbIcon },
|
||||
{ name: "Insights", href: "/insights", icon: ChartBarIcon },
|
||||
const allNavigation = [
|
||||
{ name: "Workflows", href: "/workflows", icon: RectangleStackIcon, demoOnly: true },
|
||||
{ name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false },
|
||||
{ name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true },
|
||||
];
|
||||
|
||||
export function getVisibleNavigation(demoMode: boolean) {
|
||||
return allNavigation.filter((item) => !item.demoOnly || demoMode);
|
||||
}
|
||||
|
||||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export default function AppShell({ loaderData }: Route.ComponentProps) {
|
||||
export default function AppShell({ loaderData }: any) {
|
||||
const { user, provider, demoMode } = loaderData;
|
||||
const { pathname } = useLocation();
|
||||
const matches = useMatches();
|
||||
const revalidator = useRevalidator();
|
||||
const { theme, toggle } = useTheme();
|
||||
const navigation = getVisibleNavigation(demoMode);
|
||||
const currentNav = navigation.find((item) => pathname.startsWith(item.href));
|
||||
const title = currentNav?.name ?? "";
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
|
|
@ -91,7 +58,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
const ThemeIcon = theme === "dark" ? SunIcon : MoonIcon;
|
||||
|
||||
async function toggleDemoMode() {
|
||||
await fetch("/api/v1/demo/toggle", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !demoMode }),
|
||||
});
|
||||
revalidator.revalidate();
|
||||
}
|
||||
|
||||
return (
|
||||
<DemoModeProvider value={demoMode}>
|
||||
<div className="min-h-full">
|
||||
<Disclosure as="nav" className="bg-panel/50">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
|
|
@ -128,20 +106,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-4 flex items-center gap-3 md:ml-6">
|
||||
<Form method="POST" className="flex">
|
||||
<input type="hidden" name="intent" value="toggle-demo" />
|
||||
<button
|
||||
type="submit"
|
||||
className={classNames(
|
||||
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
|
||||
demoMode ? "text-teal-500" : "text-fg-muted",
|
||||
)}
|
||||
title={demoMode ? "Switch to live data" : "Switch to demo data"}
|
||||
>
|
||||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
</Form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDemoMode}
|
||||
className={classNames(
|
||||
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
|
||||
demoMode ? "text-teal-500" : "text-fg-muted",
|
||||
)}
|
||||
title={demoMode ? "Switch to live data" : "Switch to demo data"}
|
||||
>
|
||||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
|
|
@ -151,35 +127,33 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
|
|||
<ThemeIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
{provider !== "tailscale" && (
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="relative flex max-w-xs items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
alt=""
|
||||
src={user.avatarUrl}
|
||||
className="size-8 rounded-full outline -outline-offset-1 outline-line-strong"
|
||||
/>
|
||||
</MenuButton>
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="relative flex max-w-xs items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500">
|
||||
<span className="absolute -inset-1.5" />
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<img
|
||||
alt=""
|
||||
src={user.avatarUrl}
|
||||
className="size-8 rounded-full outline -outline-offset-1 outline-line-strong"
|
||||
/>
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<Form method="POST" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</Form>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
)}
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-panel py-1 outline-1 -outline-offset-1 outline-line-strong transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem>
|
||||
<Form method="POST" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
className="block w-full px-4 py-2 text-left text-sm text-fg-3 data-focus:bg-overlay data-focus:outline-hidden"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</Form>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mr-2 flex md:hidden">
|
||||
|
|
@ -240,20 +214,18 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Form method="POST" className="flex">
|
||||
<input type="hidden" name="intent" value="toggle-demo" />
|
||||
<button
|
||||
type="submit"
|
||||
className={classNames(
|
||||
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
|
||||
demoMode ? "text-teal-500" : "text-fg-muted",
|
||||
)}
|
||||
title={demoMode ? "Switch to live data" : "Switch to demo data"}
|
||||
>
|
||||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
</Form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDemoMode}
|
||||
className={classNames(
|
||||
"rounded-full p-1.5 transition-colors hover:bg-overlay hover:text-fg",
|
||||
demoMode ? "text-teal-500" : "text-fg-muted",
|
||||
)}
|
||||
title={demoMode ? "Switch to live data" : "Switch to demo data"}
|
||||
>
|
||||
<BeakerIcon className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle demo mode</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
|
|
@ -298,5 +270,6 @@ export default function AppShell({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</DemoModeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +0,0 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "smol-toml";
|
||||
|
||||
interface AuthConfig {
|
||||
provider: "github" | "tailscale" | "insecure_disabled";
|
||||
allowed_usernames: string[];
|
||||
}
|
||||
|
||||
interface ApiConfig {
|
||||
base_url: string;
|
||||
authentication_strategy: "jwt" | "insecure_disabled";
|
||||
}
|
||||
|
||||
interface GitConfig {
|
||||
provider: "github";
|
||||
app_id: string | null;
|
||||
client_id: string | null;
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
interface Features {
|
||||
session_sandboxes: boolean;
|
||||
retros: boolean;
|
||||
}
|
||||
|
||||
interface WebConfig {
|
||||
url: string;
|
||||
auth: AuthConfig;
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
web: WebConfig;
|
||||
api: ApiConfig;
|
||||
git: GitConfig;
|
||||
features: Features;
|
||||
}
|
||||
|
||||
const AUTH_DEFAULTS: AuthConfig = {
|
||||
provider: "github",
|
||||
allowed_usernames: [],
|
||||
};
|
||||
|
||||
const WEB_DEFAULTS: WebConfig = {
|
||||
url: "http://localhost:5173",
|
||||
auth: AUTH_DEFAULTS,
|
||||
};
|
||||
|
||||
const API_DEFAULTS: ApiConfig = {
|
||||
base_url: "http://localhost:3000/api/v1",
|
||||
authentication_strategy: "jwt",
|
||||
};
|
||||
|
||||
const GIT_DEFAULTS: GitConfig = {
|
||||
provider: "github",
|
||||
app_id: null,
|
||||
client_id: null,
|
||||
slug: null,
|
||||
};
|
||||
|
||||
const FEATURES_DEFAULTS: Features = {
|
||||
session_sandboxes: false,
|
||||
retros: false,
|
||||
};
|
||||
|
||||
export const FABRO_CONFIG_PATH = join(homedir(), ".fabro", "server.toml");
|
||||
|
||||
function loadAppConfig(): AppConfig {
|
||||
const configPath = FABRO_CONFIG_PATH;
|
||||
|
||||
let raw: Record<string, unknown> = {};
|
||||
try {
|
||||
raw = parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
// File doesn't exist or is unreadable — use defaults
|
||||
}
|
||||
|
||||
const rawWeb = (raw.web ?? {}) as Record<string, unknown>;
|
||||
const rawWebAuth = (rawWeb.auth ?? {}) as Partial<AuthConfig>;
|
||||
const rawApi = (raw.api ?? {}) as Partial<ApiConfig>;
|
||||
const rawGit = (raw.git ?? {}) as Partial<GitConfig>;
|
||||
const rawFeatures = (raw.features ?? {}) as Partial<Features>;
|
||||
|
||||
const demo = process.env.FABRO_DEMO === "1";
|
||||
|
||||
return {
|
||||
web: {
|
||||
...WEB_DEFAULTS,
|
||||
url: (rawWeb.url as string) ?? WEB_DEFAULTS.url,
|
||||
auth: demo
|
||||
? { provider: "insecure_disabled", allowed_usernames: [] }
|
||||
: { ...AUTH_DEFAULTS, ...rawWebAuth },
|
||||
},
|
||||
api: demo
|
||||
? { ...API_DEFAULTS, ...rawApi, authentication_strategy: "insecure_disabled" }
|
||||
: { ...API_DEFAULTS, ...rawApi },
|
||||
git: { ...GIT_DEFAULTS, ...rawGit },
|
||||
features: { ...FEATURES_DEFAULTS, ...rawFeatures },
|
||||
};
|
||||
}
|
||||
|
||||
/** Loaded once at module init; call reloadAppConfig() to pick up changes. */
|
||||
let appConfig: AppConfig = loadAppConfig();
|
||||
|
||||
export function getAppConfig(): AppConfig {
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
export function reloadAppConfig(): void {
|
||||
appConfig = loadAppConfig();
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDatabase(): Database.Database {
|
||||
if (db) return db;
|
||||
|
||||
const fabroDir = path.join(os.homedir(), ".fabro");
|
||||
fs.mkdirSync(fabroDir, { recursive: true });
|
||||
|
||||
db = new Database(path.join(fabroDir, "fabro-web.db"));
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS web_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_url TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
expires_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_web_sessions_user_url ON web_sessions (user_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at ON web_sessions (expires_at);
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
const COOKIE_NAME = "fabro-demo";
|
||||
|
||||
/** Check whether demo mode is active for this request (cookie, then env var fallback). */
|
||||
export function isDemoMode(request: Request): boolean {
|
||||
const cookies = request.headers.get("Cookie") ?? "";
|
||||
const match = cookies.match(/(?:^|;\s*)fabro-demo=([^;]*)/);
|
||||
if (match) return match[1] === "1";
|
||||
return process.env.FABRO_DEMO === "1";
|
||||
}
|
||||
|
||||
/** Build a Set-Cookie header value to persist the demo mode preference. */
|
||||
export function demoCookieHeader(enabled: boolean): string {
|
||||
const value = enabled ? "1" : "0";
|
||||
return `${COOKIE_NAME}=${value}; Path=/; SameSite=Lax; Max-Age=${60 * 60 * 24 * 365}`;
|
||||
}
|
||||
29
apps/fabro-web/app/lib/demo-mode.test.tsx
Normal file
29
apps/fabro-web/app/lib/demo-mode.test.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { DemoModeProvider, useDemoMode } from "./demo-mode";
|
||||
|
||||
function TestConsumer() {
|
||||
const demoMode = useDemoMode();
|
||||
return <span data-demo={demoMode}>{demoMode ? "demo" : "prod"}</span>;
|
||||
}
|
||||
|
||||
describe("DemoModeProvider", () => {
|
||||
test("provides demo mode value to children", () => {
|
||||
const html = renderToString(
|
||||
<DemoModeProvider value={true}>
|
||||
<TestConsumer />
|
||||
</DemoModeProvider>,
|
||||
);
|
||||
expect(html).toContain("demo");
|
||||
expect(html).toContain('data-demo="true"');
|
||||
});
|
||||
|
||||
test("defaults to false", () => {
|
||||
const html = renderToString(
|
||||
<DemoModeProvider value={false}>
|
||||
<TestConsumer />
|
||||
</DemoModeProvider>,
|
||||
);
|
||||
expect(html).toContain("prod");
|
||||
});
|
||||
});
|
||||
21
apps/fabro-web/app/lib/demo-mode.tsx
Normal file
21
apps/fabro-web/app/lib/demo-mode.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createContext, useContext } from "react";
|
||||
|
||||
const DemoModeContext = createContext(false);
|
||||
|
||||
export function DemoModeProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DemoModeContext.Provider value={value}>
|
||||
{children}
|
||||
</DemoModeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDemoMode(): boolean {
|
||||
return useContext(DemoModeContext);
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { GitHub, generateState } from "arctic";
|
||||
import { getAppConfig } from "./config.server";
|
||||
|
||||
export { generateState };
|
||||
|
||||
export function getGitHubOAuth() {
|
||||
const clientId = getAppConfig().git.client_id;
|
||||
const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error("GitHub App is not configured");
|
||||
}
|
||||
return new GitHub(clientId, clientSecret, null);
|
||||
}
|
||||
|
||||
export function isGitHubAppConfigured(): boolean {
|
||||
return getAppConfig().git.client_id !== null;
|
||||
}
|
||||
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { mergeEnv } from "./merge-env";
|
||||
|
||||
describe("mergeEnv", () => {
|
||||
test("replaces existing key with new value", () => {
|
||||
const result = mergeEnv(
|
||||
"FOO=old\nBAR=keep\n",
|
||||
new Map([["FOO", "new"]]),
|
||||
);
|
||||
expect(result).toContain("FOO=new");
|
||||
expect(result).toContain("BAR=keep");
|
||||
});
|
||||
|
||||
test("preserves comments, blank lines, and unrelated vars", () => {
|
||||
const existing = "# A comment\n\nFOO=old\n# Another\nBAR=keep\n";
|
||||
const result = mergeEnv(existing, new Map([["FOO", "new"]]));
|
||||
expect(result).toContain("# A comment");
|
||||
expect(result).toContain("# Another");
|
||||
expect(result).toContain("FOO=new");
|
||||
expect(result).toContain("BAR=keep");
|
||||
});
|
||||
|
||||
test("appends keys not already present", () => {
|
||||
const result = mergeEnv(
|
||||
"FOO=old\n",
|
||||
new Map([
|
||||
["FOO", "new"],
|
||||
["BAZ", "added"],
|
||||
]),
|
||||
);
|
||||
expect(result).toContain("FOO=new");
|
||||
expect(result).toContain("BAZ=added");
|
||||
});
|
||||
|
||||
test("handles export prefix", () => {
|
||||
const result = mergeEnv(
|
||||
"export FOO=old\nexport BAR=keep\n",
|
||||
new Map([["FOO", "new"]]),
|
||||
);
|
||||
expect(result).toContain("export FOO=new");
|
||||
expect(result).toContain("export BAR=keep");
|
||||
});
|
||||
|
||||
test("idempotent: merging twice produces same result", () => {
|
||||
const vars = new Map([
|
||||
["FOO", "new"],
|
||||
["BAZ", "added"],
|
||||
]);
|
||||
const first = mergeEnv("FOO=old\nBAR=keep\n", vars);
|
||||
const second = mergeEnv(first, vars);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
test("full scenario matches expected output", () => {
|
||||
const result = mergeEnv(
|
||||
"FOO=old\nBAR=keep",
|
||||
new Map([
|
||||
["FOO", "new"],
|
||||
["BAZ", "added"],
|
||||
]),
|
||||
);
|
||||
expect(result).toBe("FOO=new\nBAR=keep\nBAZ=added\n");
|
||||
});
|
||||
|
||||
test("empty existing string", () => {
|
||||
const result = mergeEnv(
|
||||
"",
|
||||
new Map([
|
||||
["FOO", "bar"],
|
||||
["BAZ", "qux"],
|
||||
]),
|
||||
);
|
||||
expect(result).toContain("FOO=bar");
|
||||
expect(result).toContain("BAZ=qux");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
/**
|
||||
* Merge new key=value pairs into an existing .env file string.
|
||||
* - Replaces lines whose key matches (handles optional `export ` prefix)
|
||||
* - Preserves comments, blank lines, and unrelated variables
|
||||
* - Appends keys not already present
|
||||
*/
|
||||
export function mergeEnv(
|
||||
existing: string,
|
||||
newVars: Map<string, string>,
|
||||
): string {
|
||||
const handledKeys = new Set<string>();
|
||||
const resultLines: string[] = [];
|
||||
|
||||
for (const line of existing.split("\n")) {
|
||||
const eqPos = line.indexOf("=");
|
||||
if (eqPos !== -1) {
|
||||
let rawKey = line.slice(0, eqPos).trim();
|
||||
const hasExport = rawKey.startsWith("export ");
|
||||
if (hasExport) {
|
||||
rawKey = rawKey.slice("export ".length).trim();
|
||||
}
|
||||
if (rawKey.length > 0 && !rawKey.startsWith("#")) {
|
||||
const newVal = newVars.get(rawKey);
|
||||
if (newVal !== undefined) {
|
||||
const prefix = hasExport ? "export " : "";
|
||||
resultLines.push(`${prefix}${rawKey}=${newVal}`);
|
||||
handledKeys.add(rawKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
resultLines.push(line);
|
||||
}
|
||||
|
||||
for (const [key, val] of newVars) {
|
||||
if (!handledKeys.has(key)) {
|
||||
resultLines.push(`${key}=${val}`);
|
||||
}
|
||||
}
|
||||
|
||||
let result = resultLines.join("\n");
|
||||
if (!result.endsWith("\n")) {
|
||||
result += "\n";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { createSessionStorage } from "react-router";
|
||||
import crypto from "node:crypto";
|
||||
import { getDatabase } from "./db.server";
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
user_url: string;
|
||||
data: string;
|
||||
expires_at: number | null;
|
||||
}
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function cleanupExpiredSessions() {
|
||||
const db = getDatabase();
|
||||
db.prepare("DELETE FROM web_sessions WHERE expires_at IS NOT NULL AND expires_at < ?").run(
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export function createSqliteSessionStorage(secret: string) {
|
||||
return createSessionStorage({
|
||||
cookie: {
|
||||
name: "__fabro_session",
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secrets: [secret],
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days in seconds
|
||||
},
|
||||
async createData(data, expiresAt) {
|
||||
const db = getDatabase();
|
||||
const id = crypto.randomUUID();
|
||||
const userUrl = (data.userUrl as string) ?? "";
|
||||
const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS;
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO web_sessions (id, user_url, data, expires_at) VALUES (?, ?, ?, ?)",
|
||||
).run(id, userUrl, JSON.stringify(data), expiresAtMs);
|
||||
|
||||
// Probabilistic cleanup (~1% of creates)
|
||||
if (Math.random() < 0.01) {
|
||||
cleanupExpiredSessions();
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
async readData(id) {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.prepare("SELECT * FROM web_sessions WHERE id = ?")
|
||||
.get(id) as SessionRow | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
if (row.expires_at && row.expires_at < Date.now()) {
|
||||
db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(row.data);
|
||||
},
|
||||
async updateData(id, data, expiresAt) {
|
||||
const db = getDatabase();
|
||||
const userUrl = (data.userUrl as string) ?? "";
|
||||
const expiresAtMs = expiresAt ? expiresAt.getTime() : Date.now() + THIRTY_DAYS_MS;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE web_sessions SET user_url = ?, data = ?, expires_at = ? WHERE id = ?",
|
||||
).run(userUrl, JSON.stringify(data), expiresAtMs, id);
|
||||
},
|
||||
async deleteData(id) {
|
||||
const db = getDatabase();
|
||||
db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
|
||||
// --- Mocks (must be set up before importing module under test) ---
|
||||
|
||||
let testAuthConfig = { provider: "github" as string, allowed_usernames: [] as string[] };
|
||||
|
||||
mock.module("./config.server", () => ({
|
||||
getAppConfig: () => ({ web: { auth: testAuthConfig } }),
|
||||
reloadAppConfig: () => {},
|
||||
FABRO_CONFIG_PATH: "/tmp/test.toml",
|
||||
}));
|
||||
|
||||
let sessionData: Record<string, unknown> = {};
|
||||
|
||||
mock.module("./session-storage.server", () => ({
|
||||
createSqliteSessionStorage: () => ({
|
||||
getSession: async () => ({
|
||||
get: (key: string) => sessionData[key],
|
||||
}),
|
||||
commitSession: async () => "",
|
||||
destroySession: async () => "",
|
||||
}),
|
||||
}));
|
||||
|
||||
process.env.SESSION_SECRET = "test-secret";
|
||||
|
||||
const { getUser } = await import("./session.server");
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe("getUser", () => {
|
||||
beforeEach(() => {
|
||||
sessionData = {};
|
||||
testAuthConfig = { provider: "github", allowed_usernames: [] };
|
||||
});
|
||||
|
||||
describe("tailscale provider", () => {
|
||||
beforeEach(() => {
|
||||
testAuthConfig = { provider: "tailscale", allowed_usernames: ["user@example.com"] };
|
||||
});
|
||||
|
||||
test("returns user from headers when login is in allowed_usernames", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: {
|
||||
"Tailscale-User-Login": "user@example.com",
|
||||
"Tailscale-User-Name": "Test User",
|
||||
"Tailscale-User-Profile-Pic": "https://example.com/pic.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toEqual({
|
||||
userUrl: "tailscale:user@example.com",
|
||||
login: "user@example.com",
|
||||
name: "Test User",
|
||||
email: "user@example.com",
|
||||
avatarUrl: "https://example.com/pic.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when Tailscale-User-Login header is missing", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when login is not in allowed_usernames", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: {
|
||||
"Tailscale-User-Login": "stranger@example.com",
|
||||
"Tailscale-User-Name": "Stranger",
|
||||
},
|
||||
});
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("github provider", () => {
|
||||
beforeEach(() => {
|
||||
testAuthConfig = { provider: "github", allowed_usernames: [] };
|
||||
});
|
||||
|
||||
test("returns user from session", async () => {
|
||||
sessionData = {
|
||||
userUrl: "https://github.com/octocat",
|
||||
login: "octocat",
|
||||
name: "Octocat",
|
||||
email: "octocat@github.com",
|
||||
avatarUrl: "https://github.com/octocat.png",
|
||||
};
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toEqual({
|
||||
userUrl: "https://github.com/octocat",
|
||||
login: "octocat",
|
||||
name: "Octocat",
|
||||
email: "octocat@github.com",
|
||||
avatarUrl: "https://github.com/octocat.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when session is empty", async () => {
|
||||
sessionData = {};
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const user = await getUser(request);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getAppConfig } from "./config.server";
|
||||
import { createSqliteSessionStorage } from "./session-storage.server";
|
||||
|
||||
interface SessionData {
|
||||
userUrl: string;
|
||||
githubId: number;
|
||||
githubNodeId: string;
|
||||
login: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
function getSessionStorage() {
|
||||
const secret = process.env.SESSION_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("SESSION_SECRET is not set");
|
||||
}
|
||||
return createSqliteSessionStorage(secret);
|
||||
}
|
||||
|
||||
export async function getSession(request: Request) {
|
||||
const storage = getSessionStorage();
|
||||
return storage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
const storage = getSessionStorage();
|
||||
return storage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
const storage = getSessionStorage();
|
||||
return storage.destroySession(session);
|
||||
}
|
||||
|
||||
export async function getUser(request: Request) {
|
||||
const { provider, allowed_usernames } = getAppConfig().web.auth;
|
||||
|
||||
if (provider === "tailscale") {
|
||||
const login = request.headers.get("Tailscale-User-Login");
|
||||
if (!login || !allowed_usernames.includes(login)) return null;
|
||||
return {
|
||||
userUrl: `tailscale:${login}`,
|
||||
login,
|
||||
name: request.headers.get("Tailscale-User-Name") ?? login,
|
||||
email: login,
|
||||
avatarUrl: request.headers.get("Tailscale-User-Profile-Pic") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const session = await getSession(request);
|
||||
const login = session.get("login");
|
||||
if (!login) return null;
|
||||
return {
|
||||
userUrl: session.get("userUrl") ?? "",
|
||||
login,
|
||||
name: session.get("name") ?? login,
|
||||
email: session.get("email") ?? "",
|
||||
avatarUrl: session.get("avatarUrl") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireUser(request: Request) {
|
||||
const user = await getUser(request);
|
||||
if (!user) throw redirect("/auth/login");
|
||||
return user;
|
||||
}
|
||||
24
apps/fabro-web/app/lib/theme-selection.test.ts
Normal file
24
apps/fabro-web/app/lib/theme-selection.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { buildThemeBootScript, resolveTheme } from "./theme-selection";
|
||||
|
||||
describe("resolveTheme", () => {
|
||||
test("defaults to dark when no saved theme exists", () => {
|
||||
expect(resolveTheme(null)).toBe("dark");
|
||||
expect(resolveTheme("system")).toBe("dark");
|
||||
});
|
||||
|
||||
test("uses the saved theme when it is valid", () => {
|
||||
expect(resolveTheme("light")).toBe("light");
|
||||
expect(resolveTheme("dark")).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildThemeBootScript", () => {
|
||||
test("defaults the boot script to dark without using system preference", () => {
|
||||
const script = buildThemeBootScript();
|
||||
|
||||
expect(script).toContain('localStorage.getItem("fabro-theme")');
|
||||
expect(script).toContain('t="dark"');
|
||||
expect(script).not.toContain("matchMedia");
|
||||
});
|
||||
});
|
||||
16
apps/fabro-web/app/lib/theme-selection.ts
Normal file
16
apps/fabro-web/app/lib/theme-selection.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
type Theme = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "fabro-theme";
|
||||
|
||||
function resolveTheme(storedTheme: string | null): Theme {
|
||||
return storedTheme === "light" || storedTheme === "dark"
|
||||
? storedTheme
|
||||
: "dark";
|
||||
}
|
||||
|
||||
function buildThemeBootScript(storageKey = STORAGE_KEY) {
|
||||
return `(function(){try{var t=localStorage.getItem("${storageKey}");if(t!=="light"&&t!=="dark")t="dark";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()`;
|
||||
}
|
||||
|
||||
export { STORAGE_KEY, buildThemeBootScript, resolveTheme };
|
||||
export type { Theme };
|
||||
|
|
@ -1,16 +1,10 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "fabro-theme";
|
||||
import { STORAGE_KEY, resolveTheme } from "./theme-selection";
|
||||
import type { Theme } from "./theme-selection";
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
return resolveTheme(localStorage.getItem(STORAGE_KEY));
|
||||
}
|
||||
|
||||
function applyThemeClass(theme: Theme) {
|
||||
|
|
|
|||
|
|
@ -22,46 +22,3 @@ export function timeUntil(iso: string): string {
|
|||
return relativeTime(Math.floor((new Date(iso).getTime() - Date.now()) / 1000), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a human-readable date label for grouping (e.g. "Today", "Yesterday", "Previous 7 days").
|
||||
*/
|
||||
function dateLabel(iso: string): string {
|
||||
const now = new Date();
|
||||
const date = new Date(iso);
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const startOfYesterday = new Date(startOfToday.getTime() - 86_400_000);
|
||||
const startOf7DaysAgo = new Date(startOfToday.getTime() - 7 * 86_400_000);
|
||||
|
||||
if (date >= startOfToday) return "Today";
|
||||
if (date >= startOfYesterday) return "Yesterday";
|
||||
if (date >= startOf7DaysAgo) return "Previous 7 days";
|
||||
return "Older";
|
||||
}
|
||||
|
||||
interface SessionItem {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SessionGroup {
|
||||
label: string;
|
||||
sessions: SessionItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a flat list of sessions (already sorted newest-first) into date-labeled groups.
|
||||
*/
|
||||
export function groupSessionsByDate(sessions: SessionItem[]): SessionGroup[] {
|
||||
const groups: SessionGroup[] = [];
|
||||
let current: SessionGroup | undefined;
|
||||
for (const s of sessions) {
|
||||
const label = dateLabel(s.created_at);
|
||||
if (!current || current.label !== label) {
|
||||
current = { label, sessions: [] };
|
||||
groups.push(current);
|
||||
}
|
||||
current.sessions.push(s);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
|
|
|||
40
apps/fabro-web/app/lib/workflow-api.ts
Normal file
40
apps/fabro-web/app/lib/workflow-api.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { PaginationMeta } from "@qltysh/fabro-api-client";
|
||||
|
||||
/**
|
||||
* Opaque settings payload returned by `/api/v1/runs/:id/settings`. Mirrors the
|
||||
* v2 `SettingsFile` shape in `lib/crates/fabro-types/src/settings/tree.rs`,
|
||||
* with secret-bearing subtrees dropped before serialization. Treated as a
|
||||
* loose JSON object on the web side — consumers only render it.
|
||||
*/
|
||||
export type RunSettings = Record<string, unknown>;
|
||||
|
||||
export interface WorkflowScheduleSummary {
|
||||
expression: string;
|
||||
next_run?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowLastRunSummary {
|
||||
ran_at?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkflowListItem {
|
||||
name: string;
|
||||
slug: string;
|
||||
filename: string;
|
||||
last_run?: WorkflowLastRunSummary | null;
|
||||
schedule?: WorkflowScheduleSummary | null;
|
||||
}
|
||||
|
||||
export interface PaginatedWorkflowListResponse {
|
||||
data: WorkflowListItem[];
|
||||
pagination?: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface WorkflowDetailResponse {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
filename: string;
|
||||
settings: RunSettings;
|
||||
graph: string;
|
||||
}
|
||||
|
|
@ -1,62 +1,24 @@
|
|||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { isRouteErrorResponse, Outlet } from "react-router";
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import { buildThemeBootScript } from "./lib/theme-selection";
|
||||
import "./app.css";
|
||||
|
||||
const themeScript = `(function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()`;
|
||||
const themeScript = buildThemeBootScript();
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
|
||||
{ rel: "icon", href: "/favicon.ico", sizes: "32x32" },
|
||||
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="h-full bg-atmosphere" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="h-full">
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
export default function Root() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ThemeBoot />
|
||||
<Outlet />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
function ThemeBoot() {
|
||||
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: any) {
|
||||
let status = 500;
|
||||
let heading = "Something went wrong";
|
||||
let message = "An unexpected error occurred. Please try again.";
|
||||
|
|
@ -72,16 +34,14 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|||
message = error.statusText || message;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
if (import.meta.env.DEV) {
|
||||
message = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
message = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
const is404 = status === 404;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-full flex-col items-center justify-center px-6 py-24">
|
||||
<main className="flex min-h-screen flex-col items-center justify-center px-6 py-24">
|
||||
<div className="text-center">
|
||||
<p
|
||||
className="font-mono text-[8rem] font-bold leading-none tracking-tighter"
|
||||
|
|
|
|||
28
apps/fabro-web/app/router.test.tsx
Normal file
28
apps/fabro-web/app/router.test.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import type { RouteObject } from "react-router";
|
||||
|
||||
import { routes } from "./router";
|
||||
|
||||
function collectPaths(routeObjects: RouteObject[], prefix = ""): string[] {
|
||||
return routeObjects.flatMap((route) => {
|
||||
const path = route.index
|
||||
? prefix || "/"
|
||||
: route.path
|
||||
? route.path.startsWith("/")
|
||||
? route.path
|
||||
: `${prefix}/${route.path}`.replace(/\/+/g, "/")
|
||||
: prefix;
|
||||
|
||||
const childPaths = route.children ? collectPaths(route.children, path) : [];
|
||||
return route.path || route.index ? [path, ...childPaths] : childPaths;
|
||||
});
|
||||
}
|
||||
|
||||
describe("browser router", () => {
|
||||
test("uses /login for the sign-in page instead of the backend auth namespace", () => {
|
||||
const paths = collectPaths(routes);
|
||||
|
||||
expect(paths).toContain("/login");
|
||||
expect(paths).not.toContain("/auth/login");
|
||||
});
|
||||
});
|
||||
123
apps/fabro-web/app/router.tsx
Normal file
123
apps/fabro-web/app/router.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { createElement } from "react";
|
||||
import {
|
||||
type RouteObject,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
useParams,
|
||||
} from "react-router";
|
||||
|
||||
import Root, { ErrorBoundary as RootErrorBoundary } from "./root";
|
||||
import * as RedirectHome from "./routes/redirect-home";
|
||||
import * as Setup from "./routes/setup";
|
||||
import * as SetupComplete from "./routes/setup-complete";
|
||||
import * as AuthLogin from "./routes/auth-login";
|
||||
import * as Start from "./routes/start";
|
||||
import * as Workflows from "./routes/workflows";
|
||||
import * as WorkflowDetail from "./routes/workflow-detail";
|
||||
import * as WorkflowDefinition from "./routes/workflow-definition";
|
||||
import * as WorkflowDiagram from "./routes/workflow-diagram";
|
||||
import * as WorkflowRuns from "./routes/workflow-runs";
|
||||
import * as Runs from "./routes/runs";
|
||||
import * as RunDetail from "./routes/run-detail";
|
||||
import * as RunOverview from "./routes/run-overview";
|
||||
import * as RunStages from "./routes/run-stages";
|
||||
import * as RunSettings from "./routes/run-settings";
|
||||
import * as RunGraph from "./routes/run-graph";
|
||||
import * as RunFiles from "./routes/run-files";
|
||||
import * as RunBilling from "./routes/run-billing";
|
||||
import * as Insights from "./routes/insights";
|
||||
import * as InsightsEditor from "./routes/insights-editor";
|
||||
import * as InsightsNew from "./routes/insights-new";
|
||||
import * as Settings from "./routes/settings";
|
||||
import AppShellModule from "./layouts/app-shell";
|
||||
import { loader as appShellLoader } from "./layouts/app-shell";
|
||||
|
||||
type RouteModule = {
|
||||
default: React.ComponentType<any>;
|
||||
loader?: RouteObject["loader"];
|
||||
action?: RouteObject["action"];
|
||||
ErrorBoundary?: React.ComponentType<any>;
|
||||
};
|
||||
|
||||
function withRouteModule(module: RouteModule) {
|
||||
return function WrappedRouteComponent() {
|
||||
const loaderData = useLoaderData();
|
||||
const actionData = useActionData();
|
||||
const params = useParams();
|
||||
return createElement(module.default, { loaderData, actionData, params });
|
||||
};
|
||||
}
|
||||
|
||||
function route(
|
||||
path: string,
|
||||
module: RouteModule,
|
||||
extra: Omit<RouteObject, "path" | "Component" | "loader" | "action" | "index"> = {},
|
||||
): RouteObject {
|
||||
return {
|
||||
path,
|
||||
loader: module.loader,
|
||||
action: module.action,
|
||||
Component: withRouteModule(module),
|
||||
ErrorBoundary: module.ErrorBoundary,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function indexRoute(module: RouteModule): RouteObject {
|
||||
return {
|
||||
index: true,
|
||||
loader: module.loader,
|
||||
action: module.action,
|
||||
Component: withRouteModule(module),
|
||||
ErrorBoundary: module.ErrorBoundary,
|
||||
};
|
||||
}
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
path: "/",
|
||||
Component: Root,
|
||||
ErrorBoundary: RootErrorBoundary,
|
||||
children: [
|
||||
indexRoute(RedirectHome),
|
||||
route("setup", Setup),
|
||||
route("setup/complete", SetupComplete),
|
||||
route("login", AuthLogin),
|
||||
{
|
||||
loader: appShellLoader,
|
||||
Component: withRouteModule({
|
||||
default: AppShellModule,
|
||||
}),
|
||||
children: [
|
||||
route("start", Start),
|
||||
route("workflows", Workflows),
|
||||
route("workflows/:name", WorkflowDetail, {
|
||||
children: [
|
||||
indexRoute(WorkflowDefinition),
|
||||
route("diagram", WorkflowDiagram),
|
||||
route("runs", WorkflowRuns),
|
||||
],
|
||||
}),
|
||||
route("runs", Runs),
|
||||
route("runs/:id", RunDetail, {
|
||||
children: [
|
||||
indexRoute(RunOverview),
|
||||
route("stages/:stageId", RunStages),
|
||||
route("settings", RunSettings),
|
||||
route("graph", RunGraph),
|
||||
route("files", RunFiles),
|
||||
route("billing", RunBilling),
|
||||
],
|
||||
}),
|
||||
route("insights", Insights, {
|
||||
children: [
|
||||
indexRoute(InsightsEditor),
|
||||
route("new", InsightsNew),
|
||||
],
|
||||
}),
|
||||
route("settings", Settings),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import {
|
||||
type RouteConfig,
|
||||
index,
|
||||
layout,
|
||||
route,
|
||||
} from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
index("routes/redirect-home.tsx"),
|
||||
route("setup", "routes/setup.tsx"),
|
||||
route("setup/callback", "routes/setup-callback.tsx"),
|
||||
route("auth/login", "routes/auth-login.tsx"),
|
||||
route("auth/callback", "routes/auth-callback.tsx"),
|
||||
route("auth/logout", "routes/auth-logout.tsx"),
|
||||
layout("layouts/app-shell.tsx", [
|
||||
route("start", "routes/start.tsx"),
|
||||
route("sessions/:sessionId", "routes/session-detail.tsx"),
|
||||
route("workflows", "routes/workflows.tsx"),
|
||||
route("workflows/:name", "routes/workflow-detail.tsx", [
|
||||
index("routes/workflow-definition.tsx"),
|
||||
route("diagram", "routes/workflow-diagram.tsx"),
|
||||
route("runs", "routes/workflow-runs.tsx"),
|
||||
]),
|
||||
route("runs", "routes/runs.tsx"),
|
||||
route("runs/:id", "routes/run-detail.tsx", [
|
||||
index("routes/run-overview.tsx"),
|
||||
route("stages/:stageId", "routes/run-stages.tsx"),
|
||||
route("settings", "routes/run-settings.tsx"),
|
||||
route("graph", "routes/run-graph.tsx"),
|
||||
route("files", "routes/run-files.tsx"),
|
||||
route("verification", "routes/run-verification.tsx"),
|
||||
route("usage", "routes/run-usage.tsx"),
|
||||
route("retro", "routes/run-retro.tsx"),
|
||||
]),
|
||||
route("verification/criteria", "routes/verification-criteria.tsx"),
|
||||
route("verification/criteria/:id", "routes/verification-criterion.tsx"),
|
||||
route("verification/controls", "routes/verification-controls.tsx"),
|
||||
route("verification/controls/:id", "routes/verification-control.tsx"),
|
||||
route("retros", "routes/retros.tsx"),
|
||||
route("insights", "routes/insights.tsx", [
|
||||
index("routes/insights-editor.tsx"),
|
||||
route("new", "routes/insights-new.tsx"),
|
||||
]),
|
||||
route("settings", "routes/settings.tsx"),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { getGitHubOAuth } from "../lib/github.server";
|
||||
import { getSession, commitSession } from "../lib/session.server";
|
||||
import type { Route } from "./+types/auth-callback";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
|
||||
const cookies = request.headers.get("Cookie") ?? "";
|
||||
const stateMatch = cookies.match(/fabro_oauth_state=([^;]+)/);
|
||||
const storedState = stateMatch?.[1];
|
||||
|
||||
if (!code || !state || state !== storedState) {
|
||||
throw redirect("/auth/login");
|
||||
}
|
||||
|
||||
const github = getGitHubOAuth();
|
||||
const tokens = await github.validateAuthorizationCode(code);
|
||||
const accessToken = tokens.accessToken();
|
||||
|
||||
const [userResponse, emailsResponse] = await Promise.all([
|
||||
fetch("https://api.github.com/user", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}),
|
||||
fetch("https://api.github.com/user/emails", {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}),
|
||||
]);
|
||||
const profile = (await userResponse.json()) as {
|
||||
id: number;
|
||||
node_id: string;
|
||||
login: string;
|
||||
name: string | null;
|
||||
avatar_url: string;
|
||||
};
|
||||
const emails = (await emailsResponse.json()) as Array<{
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}>;
|
||||
const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? "";
|
||||
|
||||
const { allowed_usernames } = getAppConfig().web.auth;
|
||||
if (allowed_usernames.length > 0 && !allowed_usernames.includes(profile.login)) {
|
||||
throw redirect("/auth/login?error=unauthorized");
|
||||
}
|
||||
|
||||
const session = await getSession(request);
|
||||
session.set("userUrl", `https://github.com/${profile.login}`);
|
||||
session.set("githubId", profile.id);
|
||||
session.set("githubNodeId", profile.node_id);
|
||||
session.set("login", profile.login);
|
||||
session.set("name", profile.name ?? profile.login);
|
||||
session.set("email", primaryEmail);
|
||||
session.set("avatarUrl", profile.avatar_url);
|
||||
session.set("accessToken", accessToken);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.append("Set-Cookie", await commitSession(session));
|
||||
headers.append("Set-Cookie", "fabro_oauth_state=; HttpOnly; Path=/; Max-Age=0");
|
||||
|
||||
return redirect("/start", { headers });
|
||||
}
|
||||
|
|
@ -1,40 +1,5 @@
|
|||
import { redirect } from "react-router";
|
||||
import { AuthLayout } from "../components/auth-layout";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { getGitHubOAuth, generateState } from "../lib/github.server";
|
||||
import type { Route } from "./+types/auth-login";
|
||||
|
||||
export function loader() {
|
||||
const { provider } = getAppConfig().web.auth;
|
||||
return { provider };
|
||||
}
|
||||
|
||||
export function action({ request }: Route.ActionArgs) {
|
||||
const github = getGitHubOAuth();
|
||||
const state = generateState();
|
||||
const authUrl = github.createAuthorizationURL(state, ["read:user", "user:email"]);
|
||||
|
||||
return redirect(authUrl.toString(), {
|
||||
headers: {
|
||||
"Set-Cookie": `fabro_oauth_state=${state}; HttpOnly; Path=/; Max-Age=600; SameSite=Lax`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function AuthLogin({ loaderData }: Route.ComponentProps) {
|
||||
if (loaderData.provider === "tailscale") {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Access via Tailscale
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
This app is protected by Tailscale. Make sure you are connected to your Tailscale network and your account is authorized.
|
||||
</p>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthLogin() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
|
|
@ -43,15 +8,15 @@ export default function AuthLogin({ loaderData }: Route.ComponentProps) {
|
|||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
Authenticate with your GitHub account to continue.
|
||||
</p>
|
||||
<form method="POST" className="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
<div className="mt-6">
|
||||
<a
|
||||
href="/auth/login/github"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
>
|
||||
<GitHubMark />
|
||||
Sign in with GitHub
|
||||
</button>
|
||||
</form>
|
||||
</a>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { getSession, destroySession } from "../lib/session.server";
|
||||
import type { Route } from "./+types/auth-logout";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const { provider } = getAppConfig().web.auth;
|
||||
if (provider === "tailscale") {
|
||||
return redirect("/start");
|
||||
}
|
||||
const session = await getSession(request);
|
||||
return redirect("/auth/login", {
|
||||
headers: { "Set-Cookie": await destroySession(session) },
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { Link, Outlet, useNavigate } from "react-router";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { timeAgo } from "../lib/time";
|
||||
import type { PaginatedSavedQueryList, PaginatedHistoryEntryList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/insights";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Insights — Fabro" }];
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +28,7 @@ export interface HistoryEntry {
|
|||
rowsReturned: number;
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ request }: any) {
|
||||
const [{ data: apiQueries }, { data: apiHistory }] = await Promise.all([
|
||||
apiJson<PaginatedSavedQueryList>("/insights/queries", { request }),
|
||||
apiJson<PaginatedHistoryEntryList>("/insights/history", { request }),
|
||||
|
|
@ -49,7 +48,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
return { savedQueries, historyEntries };
|
||||
}
|
||||
|
||||
export default function InsightsLayout({ loaderData }: Route.ComponentProps) {
|
||||
export default function InsightsLayout({ loaderData }: any) {
|
||||
const { savedQueries, historyEntries } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,24 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { isGitHubAppConfigured } from "../lib/github.server";
|
||||
import { getUser } from "../lib/session.server";
|
||||
import type { Route } from "./+types/redirect-home";
|
||||
import { getAuthMe, getSetupStatus } from "../api";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { provider } = getAppConfig().web.auth;
|
||||
if (provider === "github" && !isGitHubAppConfigured()) {
|
||||
export async function loader() {
|
||||
const setup = await getSetupStatus();
|
||||
if (!setup.configured) {
|
||||
return redirect("/setup");
|
||||
}
|
||||
if (provider !== "insecure_disabled") {
|
||||
const user = await getUser(request);
|
||||
if (!user) {
|
||||
return redirect("/auth/login");
|
||||
|
||||
try {
|
||||
await getAuthMe();
|
||||
} catch (error) {
|
||||
if (error instanceof Response && error.status === 401) {
|
||||
return redirect("/login");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return redirect("/start");
|
||||
}
|
||||
|
||||
export default function RedirectHome() {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { smoothnessConfig, formatDurationMs } from "../data/retros";
|
||||
import type { SmoothnessRating } from "../data/retros";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { PaginatedRetroList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/retros";
|
||||
|
||||
interface RetroRow {
|
||||
run_id: string;
|
||||
workflow_name: string;
|
||||
goal: string;
|
||||
timestamp: string;
|
||||
smoothness?: SmoothnessRating;
|
||||
total_duration_ms: number;
|
||||
friction_point_count: number;
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { data: apiRetros } = await apiJson<PaginatedRetroList>("/retros", { request });
|
||||
const retros: RetroRow[] = apiRetros.map((r) => ({
|
||||
run_id: r.run.id,
|
||||
workflow_name: r.workflow.slug,
|
||||
goal: r.run.title,
|
||||
timestamp: r.timestamp,
|
||||
smoothness: r.smoothness as SmoothnessRating | undefined,
|
||||
total_duration_ms: r.stats.total_duration_ms,
|
||||
friction_point_count: r.friction_point_count,
|
||||
}));
|
||||
return { retros };
|
||||
}
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: "Retros \u2014 Fabro" }];
|
||||
}
|
||||
|
||||
const smoothnessOptions: Array<{ value: SmoothnessRating; label: string }> = [
|
||||
{ value: "effortless", label: "Effortless" },
|
||||
{ value: "smooth", label: "Smooth" },
|
||||
{ value: "bumpy", label: "Bumpy" },
|
||||
{ value: "struggled", label: "Struggled" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
];
|
||||
|
||||
function SmoothnesssBadge({ smoothness }: { smoothness: SmoothnessRating | undefined }) {
|
||||
if (!smoothness) {
|
||||
return <span className="text-xs text-fg-muted">--</span>;
|
||||
}
|
||||
const config = smoothnessConfig[smoothness];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${config.bg} ${config.text}`}>
|
||||
<span className={`size-1.5 rounded-full ${config.dot}`} />
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: string): string {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, maxLength) + "\u2026";
|
||||
}
|
||||
|
||||
export default function Retros({ loaderData }: Route.ComponentProps) {
|
||||
const { retros } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const [smoothnessFilter, setSmoothnessFilter] = useState<SmoothnessRating | "all">("all");
|
||||
|
||||
if (retros.length === 0) {
|
||||
return <p className="py-8 text-center text-sm text-fg-muted">No retrospectives yet.</p>;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const filtered = retros.filter(
|
||||
(r) =>
|
||||
(smoothnessFilter === "all" || r.smoothness === smoothnessFilter) &&
|
||||
(r.goal.toLowerCase().includes(lowerQuery) ||
|
||||
r.workflow_name.toLowerCase().includes(lowerQuery)),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search retros…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={smoothnessFilter}
|
||||
onChange={(e) => setSmoothnessFilter(e.target.value as SmoothnessRating | "all")}
|
||||
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
>
|
||||
<option value="all">All smoothness</option>
|
||||
{smoothnessOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<th className="px-4 py-2.5 font-medium">Workflow</th>
|
||||
<th className="px-4 py-2.5 font-medium">Goal</th>
|
||||
<th className="px-4 py-2.5 font-medium">Smoothness</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Duration</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Frictions</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((retro) => (
|
||||
<tr key={retro.run_id} className="border-b border-line last:border-b-0 transition-colors hover:bg-overlay cursor-pointer" onClick={() => navigate(`/runs/${retro.run_id}/retro`)}>
|
||||
<td className="px-4 py-3 font-mono text-xs font-medium text-teal-500">
|
||||
{retro.workflow_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-fg-2">
|
||||
{truncate(retro.goal, 60)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<SmoothnesssBadge smoothness={retro.smoothness} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
{formatDurationMs(retro.total_duration_ms)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
{retro.friction_point_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-muted">
|
||||
{formatTimestamp(retro.timestamp)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,43 +1,47 @@
|
|||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { RunUsage } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-usage";
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const usage = await apiJson<RunUsage>(`/runs/${params.id}/usage`, { request });
|
||||
const stages = usage.stages.map((s) => ({
|
||||
stage: s.stage.name,
|
||||
model: s.model.id,
|
||||
inputTokens: s.usage.input_tokens,
|
||||
outputTokens: s.usage.output_tokens,
|
||||
runtime: formatDurationSecs(s.runtime_secs),
|
||||
cost: s.usage.cost,
|
||||
}));
|
||||
const totalRuntime = formatDurationSecs(usage.totals.runtime_secs);
|
||||
const totalCost = usage.totals.cost;
|
||||
const totalInput = usage.totals.input_tokens;
|
||||
const totalOutput = usage.totals.output_tokens;
|
||||
const modelBreakdown = usage.by_model
|
||||
.map((m) => ({
|
||||
model: m.model.id,
|
||||
stages: m.stages,
|
||||
inputTokens: m.usage.input_tokens,
|
||||
outputTokens: m.usage.output_tokens,
|
||||
cost: m.usage.cost,
|
||||
}))
|
||||
.sort((a, b) => b.cost - a.cost);
|
||||
return { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown };
|
||||
}
|
||||
import type { RunBilling } from "@qltysh/fabro-api-client";
|
||||
|
||||
function formatTokens(n: number) {
|
||||
return `${(n / 1000).toFixed(1)}k`;
|
||||
}
|
||||
|
||||
export default function RunUsage({ loaderData }: Route.ComponentProps) {
|
||||
const { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown } = loaderData;
|
||||
function formatUsdMicros(usdMicros?: number) {
|
||||
return usdMicros == null ? "-" : `$${(usdMicros / 1_000_000).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: any) {
|
||||
const billing = await apiJson<RunBilling>(`/runs/${params.id}/billing`, { request });
|
||||
const stages = billing.stages.map((stage) => ({
|
||||
stage: stage.stage.name,
|
||||
model: stage.model.id,
|
||||
inputTokens: stage.billing.input_tokens,
|
||||
outputTokens: stage.billing.output_tokens + (stage.billing.reasoning_tokens ?? 0),
|
||||
runtime: formatDurationSecs(stage.runtime_secs),
|
||||
totalUsdMicros: stage.billing.total_usd_micros,
|
||||
}));
|
||||
const totalRuntime = formatDurationSecs(billing.totals.runtime_secs);
|
||||
const totalInput = billing.totals.input_tokens;
|
||||
const totalOutput = billing.totals.output_tokens + (billing.totals.reasoning_tokens ?? 0);
|
||||
const totalUsdMicros = billing.totals.total_usd_micros;
|
||||
const modelBreakdown = billing.by_model
|
||||
.map((entry) => ({
|
||||
model: entry.model.id,
|
||||
stages: entry.stages,
|
||||
inputTokens: entry.billing.input_tokens,
|
||||
outputTokens: entry.billing.output_tokens + (entry.billing.reasoning_tokens ?? 0),
|
||||
totalUsdMicros: entry.billing.total_usd_micros,
|
||||
}))
|
||||
.sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1));
|
||||
return { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown };
|
||||
}
|
||||
|
||||
export default function RunBilling({ loaderData }: any) {
|
||||
const { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown } =
|
||||
loaderData;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<div className="overflow-hidden rounded-md border border-line">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
|
|
@ -45,7 +49,7 @@ export default function RunUsage({ loaderData }: Route.ComponentProps) {
|
|||
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Run time</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -54,10 +58,13 @@ export default function RunUsage({ loaderData }: Route.ComponentProps) {
|
|||
<td className="px-4 py-3 text-fg-2">{row.stage}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-fg-3">{row.model}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span> {formatTokens(row.outputTokens)}
|
||||
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span>{" "}
|
||||
{formatTokens(row.outputTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">{row.runtime}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">${row.cost.toFixed(2)}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
|
||||
{formatUsdMicros(row.totalUsdMicros)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -66,47 +73,64 @@ export default function RunUsage({ loaderData }: Route.ComponentProps) {
|
|||
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
||||
<td />
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||
{formatTokens(totalInput)} <span className="text-fg-muted">/</span> {formatTokens(totalOutput)}
|
||||
{formatTokens(totalInput)} <span className="text-fg-muted">/</span>{" "}
|
||||
{formatTokens(totalOutput)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||
{totalRuntime}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||
{formatUsdMicros(totalUsdMicros)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">{totalRuntime}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">${totalCost.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">By Model</h3>
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">
|
||||
By Model
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-md border border-line">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Stages</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modelBreakdown.map((row) => (
|
||||
<tr key={row.model} className="border-b border-line last:border-b-0">
|
||||
<td className="px-4 py-3 font-mono text-xs text-fg-2">{row.model}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">{row.stages}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span> {formatTokens(row.outputTokens)}
|
||||
{row.stages}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span>{" "}
|
||||
{formatTokens(row.outputTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
|
||||
{formatUsdMicros(row.totalUsdMicros)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">${row.cost.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-line-strong bg-panel/40">
|
||||
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">{stages.length}</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||
{formatTokens(totalInput)} <span className="text-fg-muted">/</span> {formatTokens(totalOutput)}
|
||||
{stages.length}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||
{formatTokens(totalInput)} <span className="text-fg-muted">/</span>{" "}
|
||||
{formatTokens(totalOutput)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||
{formatUsdMicros(totalUsdMicros)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">${totalCost.toFixed(2)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
|
@ -2,37 +2,45 @@ import { useEffect } from "react";
|
|||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
import { Link, Outlet, useFetcher, useLocation } from "react-router";
|
||||
import { columnNames, mapRunListItem, statusColors } from "../data/runs";
|
||||
import type { ColumnStatus } from "../data/runs";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { PaginatedRunList, PreviewUrlResponse } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-detail";
|
||||
import { columnNames, mapRunSummaryToRunItem, statusColors } from "../data/runs";
|
||||
import type { ColumnStatus, RunSummaryResponse } from "../data/runs";
|
||||
import { apiJson } from "../api";
|
||||
import { useDemoMode } from "../lib/demo-mode";
|
||||
import type { PreviewUrlResponse } from "@qltysh/fabro-api-client";
|
||||
|
||||
const tabs = [
|
||||
{ name: "Overview", path: "", count: null },
|
||||
{ name: "Stages", path: "/stages/detect-drift", count: null },
|
||||
{ name: "Files Changed", path: "/files", count: null },
|
||||
{ name: "Verification", path: "/verification", count: null },
|
||||
{ name: "Retro", path: "/retro", count: null },
|
||||
{ name: "Usage", path: "/usage", count: null },
|
||||
const allTabs = [
|
||||
{ name: "Overview", path: "", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Stages", path: "/stages/detect-drift", count: null, demoOnly: true, broken: false },
|
||||
{ name: "Files Changed", path: "/files", count: null, demoOnly: false, broken: true },
|
||||
{ name: "Graph", path: "/graph", count: null, demoOnly: false, broken: false },
|
||||
{ name: "Billing", path: "/billing", count: null, demoOnly: false, broken: false },
|
||||
];
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const response = await apiJson<PaginatedRunList>("/runs", { request });
|
||||
const apiRun = response.data.find((r) => r.id === params.id);
|
||||
if (!apiRun) return { run: null };
|
||||
export async function loader({ request, params }: any) {
|
||||
const response = await fetch(`/api/v1/runs/${params.id}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) return { run: null };
|
||||
const summary: RunSummaryResponse = await response.json();
|
||||
const item = mapRunSummaryToRunItem(summary);
|
||||
const statusMap: Record<string, ColumnStatus> = {
|
||||
running: "working",
|
||||
paused: "pending",
|
||||
completed: "merge",
|
||||
};
|
||||
const status = statusMap[summary.status ?? ""] ?? "working";
|
||||
return {
|
||||
run: {
|
||||
...mapRunListItem(apiRun),
|
||||
status: apiRun.status as ColumnStatus,
|
||||
statusLabel: columnNames[apiRun.status as ColumnStatus] ?? apiRun.status,
|
||||
...item,
|
||||
status,
|
||||
statusLabel: columnNames[status] ?? summary.status ?? "Unknown",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ params, request }: Route.ActionArgs) {
|
||||
export async function action({ params, request }: any) {
|
||||
const formData = await request.formData();
|
||||
const port = formData.get("port");
|
||||
const expiresInSecs = formData.get("expires_in_secs");
|
||||
|
|
@ -47,16 +55,18 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||
return result;
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
export function meta({ data }: any) {
|
||||
const run = data?.run;
|
||||
return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }];
|
||||
}
|
||||
|
||||
export default function RunDetail({ loaderData, params }: Route.ComponentProps) {
|
||||
export default function RunDetail({ loaderData, params }: any) {
|
||||
const { run } = loaderData;
|
||||
const { pathname } = useLocation();
|
||||
const basePath = `/runs/${params.id}`;
|
||||
const previewFetcher = useFetcher<PreviewUrlResponse>();
|
||||
const demoMode = useDemoMode();
|
||||
const tabs = allTabs.filter((t) => !t.broken && (!t.demoOnly || demoMode));
|
||||
|
||||
useEffect(() => {
|
||||
if (previewFetcher.data?.url) {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,12 @@ import {
|
|||
type DiffLineAnnotation,
|
||||
} from "@pierre/diffs/react";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunFileList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-files";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const data = await apiJson<PaginatedRunFileList>(`/runs/${params.id}/files`, { request });
|
||||
return data;
|
||||
}
|
||||
|
|
@ -33,13 +32,13 @@ export async function run(argv: string[]) {
|
|||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
config: { type: "string", short: "c", default: "fabro.toml" },
|
||||
config: { type: "string", short: "c", default: ".fabro/project.toml" },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const opts: RunOptions = {
|
||||
config: values.config ?? "fabro.toml",
|
||||
config: values.config ?? ".fabro/project.toml",
|
||||
dryRun: values["dry-run"] ?? false,
|
||||
};
|
||||
|
||||
|
|
@ -72,14 +71,14 @@ export async function run(argv: string[]) {
|
|||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
config: { type: "string", short: "c", default: "fabro.toml" },
|
||||
config: { type: "string", short: "c", default: ".fabro/project.toml" },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
verbose: { type: "boolean", short: "v", default: false },
|
||||
},
|
||||
});
|
||||
|
||||
const opts: RunOptions = {
|
||||
config: values.config ?? "fabro.toml",
|
||||
config: values.config ?? ".fabro/project.toml",
|
||||
dryRun: values["dry-run"] ?? false,
|
||||
verbose: values.verbose ?? false,
|
||||
};
|
||||
|
|
@ -483,7 +482,7 @@ function buildAnnotationsForFile(
|
|||
return annotations;
|
||||
}
|
||||
|
||||
export default function RunFiles({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunFiles({ loaderData }: any) {
|
||||
const runFiles = loaderData;
|
||||
const files = runFiles.data.length > 0
|
||||
? runFiles.data.map((f) => ({
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
|
|||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { apiFetch, apiJson } from "../api-client";
|
||||
import { apiFetch, apiJsonOrNull } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-graph";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -22,12 +21,12 @@ interface Stage {
|
|||
duration: string;
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const [{ data: apiStages }, graphRes] = await Promise.all([
|
||||
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
export async function loader({ request, params }: any) {
|
||||
const [stagesResult, graphRes] = await Promise.all([
|
||||
apiJsonOrNull<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
apiFetch(`/runs/${params.id}/graph`, { request }),
|
||||
]);
|
||||
const stages: Stage[] = apiStages.map((s) => ({
|
||||
const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
dotId: s.dot_id ?? s.id,
|
||||
|
|
@ -190,7 +189,7 @@ function annotateRunningNodes(svg: SVGSVGElement, gt: ReturnType<typeof getGraph
|
|||
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
|
||||
const DEFAULT_ZOOM_INDEX = 2;
|
||||
|
||||
export default function RunGraph({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunGraph({ loaderData }: any) {
|
||||
const { id } = useParams();
|
||||
const { stages, graphSvg } = loaderData;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
|
|||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { getGraphTheme } from "../lib/graph-theme";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJsonOrNull } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, PaginatedRunList, WorkflowDetail } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-overview";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -21,28 +20,18 @@ interface Stage {
|
|||
duration: string;
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const [{ data: apiStages }, response] = await Promise.all([
|
||||
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
apiJson<PaginatedRunList>("/runs", { request }),
|
||||
]);
|
||||
const stages: Stage[] = apiStages.map((s) => ({
|
||||
export async function loader({ request, params }: any) {
|
||||
const stagesResult = await apiJsonOrNull<PaginatedRunStageList>(
|
||||
`/runs/${params.id}/stages`,
|
||||
{ request },
|
||||
);
|
||||
const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status as StageStatus,
|
||||
duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--",
|
||||
}));
|
||||
const run = response.data.find((r) => r.id === params.id);
|
||||
let graphDot: string | null = null;
|
||||
if (run) {
|
||||
try {
|
||||
const workflow = await apiJson<WorkflowDetail>(`/workflows/${run.workflow}`, { request });
|
||||
graphDot = workflow.graph;
|
||||
} catch {
|
||||
// workflow not found — leave graphDot null
|
||||
}
|
||||
}
|
||||
return { stages, graphDot };
|
||||
return { stages, graphDot: null };
|
||||
}
|
||||
|
||||
const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: string }> = {
|
||||
|
|
@ -286,7 +275,7 @@ function DotDiagram({ dot }: { dot: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function RunOverview({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunOverview({ loaderData }: any) {
|
||||
const { id } = useParams();
|
||||
const { stages, graphDot } = loaderData;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,232 +0,0 @@
|
|||
import { Link } from "react-router";
|
||||
import {
|
||||
smoothnessConfig,
|
||||
learningCategoryConfig,
|
||||
frictionKindConfig,
|
||||
openItemKindConfig,
|
||||
formatDurationMs,
|
||||
} from "../data/retros";
|
||||
import type { Retro } from "../data/retros";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { Route } from "./+types/run-retro";
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const retro = await apiJson<Retro>(`/runs/${params.id}/retro`, { request });
|
||||
return { retro };
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
const retro = data?.retro;
|
||||
return [{ title: retro ? `Retro: ${retro.goal} \u2014 Fabro` : "Retro \u2014 Fabro" }];
|
||||
}
|
||||
|
||||
function formatCost(cost: number | undefined): string {
|
||||
if (cost == null) return "--";
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function RunRetro({ loaderData }: Route.ComponentProps) {
|
||||
const { retro } = loaderData;
|
||||
|
||||
if (!retro) {
|
||||
return <p className="py-8 text-center text-sm text-fg-muted">No retrospective found for this run.</p>;
|
||||
}
|
||||
|
||||
const smoothness = retro.smoothness ? smoothnessConfig[retro.smoothness] : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Smoothness + Summary Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
{smoothness && (
|
||||
<span className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold ${smoothness.bg} ${smoothness.text}`}>
|
||||
<span className={`size-2.5 rounded-full ${smoothness.dot}`} />
|
||||
{smoothness.label}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-fg-3">{retro.goal}</p>
|
||||
<p className="mt-1 font-mono text-xs text-fg-muted">
|
||||
{retro.workflow_name} · {new Date(retro.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aggregate Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Duration" value={formatDurationMs(retro.stats.total_duration_ms)} />
|
||||
<StatCard label="Cost" value={formatCost(retro.stats.total_cost)} />
|
||||
<StatCard label="Retries" value={String(retro.stats.total_retries)} warn={retro.stats.total_retries > 0} />
|
||||
<StatCard label="Files" value={String(retro.stats.files_touched.length)} />
|
||||
</div>
|
||||
|
||||
{/* Intent + Outcome */}
|
||||
{(retro.intent ?? retro.outcome) && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{retro.intent && (
|
||||
<div className="rounded-md border border-line bg-panel/60 p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-fg-muted">Intent</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-fg-3">{retro.intent}</p>
|
||||
</div>
|
||||
)}
|
||||
{retro.outcome && (
|
||||
<div className="rounded-md border border-line bg-panel/60 p-4">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wider text-fg-muted">Outcome</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-fg-3">{retro.outcome}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Learnings */}
|
||||
{retro.learnings && retro.learnings.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">Learnings</h3>
|
||||
<div className="space-y-2">
|
||||
{retro.learnings.map((learning, i) => {
|
||||
const config = learningCategoryConfig[learning.category];
|
||||
return (
|
||||
<div key={learning.text} className="flex items-start gap-3 rounded-md border border-line bg-panel/60 px-4 py-3">
|
||||
<span className={`mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.text} bg-overlay`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<p className="text-sm leading-relaxed text-fg-3">{learning.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friction Points */}
|
||||
{retro.friction_points && retro.friction_points.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">Friction Points</h3>
|
||||
<div className="space-y-2">
|
||||
{retro.friction_points.map((fp, i) => {
|
||||
const config = frictionKindConfig[fp.kind];
|
||||
return (
|
||||
<div key={fp.description} className="flex items-start gap-3 rounded-md border border-line bg-panel/60 px-4 py-3">
|
||||
<span className={`mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.text} bg-overlay`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-relaxed text-fg-3">{fp.description}</p>
|
||||
{fp.stage_id && (
|
||||
<p className="mt-1 font-mono text-xs text-fg-muted">
|
||||
Stage: {retro.stages.find((s) => s.stage_id === fp.stage_id)?.stage_label ?? fp.stage_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open Items */}
|
||||
{retro.open_items && retro.open_items.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">Open Items</h3>
|
||||
<div className="space-y-2">
|
||||
{retro.open_items.map((item, i) => {
|
||||
const config = openItemKindConfig[item.kind];
|
||||
return (
|
||||
<div key={item.description} className="flex items-start gap-3 rounded-md border border-line bg-panel/60 px-4 py-3">
|
||||
<span className={`mt-0.5 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.text} bg-overlay`}>
|
||||
{config.label}
|
||||
</span>
|
||||
<p className="text-sm leading-relaxed text-fg-3">{item.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage Breakdown */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">Stage Breakdown</h3>
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<th className="px-4 py-2.5 font-medium">Stage</th>
|
||||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Duration</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Retries</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Files</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{retro.stages.map((stage) => (
|
||||
<tr key={stage.stage_id} className="border-b border-line last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={`/runs/${retro.run_id}/stages/${stage.stage_id}`}
|
||||
className="text-fg-2 hover:text-fg"
|
||||
>
|
||||
{stage.stage_label}
|
||||
</Link>
|
||||
{stage.notes && (
|
||||
<p className="mt-1 text-xs text-fg-muted">{stage.notes}</p>
|
||||
)}
|
||||
{stage.failure_reason && (
|
||||
<p className="mt-1 text-xs text-coral/80">{stage.failure_reason}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StageStatusBadge status={stage.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
{formatDurationMs(stage.duration_ms)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums">
|
||||
<span className={stage.retries > 0 ? "text-amber" : "text-fg-3"}>
|
||||
{stage.retries}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
{formatCost(stage.cost)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
{stage.files_touched.length}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-panel/60 px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-fg-muted">{label}</p>
|
||||
<p className={`mt-1 font-mono text-lg font-semibold tabular-nums ${warn ? "text-amber" : "text-fg"}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StageStatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
completed: "text-mint",
|
||||
running: "text-teal-500",
|
||||
pending: "text-fg-muted",
|
||||
failed: "text-coral",
|
||||
cancelled: "text-fg-muted",
|
||||
};
|
||||
const colorClass = styles[status] ?? "text-fg-3";
|
||||
return (
|
||||
<span className={`text-xs font-medium capitalize ${colorClass}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,10 +2,10 @@ import { Link, useParams } from "react-router";
|
|||
import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, RunSettings } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-settings";
|
||||
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
|
||||
import type { RunSettings } from "../lib/workflow-api";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: s
|
|||
cancelled: { icon: XCircleIcon, color: "text-fg-muted" },
|
||||
};
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const [{ data: apiStages }, settings] = await Promise.all([
|
||||
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
apiJson<RunSettings>(`/runs/${params.id}/settings`, { request }),
|
||||
|
|
@ -40,7 +40,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
|||
return { stages, settings };
|
||||
}
|
||||
|
||||
export default function RunSettingsPage({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunSettingsPage({ loaderData }: any) {
|
||||
const { id } = useParams();
|
||||
const { stages, settings } = loaderData;
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ export default function RunSettingsPage({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
<div className="min-w-0 flex-1">
|
||||
<CollapsibleFile
|
||||
file={{ name: "run.json", contents: JSON.stringify(settings, null, 2), lang: "json" }}
|
||||
file={{ name: "settings.json", contents: JSON.stringify(settings, null, 2), lang: "json" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,9 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
|
|||
import { DocumentTextIcon, MapIcon, CommandLineIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { ToolRow, ToolBlock } from "../components/tool-use";
|
||||
import type { ToolUse } from "../components/tool-use";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { formatDurationSecs } from "../lib/format";
|
||||
import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-stages";
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
|
|
@ -21,7 +20,7 @@ interface Stage {
|
|||
duration: string;
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const { data: apiStages } = await apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request });
|
||||
const stages: Stage[] = apiStages.map((s) => ({
|
||||
id: s.id,
|
||||
|
|
@ -84,7 +83,7 @@ function AssistantBlock({ content }: { content: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function RunStages({ loaderData }: Route.ComponentProps) {
|
||||
export default function RunStages({ loaderData }: any) {
|
||||
const { id, stageId } = useParams();
|
||||
const { stages, turns: apiTurns } = loaderData;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from "@headlessui/react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
MinusCircleIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
statusConfig,
|
||||
typeConfig,
|
||||
getCriteriaSummary,
|
||||
} from "../data/verifications";
|
||||
import type {
|
||||
VerificationResult,
|
||||
VerificationType,
|
||||
VerificationCategory,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { PaginatedRunVerificationList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/run-verification";
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const { data: apiCategories } = await apiJson<PaginatedRunVerificationList>(`/runs/${params.id}/verification`, { request });
|
||||
const categories: VerificationCategory[] = apiCategories.map((cat) => ({
|
||||
name: cat.name,
|
||||
question: cat.question,
|
||||
status: cat.status as VerificationResult,
|
||||
criteria: cat.controls.map((c) => ({
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
type: (c.type ?? null) as VerificationType | null,
|
||||
status: c.status as VerificationResult,
|
||||
})),
|
||||
}));
|
||||
return { categories };
|
||||
}
|
||||
|
||||
function StatusIcon({
|
||||
status,
|
||||
className = "size-5",
|
||||
}: {
|
||||
status: VerificationResult;
|
||||
className?: string;
|
||||
}) {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return <CheckCircleIcon className={`${className} text-mint`} />;
|
||||
case "fail":
|
||||
return <XCircleIcon className={`${className} text-coral`} />;
|
||||
case "na":
|
||||
return <MinusCircleIcon className={`${className} text-fg-muted`} />;
|
||||
}
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: VerificationType | null }) {
|
||||
if (type === null) return null;
|
||||
const config = typeConfig[type];
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.color} ${config.bg}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryCard({ category }: { category: VerificationCategory }) {
|
||||
const criteriaStats = getCriteriaSummary(category.criteria);
|
||||
const applicable = criteriaStats.total - criteriaStats.na;
|
||||
const config = statusConfig[category.status];
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
as="div"
|
||||
defaultOpen={category.status === "fail"}
|
||||
className={`rounded-md border border-line overflow-hidden border-l-2 ${config.border}`}
|
||||
>
|
||||
<DisclosureButton className="group flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-overlay">
|
||||
<StatusIcon status={category.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="shrink-0 text-sm font-semibold text-fg">
|
||||
{category.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-fg-muted">
|
||||
{category.question}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 font-mono text-xs tabular-nums text-fg-muted">
|
||||
{criteriaStats.passing}/{applicable}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-fg-muted transition-transform duration-200 group-data-open:rotate-90" />
|
||||
</DisclosureButton>
|
||||
|
||||
<DisclosurePanel
|
||||
transition
|
||||
className="origin-top transition duration-200 ease-out data-closed:-translate-y-1 data-closed:opacity-0"
|
||||
>
|
||||
<div className="border-t border-line">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{category.criteria.map((criterion) => (
|
||||
<tr
|
||||
key={criterion.name}
|
||||
className="border-b border-line last:border-b-0 cursor-pointer transition-colors hover:bg-overlay"
|
||||
>
|
||||
<td className="w-8 py-2.5 pl-5 pr-0">
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${statusConfig[criterion.status].dot}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-2 pr-3 font-medium text-fg-2">
|
||||
{criterion.name}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-fg-muted">
|
||||
{criterion.description || (
|
||||
<span className="italic">Not configured</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-3 pr-4 text-right">
|
||||
<TypeBadge type={criterion.type} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RunVerifications({ loaderData }: Route.ComponentProps) {
|
||||
const { categories } = loaderData;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{categories.map((category) => (
|
||||
<CategoryCard key={category.name} category={category} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,11 +20,10 @@ import {
|
|||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { ciConfig, statusColors, deriveCiStatus, mapRunListItem } from "../data/runs";
|
||||
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/runs";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Runs — Fabro" }];
|
||||
}
|
||||
|
||||
|
|
@ -42,8 +41,8 @@ const columnConfig: {
|
|||
{ id: "merge", name: "Merge", accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: ["Merge"] },
|
||||
];
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const response = await apiJson<PaginatedRunList>("/runs", { request });
|
||||
export async function loader({ request }: any) {
|
||||
const response = await apiJson<PaginatedRunList>("/boards/runs", { request });
|
||||
const apiRuns = response.data;
|
||||
|
||||
const grouped = new Map<ColumnStatus, RunItem[]>();
|
||||
|
|
@ -497,9 +496,13 @@ function SortableRunRow({ run }: { run: RunWithStatus }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function Runs({ loaderData }: Route.ComponentProps) {
|
||||
export default function Runs({ loaderData }: any) {
|
||||
const initialColumns = loaderData.columns;
|
||||
const allRepos = [...new Set(initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => item.repo)))].sort();
|
||||
const allRepos = [
|
||||
...new Set(
|
||||
initialColumns.flatMap((col: Column) => col.items.map((item: RunItem) => String(item.repo))),
|
||||
),
|
||||
].sort();
|
||||
const [query, setQuery] = useState("");
|
||||
const [repoFilter, setRepoFilter] = useState("all");
|
||||
const [view, setView] = useState<ViewMode>("columns");
|
||||
|
|
@ -559,7 +562,7 @@ export default function Runs({ loaderData }: Route.ComponentProps) {
|
|||
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
>
|
||||
<option value="all">All repos</option>
|
||||
{allRepos.map((repo) => (
|
||||
{allRepos.map((repo: string) => (
|
||||
<option key={repo} value={repo}>{repo}</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -1,449 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import {
|
||||
ChatBubbleLeftIcon,
|
||||
ClipboardDocumentIcon,
|
||||
CheckIcon,
|
||||
PencilSquareIcon,
|
||||
UserIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ToolRow, ToolBlock } from "../components/tool-use";
|
||||
import type { ToolUse } from "../components/tool-use";
|
||||
import { timeAgo, groupSessionsByDate } from "../lib/time";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { SessionDetail as ApiSessionDetail, PaginatedSessionList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/session-detail";
|
||||
|
||||
export const handle = { hideHeader: true, wide: true };
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: "Session — Fabro" }];
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const [apiSession, { data: apiSessions }] = await Promise.all([
|
||||
apiJson<ApiSessionDetail>(`/sessions/${params.sessionId}`, { request }),
|
||||
apiJson<PaginatedSessionList>("/sessions", { request }),
|
||||
]);
|
||||
const session: Session = {
|
||||
id: apiSession.id,
|
||||
title: apiSession.title,
|
||||
model: apiSession.model.id,
|
||||
created_at: apiSession.created_at,
|
||||
updated_at: apiSession.updated_at,
|
||||
turns: apiSession.turns.map((t): Turn => {
|
||||
switch (t.kind) {
|
||||
case "tool":
|
||||
return {
|
||||
kind: "tool",
|
||||
tools: t.tools.map((tu) => ({
|
||||
id: tu.id,
|
||||
toolName: tu.tool_name,
|
||||
input: tu.input,
|
||||
result: tu.result,
|
||||
isError: tu.is_error,
|
||||
durationMs: tu.duration_ms,
|
||||
})),
|
||||
};
|
||||
case "user":
|
||||
return { kind: "user", content: t.content, created_at: t.created_at };
|
||||
case "assistant":
|
||||
return { kind: "assistant", content: t.content };
|
||||
}
|
||||
}),
|
||||
};
|
||||
const sessionGroups = groupSessionsByDate(
|
||||
apiSessions.map((s) => ({ id: s.id, title: s.title, created_at: s.created_at }))
|
||||
);
|
||||
return { session, sessionGroups };
|
||||
}
|
||||
|
||||
type Turn =
|
||||
| { kind: "user"; content: string; created_at?: string }
|
||||
| { kind: "assistant"; content: string }
|
||||
| { kind: "tool"; tools: ToolUse[] };
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
turns: Turn[];
|
||||
}
|
||||
|
||||
// Keep hardcoded sessions as fallback
|
||||
const sessions: Record<string, Session> = {
|
||||
s1: {
|
||||
id: "s1",
|
||||
title: "Add rate limiting to auth endpoints",
|
||||
model: "Opus 4.6",
|
||||
created_at: "2026-03-06T14:30:00Z",
|
||||
updated_at: "2026-03-06T15:45:00Z",
|
||||
turns: [
|
||||
{
|
||||
kind: "user",
|
||||
created_at: "2026-02-28T10:00:00Z",
|
||||
content: "Add rate limiting to the auth endpoints. We're getting hit with brute force attempts on /api/auth/login and /api/auth/register. Use a sliding window approach with Redis, 10 requests per minute per IP.",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
content: "I'll implement sliding window rate limiting using Redis. Let me first look at the existing auth routes and middleware setup.",
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
tools: [
|
||||
{
|
||||
id: "toolu_s1_01",
|
||||
toolName: "read_file",
|
||||
input: `{ "path": "src/routes/auth.ts" }`,
|
||||
result: `import { Router } from "express";\nimport { loginHandler } from "../handlers/auth";\nimport { registerHandler } from "../handlers/auth";\n\nconst router = Router();\nrouter.post("/login", loginHandler);\nrouter.post("/register", registerHandler);\n\nexport default router;`,
|
||||
isError: false,
|
||||
durationMs: 45,
|
||||
},
|
||||
{
|
||||
id: "toolu_s1_02",
|
||||
toolName: "read_file",
|
||||
input: `{ "path": "src/middleware/index.ts" }`,
|
||||
result: `export { authMiddleware } from "./auth";\nexport { corsMiddleware } from "./cors";\nexport { loggingMiddleware } from "./logging";`,
|
||||
isError: false,
|
||||
durationMs: 38,
|
||||
},
|
||||
{
|
||||
id: "toolu_s1_03",
|
||||
toolName: "search_files",
|
||||
input: `{ "pattern": "redis", "path": "src/" }`,
|
||||
result: `src/lib/redis.ts:1: import Redis from "ioredis";\nsrc/lib/redis.ts:3: export const redis = new Redis(process.env.REDIS_URL);`,
|
||||
isError: false,
|
||||
durationMs: 210,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
content: "Good — there's already a Redis client at `src/lib/redis.ts`. I'll create a rate limiting middleware using the sliding window algorithm and apply it to the auth routes.\n\nHere's the plan:\n1. Create `src/middleware/rate-limit.ts` with sliding window logic\n2. Apply it to `/login` and `/register` routes\n3. Return `429 Too Many Requests` when the limit is exceeded",
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
tools: [
|
||||
{
|
||||
id: "toolu_s1_04",
|
||||
toolName: "write_file",
|
||||
input: `{ "path": "src/middleware/rate-limit.ts" }`,
|
||||
result: `File written: src/middleware/rate-limit.ts (47 lines)`,
|
||||
isError: false,
|
||||
durationMs: 65,
|
||||
},
|
||||
{
|
||||
id: "toolu_s1_05",
|
||||
toolName: "edit_file",
|
||||
input: `{ "path": "src/routes/auth.ts", "operations": [{ "type": "insert", "line": 3 }] }`,
|
||||
result: `Applied 1 edit to src/routes/auth.ts`,
|
||||
isError: false,
|
||||
durationMs: 42,
|
||||
},
|
||||
{
|
||||
id: "toolu_s1_06",
|
||||
toolName: "edit_file",
|
||||
input: `{ "path": "src/middleware/index.ts", "operations": [{ "type": "append" }] }`,
|
||||
result: `Applied 1 edit to src/middleware/index.ts`,
|
||||
isError: false,
|
||||
durationMs: 55,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
content: "Done. I've created the rate limiter and wired it up:\n\n- **`src/middleware/rate-limit.ts`** — Sliding window rate limiter using Redis sorted sets. Each request adds a timestamped entry, expired entries are pruned, and the count is checked against the limit.\n- **`src/routes/auth.ts`** — Applied `rateLimit({ windowMs: 60_000, max: 10 })` to both `/login` and `/register`.\n- **`src/middleware/index.ts`** — Re-exported the new middleware.\n\nWhen the limit is exceeded, clients get a `429` response with a `Retry-After` header indicating seconds until the window resets.",
|
||||
},
|
||||
],
|
||||
},
|
||||
s2: {
|
||||
id: "s2",
|
||||
title: "Fix config parsing for nested values",
|
||||
model: "Sonnet 4.6",
|
||||
created_at: "2026-03-06T12:30:00Z",
|
||||
updated_at: "2026-03-06T13:15:00Z",
|
||||
turns: [
|
||||
{
|
||||
kind: "user",
|
||||
created_at: "2026-02-28T10:00:00Z",
|
||||
content: "The CLI crashes when parsing nested TOML config values like [database.connection]. Can you debug and fix this?",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
content: "Let me look at the config parser to understand how nested keys are handled.",
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
tools: [
|
||||
{
|
||||
id: "toolu_s2_01",
|
||||
toolName: "read_file",
|
||||
input: `{ "path": "src/config/parser.ts" }`,
|
||||
result: `export function parseConfig(raw: string): Record<string, string> {\n const result: Record<string, string> = {};\n for (const line of raw.split("\\n")) {\n const match = line.match(/^(\\w+)\\s*=\\s*(.+)$/);\n if (match) result[match[1]] = match[2].trim();\n }\n return result;\n}`,
|
||||
isError: false,
|
||||
durationMs: 52,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
content: "Found the issue. The parser uses a flat `Record<string, string>` and the regex `^(\\w+)` only matches simple keys — it can't handle TOML section headers like `[database.connection]` or dotted keys like `database.host`.\n\nI'll rewrite the parser to support nested sections and dotted keys, producing a properly nested object.",
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
tools: [
|
||||
{
|
||||
id: "toolu_s2_02",
|
||||
toolName: "write_file",
|
||||
input: `{ "path": "src/config/parser.ts" }`,
|
||||
result: `File written: src/config/parser.ts (62 lines)`,
|
||||
isError: false,
|
||||
durationMs: 78,
|
||||
},
|
||||
{
|
||||
id: "toolu_s2_03",
|
||||
toolName: "run_command",
|
||||
input: `{ "command": "npm test -- --grep 'config parser'" }`,
|
||||
result: `PASS src/config/__tests__/parser.test.ts\n config parser\n ✓ parses flat keys (2ms)\n ✓ parses section headers (1ms)\n ✓ parses dotted keys (1ms)\n ✓ handles nested sections (1ms)\n\nTest Suites: 1 passed\nTests: 4 passed`,
|
||||
isError: false,
|
||||
durationMs: 2150,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
content: "Fixed. The parser now:\n\n1. Tracks the current section header (e.g. `[database.connection]`)\n2. Constructs nested objects from dotted paths\n3. Handles both `key = value` within sections and bare `dotted.key = value` syntax\n\nAll 4 tests pass, including the new nested section case.",
|
||||
},
|
||||
],
|
||||
},
|
||||
s3: {
|
||||
id: "s3",
|
||||
title: "Migrate to React Router v7",
|
||||
model: "Opus 4.6",
|
||||
created_at: "2026-03-05T10:00:00Z",
|
||||
updated_at: "2026-03-05T11:30:00Z",
|
||||
turns: [
|
||||
{ kind: "user", created_at: "2026-02-26T10:00:00Z", content: "Help me migrate our app from React Router v6 to v7. We're using createBrowserRouter with data loaders." },
|
||||
{ kind: "assistant", content: "I'll audit your current router setup and identify what needs to change for v7. Let me scan the codebase." },
|
||||
{
|
||||
kind: "tool",
|
||||
tools: [
|
||||
{ id: "toolu_s3_01", toolName: "search_files", input: `{ "pattern": "createBrowserRouter" }`, result: `src/main.tsx:5: import { createBrowserRouter } from "react-router-dom";\nsrc/main.tsx:8: const router = createBrowserRouter([`, isError: false, durationMs: 180 },
|
||||
{ id: "toolu_s3_02", toolName: "read_file", input: `{ "path": "package.json" }`, result: `"react-router-dom": "^6.22.0"`, isError: false, durationMs: 35 },
|
||||
],
|
||||
},
|
||||
{ kind: "assistant", content: "You're on React Router 6.22. The migration to v7 involves:\n\n1. Replace `react-router-dom` with `react-router` (single package)\n2. Update `createBrowserRouter` import path\n3. Migrate data loaders to the new `Route.LoaderArgs` type\n4. Update any `useLoaderData` calls to be type-safe\n\nLet me make these changes." },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Fallback for unknown session IDs
|
||||
function makeFallbackSession(id: string): Session {
|
||||
return {
|
||||
id,
|
||||
title: "Session",
|
||||
model: "Opus 4.6",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
turns: [
|
||||
{ kind: "user", created_at: "2026-02-28T10:00:00Z", content: "Hello, let's get started." },
|
||||
{ kind: "assistant", content: "Sure! What would you like to work on?" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
interface SessionGroupType {
|
||||
label: string;
|
||||
sessions: { id: string; title: string; created_at: string }[];
|
||||
}
|
||||
|
||||
const sessionGroups: SessionGroupType[] = [
|
||||
{
|
||||
label: "Today",
|
||||
sessions: [
|
||||
{ id: "s1", title: "Add rate limiting to auth endpoints", created_at: "2026-03-06T14:30:00Z" },
|
||||
{ id: "s2", title: "Fix config parsing for nested values", created_at: "2026-03-06T12:30:00Z" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Yesterday",
|
||||
sessions: [
|
||||
{ id: "s3", title: "Migrate to React Router v7", created_at: "2026-03-05T10:00:00Z" },
|
||||
{ id: "s4", title: "Add dark mode toggle", created_at: "2026-03-05T09:00:00Z" },
|
||||
{ id: "s5", title: "Update OpenAPI spec for v3", created_at: "2026-03-05T08:00:00Z" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Previous 7 days",
|
||||
sessions: [
|
||||
{ id: "s6", title: "Terraform module for Redis cluster", created_at: "2026-03-03T15:00:00Z" },
|
||||
{ id: "s7", title: "Add pipeline event types", created_at: "2026-03-01T11:00:00Z" },
|
||||
{ id: "s8", title: "Implement webhook retry logic", created_at: "2026-02-28T09:00:00Z" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center justify-center rounded-md border border-line bg-panel/80 p-1.5 text-fg-muted transition-colors hover:border-line-strong hover:text-fg-3"
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied
|
||||
? <CheckIcon className="size-3.5" />
|
||||
: <ClipboardDocumentIcon className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UserBlock({ content, created_at }: { content: string; created_at?: string }) {
|
||||
return (
|
||||
<div className="group">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-panel border border-line-strong">
|
||||
<UserIcon className="size-3.5 text-fg-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm leading-relaxed text-fg-2">{content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-10 mt-1 flex h-6 items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<CopyButton text={content} />
|
||||
{created_at != null && <span className="text-[11px] text-fg-muted">{timeAgo(created_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantBlock({ content, showCopy }: { content: string; showCopy: boolean }) {
|
||||
return (
|
||||
<div className="group">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-full bg-teal-500/10 border border-teal-500/20">
|
||||
<ChatBubbleLeftIcon className="size-3.5 text-teal-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm leading-relaxed text-fg-3">{content}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{showCopy && (
|
||||
<div className="ml-10 mt-1 h-6 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<CopyButton text={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionSidebar({ activeId, groups }: { activeId: string; groups: SessionGroupType[] }) {
|
||||
return (
|
||||
<aside className="w-64 shrink-0 border-r border-line flex flex-col h-[calc(100vh-4rem)]">
|
||||
<div className="p-3">
|
||||
<Link
|
||||
to="/start"
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-line bg-panel/60 px-3 py-2 text-sm text-fg-2 transition-colors hover:bg-panel hover:border-line-strong"
|
||||
>
|
||||
<PencilSquareIcon className="size-4 text-fg-muted" />
|
||||
New session
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-3 pb-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className="mt-4 first:mt-1">
|
||||
<p className="px-2 mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-muted">
|
||||
{group.label}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{group.sessions.map((s) => (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
className={`flex w-full flex-col rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||
activeId === s.id
|
||||
? "bg-overlay text-fg-2"
|
||||
: "text-fg-3 hover:bg-overlay"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate text-sm">{s.title}</span>
|
||||
<span className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[11px] text-fg-muted">{timeAgo(s.created_at)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SessionDetail({ loaderData }: Route.ComponentProps) {
|
||||
const { session, sessionGroups: loaderGroups } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="flex -mx-4 sm:-mx-6 lg:-mx-8 -my-6">
|
||||
<SessionSidebar activeId={session.id} groups={loaderGroups} />
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-[calc(100vh-4rem)]">
|
||||
<div className="border-b border-line px-6 py-3 flex items-center gap-3">
|
||||
<h1 className="text-sm font-medium text-fg-2">{session.title}</h1>
|
||||
<span className="text-xs text-fg-muted">{timeAgo(session.created_at)}</span>
|
||||
<span className="ml-auto font-mono text-xs text-fg-muted">{session.model}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
<div className="mx-auto max-w-3xl space-y-5">
|
||||
{session.turns.map((turn, i) => {
|
||||
switch (turn.kind) {
|
||||
case "user":
|
||||
return <UserBlock key={`turn-${i}`} content={turn.content} created_at={turn.created_at} />;
|
||||
case "assistant": {
|
||||
const next = session.turns[i + 1];
|
||||
const showCopy = next?.kind !== "tool";
|
||||
return <AssistantBlock key={`turn-${i}`} content={turn.content} showCopy={showCopy} />;
|
||||
}
|
||||
case "tool":
|
||||
return <div key={`turn-${i}`} className="pl-10"><ToolBlock tools={turn.tools} /></div>;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-line px-6 py-4">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-line bg-panel/80 px-4 py-3 focus-within:border-focus">
|
||||
<textarea
|
||||
placeholder="Send a message..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent text-sm text-fg-2 placeholder-fg-muted outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-teal-500 text-white transition-colors hover:bg-teal-400"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className="size-4" aria-hidden="true">
|
||||
<path d="M3.105 2.288a.75.75 0 0 0-.826.95l1.414 4.926A1.5 1.5 0 0 0 5.135 9.25h6.115a.75.75 0 0 1 0 1.5H5.135a1.5 1.5 0 0 0-1.442 1.086l-1.414 4.926a.75.75 0 0 0 .826.95l14.095-5.637a.75.75 0 0 0 0-1.395L3.105 2.289Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +1,25 @@
|
|||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { CollapsibleFile } from "../components/collapsible-file";
|
||||
import type { ServerSettings } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/settings";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
/**
|
||||
* Opaque server settings payload returned by `/api/v1/settings`. Mirrors the
|
||||
* v2 `SettingsFile` shape with secret-bearing subtrees dropped before
|
||||
* serialization. The UI only renders it as JSON.
|
||||
*/
|
||||
type ServerSettings = Record<string, unknown>;
|
||||
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Settings — Fabro" }];
|
||||
}
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ request }: any) {
|
||||
const settings = await apiJson<ServerSettings>("/settings", { request });
|
||||
return { settings };
|
||||
}
|
||||
|
||||
export default function Settings({ loaderData }: Route.ComponentProps) {
|
||||
export default function Settings({ loaderData }: any) {
|
||||
const { settings } = loaderData;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { redirect } from "react-router";
|
||||
import { parse, stringify } from "smol-toml";
|
||||
import { FABRO_CONFIG_PATH, reloadAppConfig } from "../lib/config.server";
|
||||
import { mergeEnv } from "../lib/merge-env";
|
||||
import type { Route } from "./+types/setup-callback";
|
||||
|
||||
const ENV_PATH = resolve(import.meta.dirname, "../../../../.env");
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
return { error: "Missing code parameter" };
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.github.com/app-manifests/${code}/conversions`,
|
||||
{ method: "POST", headers: { Accept: "application/vnd.github+json" } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return { error: `GitHub API error: ${response.status} ${body}` };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
id: number;
|
||||
slug: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
webhook_secret: string;
|
||||
pem: string;
|
||||
};
|
||||
|
||||
const sessionSecret = randomBytes(32).toString("hex");
|
||||
|
||||
// Write non-secrets to TOML config
|
||||
let tomlConfig: Record<string, unknown> = {};
|
||||
try {
|
||||
tomlConfig = parse(await readFile(FABRO_CONFIG_PATH, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
// file doesn't exist yet
|
||||
}
|
||||
tomlConfig.git = {
|
||||
...((tomlConfig.git as Record<string, unknown>) ?? {}),
|
||||
provider: "github",
|
||||
app_id: String(data.id),
|
||||
client_id: data.client_id,
|
||||
slug: data.slug,
|
||||
};
|
||||
await mkdir(dirname(FABRO_CONFIG_PATH), { recursive: true });
|
||||
await writeFile(FABRO_CONFIG_PATH, stringify(tomlConfig), "utf-8");
|
||||
reloadAppConfig();
|
||||
|
||||
// Write secrets to .env (merge to avoid duplicates on re-run)
|
||||
let existing = "";
|
||||
try {
|
||||
existing = await readFile(ENV_PATH, "utf-8");
|
||||
} catch {
|
||||
// file doesn't exist yet
|
||||
}
|
||||
|
||||
const envContent = mergeEnv(
|
||||
existing,
|
||||
new Map([
|
||||
["SESSION_SECRET", sessionSecret],
|
||||
["GITHUB_APP_CLIENT_SECRET", data.client_secret],
|
||||
["GITHUB_APP_WEBHOOK_SECRET", data.webhook_secret],
|
||||
["GITHUB_APP_PRIVATE_KEY", Buffer.from(data.pem).toString("base64")],
|
||||
]),
|
||||
);
|
||||
await writeFile(ENV_PATH, envContent, "utf-8");
|
||||
|
||||
process.env.SESSION_SECRET = sessionSecret;
|
||||
process.env.GITHUB_APP_CLIENT_SECRET = data.client_secret;
|
||||
process.env.GITHUB_APP_WEBHOOK_SECRET = data.webhook_secret;
|
||||
process.env.GITHUB_APP_PRIVATE_KEY = Buffer.from(data.pem).toString("base64");
|
||||
|
||||
throw redirect("/auth/login");
|
||||
}
|
||||
|
||||
export default function SetupCallback({ loaderData }: Route.ComponentProps) {
|
||||
const { error } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-atmosphere px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<img src="/logo.svg" alt="Fabro" className="h-12 w-12" draggable={false} />
|
||||
</div>
|
||||
<div className="rounded-xl border border-line bg-panel/80 p-8 shadow-lg backdrop-blur-sm">
|
||||
<div className="mx-auto mb-4 flex h-10 w-10 items-center justify-center rounded-full bg-coral/10">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="text-coral">
|
||||
<path d="M7 7L13 13M13 7L7 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Setup failed
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">{error}</p>
|
||||
<a
|
||||
href="/setup"
|
||||
className="mt-6 flex w-full items-center justify-center rounded-lg border border-line-strong px-4 py-2.5 text-sm font-medium text-fg-2 transition-colors hover:bg-overlay-strong"
|
||||
>
|
||||
Try again
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/fabro-web/app/routes/setup-complete.tsx
Normal file
105
apps/fabro-web/app/routes/setup-complete.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AuthLayout } from "../components/auth-layout";
|
||||
|
||||
type SetupState = "registering" | "done" | "error";
|
||||
|
||||
export default function SetupComplete() {
|
||||
const code = useMemo(() => new URLSearchParams(window.location.search).get("code"), []);
|
||||
const [state, setState] = useState<SetupState>(code ? "registering" : "done");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [restartRequired, setRestartRequired] = useState(false);
|
||||
const registeredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (registeredRef.current) return;
|
||||
registeredRef.current = true;
|
||||
|
||||
async function register() {
|
||||
if (!code) {
|
||||
setState("done");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/v1/setup/register", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
setError(payload.error ?? "Setup registration failed.");
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
setRestartRequired(payload.restart_required === true);
|
||||
setState("done");
|
||||
window.history.replaceState({}, "", "/setup/complete");
|
||||
}
|
||||
|
||||
void register();
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
{state === "registering" && (
|
||||
<>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Finishing setup
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
Registering your GitHub App and writing local configuration.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "done" && (
|
||||
<>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Setup complete
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
{restartRequired
|
||||
? "Your GitHub App is configured. Restart the Fabro server before attempting login."
|
||||
: "Your GitHub App has been registered and configured."}
|
||||
</p>
|
||||
{restartRequired ? (
|
||||
<a
|
||||
href="/setup"
|
||||
className="mt-6 flex w-full items-center justify-center rounded-lg border border-line-strong px-4 py-2.5 text-sm font-medium text-fg-2 transition-colors hover:bg-overlay-strong"
|
||||
>
|
||||
Back to setup
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href="/login"
|
||||
className="mt-6 flex w-full items-center justify-center rounded-lg bg-teal-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-300"
|
||||
>
|
||||
Continue to sign in
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{state === "error" && (
|
||||
<>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Setup failed
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
{error}
|
||||
</p>
|
||||
<a
|
||||
href="/setup"
|
||||
className="mt-6 flex w-full items-center justify-center rounded-lg border border-line-strong px-4 py-2.5 text-sm font-medium text-fg-2 transition-colors hover:bg-overlay-strong"
|
||||
>
|
||||
Try again
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +1,27 @@
|
|||
import { redirect } from "react-router";
|
||||
import { useMemo } from "react";
|
||||
import { AuthLayout } from "../components/auth-layout";
|
||||
import { isGitHubAppConfigured } from "../lib/github.server";
|
||||
import type { Route } from "./+types/setup";
|
||||
|
||||
export function loader({ request }: Route.LoaderArgs) {
|
||||
if (isGitHubAppConfigured()) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const baseUrl = `${url.protocol}//${url.host}`;
|
||||
const suffix = Math.random().toString(16).slice(2, 8);
|
||||
|
||||
const manifest = {
|
||||
name: `Fabro-${suffix}`,
|
||||
url: baseUrl,
|
||||
redirect_url: `${baseUrl}/setup/callback`,
|
||||
callback_urls: [`${baseUrl}/auth/callback`],
|
||||
setup_url: `${baseUrl}/setup/callback`,
|
||||
public: false,
|
||||
default_permissions: {
|
||||
contents: "write",
|
||||
metadata: "read",
|
||||
pull_requests: "write",
|
||||
checks: "write",
|
||||
issues: "write",
|
||||
emails: "read",
|
||||
},
|
||||
default_events: [] as string[],
|
||||
};
|
||||
|
||||
return { manifest: JSON.stringify(manifest), baseUrl };
|
||||
}
|
||||
|
||||
export default function Setup({ loaderData }: Route.ComponentProps) {
|
||||
const { manifest } = loaderData;
|
||||
export default function Setup() {
|
||||
const manifest = useMemo(() => {
|
||||
const baseUrl = window.location.origin;
|
||||
const suffix = Math.random().toString(16).slice(2, 8);
|
||||
return JSON.stringify({
|
||||
name: `Fabro-${suffix}`,
|
||||
url: "https://fabro.sh",
|
||||
redirect_url: `${baseUrl}/setup/complete`,
|
||||
callback_urls: [`${baseUrl}/auth/callback/github`],
|
||||
setup_url: `${baseUrl}/setup/complete`,
|
||||
public: false,
|
||||
default_permissions: {
|
||||
contents: "write",
|
||||
metadata: "read",
|
||||
pull_requests: "write",
|
||||
checks: "write",
|
||||
issues: "write",
|
||||
emails: "read",
|
||||
},
|
||||
default_events: [],
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
|
|
|
|||
|
|
@ -11,34 +11,22 @@ import {
|
|||
FolderIcon,
|
||||
} from "@heroicons/react/16/solid";
|
||||
import {
|
||||
BoltIcon,
|
||||
BugAntIcon,
|
||||
CodeBracketIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PencilSquareIcon,
|
||||
ShieldCheckIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link } from "react-router";
|
||||
import { timeAgo, groupSessionsByDate } from "../lib/time";
|
||||
import { apiJson } from "../api-client";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import type { PaginatedSessionList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/start";
|
||||
import { getAuthMe } from "../api";
|
||||
|
||||
export const handle = { hideHeader: true, wide: true };
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Start — Fabro" }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { features } = getAppConfig();
|
||||
const { data: apiSessions } = await apiJson<PaginatedSessionList>("/sessions", { request });
|
||||
const sessionGroups = groupSessionsByDate(
|
||||
apiSessions.map((s) => ({ id: s.id, title: s.title, created_at: s.created_at }))
|
||||
);
|
||||
return { sessionGroups, features };
|
||||
export async function loader({ request }: any) {
|
||||
const { features } = await getAuthMe();
|
||||
return { features };
|
||||
}
|
||||
|
||||
const projects = [
|
||||
|
|
@ -61,45 +49,8 @@ function BranchIcon({ className }: { className?: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SessionSidebar({ groups }: { groups: { label: string; sessions: { id: string; title: string; created_at: string }[] }[] }) {
|
||||
return (
|
||||
<aside className="w-64 shrink-0 border-r border-line flex flex-col h-[calc(100vh-4rem)]">
|
||||
<div className="p-3">
|
||||
<div className="flex w-full items-center gap-2 rounded-lg border border-teal-500/20 bg-panel/60 px-3 py-2 text-sm text-fg-2">
|
||||
<PencilSquareIcon className="size-4 text-teal-500" />
|
||||
New session
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto px-3 pb-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className="mt-4 first:mt-1">
|
||||
<p className="px-2 mb-1.5 text-[11px] font-medium uppercase tracking-wider text-fg-muted">
|
||||
{group.label}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{group.sessions.map((session) => (
|
||||
<li key={session.id}>
|
||||
<Link
|
||||
to={`/sessions/${session.id}`}
|
||||
className="flex w-full flex-col rounded-lg px-2.5 py-2 text-left transition-colors text-fg-3 hover:bg-overlay"
|
||||
>
|
||||
<span className="truncate text-sm">{session.title}</span>
|
||||
<span className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="text-[11px] text-fg-muted">{timeAgo(session.created_at)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Start({ loaderData }: Route.ComponentProps) {
|
||||
const { sessionGroups, features } = loaderData;
|
||||
export default function Start({ loaderData }: any) {
|
||||
const { features } = loaderData;
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [project, setProject] = useState(projects[0]);
|
||||
const [branch, setBranch] = useState(branches[0]);
|
||||
|
|
@ -131,8 +82,6 @@ export default function Start({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
return (
|
||||
<div className="flex -mx-4 sm:-mx-6 lg:-mx-8 -my-6">
|
||||
<SessionSidebar groups={sessionGroups} />
|
||||
|
||||
<div className="flex-1 flex flex-col items-center pt-[12vh] px-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
<h1 className="flex items-center justify-center gap-3 text-[2rem] font-medium tracking-tight text-fg-2 text-center mb-8">
|
||||
|
|
|
|||
|
|
@ -1,397 +0,0 @@
|
|||
import { Link, useParams } from "react-router";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LightBulbIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
BookOpenIcon,
|
||||
FunnelIcon,
|
||||
Bars3BottomLeftIcon,
|
||||
WrenchIcon,
|
||||
PaintBrushIcon,
|
||||
CheckBadgeIcon,
|
||||
BugAntIcon,
|
||||
BoltIcon,
|
||||
BeakerIcon,
|
||||
StarIcon,
|
||||
ComputerDesktopIcon,
|
||||
CubeTransparentIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
DocumentDuplicateIcon,
|
||||
SparklesIcon,
|
||||
ArchiveBoxXMarkIcon,
|
||||
ShieldExclamationIcon,
|
||||
ServerStackIcon,
|
||||
ExclamationTriangleIcon,
|
||||
LockClosedIcon,
|
||||
PuzzlePieceIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
EyeIcon,
|
||||
CurrencyDollarIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
CpuChipIcon,
|
||||
FingerPrintIcon,
|
||||
HandRaisedIcon,
|
||||
ScaleIcon,
|
||||
MapPinIcon,
|
||||
DocumentTextIcon,
|
||||
ShieldCheckIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
KeyIcon,
|
||||
RocketLaunchIcon,
|
||||
BuildingLibraryIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
MinusCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
slugify,
|
||||
typeConfig,
|
||||
modeConfig,
|
||||
statusConfig,
|
||||
} from "../data/verifications";
|
||||
import type {
|
||||
VerificationType,
|
||||
VerificationMode,
|
||||
VerificationResult,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import { timeAgo } from "../lib/time";
|
||||
import type { VerificationDetailResponse } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/verification-control";
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const data = await apiJson<VerificationDetailResponse>(`/verification/controls/${params.id}`, { request });
|
||||
return { data };
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
const name = data?.data?.control?.name ?? "Verification";
|
||||
return [{ title: `${name} — Verification — Fabro` }];
|
||||
}
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>;
|
||||
|
||||
const criterionIcons: Record<string, IconComponent> = {
|
||||
"Motivation": LightBulbIcon,
|
||||
"Specifications": ClipboardDocumentListIcon,
|
||||
"Documentation": BookOpenIcon,
|
||||
"Minimization": FunnelIcon,
|
||||
"Formatting": Bars3BottomLeftIcon,
|
||||
"Linting": WrenchIcon,
|
||||
"Style": PaintBrushIcon,
|
||||
"Completeness": CheckBadgeIcon,
|
||||
"Defects": BugAntIcon,
|
||||
"Performance": BoltIcon,
|
||||
"Test Coverage": BeakerIcon,
|
||||
"Test Quality": StarIcon,
|
||||
"E2E Coverage": ComputerDesktopIcon,
|
||||
"Architecture": CubeTransparentIcon,
|
||||
"Interfaces": ArrowsRightLeftIcon,
|
||||
"Duplication": DocumentDuplicateIcon,
|
||||
"Simplicity": SparklesIcon,
|
||||
"Dead Code": ArchiveBoxXMarkIcon,
|
||||
"Vulnerabilities": ShieldExclamationIcon,
|
||||
"IaC Scanning": ServerStackIcon,
|
||||
"Dependency Alerts": ExclamationTriangleIcon,
|
||||
"Security Controls": LockClosedIcon,
|
||||
"Compatibility": PuzzlePieceIcon,
|
||||
"Rollout / Rollback": ArrowUturnLeftIcon,
|
||||
"Observability": EyeIcon,
|
||||
"Cost": CurrencyDollarIcon,
|
||||
"Change Control": ClipboardDocumentCheckIcon,
|
||||
"AI Governance": CpuChipIcon,
|
||||
"Privacy": FingerPrintIcon,
|
||||
"Accessibility": HandRaisedIcon,
|
||||
"Licensing": ScaleIcon,
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, IconComponent> = {
|
||||
"Traceability": MapPinIcon,
|
||||
"Readability": DocumentTextIcon,
|
||||
"Reliability": ShieldCheckIcon,
|
||||
"Code Coverage": BeakerIcon,
|
||||
"Maintainability": WrenchScrewdriverIcon,
|
||||
"Security": KeyIcon,
|
||||
"Deployability": RocketLaunchIcon,
|
||||
"Compliance": BuildingLibraryIcon,
|
||||
};
|
||||
|
||||
function TypeBadge({ type }: { type: VerificationType | null }) {
|
||||
if (type === null) return null;
|
||||
const config = typeConfig[type];
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.color} ${config.bg}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeBadge({ mode }: { mode: VerificationMode }) {
|
||||
const config = modeConfig[mode];
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.color} ${config.bg}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-panel/60 px-4 py-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-fg-muted">{label}</p>
|
||||
<p className={`mt-1 font-mono text-lg font-semibold tabular-nums ${warn ? "text-amber" : "text-fg"}`}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvaluationBar({ evaluations }: { evaluations: readonly VerificationResult[] }) {
|
||||
if (evaluations.length === 0) {
|
||||
return <p className="text-sm italic text-fg-muted">No evaluations yet</p>;
|
||||
}
|
||||
|
||||
const passCount = evaluations.filter((e) => e === "pass").length;
|
||||
const failCount = evaluations.filter((e) => e === "fail").length;
|
||||
const skipCount = evaluations.filter((e) => e === "skip").length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
{evaluations.map((result, i) => (
|
||||
<div
|
||||
key={`eval-${i}`}
|
||||
className={`h-6 flex-1 rounded-sm ${
|
||||
result === "pass"
|
||||
? "bg-mint/70"
|
||||
: result === "fail"
|
||||
? "bg-coral/70"
|
||||
: "bg-navy-600/50"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-4 text-xs text-fg-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block size-2 rounded-sm bg-mint/70" />
|
||||
{passCount} pass
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block size-2 rounded-sm bg-coral/70" />
|
||||
{failCount} fail
|
||||
</span>
|
||||
{skipCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block size-2 rounded-sm bg-navy-600/50" />
|
||||
{skipCount} skip
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultIcon({ result }: { result: VerificationResult }) {
|
||||
const config = statusConfig[result];
|
||||
if (result === "pass") return <CheckCircleIcon className={`size-4 ${config.color}`} />;
|
||||
if (result === "fail") return <XCircleIcon className={`size-4 ${config.color}`} />;
|
||||
return <MinusCircleIcon className={`size-4 ${config.color}`} />;
|
||||
}
|
||||
|
||||
export default function VerificationDetail({ loaderData }: Route.ComponentProps) {
|
||||
const { data } = loaderData;
|
||||
const { control: controlInfo, performance: apiPerf, control_detail: apiDetail, recent_results: apiRecentResults, siblings: apiSiblings } = data;
|
||||
|
||||
const criterion = {
|
||||
name: controlInfo.name,
|
||||
description: controlInfo.description,
|
||||
type: (controlInfo.type ?? null) as VerificationType | null,
|
||||
};
|
||||
const categoryName = controlInfo.criterion.name;
|
||||
const performance = {
|
||||
f1: apiPerf.f1 ?? null,
|
||||
passAt1: apiPerf.pass_at_1 ?? null,
|
||||
mode: apiPerf.mode as VerificationMode,
|
||||
evaluations: apiPerf.evaluations as VerificationResult[],
|
||||
};
|
||||
const detail = apiDetail ? {
|
||||
description: apiDetail.rationale,
|
||||
checks: apiDetail.checks,
|
||||
passExample: apiDetail.pass_example,
|
||||
failExample: apiDetail.fail_example,
|
||||
} : null;
|
||||
const recentResults = apiRecentResults.map((r) => ({
|
||||
runId: r.run.id,
|
||||
runTitle: r.run.title,
|
||||
workflow: r.workflow.slug,
|
||||
result: r.result as VerificationResult,
|
||||
timestamp: r.timestamp,
|
||||
}));
|
||||
const siblings = apiSiblings.map((s) => ({
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
type: (s.type ?? null) as VerificationType | null,
|
||||
mode: (s.mode ?? "disabled") as VerificationMode,
|
||||
}));
|
||||
|
||||
const Icon = criterionIcons[criterion.name];
|
||||
const CatIcon = categoryIcons[categoryName];
|
||||
|
||||
const passRate = performance.evaluations.length > 0
|
||||
? (performance.evaluations.filter((e) => e === "pass").length / performance.evaluations.length * 100).toFixed(0)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1 text-sm text-fg-muted">
|
||||
<Link to="/verification/criteria" className="text-fg-3 hover:text-fg">Verification</Link>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
<span className="text-fg-3">{categoryName}</span>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
<span>{criterion.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
{Icon && <Icon className="mt-0.5 size-6 text-fg-3" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl font-semibold text-fg">{criterion.name}</h2>
|
||||
<p className="mt-1 text-sm text-fg-muted">{criterion.description}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<TypeBadge type={criterion.type} />
|
||||
<ModeBadge mode={performance.mode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description block */}
|
||||
{detail && (
|
||||
<div className="rounded-md border border-line bg-panel/60 px-5 py-4">
|
||||
<p className="text-sm leading-relaxed text-fg-2">{detail.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<StatCard label="Accuracy (F1)" value={performance.f1 != null ? performance.f1.toFixed(2) : "—"} />
|
||||
<StatCard label="pass@1" value={performance.passAt1 != null ? performance.passAt1.toFixed(2) : "—"} />
|
||||
<StatCard label="Pass Rate" value={passRate != null ? `${passRate}%` : "—"} />
|
||||
<StatCard label="Total Evals" value={String(performance.evaluations.length)} />
|
||||
</div>
|
||||
|
||||
{/* Evaluation history */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg">Evaluation History</h3>
|
||||
<EvaluationBar evaluations={performance.evaluations} />
|
||||
</div>
|
||||
|
||||
{/* Recent runs table */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg">Recent Runs</h3>
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<th className="py-2.5 pl-4 pr-3 font-medium">Run</th>
|
||||
<th className="py-2.5 px-3 font-medium w-8">Result</th>
|
||||
<th className="py-2.5 px-3 font-medium">Workflow</th>
|
||||
<th className="py-2.5 pl-3 pr-4 font-medium text-right">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentResults.map((run) => (
|
||||
<tr key={run.runId} className="border-b border-line last:border-b-0 cursor-pointer transition-colors hover:bg-overlay">
|
||||
<td className="py-2.5 pl-4 pr-3">
|
||||
<Link to={`/runs/${run.runId}`} className="font-medium text-fg-2 hover:text-fg">
|
||||
{run.runTitle}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<ResultIcon result={run.result} />
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-fg-muted">{run.workflow}</td>
|
||||
<td className="py-2.5 pl-3 pr-4 text-right text-fg-muted">{timeAgo(run.timestamp)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What this checks / Examples */}
|
||||
{detail && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-md border border-line bg-panel/60 px-5 py-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg">What This Checks</h3>
|
||||
<ul className="space-y-2 text-sm text-fg-2">
|
||||
{detail.checks.map((check) => (
|
||||
<li key={check} className="flex items-start gap-2">
|
||||
<span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-muted" />
|
||||
{check}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-line bg-panel/60 px-5 py-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-mint">Pass Example</h3>
|
||||
<p className="text-sm text-fg-2">{detail.passExample}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-line bg-panel/60 px-5 py-4">
|
||||
<h3 className="mb-2 text-sm font-semibold text-coral">Fail Example</h3>
|
||||
<p className="text-sm text-fg-2">{detail.failExample}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sibling controls */}
|
||||
{siblings.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold text-fg">
|
||||
{CatIcon && <CatIcon className="mr-1.5 inline size-4 text-fg-3" />}
|
||||
Other {categoryName} Controls
|
||||
</h3>
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{siblings.map((sibling) => {
|
||||
const SibIcon = criterionIcons[sibling.name];
|
||||
return (
|
||||
<tr key={sibling.name} className="border-b border-line last:border-b-0 cursor-pointer transition-colors hover:bg-overlay">
|
||||
<td className="w-8 py-2.5 pl-4 pr-0">
|
||||
{SibIcon && <SibIcon className="size-4 text-fg-3" />}
|
||||
</td>
|
||||
<td className="py-2.5 pl-2 pr-3">
|
||||
<Link
|
||||
to={`/verification/controls/${sibling.slug}`}
|
||||
className="font-medium text-fg-2 hover:text-fg"
|
||||
>
|
||||
{sibling.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-fg-muted" />
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right">
|
||||
<TypeBadge type={sibling.type} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-3 pr-4">
|
||||
<ModeBadge mode={sibling.mode} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
LightBulbIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
BookOpenIcon,
|
||||
FunnelIcon,
|
||||
Bars3BottomLeftIcon,
|
||||
WrenchIcon,
|
||||
PaintBrushIcon,
|
||||
CheckBadgeIcon,
|
||||
BugAntIcon,
|
||||
BoltIcon,
|
||||
BeakerIcon,
|
||||
StarIcon,
|
||||
ComputerDesktopIcon,
|
||||
CubeTransparentIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
DocumentDuplicateIcon,
|
||||
SparklesIcon,
|
||||
ArchiveBoxXMarkIcon,
|
||||
ShieldExclamationIcon,
|
||||
ServerStackIcon,
|
||||
ExclamationTriangleIcon,
|
||||
LockClosedIcon,
|
||||
PuzzlePieceIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
EyeIcon,
|
||||
CurrencyDollarIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
CpuChipIcon,
|
||||
FingerPrintIcon,
|
||||
HandRaisedIcon,
|
||||
ScaleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
typeConfig,
|
||||
modeConfig,
|
||||
} from "../data/verifications";
|
||||
import type {
|
||||
VerificationType,
|
||||
VerificationMode,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { VerificationControlListItem } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/verification-controls";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { data: controls } = await apiJson<{ data: VerificationControlListItem[] }>("/verification/controls", { request });
|
||||
return { controls };
|
||||
}
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: "Controls — Verification — Fabro" }];
|
||||
}
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>;
|
||||
|
||||
const controlIcons: Record<string, IconComponent> = {
|
||||
"Motivation": LightBulbIcon,
|
||||
"Specifications": ClipboardDocumentListIcon,
|
||||
"Documentation": BookOpenIcon,
|
||||
"Minimization": FunnelIcon,
|
||||
"Formatting": Bars3BottomLeftIcon,
|
||||
"Linting": WrenchIcon,
|
||||
"Style": PaintBrushIcon,
|
||||
"Completeness": CheckBadgeIcon,
|
||||
"Defects": BugAntIcon,
|
||||
"Performance": BoltIcon,
|
||||
"Test Coverage": BeakerIcon,
|
||||
"Test Quality": StarIcon,
|
||||
"E2E Coverage": ComputerDesktopIcon,
|
||||
"Architecture": CubeTransparentIcon,
|
||||
"Interfaces": ArrowsRightLeftIcon,
|
||||
"Duplication": DocumentDuplicateIcon,
|
||||
"Simplicity": SparklesIcon,
|
||||
"Dead Code": ArchiveBoxXMarkIcon,
|
||||
"Vulnerabilities": ShieldExclamationIcon,
|
||||
"IaC Scanning": ServerStackIcon,
|
||||
"Dependency Alerts": ExclamationTriangleIcon,
|
||||
"Security Controls": LockClosedIcon,
|
||||
"Compatibility": PuzzlePieceIcon,
|
||||
"Rollout / Rollback": ArrowUturnLeftIcon,
|
||||
"Observability": EyeIcon,
|
||||
"Cost": CurrencyDollarIcon,
|
||||
"Change Control": ClipboardDocumentCheckIcon,
|
||||
"AI Governance": CpuChipIcon,
|
||||
"Privacy": FingerPrintIcon,
|
||||
"Accessibility": HandRaisedIcon,
|
||||
"Licensing": ScaleIcon,
|
||||
};
|
||||
|
||||
function TypeBadge({ type }: { type: VerificationType | null }) {
|
||||
if (type === null) return null;
|
||||
const config = typeConfig[type];
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.color} ${config.bg}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeBadge({ mode }: { mode: VerificationMode }) {
|
||||
const config = modeConfig[mode];
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.color} ${config.bg}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerificationControls({ loaderData }: Route.ComponentProps) {
|
||||
const { controls } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<th className="w-8 py-2.5 pl-4 pr-0 font-medium" />
|
||||
<th className="py-2.5 pl-2 pr-3 font-medium">Control</th>
|
||||
<th className="py-2.5 px-3 font-medium">Description</th>
|
||||
<th className="py-2.5 px-3 font-medium">Criterion</th>
|
||||
<th className="py-2.5 px-3 font-medium text-right">Type</th>
|
||||
<th className="py-2.5 px-3 font-medium text-right">Accuracy (F1)</th>
|
||||
<th className="py-2.5 px-3 font-medium text-right">pass@1</th>
|
||||
<th className="py-2.5 pl-3 pr-4 font-medium">Mode</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{controls.map((control) => {
|
||||
const Icon = controlIcons[control.name];
|
||||
const mode = (control.mode ?? "disabled") as VerificationMode;
|
||||
return (
|
||||
<tr
|
||||
key={control.slug}
|
||||
className="border-b border-line last:border-b-0 cursor-pointer transition-colors hover:bg-overlay"
|
||||
onClick={() => navigate(`/verification/controls/${control.slug}`)}
|
||||
>
|
||||
<td className="w-8 py-2.5 pl-4 pr-0">
|
||||
{Icon && <Icon className="size-4 text-fg-3" />}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-2 pr-3 font-medium text-fg-2">
|
||||
{control.name}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-fg-muted">
|
||||
{control.description || (
|
||||
<span className="italic">Not configured</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-xs text-fg-muted">
|
||||
{control.criterion.name}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right">
|
||||
<TypeBadge type={(control.type ?? null) as VerificationType | null} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right font-mono text-xs tabular-nums text-fg-2">
|
||||
{control.f1 != null ? control.f1.toFixed(2) : <span className="text-fg-muted">—</span>}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right font-mono text-xs tabular-nums text-fg-2">
|
||||
{control.pass_at_1 != null ? control.pass_at_1.toFixed(2) : <span className="text-fg-muted">—</span>}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-3 pr-4">
|
||||
<ModeBadge mode={mode} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,450 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from "@headlessui/react";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LightBulbIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
BookOpenIcon,
|
||||
FunnelIcon,
|
||||
Bars3BottomLeftIcon,
|
||||
WrenchIcon,
|
||||
PaintBrushIcon,
|
||||
CheckBadgeIcon,
|
||||
BugAntIcon,
|
||||
BoltIcon,
|
||||
BeakerIcon,
|
||||
StarIcon,
|
||||
ComputerDesktopIcon,
|
||||
CubeTransparentIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
DocumentDuplicateIcon,
|
||||
SparklesIcon,
|
||||
ArchiveBoxXMarkIcon,
|
||||
ShieldExclamationIcon,
|
||||
ServerStackIcon,
|
||||
ExclamationTriangleIcon,
|
||||
LockClosedIcon,
|
||||
PuzzlePieceIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
EyeIcon,
|
||||
CurrencyDollarIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
CpuChipIcon,
|
||||
FingerPrintIcon,
|
||||
HandRaisedIcon,
|
||||
ScaleIcon,
|
||||
MapPinIcon,
|
||||
DocumentTextIcon,
|
||||
ShieldCheckIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
KeyIcon,
|
||||
RocketLaunchIcon,
|
||||
BuildingLibraryIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
typeConfig,
|
||||
modeConfig,
|
||||
slugify,
|
||||
} from "../data/verifications";
|
||||
import type {
|
||||
VerificationType,
|
||||
VerificationMode,
|
||||
VerificationResult,
|
||||
VerificationCategory,
|
||||
CriterionPerformance,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { VerificationCriterion as ApiVerificationCriterion } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/verification-criteria";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { data: apiCategories } = await apiJson<{ data: ApiVerificationCriterion[] }>("/verification/criteria", { request });
|
||||
const categories: VerificationCategory[] = apiCategories.map((cat) => ({
|
||||
name: cat.name,
|
||||
question: cat.question,
|
||||
status: "pass" as const,
|
||||
criteria: cat.controls.map((c) => ({
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
type: (c.type ?? null) as VerificationType | null,
|
||||
status: "pass" as const,
|
||||
})),
|
||||
}));
|
||||
// Build a performance map from API data
|
||||
const criterionPerformance: Record<string, CriterionPerformance> = {};
|
||||
for (const cat of apiCategories) {
|
||||
for (const ctrl of cat.controls) {
|
||||
criterionPerformance[ctrl.name] = {
|
||||
f1: ctrl.f1 ?? null,
|
||||
passAt1: ctrl.pass_at_1 ?? null,
|
||||
mode: (ctrl.mode ?? "disabled") as VerificationMode,
|
||||
evaluations: (ctrl.evaluations ?? []) as VerificationResult[],
|
||||
};
|
||||
}
|
||||
}
|
||||
return { categories, criterionPerformance };
|
||||
}
|
||||
|
||||
export const handle = { wide: true };
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: "Verifications — Fabro" }];
|
||||
}
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>;
|
||||
|
||||
function TrafficLightIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className={className}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7 3a3 3 0 0 1 6 0v14a3 3 0 0 1-6 0V3Zm3 1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm0 5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm0 5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const criterionIcons: Record<string, IconComponent> = {
|
||||
"Motivation": LightBulbIcon,
|
||||
"Specifications": ClipboardDocumentListIcon,
|
||||
"Documentation": BookOpenIcon,
|
||||
"Minimization": FunnelIcon,
|
||||
"Formatting": Bars3BottomLeftIcon,
|
||||
"Linting": WrenchIcon,
|
||||
"Style": PaintBrushIcon,
|
||||
"Completeness": CheckBadgeIcon,
|
||||
"Defects": BugAntIcon,
|
||||
"Performance": BoltIcon,
|
||||
"Test Coverage": BeakerIcon,
|
||||
"Test Quality": StarIcon,
|
||||
"E2E Coverage": ComputerDesktopIcon,
|
||||
"Architecture": CubeTransparentIcon,
|
||||
"Interfaces": ArrowsRightLeftIcon,
|
||||
"Duplication": DocumentDuplicateIcon,
|
||||
"Simplicity": SparklesIcon,
|
||||
"Dead Code": ArchiveBoxXMarkIcon,
|
||||
"Vulnerabilities": ShieldExclamationIcon,
|
||||
"IaC Scanning": ServerStackIcon,
|
||||
"Dependency Alerts": ExclamationTriangleIcon,
|
||||
"Security Controls": LockClosedIcon,
|
||||
"Compatibility": PuzzlePieceIcon,
|
||||
"Rollout / Rollback": ArrowUturnLeftIcon,
|
||||
"Observability": EyeIcon,
|
||||
"Cost": CurrencyDollarIcon,
|
||||
"Change Control": ClipboardDocumentCheckIcon,
|
||||
"AI Governance": CpuChipIcon,
|
||||
"Privacy": FingerPrintIcon,
|
||||
"Accessibility": HandRaisedIcon,
|
||||
"Licensing": ScaleIcon,
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, IconComponent> = {
|
||||
"Traceability": MapPinIcon,
|
||||
"Readability": DocumentTextIcon,
|
||||
"Reliability": ShieldCheckIcon,
|
||||
"Code Coverage": TrafficLightIcon,
|
||||
"Maintainability": WrenchScrewdriverIcon,
|
||||
"Security": KeyIcon,
|
||||
"Deployability": RocketLaunchIcon,
|
||||
"Compliance": BuildingLibraryIcon,
|
||||
};
|
||||
|
||||
function TypeBadge({ type }: { type: VerificationType | null }) {
|
||||
if (type === null) return null;
|
||||
const config = typeConfig[type];
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.color} ${config.bg}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type ViewMode = "grouped" | "ungrouped";
|
||||
|
||||
function CriterionRow({ slug, children }: { slug: string; children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-line last:border-b-0 cursor-pointer transition-colors hover:bg-overlay"
|
||||
onClick={() => navigate(`/verification/controls/${slug}`)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryCard({ category, perfMap }: { category: VerificationCategory; perfMap: Record<string, CriterionPerformance> }) {
|
||||
return (
|
||||
<Disclosure
|
||||
as="div"
|
||||
className="rounded-md border border-line overflow-hidden"
|
||||
>
|
||||
<DisclosureButton className="group flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-overlay">
|
||||
{(() => {
|
||||
const CatIcon = categoryIcons[category.name];
|
||||
return CatIcon ? <CatIcon className="size-5 shrink-0 text-fg-3" /> : null;
|
||||
})()}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="shrink-0 text-sm font-semibold text-fg">
|
||||
{category.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-fg-muted">
|
||||
{category.question}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-fg-muted">
|
||||
<span className="font-mono tabular-nums">{category.criteria.length}</span> controls
|
||||
</span>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-fg-muted transition-transform duration-200 group-data-open:rotate-90" />
|
||||
</DisclosureButton>
|
||||
|
||||
<DisclosurePanel
|
||||
transition
|
||||
className="origin-top transition duration-200 ease-out data-closed:-translate-y-1 data-closed:opacity-0"
|
||||
>
|
||||
<div className="border-t border-line">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{category.criteria.map((criterion) => {
|
||||
const Icon = criterionIcons[criterion.name];
|
||||
const perf = perfMap[criterion.name];
|
||||
return (
|
||||
<CriterionRow key={criterion.name} slug={slugify(criterion.name)}>
|
||||
<td className="w-8 py-2.5 pl-5 pr-0">
|
||||
{Icon && <Icon className="size-4 text-fg-3" />}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-2 pr-3 font-medium text-fg-2">
|
||||
{criterion.name}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-fg-muted">
|
||||
{criterion.description || (
|
||||
<span className="italic">Not configured</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-3 pr-1 text-right">
|
||||
<TypeBadge type={criterion.type} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-1">
|
||||
{perf && <ModeBadge mode={perf.mode} />}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-1 pr-4">
|
||||
{perf && <EvaluationDots evaluations={perf.evaluations} />}
|
||||
</td>
|
||||
</CriterionRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupedView({ categories, perfMap }: { categories: readonly VerificationCategory[]; perfMap: Record<string, CriterionPerformance> }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{categories.map((category) => (
|
||||
<CategoryCard key={category.name} category={category} perfMap={perfMap} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeBadge({ mode }: { mode: VerificationMode }) {
|
||||
const config = modeConfig[mode];
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.color} ${config.bg}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EvaluationDots({ evaluations }: { evaluations: readonly VerificationResult[] }) {
|
||||
if (evaluations.length === 0) {
|
||||
return <span className="text-xs italic text-fg-muted">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{evaluations.map((result, i) => (
|
||||
<span
|
||||
key={`eval-${i}`}
|
||||
className={`inline-block size-2.5 rounded-sm ${
|
||||
result === "pass"
|
||||
? "bg-mint/70"
|
||||
: result === "fail"
|
||||
? "bg-coral/70"
|
||||
: "bg-navy-600/50"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UngroupedView({ categories, perfMap }: { categories: readonly VerificationCategory[]; perfMap: Record<string, CriterionPerformance> }) {
|
||||
return (
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<th className="w-8 py-2.5 pl-4 pr-0 font-medium" />
|
||||
<th className="py-2.5 pl-2 pr-3 font-medium">Verification</th>
|
||||
<th className="py-2.5 px-3 font-medium">Description</th>
|
||||
<th className="py-2.5 px-3 font-medium">Category</th>
|
||||
<th className="py-2.5 px-3 font-medium text-right">Type</th>
|
||||
<th className="py-2.5 px-3 font-medium text-right">Accuracy (F1)</th>
|
||||
<th className="py-2.5 px-3 font-medium text-right">pass@1</th>
|
||||
<th className="py-2.5 px-3 font-medium">Mode</th>
|
||||
<th className="py-2.5 pl-3 pr-4 font-medium">Evaluations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.flatMap((category) =>
|
||||
category.criteria.map((criterion) => {
|
||||
const Icon = criterionIcons[criterion.name];
|
||||
const perf = perfMap[criterion.name];
|
||||
return (
|
||||
<CriterionRow key={`${category.name}-${criterion.name}`} slug={slugify(criterion.name)}>
|
||||
<td className="w-8 py-2.5 pl-4 pr-0">
|
||||
{Icon && <Icon className="size-4 text-fg-3" />}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-2 pr-3 font-medium text-fg-2">
|
||||
{criterion.name}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-fg-muted">
|
||||
{criterion.description || (
|
||||
<span className="italic">Not configured</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-xs text-fg-muted">
|
||||
{category.name}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right">
|
||||
<TypeBadge type={criterion.type} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right font-mono text-xs tabular-nums text-fg-2">
|
||||
{perf?.f1 != null ? perf.f1.toFixed(2) : <span className="text-fg-muted">—</span>}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right font-mono text-xs tabular-nums text-fg-2">
|
||||
{perf?.passAt1 != null ? perf.passAt1.toFixed(2) : <span className="text-fg-muted">—</span>}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3">
|
||||
{perf && <ModeBadge mode={perf.mode} />}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-3 pr-4">
|
||||
{perf && <EvaluationDots evaluations={perf.evaluations} />}
|
||||
</td>
|
||||
</CriterionRow>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function filterCategories(
|
||||
categories: readonly VerificationCategory[],
|
||||
query: string,
|
||||
modeFilter: VerificationMode | "all",
|
||||
perfMap: Record<string, CriterionPerformance>,
|
||||
): VerificationCategory[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return categories
|
||||
.map((category) => {
|
||||
const filtered = category.criteria.filter((c) => {
|
||||
const perf = perfMap[c.name];
|
||||
const matchesMode = modeFilter === "all" || perf?.mode === modeFilter;
|
||||
const matchesQuery =
|
||||
lowerQuery === "" ||
|
||||
c.name.toLowerCase().includes(lowerQuery) ||
|
||||
c.description.toLowerCase().includes(lowerQuery) ||
|
||||
category.name.toLowerCase().includes(lowerQuery);
|
||||
return matchesMode && matchesQuery;
|
||||
});
|
||||
return { ...category, criteria: filtered };
|
||||
})
|
||||
.filter((category) => category.criteria.length > 0);
|
||||
}
|
||||
|
||||
export default function Verifications({ loaderData }: Route.ComponentProps) {
|
||||
const { categories: verificationCategories, criterionPerformance } = loaderData;
|
||||
const [view, setView] = useState<ViewMode>("grouped");
|
||||
const [query, setQuery] = useState("");
|
||||
const [modeFilter, setModeFilter] = useState<VerificationMode | "all">("all");
|
||||
|
||||
const filtered = filterCategories(verificationCategories, query, modeFilter, criterionPerformance);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search verifications…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={modeFilter}
|
||||
onChange={(e) => setModeFilter(e.target.value as VerificationMode | "all")}
|
||||
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
|
||||
>
|
||||
<option value="all">All modes</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="evaluate">Evaluate</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-line bg-panel/80 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("grouped")}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${view === "grouped" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="size-3.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 0 1-1.125-1.125v-3.75ZM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-8.25ZM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 0 1-1.125-1.125v-1.5Z" />
|
||||
</svg>
|
||||
Grouped
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("ungrouped")}
|
||||
className={`inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors ${view === "ungrouped" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="size-3.5" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" />
|
||||
</svg>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "grouped" ? <GroupedView categories={filtered} perfMap={criterionPerformance} /> : <UngroupedView categories={filtered} perfMap={criterionPerformance} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
import { Link, useNavigate } from "react-router";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LightBulbIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
BookOpenIcon,
|
||||
FunnelIcon,
|
||||
Bars3BottomLeftIcon,
|
||||
WrenchIcon,
|
||||
PaintBrushIcon,
|
||||
CheckBadgeIcon,
|
||||
BugAntIcon,
|
||||
BoltIcon,
|
||||
BeakerIcon,
|
||||
StarIcon,
|
||||
ComputerDesktopIcon,
|
||||
CubeTransparentIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
DocumentDuplicateIcon,
|
||||
SparklesIcon,
|
||||
ArchiveBoxXMarkIcon,
|
||||
ShieldExclamationIcon,
|
||||
ServerStackIcon,
|
||||
ExclamationTriangleIcon,
|
||||
LockClosedIcon,
|
||||
PuzzlePieceIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
EyeIcon,
|
||||
CurrencyDollarIcon,
|
||||
ClipboardDocumentCheckIcon,
|
||||
CpuChipIcon,
|
||||
FingerPrintIcon,
|
||||
HandRaisedIcon,
|
||||
ScaleIcon,
|
||||
MapPinIcon,
|
||||
DocumentTextIcon,
|
||||
ShieldCheckIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
KeyIcon,
|
||||
RocketLaunchIcon,
|
||||
BuildingLibraryIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import {
|
||||
typeConfig,
|
||||
modeConfig,
|
||||
slugify,
|
||||
} from "../data/verifications";
|
||||
import type {
|
||||
VerificationType,
|
||||
VerificationMode,
|
||||
VerificationResult,
|
||||
} from "../data/verifications";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { VerificationCriterionDetail } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/verification-criterion";
|
||||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
const data = await apiJson<VerificationCriterionDetail>(`/verification/criteria/${params.id}`, { request });
|
||||
return { data };
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
const name = data?.data?.name ?? "Criterion";
|
||||
return [{ title: `${name} — Verification — Fabro` }];
|
||||
}
|
||||
|
||||
type IconComponent = React.ComponentType<{ className?: string }>;
|
||||
|
||||
function TrafficLightIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" className={className}>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7 3a3 3 0 0 1 6 0v14a3 3 0 0 1-6 0V3Zm3 1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm0 5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm0 5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const controlIcons: Record<string, IconComponent> = {
|
||||
"Motivation": LightBulbIcon,
|
||||
"Specifications": ClipboardDocumentListIcon,
|
||||
"Documentation": BookOpenIcon,
|
||||
"Minimization": FunnelIcon,
|
||||
"Formatting": Bars3BottomLeftIcon,
|
||||
"Linting": WrenchIcon,
|
||||
"Style": PaintBrushIcon,
|
||||
"Completeness": CheckBadgeIcon,
|
||||
"Defects": BugAntIcon,
|
||||
"Performance": BoltIcon,
|
||||
"Test Coverage": BeakerIcon,
|
||||
"Test Quality": StarIcon,
|
||||
"E2E Coverage": ComputerDesktopIcon,
|
||||
"Architecture": CubeTransparentIcon,
|
||||
"Interfaces": ArrowsRightLeftIcon,
|
||||
"Duplication": DocumentDuplicateIcon,
|
||||
"Simplicity": SparklesIcon,
|
||||
"Dead Code": ArchiveBoxXMarkIcon,
|
||||
"Vulnerabilities": ShieldExclamationIcon,
|
||||
"IaC Scanning": ServerStackIcon,
|
||||
"Dependency Alerts": ExclamationTriangleIcon,
|
||||
"Security Controls": LockClosedIcon,
|
||||
"Compatibility": PuzzlePieceIcon,
|
||||
"Rollout / Rollback": ArrowUturnLeftIcon,
|
||||
"Observability": EyeIcon,
|
||||
"Cost": CurrencyDollarIcon,
|
||||
"Change Control": ClipboardDocumentCheckIcon,
|
||||
"AI Governance": CpuChipIcon,
|
||||
"Privacy": FingerPrintIcon,
|
||||
"Accessibility": HandRaisedIcon,
|
||||
"Licensing": ScaleIcon,
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, IconComponent> = {
|
||||
"Traceability": MapPinIcon,
|
||||
"Readability": DocumentTextIcon,
|
||||
"Reliability": ShieldCheckIcon,
|
||||
"Code Coverage": TrafficLightIcon,
|
||||
"Maintainability": WrenchScrewdriverIcon,
|
||||
"Security": KeyIcon,
|
||||
"Deployability": RocketLaunchIcon,
|
||||
"Compliance": BuildingLibraryIcon,
|
||||
};
|
||||
|
||||
function TypeBadge({ type }: { type: VerificationType | null }) {
|
||||
if (type === null) return null;
|
||||
const config = typeConfig[type];
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.color} ${config.bg}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeBadge({ mode }: { mode: VerificationMode }) {
|
||||
const config = modeConfig[mode];
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${config.color} ${config.bg}`}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function EvaluationDots({ evaluations }: { evaluations: readonly VerificationResult[] }) {
|
||||
if (evaluations.length === 0) {
|
||||
return <span className="text-xs italic text-fg-muted">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{evaluations.map((result, i) => (
|
||||
<span
|
||||
key={`eval-${i}`}
|
||||
className={`inline-block size-2.5 rounded-sm ${
|
||||
result === "pass"
|
||||
? "bg-mint/70"
|
||||
: result === "fail"
|
||||
? "bg-coral/70"
|
||||
: "bg-navy-600/50"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerificationCriterion({ loaderData }: Route.ComponentProps) {
|
||||
const { data } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const CatIcon = categoryIcons[data.name];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1 text-sm text-fg-muted">
|
||||
<Link to="/verification/criteria" className="text-fg-3 hover:text-fg">Verification</Link>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
<span>{data.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
{CatIcon && <CatIcon className="mt-0.5 size-6 text-fg-3" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl font-semibold text-fg">{data.name}</h2>
|
||||
<p className="mt-1 text-sm text-fg-muted">{data.question}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls table */}
|
||||
<div className="rounded-md border border-line overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
|
||||
<th className="w-8 py-2.5 pl-4 pr-0 font-medium" />
|
||||
<th className="py-2.5 pl-2 pr-3 font-medium">Control</th>
|
||||
<th className="py-2.5 px-3 font-medium">Description</th>
|
||||
<th className="py-2.5 px-3 font-medium text-right">Type</th>
|
||||
<th className="py-2.5 px-3 font-medium text-right">Accuracy (F1)</th>
|
||||
<th className="py-2.5 px-3 font-medium text-right">pass@1</th>
|
||||
<th className="py-2.5 px-3 font-medium">Mode</th>
|
||||
<th className="py-2.5 pl-3 pr-4 font-medium">Evaluations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.controls.map((control) => {
|
||||
const Icon = controlIcons[control.name];
|
||||
const mode = (control.mode ?? "disabled") as VerificationMode;
|
||||
return (
|
||||
<tr
|
||||
key={control.slug}
|
||||
className="border-b border-line last:border-b-0 cursor-pointer transition-colors hover:bg-overlay"
|
||||
onClick={() => navigate(`/verification/controls/${slugify(control.name)}`)}
|
||||
>
|
||||
<td className="w-8 py-2.5 pl-4 pr-0">
|
||||
{Icon && <Icon className="size-4 text-fg-3" />}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-2 pr-3 font-medium text-fg-2">
|
||||
{control.name}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-fg-muted">
|
||||
{control.description || (
|
||||
<span className="italic">Not configured</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right">
|
||||
<TypeBadge type={(control.type ?? null) as VerificationType | null} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right font-mono text-xs tabular-nums text-fg-2">
|
||||
{control.f1 != null ? control.f1.toFixed(2) : <span className="text-fg-muted">—</span>}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3 text-right font-mono text-xs tabular-nums text-fg-2">
|
||||
{control.pass_at_1 != null ? control.pass_at_1.toFixed(2) : <span className="text-fg-muted">—</span>}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 px-3">
|
||||
<ModeBadge mode={mode} />
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2.5 pl-3 pr-4">
|
||||
<EvaluationDots evaluations={(control.evaluations ?? []) as VerificationResult[]} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ export default function WorkflowDefinition() {
|
|||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<CollapsibleFile
|
||||
file={{ name: "run.json", contents: JSON.stringify(workflow.settings, null, 2), lang: "json" }}
|
||||
file={{ name: "settings.json", contents: JSON.stringify(workflow.settings, null, 2), lang: "json" }}
|
||||
defaultOpen={false}
|
||||
/>
|
||||
{dotReady && (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Link, Outlet, useLocation, useParams } from "react-router";
|
||||
import { apiJson } from "../api-client";
|
||||
import type { WorkflowDetail as ApiWorkflowDetail, RunSettings } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/workflow-detail";
|
||||
import { apiJson } from "../api";
|
||||
import type { RunSettings, WorkflowDetailResponse as ApiWorkflowDetail } from "../lib/workflow-api";
|
||||
|
||||
export interface WorkflowEntry {
|
||||
name: string;
|
||||
|
|
@ -13,8 +12,11 @@ export interface WorkflowEntry {
|
|||
graph: string;
|
||||
}
|
||||
|
||||
// Keep this exported for backward compatibility with other routes that import it.
|
||||
// It will be populated by the loader, but the static version is kept as fallback.
|
||||
// Static sample data used by the `workflow-definition` index route for the
|
||||
// hardcoded showcase workflows. Shape mirrors the v2 `SettingsFile` JSON
|
||||
// returned by `/api/v1/runs/:id/settings` (see the Rust
|
||||
// `fabro_types::settings::SettingsFile` type). Fields are opaque to the
|
||||
// `RunSettings` TypeScript type, which is a bare `Record<string, unknown>`.
|
||||
export const workflowData: Record<string, WorkflowEntry> = {
|
||||
fix_build: {
|
||||
name: "Fix Build",
|
||||
|
|
@ -22,17 +24,18 @@ export const workflowData: Record<string, WorkflowEntry> = {
|
|||
filename: "fix_build.fabro",
|
||||
description: "Automatically diagnoses and fixes CI build failures by analyzing error logs, identifying root causes, and applying targeted code changes.",
|
||||
settings: {
|
||||
version: 1,
|
||||
goal: "Diagnose and fix CI build failures",
|
||||
graph: "fix_build.fabro",
|
||||
llm: { model: "claude-sonnet" },
|
||||
vars: { repo_url: "https://github.com/org/service", branch: "main" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 60,
|
||||
labels: { project: "fix-build" },
|
||||
snapshot: { name: "fix-build-dev", cpu: 4, memory: 8, disk: 10 },
|
||||
_version: 1,
|
||||
run: {
|
||||
goal: "Diagnose and fix CI build failures",
|
||||
inputs: { repo_url: "https://github.com/org/service", branch: "main" },
|
||||
model: { name: "claude-sonnet" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 60,
|
||||
labels: { project: "fix-build" },
|
||||
snapshot: { name: "fix-build-dev", cpu: 4, memory: "8GB", disk: "10GB" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -63,18 +66,25 @@ export const workflowData: Record<string, WorkflowEntry> = {
|
|||
filename: "implement.fabro",
|
||||
description: "Generates production-ready code from a technical blueprint, including tests, documentation, and a pull request ready for review.",
|
||||
settings: {
|
||||
version: 1,
|
||||
goal: "Implement feature from technical blueprint",
|
||||
graph: "implement.fabro",
|
||||
llm: { model: "claude-sonnet" },
|
||||
vars: { spec_path: "specs/feature.md", test_framework: "vitest" },
|
||||
setup: { commands: ["bun install", "bun run typecheck"], timeout_ms: 120000 },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 120,
|
||||
labels: { project: "implement", team: "engineering" },
|
||||
snapshot: { name: "implement-dev", cpu: 4, memory: 8, disk: 20 },
|
||||
_version: 1,
|
||||
run: {
|
||||
goal: "Implement feature from technical blueprint",
|
||||
inputs: { spec_path: "specs/feature.md", test_framework: "vitest" },
|
||||
model: { name: "claude-sonnet" },
|
||||
prepare: {
|
||||
steps: [
|
||||
{ command: ["bun", "install"] },
|
||||
{ command: ["bun", "run", "typecheck"] },
|
||||
],
|
||||
timeout: "120s",
|
||||
},
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 120,
|
||||
labels: { project: "implement", team: "engineering" },
|
||||
snapshot: { name: "implement-dev", cpu: 4, memory: "8GB", disk: "20GB" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -119,17 +129,18 @@ export const workflowData: Record<string, WorkflowEntry> = {
|
|||
filename: "sync_drift.fabro",
|
||||
description: "Detects configuration and code drift between environments, then generates reconciliation patches to bring everything back in sync.",
|
||||
settings: {
|
||||
version: 1,
|
||||
goal: "Detect and reconcile configuration drift across environments",
|
||||
graph: "sync_drift.fabro",
|
||||
llm: { model: "claude-sonnet" },
|
||||
vars: { source_env: "production", target_env: "staging", drift_threshold: "warn" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 120,
|
||||
labels: { project: "sync-drift", team: "platform" },
|
||||
snapshot: { name: "sync-drift-dev", cpu: 2, memory: 4, disk: 10 },
|
||||
_version: 1,
|
||||
run: {
|
||||
goal: "Detect and reconcile configuration drift across environments",
|
||||
inputs: { source_env: "production", target_env: "staging", drift_threshold: "warn" },
|
||||
model: { name: "claude-sonnet" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 120,
|
||||
labels: { project: "sync-drift", team: "platform" },
|
||||
snapshot: { name: "sync-drift-dev", cpu: 2, memory: "4GB", disk: "10GB" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -164,17 +175,18 @@ export const workflowData: Record<string, WorkflowEntry> = {
|
|||
filename: "expand.fabro",
|
||||
description: "Evolves the product by analyzing usage patterns and specifications to propose and implement incremental improvements.",
|
||||
settings: {
|
||||
version: 1,
|
||||
goal: "Propose and implement incremental product improvements",
|
||||
graph: "expand.fabro",
|
||||
llm: { model: "claude-sonnet" },
|
||||
vars: { analytics_window: "30d", min_confidence: "0.8" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 180,
|
||||
labels: { project: "expand", team: "product" },
|
||||
snapshot: { name: "expand-dev", cpu: 2, memory: 4, disk: 10 },
|
||||
_version: 1,
|
||||
run: {
|
||||
goal: "Propose and implement incremental product improvements",
|
||||
inputs: { analytics_window: "30d", min_confidence: "0.8" },
|
||||
model: { name: "claude-sonnet" },
|
||||
sandbox: {
|
||||
provider: "daytona",
|
||||
daytona: {
|
||||
auto_stop_interval: 180,
|
||||
labels: { project: "expand", team: "product" },
|
||||
snapshot: { name: "expand-dev", cpu: 2, memory: "4GB", disk: "10GB" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -209,7 +221,7 @@ const tabs = [
|
|||
|
||||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const apiWorkflow = await apiJson<ApiWorkflowDetail>(`/workflows/${params.name}`, { request });
|
||||
const workflow: WorkflowEntry = {
|
||||
name: apiWorkflow.name,
|
||||
|
|
@ -222,12 +234,12 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
|||
return { workflow };
|
||||
}
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
export function meta({ data }: any) {
|
||||
const title = data?.workflow?.name ?? "Workflow";
|
||||
return [{ title: `${title} — Fabro` }];
|
||||
}
|
||||
|
||||
export default function WorkflowDetail({ loaderData }: Route.ComponentProps) {
|
||||
export default function WorkflowDetail({ loaderData }: any) {
|
||||
const { name } = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const workflow = loaderData.workflow;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outlin
|
|||
import { Link, useParams } from "react-router";
|
||||
import { ciConfig, columnNames, deriveCiStatus, mapRunListItem, statusColors } from "../data/runs";
|
||||
import type { ColumnStatus, RunWithStatus } from "../data/runs";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/workflow-runs";
|
||||
|
||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||||
export async function loader({ request, params }: any) {
|
||||
const { data: apiRuns } = await apiJson<PaginatedRunList>(`/workflows/${params.name}/runs`, { request });
|
||||
const runs: RunWithStatus[] = apiRuns.map((r) => ({
|
||||
...mapRunListItem(r),
|
||||
|
|
@ -68,7 +67,7 @@ function RunRow({ run }: { run: RunWithStatus }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function WorkflowRuns({ loaderData }: Route.ComponentProps) {
|
||||
export default function WorkflowRuns({ loaderData }: any) {
|
||||
const { runs } = loaderData;
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<ColumnStatus | "all">("all");
|
||||
|
|
|
|||
|
|
@ -13,12 +13,11 @@ import {
|
|||
WrenchIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Link } from "react-router";
|
||||
import { apiJson } from "../api-client";
|
||||
import { apiJson } from "../api";
|
||||
import { timeAgo, timeUntil } from "../lib/time";
|
||||
import type { PaginatedWorkflowList } from "@qltysh/fabro-api-client";
|
||||
import type { Route } from "./+types/workflows";
|
||||
import type { PaginatedWorkflowListResponse } from "../lib/workflow-api";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
export function meta({}: any) {
|
||||
return [{ title: "Workflows — Fabro" }];
|
||||
}
|
||||
|
||||
|
|
@ -105,8 +104,8 @@ interface WorkflowData {
|
|||
nextRun?: string;
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { data: apiWorkflows } = await apiJson<PaginatedWorkflowList>("/workflows", { request });
|
||||
export async function loader({ request }: any) {
|
||||
const { data: apiWorkflows } = await apiJson<PaginatedWorkflowListResponse>("/workflows", { request });
|
||||
const workflows: WorkflowData[] = apiWorkflows.map((w) => ({
|
||||
name: w.name,
|
||||
slug: w.slug,
|
||||
|
|
@ -202,7 +201,7 @@ function WorkflowCard({ workflow }: { workflow: Workflow }) {
|
|||
|
||||
type TriggerFilter = "all" | "scheduled" | "manual";
|
||||
|
||||
export default function Workflows({ loaderData }: Route.ComponentProps) {
|
||||
export default function Workflows({ loaderData }: any) {
|
||||
const workflows = enrichWorkflows(loaderData.workflows);
|
||||
const [query, setQuery] = useState("");
|
||||
const [triggerFilter, setTriggerFilter] = useState<TriggerFilter>("all");
|
||||
|
|
|
|||
25
apps/fabro-web/index.template.html
Normal file
25
apps/fabro-web/index.template.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-atmosphere">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Fabro</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
/>
|
||||
<script>
|
||||
(function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t="dark";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})()
|
||||
</script>
|
||||
{{styles}}
|
||||
</head>
|
||||
<body class="h-full font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
{{scripts}}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,11 +3,10 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"build": "bun run scripts/build.ts",
|
||||
"dev": "bun run scripts/build.ts --watch",
|
||||
"test": "bun test",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -17,28 +16,17 @@
|
|||
"@heroicons/react": "^2.2.0",
|
||||
"@pierre/diffs": "^1.0.11",
|
||||
"@qltysh/fabro-api-client": "workspace:*",
|
||||
"@react-router/node": "7.12.0",
|
||||
"@react-router/serve": "7.12.0",
|
||||
"@viz-js/viz": "^3.24.0",
|
||||
"arctic": "^3.7.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"isbot": "^5.1.31",
|
||||
"jose": "^6.1.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "7.12.0",
|
||||
"smol-toml": "^1.6.0"
|
||||
"react-router": "7.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "7.12.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@tailwindcss/cli": "^4.1.13",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
32
apps/fabro-web/scripts/build.test.ts
Normal file
32
apps/fabro-web/scripts/build.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { test, expect } from "bun:test";
|
||||
|
||||
const root = Bun.fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
test("watch mode keeps running until interrupted", async () => {
|
||||
const process = Bun.spawn([
|
||||
"bun",
|
||||
"run",
|
||||
"scripts/build.ts",
|
||||
"--watch",
|
||||
], {
|
||||
cwd: root,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
process.exited.then((code) => ({ kind: "exited" as const, code })),
|
||||
Bun.sleep(1000).then(() => ({ kind: "running" as const })),
|
||||
]);
|
||||
|
||||
if (result.kind === "exited") {
|
||||
const stderr = await new Response(process.stderr).text();
|
||||
const stdout = await new Response(process.stdout).text();
|
||||
throw new Error(
|
||||
`watch process exited unexpectedly with code ${result.code}\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
process.kill("SIGINT");
|
||||
expect([0, 130]).toContain(await process.exited);
|
||||
});
|
||||
121
apps/fabro-web/scripts/build.ts
Normal file
121
apps/fabro-web/scripts/build.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { watch as fsWatch } from "node:fs";
|
||||
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
declare const Bun: any;
|
||||
|
||||
const root = new URL("..", import.meta.url);
|
||||
const rootPath = Bun.fileURLToPath(root);
|
||||
const distDir = join(rootPath, "dist");
|
||||
const assetsDir = join(distDir, "assets");
|
||||
const publicDir = join(rootPath, "public");
|
||||
const templatePath = join(rootPath, "index.template.html");
|
||||
const watch = Bun.argv.includes("--watch");
|
||||
|
||||
async function buildOnce() {
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
await mkdir(assetsDir, { recursive: true });
|
||||
|
||||
const result = await Bun.build({
|
||||
entrypoints: [join(rootPath, "app", "entry.tsx")],
|
||||
outdir: assetsDir,
|
||||
naming: "[name]-[hash].[ext]",
|
||||
minify: true,
|
||||
splitting: true,
|
||||
target: "browser",
|
||||
sourcemap: "external",
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.logs.map((log: any) => log.message).join("\n"));
|
||||
}
|
||||
|
||||
const cssResult = await Bun.spawn([
|
||||
"bunx",
|
||||
"@tailwindcss/cli",
|
||||
"-i",
|
||||
"app/app.css",
|
||||
"-o",
|
||||
"dist/assets/app.css",
|
||||
"--minify",
|
||||
], {
|
||||
cwd: rootPath,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).exited;
|
||||
|
||||
if (cssResult !== 0) {
|
||||
throw new Error("Tailwind build failed");
|
||||
}
|
||||
|
||||
await cp(publicDir, distDir, { recursive: true });
|
||||
await writeIndexHtml(result.outputs.map((output: any) => relative(distDir, output.path)));
|
||||
}
|
||||
|
||||
async function writeIndexHtml(outputs: string[]) {
|
||||
const template = await readFile(templatePath, "utf8");
|
||||
const scripts = outputs
|
||||
.filter((path) => path.endsWith(".js"))
|
||||
.map((path) => `<script type="module" src="/${path.replaceAll("\\\\", "/")}"></script>`)
|
||||
.join("\n ");
|
||||
const styles = [
|
||||
"/assets/app.css",
|
||||
...outputs.filter((path) => path.endsWith(".css")).map((path) => `/${path.replaceAll("\\\\", "/")}`),
|
||||
]
|
||||
.filter((value, index, array) => array.indexOf(value) === index)
|
||||
.map((path) => `<link rel="stylesheet" href="${path}" />`)
|
||||
.join("\n ");
|
||||
|
||||
const html = template
|
||||
.replace("{{styles}}", styles)
|
||||
.replace("{{scripts}}", scripts);
|
||||
|
||||
await writeFile(join(distDir, "index.html"), html, "utf8");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!watch) {
|
||||
await buildOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
await buildOnce();
|
||||
let building = false;
|
||||
let rebuildQueued = false;
|
||||
|
||||
async function rebuild() {
|
||||
if (building) {
|
||||
rebuildQueued = true;
|
||||
return;
|
||||
}
|
||||
|
||||
building = true;
|
||||
do {
|
||||
rebuildQueued = false;
|
||||
try {
|
||||
await buildOnce();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} while (rebuildQueued);
|
||||
building = false;
|
||||
}
|
||||
|
||||
const watchers = [
|
||||
fsWatch(join(rootPath, "app"), { recursive: true }, rebuild),
|
||||
fsWatch(publicDir, { recursive: true }, rebuild),
|
||||
fsWatch(templatePath, rebuild),
|
||||
];
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
for (const watcher of watchers) {
|
||||
watcher.close();
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
{
|
||||
"include": [
|
||||
"**/*",
|
||||
"**/.server/**/*",
|
||||
"**/.client/**/*",
|
||||
".react-router/types/**/*"
|
||||
"app/**/*",
|
||||
"scripts/**/*"
|
||||
],
|
||||
"exclude": ["**/*.test.ts"],
|
||||
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"types": ["node"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./app/*"],
|
||||
|
|
@ -24,6 +21,7 @@
|
|||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
"strict": false,
|
||||
"noImplicitAny": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
ssr: { external: ["better-sqlite3"] },
|
||||
});
|
||||
11
clippy.toml
11
clippy.toml
|
|
@ -1,2 +1,13 @@
|
|||
absolute-paths-max-segments = 2
|
||||
absolute-paths-allowed-crates = ["std", "core", "alloc"]
|
||||
disallowed-methods = [
|
||||
{ path = "std::thread::sleep", reason = "Prefer tokio::time::sleep on Tokio paths; document intentional blocking sleeps with #[expect(clippy::disallowed_methods, reason = \"...\")]", replacement = "tokio::time::sleep" },
|
||||
{ path = "std::thread::spawn", reason = "Prefer Tokio task APIs on async paths; document intentional dedicated OS threads with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
|
||||
{ path = "std::thread::Builder::spawn", reason = "Prefer Tokio task APIs on async paths; document intentional dedicated OS threads with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
|
||||
{ path = "std::process::Command::new", reason = "Prefer tokio::process::Command on Tokio paths; document intentional synchronous subprocesses with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
|
||||
{ path = "reqwest::Client::new", reason = "Use fabro_http::http_client() or fabro_http::test_http_client()", allow-invalid = true },
|
||||
{ path = "reqwest::Client::builder", reason = "Use fabro_http::HttpClientBuilder::new()", allow-invalid = true },
|
||||
{ path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true },
|
||||
{ path = "reqwest::blocking::Client::builder", reason = "Use fabro_http::BlockingHttpClientBuilder::new()", allow-invalid = true },
|
||||
{ path = "reqwest::get", reason = "Build a fabro_http client and send the request explicitly", allow-invalid = true },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ services:
|
|||
environment:
|
||||
- FABRO_DEMO=1
|
||||
volumes:
|
||||
- ./demo-server.toml:/root/.fabro/server.toml:ro
|
||||
- ./demo-settings.toml:/root/.fabro/settings.toml:ro
|
||||
- ../apps/fabro-web/public:/app/apps/fabro-web/public
|
||||
depends_on:
|
||||
- api
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
const service = process.argv[2];
|
||||
|
||||
type ServiceConfig = {
|
||||
type ServiceOptions = {
|
||||
command: string[];
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
const services: Record<string, ServiceConfig> = {
|
||||
const services: Record<string, ServiceOptions> = {
|
||||
api: {
|
||||
command: ["fabro", "serve", "--host", "0.0.0.0"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ graph = "14-search-imagegen.fabro"
|
|||
provider = "daytona"
|
||||
|
||||
[sandbox.env]
|
||||
GEMINI_API_KEY = "${env.GEMINI_API_KEY}"
|
||||
GEMINI_API_KEY = "{{ env.GEMINI_API_KEY }}"
|
||||
|
||||
[sandbox.daytona.snapshot]
|
||||
name = "imagegen-tools-v3"
|
||||
cpu = 4
|
||||
memory = 8
|
||||
disk = 10
|
||||
dockerfile = { path = "../../fabro/workflows/imagegen/Dockerfile.imagegen" }
|
||||
dockerfile = { path = "../../.fabro/workflows/imagegen/Dockerfile.imagegen" }
|
||||
|
||||
[assets]
|
||||
include = ["output/**"]
|
||||
|
|
|
|||
376
docs-internal/event-schema-competitive-analysis.md
Normal file
376
docs-internal/event-schema-competitive-analysis.md
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# Event Schema Competitive Analysis
|
||||
|
||||
Date: 2026-04-08
|
||||
|
||||
This report compares the event schemas used by:
|
||||
|
||||
- Claude Sessions API
|
||||
- Claude Code
|
||||
- Goose
|
||||
- OpenAI Codex
|
||||
- OpenCode
|
||||
- pi-mono
|
||||
|
||||
Goal: identify patterns Fabro should copy, avoid, or formalize more clearly.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Fabro's current event model is already ahead of most of the field on one important point: it has a canonical envelope with stable metadata (`id`, `ts`, `run_id`, `event`, optional `session_id`, `parent_session_id`, `node_id`, `node_label`) and a typed internal-to-external mapping.
|
||||
|
||||
The biggest improvement opportunities are not "more events." They are:
|
||||
|
||||
1. Keep transport concerns separate from domain events, but document them as part of the contract.
|
||||
2. Make every streamed event part of one explicit public schema. Avoid opaque blobs and server-injected fields that the schema does not admit.
|
||||
3. Add more first-class correlation fields where the UI or downstream systems need them, especially `turn_id`, `message_id`, `tool_call_id`, `request_id`, and retry/attempt IDs.
|
||||
4. Make retry, stop, idle, and requires-action states machine-readable unions instead of loose strings.
|
||||
5. Be explicit about delta vs snapshot semantics and replay behavior.
|
||||
|
||||
## Fabro Baseline
|
||||
|
||||
Fabro's current strategy is documented in `docs-internal/events-strategy.md`. The canonical external shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuidv7",
|
||||
"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",
|
||||
"properties": { "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
That envelope is stronger than most comparator systems. It gives Fabro stable top-level metadata, keeps event-specific data inside `properties`, and avoids flattening arbitrary fields into the root.
|
||||
|
||||
Relevant current Fabro sources:
|
||||
|
||||
- `docs-internal/events-strategy.md`
|
||||
- `lib/crates/fabro-workflow/src/event.rs`
|
||||
- `lib/crates/fabro-types/src/run_event/mod.rs`
|
||||
- `lib/crates/fabro-agent/src/types.rs`
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| System | Public event surface | Discriminator | Universal envelope fields on every event | Replay / ordering story | Main strength | Main weakness |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Claude Sessions | 20-event public union | `type` | `id`, `processed_at` | Event IDs exist; replay semantics are not part of the event payload | Very explicit, stable union with typed nested states | Less transport detail and fewer workflow-specific events |
|
||||
| Claude Code | 24 core SDK messages, 31 stdout/control variants | `type`, often `subtype` | Usually `uuid`, `session_id`; no universal timestamp | Streaming is batched/coalesced; control and domain share the same channel | Rich task, hook, status, and tool progress | Nested stream payload is opaque at runtime; control and data are mixed |
|
||||
| Goose | 7 `MessageEvent` variants | `type` | None in JSON payload; SSE `id` is outside payload | Strong SSE replay via monotonic seq + `Last-Event-ID` + replay buffer | Reattach/replay semantics are clear | Transport fields are injected outside schema; payload typing is shallow |
|
||||
| Codex SDK | 8 thread events | `type` | No universal ID/timestamp | Ordered stream, no replay contract in payload | Very simple client model | Too minimal for rich UIs and analytics |
|
||||
| Codex app-server | 49 notification methods | `method` + `params` | No universal ID/timestamp in params | Ordered notifications, no seq/replay field | Richest low-level protocol in the set | Fragmented event story; transport shape leaks into the schema |
|
||||
| OpenCode | 45 generated event variants | `type` + `properties` | No universal ID/timestamp | Plain SSE; client supports SSE IDs but server does not emit them | Broadest app/runtime event coverage | Wire/schema drift and no universal envelope |
|
||||
| pi-mono | 12 assistant stream events, 10 agent events, 14 session events | `type` | Session header only; not per event | JSONL stream, no replay contract | Excellent streaming lifecycle grammar | No durable universal envelope for downstream consumers |
|
||||
|
||||
## System Notes
|
||||
|
||||
### Claude Sessions
|
||||
|
||||
What it does well:
|
||||
|
||||
- One explicit public union.
|
||||
- Every event has `id`, `type`, and `processed_at`.
|
||||
- Tool confirmation, custom tool results, MCP tool use, session errors, session status, and model-span events are all first-class.
|
||||
- Terminal and waiting states are structured. `session.status_idle.stop_reason` is not a loose string; it is a small union.
|
||||
- Error reporting is structured. `session.error.error` is a tagged union, not just a message.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- This is the cleanest example of a public agent-session event API that is still small enough to understand.
|
||||
- The main idea to copy is not the exact event list. It is the discipline: explicit tagged unions for stop reasons, errors, and status transitions.
|
||||
|
||||
Sources:
|
||||
|
||||
- `https://platform.claude.com/docs/en/api/beta/sessions/events/stream`
|
||||
- `https://platform.claude.com/docs/specs/merged.53db30dfcc06f431.json.gz`
|
||||
|
||||
### Claude Code
|
||||
|
||||
Schema shape:
|
||||
|
||||
- `SDKMessageSchema` contains 24 core message variants.
|
||||
- `StdoutMessageSchema` expands the stdout protocol to 31 variants once control messages and keep-alives are included.
|
||||
- Many events use `type: "system"` plus a `subtype`, for example `init`, `status`, `api_retry`, `hook_started`, `task_progress`, and `session_state_changed`.
|
||||
- Streaming assistant output is wrapped as `type: "stream_event"`.
|
||||
|
||||
What it does well:
|
||||
|
||||
- It covers more than just model output: task lifecycle, hook lifecycle, compaction boundaries, retries, authentication state, file persistence, tool progress, prompt suggestions.
|
||||
- It carries `uuid` and `session_id` widely, which is useful for correlation.
|
||||
- It includes explicit session-state transitions (`idle`, `running`, `requires_action`).
|
||||
|
||||
What is weak:
|
||||
|
||||
- The nested streaming payload is not explicitly validated at runtime. `RawMessageStreamEventPlaceholder` is `z.unknown()`.
|
||||
- Control protocol messages live in the same stream as domain messages.
|
||||
- `type: "system"` plus `subtype` is workable, but less ergonomic than a flatter public union.
|
||||
- There is no universal top-level timestamp on every event.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- Copy the breadth, not the shape.
|
||||
- Avoid opaque inner payloads in public schemas.
|
||||
- Avoid mixing keep-alive/control/config traffic into the same schema that product consumers use for analytics and UI rendering.
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/coreSchemas.ts`
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/controlSchemas.ts`
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/remote/sdkMessageAdapter.ts`
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/cli/transports/ccrClient.ts`
|
||||
- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/utils/sdkEventQueue.ts`
|
||||
|
||||
### Goose
|
||||
|
||||
Schema shape:
|
||||
|
||||
- One small SSE payload union: `Message`, `Error`, `Finish`, `Notification`, `UpdateConversation`, `ActiveRequests`, `Ping`.
|
||||
- SSE `id:` carries a monotonic sequence number.
|
||||
- Session replay uses `Last-Event-ID` plus a replay buffer.
|
||||
- `request_id` and `chat_request_id` are injected at the SSE framing layer, not modeled in the payload type.
|
||||
|
||||
What it does well:
|
||||
|
||||
- Clear reattach story.
|
||||
- Monotonic sequence numbers are transport-level, not payload-level.
|
||||
- `ActiveRequests` lets the client discover in-flight work when reconnecting.
|
||||
|
||||
What is weak:
|
||||
|
||||
- The public event payload omits fields the client actually consumes.
|
||||
- `Notification.message` is effectively an untyped object.
|
||||
- The session stream also emits comment heartbeats outside the schema, and there is a separate `Ping` payload variant in the shared enum. That split is easy to drift.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- Goose is the best example here for replay and reconnect semantics.
|
||||
- The lesson is not "put sequence numbers in the payload." The lesson is "formalize replay outside the payload, and do not rely on undocumented injected fields."
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/reply.rs`
|
||||
- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/session_events.rs`
|
||||
- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/session_event_bus.rs`
|
||||
- `/Users/bhelmkamp/p/block/goose/ui/desktop/openapi.json`
|
||||
- `/Users/bhelmkamp/p/block/goose/ui/desktop/src/hooks/useSessionEvents.ts`
|
||||
|
||||
### OpenAI Codex
|
||||
|
||||
There are really two event systems:
|
||||
|
||||
1. The TypeScript SDK `ThreadEvent` surface.
|
||||
2. The app-server `ServerNotification` protocol.
|
||||
|
||||
SDK shape:
|
||||
|
||||
- 8 high-level events: thread started, turn started/completed/failed, item started/updated/completed, fatal stream error.
|
||||
- Rich detail is pushed down into `ThreadItem`, which includes `agent_message`, `reasoning`, `command_execution`, `file_change`, `mcp_tool_call`, `web_search`, `todo_list`, and `error`.
|
||||
|
||||
App-server shape:
|
||||
|
||||
- 49 server notification methods.
|
||||
- Notifications are discriminated by `method`, with a typed `params` object.
|
||||
- Coverage includes thread lifecycle, turn lifecycle, item lifecycle, deltas, token usage, command output, MCP progress, model reroutes, config warnings, and experimental realtime notifications.
|
||||
|
||||
What it does well:
|
||||
|
||||
- Good separation of a simple developer-facing SDK from a richer system protocol.
|
||||
- The low-level protocol is broad and explicit.
|
||||
- Experimental notifications are clearly labeled as experimental.
|
||||
|
||||
What is weak:
|
||||
|
||||
- The event story is fragmented. "Which schema should I build against?" depends on which integration layer you pick.
|
||||
- There is no universal timestamp or universal event ID in the event bodies.
|
||||
- `method` + `params` is transport-shaped. It works well for JSON-RPC, but it is not as clean as a transport-agnostic event envelope.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- If Fabro needs both a high-level SDK and a low-level protocol, document the layering explicitly.
|
||||
- If Fabro only needs one event stream, a single canonical envelope is simpler than method-shaped notifications.
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/events.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/items.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/thread.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/src/protocol/common.rs`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/AgentMessageDeltaNotification.ts`
|
||||
- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts`
|
||||
|
||||
### OpenCode
|
||||
|
||||
Schema shape:
|
||||
|
||||
- One generated `Event` union with 45 variants.
|
||||
- `GlobalEvent` adds `directory` plus `payload: Event`.
|
||||
- Events use `type` plus a `properties` object.
|
||||
- Coverage includes questions, permissions, messages, message parts, session status/idle/compacted/error/diff, workspace readiness, PTYs, worktrees, VCS, file edits, MCP, TUI commands, and more.
|
||||
|
||||
What it does well:
|
||||
|
||||
- Broad coverage.
|
||||
- Generated API types from the server surface.
|
||||
- Clear split between session-scoped events and global events.
|
||||
- Status is partly structured. `SessionStatus` is a union of `idle`, `retry`, and `busy`.
|
||||
|
||||
What is weak:
|
||||
|
||||
- No universal event ID.
|
||||
- No universal timestamp.
|
||||
- No replay cursor or sequence field.
|
||||
- The wire stream emits `server.heartbeat`, but the generated `Event` union does not include it.
|
||||
- The global event schema says `directory` is present, but the initial global `server.connected` and heartbeat frames omit it.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- OpenCode shows how far a generated event surface can go.
|
||||
- It also shows the cost of not having a canonical envelope: clients must reconstruct correlation from nested `sessionID`, `messageID`, `partID`, and route-specific wrappers.
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/types.gen.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/server.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/routes/global.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/event.ts`
|
||||
- `/Users/bhelmkamp/p/anomalyco/opencode/packages/web/src/content/docs/server.mdx`
|
||||
|
||||
### pi-mono
|
||||
|
||||
Schema shape:
|
||||
|
||||
- `AssistantMessageEvent` has 12 streaming variants: `start`, block start/delta/end for text, thinking, and tool calls, then `done` or `error`.
|
||||
- `AgentEvent` has 10 lifecycle variants across agent, turn, message, and tool execution.
|
||||
- `AgentSessionEvent` extends `AgentEvent` with 4 session-only retry/compaction events.
|
||||
- JSON mode starts with a session header, then emits JSONL events.
|
||||
|
||||
What it does well:
|
||||
|
||||
- Excellent streaming lifecycle grammar.
|
||||
- Strong layering:
|
||||
- low-level assistant stream events
|
||||
- mid-level agent lifecycle events
|
||||
- high-level session events
|
||||
- The proxy mode has a bandwidth-optimized streaming shape that intentionally strips partial message snapshots and reconstructs them client-side.
|
||||
|
||||
What is weak:
|
||||
|
||||
- There is no universal per-event envelope.
|
||||
- There are no event IDs or replay semantics.
|
||||
- Timestamping is inconsistent. The session header has a timestamp, and some embedded message objects have timestamps, but not every event line does.
|
||||
|
||||
Why it matters for Fabro:
|
||||
|
||||
- pi-mono is the best example here for event layering and start/delta/end/done grammar.
|
||||
- Fabro should borrow that lifecycle discipline if it expands live agent streaming, but keep Fabro's stronger envelope.
|
||||
|
||||
Sources:
|
||||
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/ai/src/types.ts`
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/types.ts`
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/proxy.ts`
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/src/core/agent-session.ts`
|
||||
- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/docs/json.md`
|
||||
|
||||
## Cross-System Patterns
|
||||
|
||||
### Patterns worth copying
|
||||
|
||||
- One obvious discriminator per public event.
|
||||
- Explicit unions for error state, stop reason, retry state, and requires-action state.
|
||||
- Stable correlation IDs for session/thread/turn/message/tool levels.
|
||||
- A documented replay story for long-running streams.
|
||||
- Generated public schemas from one source of truth.
|
||||
- A clear distinction between snapshot events and delta events.
|
||||
|
||||
### Patterns worth avoiding
|
||||
|
||||
- Opaque `unknown` payloads inside otherwise typed events.
|
||||
- Server-injected fields that the public schema does not model.
|
||||
- Mixing keep-alives, control RPCs, and domain events in one event contract.
|
||||
- Event systems that only make sense in the context of one transport, for example JSON-RPC `method`/`params`, when the real need is a transport-agnostic event log.
|
||||
- No universal ID or timestamp on durable events.
|
||||
|
||||
## Recommendations For Fabro
|
||||
|
||||
### 1. Keep the canonical envelope
|
||||
|
||||
Fabro should keep `id`, `ts`, `run_id`, `event`, `session_id`, `parent_session_id`, `node_id`, and `node_label` exactly as the backbone of the public schema. That is already better than every comparator except Claude Sessions on consistency.
|
||||
|
||||
### 2. Do not let transport metadata leak informally
|
||||
|
||||
If Fabro supports SSE replay or live reattach, define transport rules explicitly:
|
||||
|
||||
- SSE `id`
|
||||
- replay cursor semantics
|
||||
- comment heartbeat vs payload heartbeat
|
||||
- reconnect guarantees
|
||||
|
||||
Do not make clients depend on extra fields injected by one server path that are absent from the formal schema.
|
||||
|
||||
### 3. Add deeper correlation IDs where the product needs them
|
||||
|
||||
Fabro already has run/session/node metadata. The next likely additions are:
|
||||
|
||||
- `turn_id`
|
||||
- `message_id`
|
||||
- `tool_call_id`
|
||||
- `request_id`
|
||||
- `attempt`
|
||||
|
||||
Those should be explicit schema fields, not encoded into ad hoc strings.
|
||||
|
||||
### 4. Prefer unions over stringly terminal state
|
||||
|
||||
If Fabro expands live session or agent events, model terminal and waiting states like Claude Sessions does:
|
||||
|
||||
- `stop_reason`
|
||||
- `retry_status`
|
||||
- `requires_action`
|
||||
- `error_kind`
|
||||
|
||||
Avoid free-form strings when a small tagged union will do.
|
||||
|
||||
### 5. Standardize lifecycle families
|
||||
|
||||
If Fabro emits live agent output, choose one lifecycle grammar and document it:
|
||||
|
||||
- `.started`
|
||||
- `.delta`
|
||||
- `.completed`
|
||||
- `.failed`
|
||||
|
||||
If snapshot replacement events also exist, mark them clearly and document when clients should treat them as authoritative replacement vs append-only updates.
|
||||
|
||||
### 6. Keep domain events separate from control and keep-alive traffic
|
||||
|
||||
Claude Code shows the downside of multiplexing control requests, control responses, keep-alives, and domain messages in one stream contract. Fabro's durable run events should stay product-facing and analyzable.
|
||||
|
||||
### 7. Add schema-drift tests for streamed events
|
||||
|
||||
OpenCode and Goose both show how easy it is for the wire stream to diverge from the published schema. Fabro should keep tests that validate:
|
||||
|
||||
- every emitted streamed payload is representable by the public schema
|
||||
- no consumer-visible fields are injected outside the schema
|
||||
- replay/heartbeat frames are documented and tested separately
|
||||
|
||||
## Bottom Line
|
||||
|
||||
Fabro does not need to copy any one competitor's schema wholesale.
|
||||
|
||||
The best composite design is:
|
||||
|
||||
- Claude Sessions' explicit unions for status and errors
|
||||
- Goose's replay semantics
|
||||
- Codex's separation between a simple high-level client view and a richer low-level view, if Fabro ever needs both
|
||||
- OpenCode's breadth of runtime events
|
||||
- pi-mono's streaming lifecycle grammar
|
||||
- Fabro's existing canonical envelope as the foundation
|
||||
|
||||
That combination would produce an event model that is both durable and ergonomic: good for live UI streaming, replay, analytics, tests, and long-term compatibility.
|
||||
|
|
@ -1,34 +1,35 @@
|
|||
# Fabro Events Strategy
|
||||
|
||||
Fabro emits structured **workflow run events** during execution for observability. Events are the durable audit trail for a run: they drive `progress.jsonl`, `live.json`, the run store, SSE streaming, CLI progress rendering, and retro analysis.
|
||||
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, retro analysis, and optional JSONL sinks.
|
||||
|
||||
Events are distinct from tracing logs. Tracing is developer diagnostics; events are product-facing state transitions and activity records that other systems consume.
|
||||
|
||||
Detached runs rely on this distinction. If something needs to be visible after reattach, emit a `WorkflowRunEvent` rather than only logging to stderr or `detach.log`.
|
||||
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`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
Engine/Handler -> WorkflowRunEvent -> EventEmitter::emit()
|
||||
|- trace(raw event)
|
||||
|- canonicalize -> RunEventEnvelope
|
||||
`- on_event(&RunEventEnvelope)
|
||||
|- progress.jsonl + live.json
|
||||
Engine/Handler -> Event -> Emitter::emit()
|
||||
|- trace(raw event)
|
||||
|- canonicalize -> RunEvent
|
||||
`- on_event(&RunEvent)
|
||||
|- run store
|
||||
|- SSE
|
||||
|- optional JSONL/debug sinks
|
||||
`- CLI / tests / metrics listeners
|
||||
```
|
||||
|
||||
The canonical envelope is built exactly once in `fabro-workflow/src/event.rs`.
|
||||
The canonical `RunEvent` is built exactly once in `fabro-workflow/src/event.rs`.
|
||||
|
||||
- `WorkflowRunEvent` remains the internal typed source of truth.
|
||||
- `EventEmitter` owns an immutable `run_id` and converts typed events into `RunEventEnvelope`.
|
||||
- Every listener receives `&RunEventEnvelope`, not `&WorkflowRunEvent`.
|
||||
- Bypass paths that cannot go through the emitter must call `canonicalize_event()` once and reuse the same envelope for every sink.
|
||||
- `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.
|
||||
|
||||
## Canonical Envelope
|
||||
|
||||
Each line in `progress.jsonl` is a `RunEventEnvelope`:
|
||||
Each serialized `RunEvent` uses this canonical envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -100,43 +101,48 @@ Agent events now use explicit session links:
|
|||
|
||||
## Direct-Write Paths
|
||||
|
||||
Most events flow through `EventEmitter::emit()`. The remaining direct-write paths must use:
|
||||
Most events flow through `Emitter::emit()`. The remaining direct-write paths must use:
|
||||
|
||||
1. `canonicalize_event(run_id, event)`
|
||||
1. `to_run_event(run_id, event)`
|
||||
2. Serialize and redact once
|
||||
3. Reuse that exact serialized envelope for every sink
|
||||
3. Reuse that exact `RunEvent` for every sink
|
||||
|
||||
Never canonicalize the same logical event twice if multiple sinks receive it.
|
||||
Never build the same `RunEvent` twice if multiple sinks receive it.
|
||||
|
||||
## Adding A New Event
|
||||
|
||||
### 1. Add the typed event
|
||||
|
||||
Add a variant to `WorkflowRunEvent`, `AgentEvent`, or `SandboxEvent` as appropriate.
|
||||
Add a variant to `Event`, `AgentEvent`, or `SandboxEvent` as appropriate.
|
||||
|
||||
### 2. Add tracing
|
||||
|
||||
Extend `WorkflowRunEvent::trace()` so the raw event is observable in tracing output.
|
||||
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. Map envelope fields
|
||||
### 4. Add the `EventBody` variant
|
||||
|
||||
Update `extract_envelope_fields()`:
|
||||
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 `fabro-workflow/src/event.rs`:
|
||||
|
||||
- Move `node_id`, `node_label`, `session_id`, and `parent_session_id` into the envelope when appropriate.
|
||||
- Keep event-specific data in `properties`.
|
||||
- Flatten structured failure details into explicit property keys when needed.
|
||||
- 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.
|
||||
|
||||
### 5. Emit it
|
||||
### 6. Emit it
|
||||
|
||||
Prefer `EventEmitter::emit(&WorkflowRunEvent::...)`.
|
||||
Prefer `Emitter::emit(&Event::...)`.
|
||||
|
||||
Use `canonicalize_event()` only for true bypass paths.
|
||||
Use `to_run_event()` only for true bypass paths.
|
||||
|
||||
### 6. Update consumers
|
||||
### 7. Update consumers
|
||||
|
||||
Check:
|
||||
|
||||
|
|
@ -148,17 +154,23 @@ Check:
|
|||
|
||||
## Consumer Guidance
|
||||
|
||||
When writing listeners:
|
||||
When writing Rust consumers (listeners, store projections, CLI progress):
|
||||
|
||||
- Match on `envelope.event`, not Rust variant names.
|
||||
- Read event payload from `envelope.properties`.
|
||||
- Read stage/branch identity from `node_id` and `node_label`.
|
||||
- Read agent hierarchy from `session_id` and `parent_session_id`.
|
||||
- Match on `event.body` using `EventBody::*` variants. This gives you typed access to event-specific fields.
|
||||
- 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.
|
||||
|
||||
Do not rebuild or mutate the envelope in downstream listeners.
|
||||
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
|
||||
|
||||
`progress.jsonl`, the run store, and SSE should reflect the same canonical envelope bytes after redaction.
|
||||
Any JSONL sink, the run store, and SSE should reflect the same canonical envelope bytes after redaction.
|
||||
|
||||
`status.json` remains the authoritative completion signal for detached runs. Terminal run status should only be written after all post-run work is finished.
|
||||
|
|
|
|||
2149
docs-internal/events.md
Normal file
2149
docs-internal/events.md
Normal file
File diff suppressed because it is too large
Load diff
484
docs-internal/fabro-event-schema-v2-concrete-shape.md
Normal file
484
docs-internal/fabro-event-schema-v2-concrete-shape.md
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
# 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/crates/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/crates/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/crates/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/crates/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/crates/fabro-types/src/run_event/run.rs)
|
||||
- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/stage.rs)
|
||||
- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/agent.rs)
|
||||
- infra/setup/devcontainer props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/infra.rs)
|
||||
- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/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
|
||||
|
||||
- `agent.session.started`
|
||||
- `agent.session.ended`
|
||||
- `agent.processing.end`
|
||||
- `agent.input`
|
||||
- `agent.message`
|
||||
- `agent.tool.started`
|
||||
- `agent.tool.completed`
|
||||
- `agent.error`
|
||||
- `agent.warning`
|
||||
- `agent.loop.detected`
|
||||
- `agent.turn.limit`
|
||||
- `agent.steering.injected`
|
||||
- `agent.compaction.started`
|
||||
- `agent.compaction.completed`
|
||||
- `agent.llm.retry`
|
||||
- `agent.sub.spawned`
|
||||
- `agent.sub.completed`
|
||||
- `agent.sub.failed`
|
||||
- `agent.sub.closed`
|
||||
- `agent.mcp.ready`
|
||||
- `agent.mcp.failed`
|
||||
- `agent.failover`
|
||||
|
||||
### Git
|
||||
|
||||
- `git.commit`
|
||||
- `git.push`
|
||||
- `git.branch`
|
||||
- `git.worktree.added`
|
||||
- `git.worktree.removed`
|
||||
- `git.fetch`
|
||||
- `git.reset`
|
||||
|
||||
### Infra And Execution
|
||||
|
||||
- `sandbox.*`
|
||||
- `setup.*`
|
||||
- `cli.ensure.*`
|
||||
- `command.*`
|
||||
- `agent.cli.*`
|
||||
- `devcontainer.*`
|
||||
- `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.start`
|
||||
- `agent.output.replace`
|
||||
- `agent.text.delta`
|
||||
- `agent.reasoning.delta`
|
||||
- `agent.tool.output.delta`
|
||||
- `agent.skill.expanded`
|
||||
|
||||
`agent.skill.expanded` stays in this non-durable bucket because it is display-oriented expansion metadata, not a durable workflow fact.
|
||||
|
||||
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
|
||||
633
docs-internal/fabro-event-schema-v2-proposal.md
Normal file
633
docs-internal/fabro-event-schema-v2-proposal.md
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
# Fabro Event Schema V2 Proposal
|
||||
|
||||
Date: 2026-04-08
|
||||
|
||||
Status: proposal
|
||||
|
||||
Assumptions:
|
||||
|
||||
- greenfield redesign
|
||||
- no production deployments
|
||||
- no backward-compatibility constraints
|
||||
- optimize for the best long-term public event contract
|
||||
|
||||
This proposal turns the earlier ideation into concrete schema changes.
|
||||
|
||||
## Design Goal
|
||||
|
||||
Fabro should expose:
|
||||
|
||||
1. a durable, append-only event log for audit, storage, replay, and projections
|
||||
2. a separate live stream for UI-oriented snapshots, deltas, and fast progress
|
||||
|
||||
They should share IDs and correlation fields, but they should not be the same contract.
|
||||
|
||||
## Top 10 Concrete Improvements
|
||||
|
||||
### 1. Split the single event story into two concrete public APIs
|
||||
|
||||
#### Proposal
|
||||
|
||||
Introduce two top-level event contracts:
|
||||
|
||||
- `DurableEvent`
|
||||
- `LiveEvent`
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `GET /runs/:run_id/events`
|
||||
- append-only durable events
|
||||
- replayable
|
||||
- no keep-alive payload events
|
||||
- `GET /runs/:run_id/live`
|
||||
- live UI stream
|
||||
- snapshots + deltas + keep-alives
|
||||
- resumable with cursor
|
||||
|
||||
#### Durable event shape
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "durable",
|
||||
"id": "evt_01960d0c...",
|
||||
"seq": 182,
|
||||
"ts": "2026-04-08T15:01:02.123Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"event": "agent.tool.started",
|
||||
"session_id": "ses_123",
|
||||
"node_id": "code",
|
||||
"properties": {
|
||||
"tool_call_id": "tool_abc",
|
||||
"tool_name": "read_file",
|
||||
"arguments": { "path": "src/main.rs" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Live event shape
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "live",
|
||||
"id": "levt_01960d0d...",
|
||||
"seq": 991,
|
||||
"ts": "2026-04-08T15:01:03.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"event": "message.delta",
|
||||
"session_id": "ses_123",
|
||||
"message_id": "msg_456",
|
||||
"part_id": "part_1",
|
||||
"properties": {
|
||||
"block_type": "text",
|
||||
"delta": "Let me check that file..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Durable events stay stable and analyzable.
|
||||
- Live events can be noisy and UI-oriented without polluting projections.
|
||||
- Keeps Fabro from repeating the Claude Code / OpenCode problem of mixing control, transport, and product semantics.
|
||||
|
||||
### 2. Add explicit stream ordering, replay, and recovery semantics
|
||||
|
||||
#### Proposal
|
||||
|
||||
Every durable and live stream event gets:
|
||||
|
||||
- `seq: u64`
|
||||
- SSE `id:` = `seq`
|
||||
- replay semantics based on `Last-Event-ID`
|
||||
|
||||
Server rules:
|
||||
|
||||
- if `Last-Event-ID` is present and still buffered, replay `seq > cursor`
|
||||
- if cursor is too old, return a structured reset event in live streams and `409 replay_reset_required` in durable streams
|
||||
- durable streams never emit synthetic snapshots
|
||||
- live streams may start with a `*.snapshot` event after reconnect
|
||||
|
||||
#### New live-only events
|
||||
|
||||
- `stream.heartbeat`
|
||||
- `run.snapshot`
|
||||
- `session.snapshot`
|
||||
- `node.snapshot`
|
||||
- `stream.reset_required`
|
||||
|
||||
#### Example `stream.reset_required`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "live",
|
||||
"id": "levt_01960d0e...",
|
||||
"seq": 1200,
|
||||
"ts": "2026-04-08T15:02:00.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"event": "stream.reset_required",
|
||||
"properties": {
|
||||
"reason": "cursor_too_old",
|
||||
"expected_from_seq": 1170
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Reattach behavior becomes deterministic.
|
||||
- Clients no longer guess whether they missed data.
|
||||
- Replay is part of the contract, not an implementation detail.
|
||||
|
||||
### 3. Expand the envelope into a first-class correlation model
|
||||
|
||||
#### Proposal
|
||||
|
||||
Extend the shared envelope with these optional fields:
|
||||
|
||||
- `workflow_id`
|
||||
- `stage_id`
|
||||
- `branch_id`
|
||||
- `checkpoint_id`
|
||||
- `session_id`
|
||||
- `parent_session_id`
|
||||
- `turn_id`
|
||||
- `message_id`
|
||||
- `part_id`
|
||||
- `tool_call_id`
|
||||
- `request_id`
|
||||
- `causation_id`
|
||||
- `correlation_id`
|
||||
|
||||
Rules:
|
||||
|
||||
- `id` is the event's own identity
|
||||
- `causation_id` points to the immediate triggering event, if any
|
||||
- `correlation_id` groups a whole logical operation, for example one user request or one retry attempt tree
|
||||
- `request_id` is transport/API request scoped, not workflow scoped
|
||||
|
||||
#### Concrete change
|
||||
|
||||
Move these IDs out of ad hoc `properties` payloads when they are structural identifiers.
|
||||
|
||||
Good:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "agent.tool.completed",
|
||||
"tool_call_id": "tool_abc",
|
||||
"message_id": "msg_456",
|
||||
"properties": {
|
||||
"tool_name": "read_file",
|
||||
"is_error": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "agent.tool.completed",
|
||||
"properties": {
|
||||
"tool_call_id": "tool_abc",
|
||||
"message_id": "msg_456",
|
||||
"tool_name": "read_file"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Correlation becomes universal instead of event-family-specific.
|
||||
- UI and analytics consumers can join without parsing `properties`.
|
||||
- Parent/child agent and retry trees become much easier to reason about.
|
||||
|
||||
### 4. Replace stringly state with concrete tagged unions
|
||||
|
||||
#### Proposal
|
||||
|
||||
Define explicit union types for stateful fields.
|
||||
|
||||
Examples:
|
||||
|
||||
```ts
|
||||
type StopReason =
|
||||
| { type: "completed" }
|
||||
| { type: "requires_input"; request_id: string }
|
||||
| { type: "interrupted"; interrupt_reason: InterruptReason }
|
||||
| { type: "failed"; error_kind: ErrorKind }
|
||||
| { type: "retries_exhausted"; attempts: number };
|
||||
|
||||
type RetryStatus =
|
||||
| { type: "not_retrying" }
|
||||
| { type: "retry_scheduled"; attempt: number; next_retry_at: string }
|
||||
| { type: "retrying"; attempt: number }
|
||||
| { type: "retries_exhausted"; attempts: number };
|
||||
|
||||
type ApprovalStatus =
|
||||
| { type: "not_required" }
|
||||
| { type: "requested"; approval_id: string }
|
||||
| { type: "approved"; approval_id: string; actor: string }
|
||||
| { type: "denied"; approval_id: string; actor: string; reason?: string };
|
||||
```
|
||||
|
||||
#### Concrete fields to replace
|
||||
|
||||
- `status`
|
||||
- `reason`
|
||||
- `failure_class`
|
||||
- `interrupt_reason`
|
||||
- `stop_reason`
|
||||
- `approval_status`
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Eliminates string drift.
|
||||
- Makes reducers and policy engines much safer.
|
||||
- Makes test fixtures much more stable.
|
||||
|
||||
### 5. Standardize event family grammar across the entire product
|
||||
|
||||
#### Proposal
|
||||
|
||||
Use one lifecycle vocabulary:
|
||||
|
||||
- `.created`
|
||||
- `.started`
|
||||
- `.snapshot`
|
||||
- `.delta`
|
||||
- `.updated`
|
||||
- `.completed`
|
||||
- `.failed`
|
||||
- `.cancelled`
|
||||
- `.interrupted`
|
||||
- `.deleted`
|
||||
|
||||
Apply it consistently to the same kinds of things:
|
||||
|
||||
- `run.*`
|
||||
- `stage.*`
|
||||
- `session.*`
|
||||
- `turn.*`
|
||||
- `message.*`
|
||||
- `message.part.*`
|
||||
- `tool.*`
|
||||
- `command.*`
|
||||
- `checkpoint.*`
|
||||
- `parallel.branch.*`
|
||||
- `retro.*`
|
||||
|
||||
#### Concrete renames
|
||||
|
||||
Current style is already decent, but V2 should be stricter.
|
||||
|
||||
Examples:
|
||||
|
||||
- `agent.output.start` -> `message.part.started`
|
||||
- `agent.text.delta` -> `message.part.delta`
|
||||
- `agent.tool.output.delta` -> `tool.output.delta`
|
||||
- `agent.processing.end` -> `turn.completed` or `session.idle`, depending on actual semantics
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Consumers can infer behavior from naming alone.
|
||||
- Reduces one-off event families that encode bespoke lifecycle semantics.
|
||||
|
||||
### 6. Introduce typed content blocks and block-level deltas
|
||||
|
||||
#### Proposal
|
||||
|
||||
Represent streamable content as typed message parts.
|
||||
|
||||
Base union:
|
||||
|
||||
```ts
|
||||
type MessagePart =
|
||||
| { type: "text"; part_id: string; text: string }
|
||||
| { type: "reasoning"; part_id: string; text: string }
|
||||
| { type: "tool_call"; part_id: string; tool_call_id: string; tool_name: string; input: unknown }
|
||||
| { type: "tool_result"; part_id: string; tool_call_id: string; output: unknown; is_error: boolean }
|
||||
| { type: "patch"; part_id: string; patch_ref: string }
|
||||
| { type: "file_ref"; part_id: string; file_id: string; path: string }
|
||||
| { type: "artifact_ref"; part_id: string; artifact_id: string; label: string }
|
||||
| { type: "plan"; part_id: string; items: PlanItem[] }
|
||||
| { type: "todo"; part_id: string; items: TodoItem[] }
|
||||
| { type: "command_output"; part_id: string; command_id: string; stream: "stdout" | "stderr"; text: string };
|
||||
```
|
||||
|
||||
Live delta event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "message.part.delta",
|
||||
"message_id": "msg_456",
|
||||
"part_id": "part_1",
|
||||
"properties": {
|
||||
"part_type": "text",
|
||||
"delta": "checking src/main.rs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Durable completion event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "message.completed",
|
||||
"message_id": "msg_456",
|
||||
"properties": {
|
||||
"parts": [
|
||||
{ "type": "text", "part_id": "part_1", "text": "checking src/main.rs" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Supports rich UI without reparsing free-form text.
|
||||
- Supports structured summarization, compaction, and retro generation.
|
||||
- Aligns Fabro with the best parts of Claude Sessions and pi-mono.
|
||||
|
||||
### 7. Make approvals, questions, and operator interventions first-class durable events
|
||||
|
||||
#### Proposal
|
||||
|
||||
Add explicit event families:
|
||||
|
||||
- `approval.requested`
|
||||
- `approval.responded`
|
||||
- `question.asked`
|
||||
- `question.answered`
|
||||
- `interrupt.requested`
|
||||
- `interrupt.applied`
|
||||
- `resume.required`
|
||||
- `resume.applied`
|
||||
|
||||
#### Example `approval.requested`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "durable",
|
||||
"id": "evt_01960d0f...",
|
||||
"seq": 201,
|
||||
"ts": "2026-04-08T15:03:00.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"session_id": "ses_123",
|
||||
"tool_call_id": "tool_abc",
|
||||
"event": "approval.requested",
|
||||
"properties": {
|
||||
"approval_id": "apr_1",
|
||||
"scope": "tool_call",
|
||||
"tool_name": "exec_command",
|
||||
"request": {
|
||||
"cmd": "git push origin branch"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Example `approval.responded`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "durable",
|
||||
"id": "evt_01960d10...",
|
||||
"seq": 202,
|
||||
"ts": "2026-04-08T15:03:10.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"event": "approval.responded",
|
||||
"properties": {
|
||||
"approval_id": "apr_1",
|
||||
"result": {
|
||||
"type": "approved",
|
||||
"actor": "user"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Human-in-loop behavior becomes queryable and replayable.
|
||||
- Workflow interruption is no longer hidden in transport or UI state.
|
||||
|
||||
### 8. Add real snapshot events instead of relying on ad hoc reconstruction
|
||||
|
||||
#### Proposal
|
||||
|
||||
Define explicit snapshot events for live attach and projection recovery:
|
||||
|
||||
- `run.snapshot`
|
||||
- `session.snapshot`
|
||||
- `node.snapshot`
|
||||
- `checkpoint.saved`
|
||||
|
||||
#### Example `session.snapshot`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "live",
|
||||
"id": "levt_01960d11...",
|
||||
"seq": 1500,
|
||||
"ts": "2026-04-08T15:04:00.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"session_id": "ses_123",
|
||||
"event": "session.snapshot",
|
||||
"properties": {
|
||||
"state": { "type": "running" },
|
||||
"turn_id": "turn_9",
|
||||
"messages": [
|
||||
{
|
||||
"message_id": "msg_456",
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{ "type": "text", "part_id": "part_1", "text": "checking src/main.rs" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"active_tool_calls": [
|
||||
{
|
||||
"tool_call_id": "tool_abc",
|
||||
"tool_name": "read_file",
|
||||
"status": "running"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Concrete rule
|
||||
|
||||
- snapshots are authoritative replacement state for live consumers
|
||||
- snapshots are optional in durable streams
|
||||
- checkpoints are durable domain snapshots, not just UI snapshots
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Fast attach becomes trivial.
|
||||
- Projections can self-heal from snapshots.
|
||||
- Checkpoint semantics become explicit rather than emergent.
|
||||
|
||||
### 9. Make model, tool, command, and MCP work first-class span families
|
||||
|
||||
#### Proposal
|
||||
|
||||
Create event families with shared semantics:
|
||||
|
||||
- `model.request.started`
|
||||
- `model.request.completed`
|
||||
- `model.request.failed`
|
||||
- `tool.started`
|
||||
- `tool.output.delta`
|
||||
- `tool.completed`
|
||||
- `tool.failed`
|
||||
- `command.started`
|
||||
- `command.output.delta`
|
||||
- `command.completed`
|
||||
- `command.failed`
|
||||
- `mcp.call.started`
|
||||
- `mcp.call.progress`
|
||||
- `mcp.call.completed`
|
||||
- `mcp.call.failed`
|
||||
|
||||
#### Example `model.request.completed`
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "durable",
|
||||
"id": "evt_01960d12...",
|
||||
"seq": 220,
|
||||
"ts": "2026-04-08T15:05:00.000Z",
|
||||
"run_id": "run_01JQ...",
|
||||
"session_id": "ses_123",
|
||||
"turn_id": "turn_9",
|
||||
"request_id": "req_llm_1",
|
||||
"event": "model.request.completed",
|
||||
"properties": {
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4",
|
||||
"latency_ms": 1834,
|
||||
"usage": {
|
||||
"input_tokens": 1400,
|
||||
"output_tokens": 380,
|
||||
"reasoning_tokens": 120,
|
||||
"cache_read_tokens": 900,
|
||||
"cache_write_tokens": 0
|
||||
},
|
||||
"retry_status": { "type": "not_retrying" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Cost and latency analysis become first-class.
|
||||
- Policy engines can reason about real operations, not just stage summaries.
|
||||
- Cross-provider comparison gets much easier.
|
||||
|
||||
### 10. Generate and enforce the public schema, docs, and examples from one registry
|
||||
|
||||
#### Proposal
|
||||
|
||||
Build a single `event_schema_registry` source that defines:
|
||||
|
||||
- envelope fields
|
||||
- event families
|
||||
- payload types
|
||||
- union types
|
||||
- versioning
|
||||
- example payloads
|
||||
|
||||
Artifacts generated from it:
|
||||
|
||||
- Rust types
|
||||
- TypeScript types
|
||||
- JSON Schema
|
||||
- OpenAPI / SSE docs
|
||||
- sample event fixtures
|
||||
- validation tests
|
||||
|
||||
#### Concrete rules
|
||||
|
||||
- every public event must have:
|
||||
- one schema definition
|
||||
- one example payload
|
||||
- one validation test
|
||||
- no endpoint may inject extra consumer-visible fields outside the schema
|
||||
- keep-alive frames are documented separately from payload events
|
||||
|
||||
#### Why this is better
|
||||
|
||||
- Prevents the OpenCode and Goose class of drift.
|
||||
- Makes Fabro's event API publishable and stable from day one.
|
||||
|
||||
## Recommended V2 Event Families
|
||||
|
||||
If Fabro were starting from scratch, I would structure the public families like this:
|
||||
|
||||
- `run.*`
|
||||
- `stage.*`
|
||||
- `checkpoint.*`
|
||||
- `parallel.branch.*`
|
||||
- `session.*`
|
||||
- `turn.*`
|
||||
- `message.*`
|
||||
- `message.part.*`
|
||||
- `model.request.*`
|
||||
- `tool.*`
|
||||
- `command.*`
|
||||
- `mcp.call.*`
|
||||
- `approval.*`
|
||||
- `question.*`
|
||||
- `interrupt.*`
|
||||
- `resume.*`
|
||||
- `compaction.*`
|
||||
- `retro.*`
|
||||
- `artifact.*`
|
||||
- `stream.*` (live only)
|
||||
|
||||
## Recommended Field Placement Rules
|
||||
|
||||
Top-level envelope:
|
||||
|
||||
- identity and correlation
|
||||
- ordering
|
||||
- timestamps
|
||||
- scope
|
||||
|
||||
`properties`:
|
||||
|
||||
- event-family-specific payload
|
||||
- business data
|
||||
- structured state payloads
|
||||
|
||||
Never in `properties` if they are structural:
|
||||
|
||||
- `run_id`
|
||||
- `seq`
|
||||
- `event`
|
||||
- `session_id`
|
||||
- `message_id`
|
||||
- `tool_call_id`
|
||||
- `request_id`
|
||||
- `causation_id`
|
||||
- `correlation_id`
|
||||
|
||||
## Bottom Line
|
||||
|
||||
The best greenfield version of Fabro is not "the current schema plus more events."
|
||||
|
||||
It is:
|
||||
|
||||
- separate durable and live contracts
|
||||
- replayable ordered streams
|
||||
- a richer envelope
|
||||
- typed state unions
|
||||
- typed content blocks
|
||||
- explicit snapshots
|
||||
- first-class HITL events
|
||||
- first-class span families
|
||||
- generated schema/docs/tests from one registry
|
||||
|
||||
That would give Fabro a better event platform than any of the compared systems.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue