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:
Bryan Helmkamp 2026-04-12 15:52:14 -04:00
commit 2299d30afd
No known key found for this signature in database
1447 changed files with 106040 additions and 61523 deletions

View file

@ -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"
}
]
}

View file

@ -1 +1 @@
ed651dcd571699e92162cf657b8eca42d7a6d6b1
6c53fc29b5f1309bfd026a883a23006db2bb441c

View file

@ -1 +1 @@
1afa8419b53670f95c5d6a041cfd373c4a8c9371
6c53fc29b5f1309bfd026a883a23006db2bb441c

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,7 @@
_version = 1
[server.integrations.github]
[server.integrations.github.permissions]
pull_requests = "read"
issues = "read"

View file

@ -0,0 +1,7 @@
_version = 1
[server.integrations.github]
[server.integrations.github.permissions]
issues = "read"
pull_requests = "write"

View file

@ -0,0 +1 @@
_version = 1

View file

@ -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

View file

@ -0,0 +1 @@
_version = 1

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
lib/crates/fabro-spa/assets/** linguist-generated=true -diff

View file

@ -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)

View file

@ -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)

View file

@ -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
View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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();
}
});
});

View file

@ -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>;
}

View 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
View 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();
}

View file

@ -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 };

View 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");
});
});

View file

@ -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";

View file

@ -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, "");
}

View 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>,
);

View 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");
});
});

View file

@ -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>
);
}

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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}`;
}

View 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");
});
});

View 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);
}

View file

@ -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;
}

View file

@ -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");
});
});

View file

@ -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;
}

View file

@ -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);
},
});
}

View file

@ -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();
});
});
});

View file

@ -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;
}

View 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");
});
});

View 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 };

View file

@ -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) {

View file

@ -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;
}

View 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;
}

View file

@ -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"

View 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");
});
});

View 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),
],
},
],
},
];

View file

@ -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;

View file

@ -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 });
}

View file

@ -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>
);
}

View file

@ -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) },
});
}

View file

@ -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();

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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) {

View file

@ -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) => ({

View file

@ -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);

View file

@ -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;

View file

@ -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} &middot; {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>
);
}

View file

@ -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>

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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 (

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>

View file

@ -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">

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 && (

View file

@ -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;

View file

@ -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");

View file

@ -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");

View 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>

View file

@ -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"
}
}
}

View file

@ -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;

View 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);
});

View 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);
});

View file

@ -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
}
}

View file

@ -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"] },
});

618
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -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 },
]

View file

@ -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

View file

@ -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"],
},

View file

@ -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/**"]

View 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.

View file

@ -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

File diff suppressed because it is too large Load diff

View 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

View 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