diff --git a/.cargo/config.toml b/.cargo/config.toml index 3a6f66c2b..dc7058400 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,5 +1,5 @@ [alias] -dev = "run --package fabro-dev --" +dev = "run --package fabro-dev --features dev --" t = "test -- --format terse" [env] diff --git a/.claude/skills/changelog/watermark b/.claude/skills/changelog/watermark index 6707ae261..09876abd5 100644 --- a/.claude/skills/changelog/watermark +++ b/.claude/skills/changelog/watermark @@ -1 +1 @@ -061ccc673b0409778edb759bb4fbdd1bee923247 +9a3ab8bbba2e71d72ff7703a68e9892e8ce94d8d diff --git a/.claude/skills/docs/watermark b/.claude/skills/docs/watermark index 1a0244d12..578b5ffc5 100644 --- a/.claude/skills/docs/watermark +++ b/.claude/skills/docs/watermark @@ -1 +1 @@ -31cbdb5c316e0e128d935371989afef0d4714209 +2f10ee39afe6bd9a1c1df987da52fb82c91b422e diff --git a/.fabro/workflows/gh-list/workflow.fabro b/.fabro/workflows/gh-list/workflow.fabro new file mode 100644 index 000000000..176e6bfe3 --- /dev/null +++ b/.fabro/workflows/gh-list/workflow.fabro @@ -0,0 +1,12 @@ +digraph GhList { + graph [goal="List open PRs and issues via the gh CLI to verify GITHUB_TOKEN injection"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + list_prs [label="List PRs", shape=parallelogram, script="gh pr list --state open --limit 50 2>&1"] + list_issues [label="List Issues", shape=parallelogram, script="gh issue list --state open --limit 50 2>&1"] + + start -> list_prs -> list_issues -> exit +} diff --git a/.fabro/workflows/gh-list/workflow.toml b/.fabro/workflows/gh-list/workflow.toml new file mode 100644 index 000000000..df42f2eea --- /dev/null +++ b/.fabro/workflows/gh-list/workflow.toml @@ -0,0 +1,5 @@ +_version = 1 + +[run.integrations.github.permissions] +pull_requests = "read" +issues = "read" diff --git a/.fabro/workflows/gh-triage/workflow.toml b/.fabro/workflows/gh-triage/workflow.toml index 8fafbcab1..3c2d95626 100644 --- a/.fabro/workflows/gh-triage/workflow.toml +++ b/.fabro/workflows/gh-triage/workflow.toml @@ -1,7 +1,5 @@ _version = 1 -[server.integrations.github] - -[server.integrations.github.permissions] -pull_requests = "read" +[run.integrations.github.permissions] issues = "read" +pull_requests = "write" diff --git a/.fabro/workflows/implement-issue/workflow.toml b/.fabro/workflows/implement-issue/workflow.toml index 2a0f45f16..df42f2eea 100644 --- a/.fabro/workflows/implement-issue/workflow.toml +++ b/.fabro/workflows/implement-issue/workflow.toml @@ -1,7 +1,5 @@ _version = 1 -[server.integrations.github] - -[server.integrations.github.permissions] +[run.integrations.github.permissions] +pull_requests = "read" issues = "read" -pull_requests = "write" diff --git a/.fabro/workflows/interview/workflow.fabro b/.fabro/workflows/interview/workflow.fabro new file mode 100644 index 000000000..4fa8ce18f --- /dev/null +++ b/.fabro/workflows/interview/workflow.fabro @@ -0,0 +1,60 @@ +digraph Interview { + graph [goal="Run a progressive human interview and summarize the answers"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + yes_no [ + shape=hexagon, + label="Is this interview workflow easy to follow so far?", + question_type="yes_no" + ] + confirmation [ + shape=hexagon, + label="Confirm that you want to continue into more structured questions.", + question_type="confirmation" + ] + multiple_choice [ + shape=hexagon, + label="Which theme should be the center of the final summary?", + question_type="multiple_choice" + ] + multi_select [ + shape=hexagon, + label="Which supporting areas should the final summary emphasize?", + question_type="multi_select" + ] + freeform [ + shape=hexagon, + label="Add any final context, constraints, or nuance for the summary.", + question_type="freeform" + ] + summarize [ + shape=tab, + label="Summarize Interview", + fidelity="summary:high", + prompt="Summarize the full human interview. Include each question and answer in order, then synthesize the user's priorities, constraints, and open questions. Use the human.gate..question and human.gate..answer context keys when present. Do not invent missing answers." + ] + + start -> yes_no + + yes_no -> confirmation [label="[Y] Yes"] + yes_no -> confirmation [label="[N] No"] + + confirmation -> multiple_choice [label="[Y] Continue"] + confirmation -> multiple_choice [label="[N] Stop after this, but still summarize"] + + multiple_choice -> multi_select [label="[G] Goals"] + multiple_choice -> multi_select [label="[R] Risks"] + multiple_choice -> multi_select [label="[T] Tradeoffs"] + multiple_choice -> multi_select [label="[Q] Open questions"] + + multi_select -> freeform [label="[S] Success criteria"] + multi_select -> freeform [label="[B] Blockers"] + multi_select -> freeform [label="[D] Decisions needed"] + multi_select -> freeform [label="[N] Next steps"] + + freeform -> summarize [freeform=true] + summarize -> exit +} diff --git a/.fabro/workflows/interview/workflow.toml b/.fabro/workflows/interview/workflow.toml new file mode 100644 index 000000000..9e79c2378 --- /dev/null +++ b/.fabro/workflows/interview/workflow.toml @@ -0,0 +1 @@ +_version = 1 diff --git a/.fabro/workflows/sleeper/workflow.fabro b/.fabro/workflows/sleeper/workflow.fabro new file mode 100644 index 000000000..3f50c3000 --- /dev/null +++ b/.fabro/workflows/sleeper/workflow.fabro @@ -0,0 +1,11 @@ +digraph Sleeper { + graph [goal="Sleep long enough for a human to practice interrupts and steering"] + rankdir=LR + + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + + nap [label="Nap", timeout="360s", prompt="Run exactly one shell command using the Bash tool: `sleep 300`, with the tool `timeout_ms` parameter set to 310000 or higher. Wait for it to return. Do not run any other commands, do not read or edit any files, do not check the environment first. After `sleep 300` returns, reply with the single word 'done' and stop."] + + start -> nap -> exit +} diff --git a/.fabro/workflows/sleeper/workflow.toml b/.fabro/workflows/sleeper/workflow.toml new file mode 100644 index 000000000..9e79c2378 --- /dev/null +++ b/.fabro/workflows/sleeper/workflow.toml @@ -0,0 +1 @@ +_version = 1 diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 0e50054a3..c240e3837 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -59,7 +59,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - run: bun install --frozen-lockfile - - run: cd apps/fabro-web && bun test + - run: cd apps/fabro-web && bun run test build: name: Build diff --git a/.gitignore b/.gitignore index a67af4010..605e98729 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ target .env .entire node_modules -apps/fabro-web/dist/ +apps/fabro-web/dist +apps/fabro-web/.dist-builds/ lib/crates/fabro-spa/assets/* !lib/crates/fabro-spa/assets/.gitkeep tmp diff --git a/AGENTS.md b/AGENTS.md index a62c665b2..61b329f67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as ### Rust crates (`lib/crates/`) - **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-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, 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-sandbox** — Local, Docker, and Daytona sandbox providers. Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads. - **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header @@ -180,7 +180,6 @@ Never run `cargo insta accept` without first checking what's pending — it acce ## Testing workflows - `fabro run ` — run a workflow by name (resolves `.fabro/workflows//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. diff --git a/Cargo.lock b/Cargo.lock index e4e138ffa..655be86cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,6 +373,7 @@ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -391,8 +392,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.28.0", "tower", "tower-layer", "tower-service", @@ -1208,7 +1211,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "daytona-api-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fa4870f#fa4870f2a1769effa45dbabaeaf9d2fbb2406ea2" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -1222,7 +1225,7 @@ dependencies = [ [[package]] name = "daytona-sdk" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fa4870f#fa4870f2a1769effa45dbabaeaf9d2fbb2406ea2" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738" dependencies = [ "daytona-api-client", "daytona-toolbox-client", @@ -1235,14 +1238,14 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "url", ] [[package]] name = "daytona-toolbox-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fa4870f#fa4870f2a1769effa45dbabaeaf9d2fbb2406ea2" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=fc58e22f7f25183df6264276ee186bbc32635738#fc58e22f7f25183df6264276ee186bbc32635738" dependencies = [ "reqwest 0.13.2", "reqwest-middleware", @@ -1536,7 +1539,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1575,7 +1578,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1596,7 +1599,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1618,9 +1621,13 @@ dependencies = [ "tokio", ] +[[package]] +name = "fabro-build-support" +version = "0.230.0-nightly.0" + [[package]] name = "fabro-checkpoint" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1636,7 +1643,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1657,6 +1664,7 @@ dependencies = [ "fabro-agent", "fabro-api", "fabro-auth", + "fabro-build-support", "fabro-checkpoint", "fabro-client", "fabro-config", @@ -1675,12 +1683,12 @@ dependencies = [ "fabro-oauth", "fabro-proc", "fabro-redact", - "fabro-retro", "fabro-sandbox", "fabro-server", "fabro-static", "fabro-store", "fabro-telemetry", + "fabro-template", "fabro-test", "fabro-types", "fabro-util", @@ -1698,6 +1706,7 @@ dependencies = [ "jsonwebtoken", "libc", "miette", + "nix 0.30.1", "object_store", "openssl", "paste", @@ -1732,7 +1741,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1761,7 +1770,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -1788,7 +1797,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -1803,14 +1812,13 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", "chrono", "clap", "csv", - "fabro-cli", "fabro-config", "fabro-options-metadata", "quick-xml 0.36.2", @@ -1823,7 +1831,7 @@ dependencies = [ [[package]] name = "fabro-devcontainer" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "fabro-http", "fabro-static", @@ -1840,7 +1848,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1854,7 +1862,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -1876,7 +1884,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -1890,7 +1898,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -1914,7 +1922,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "fabro-static", "http", @@ -1924,7 +1932,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -1939,7 +1947,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -1954,7 +1962,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1986,7 +1994,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -1997,7 +2005,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "fabro-config", @@ -2013,7 +2021,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "fabro-static", "insta", @@ -2024,7 +2032,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2046,7 +2054,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2054,7 +2062,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "cc", "libc", @@ -2063,7 +2071,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2077,28 +2085,9 @@ dependencies = [ "url", ] -[[package]] -name = "fabro-retro" -version = "0.223.0-nightly.0" -dependencies = [ - "anyhow", - "chrono", - "fabro-agent", - "fabro-dump", - "fabro-llm", - "fabro-store", - "fabro-types", - "fabro-util", - "serde", - "serde_json", - "tempfile", - "tokio", - "tracing", -] - [[package]] name = "fabro-sandbox" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2117,11 +2106,13 @@ dependencies = [ "fabro-types", "fabro-util", "futures", + "futures-util", "git2", "glob", "httpmock", "rand 0.9.4", "reqwest-middleware", + "rustls", "serde", "serde_json", "shlex", @@ -2130,6 +2121,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-tungstenite 0.26.2", "tokio-util", "toml 0.8.23", "tracing", @@ -2138,7 +2130,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2153,6 +2145,7 @@ dependencies = [ "fabro-agent", "fabro-api", "fabro-auth", + "fabro-build-support", "fabro-config", "fabro-github", "fabro-graphviz", @@ -2165,7 +2158,6 @@ dependencies = [ "fabro-model", "fabro-proc", "fabro-redact", - "fabro-retro", "fabro-sandbox", "fabro-slack", "fabro-spa", @@ -2219,7 +2211,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -2232,7 +2224,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.26.2", "toml 0.8.23", "tracing", "tracing-subscriber", @@ -2240,18 +2232,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" [[package]] name = "fabro-store" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -2278,7 +2270,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2304,7 +2296,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "fabro-util", @@ -2316,7 +2308,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "assert_cmd", "axum", @@ -2339,7 +2331,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2353,7 +2345,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "chrono", "clap", @@ -2374,7 +2366,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -2394,7 +2386,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "fabro-graphviz", "fabro-model", @@ -2404,7 +2396,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "chrono", "fabro-types", @@ -2416,7 +2408,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2442,7 +2434,6 @@ dependencies = [ "fabro-mcp", "fabro-model", "fabro-redact", - "fabro-retro", "fabro-sandbox", "fabro-static", "fabro-store", @@ -6873,10 +6864,22 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.26.2", "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -7118,9 +7121,26 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "twin-github" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "axum", "base64", @@ -7139,7 +7159,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 48598329d..17fc5e550 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.223.0-nightly.0" +version = "0.230.0-nightly.0" license = "MIT" [workspace.dependencies] @@ -68,8 +68,8 @@ twin-openai = { path = "test/twin/openai" } twin-github = { path = "test/twin/github" } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fa4870f", package = "daytona-sdk" } -daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fa4870f", package = "daytona-api-client" } +daytona-sdk = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fc58e22f7f25183df6264276ee186bbc32635738", package = "daytona-sdk" } +daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "fc58e22f7f25183df6264276ee186bbc32635738", package = "daytona-api-client" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/README.md b/README.md index 0a3caeec9..7cc53e30a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Then run `fabro server start` to finish setup in your browser. The server opens - **Run agents 24/7** — Fabro's API server queues and executes runs continuously. Close your laptop — workflows keep running and results are waiting when you return. - **Scale infinitely** — Move execution off your laptop and into cloud sandboxes. Run as many concurrent workflows as your infrastructure allows. - **Guarantee code quality** — Layer deterministic verifications — test suites, linters, type checkers, LLM-as-judge — into your workflow graph. Failures trigger fix loops automatically. -- **Achieve compounding engineering** — Automatic retrospectives after every run feed a continuous improvement loop. Your workflows get better over time, not just your code. +- **Inspect every run** — Query durable event streams, checkpoints, conclusions, and stage outputs to understand what happened and improve the workflow. - **Specify in natural language** — Define requirements as natural-language specs and let Fabro generate — and regenerate — implementations that conform to them. --- @@ -56,7 +56,7 @@ Then run `fabro server start` to finish setup in your browser. The server opens | ☁️ | Cloud sandboxes | Run agents in isolated Daytona cloud VMs with snapshot-based setup, network controls, and automatic cleanup | | 🔌 | SSH access and preview links | Shell into running sandboxes with `fabro sandbox ssh` and expose ports with `fabro sandbox preview` for live debugging | | 🌲 | Git checkpointing | Every stage commits code changes and execution metadata to Git branches. Resume, revert, or trace any change | -| 📊 | Automatic retros | Each run generates a retrospective with cost, duration, files touched, and an LLM-written narrative | +| 📊 | Run observability | Durable events, checkpoints, conclusions, and stage outputs make every run inspectable and exportable | | ⚡ | Comprehensive API | REST API with SSE event streaming and a React web UI. Run workflows programmatically or as a service | | 🦀 | Single binary, no runtime | One compiled Rust executable with zero dependencies. No Python, no Node, no Docker required | | ⚖️ | Open source (MIT) | Full source code, no vendor lock-in. Self-host, fork, or extend to fit your workflow | @@ -104,7 +104,7 @@ Fabro ships with [comprehensive documentation](https://docs.fabro.sh) covering e - [**Getting Started**](https://docs.fabro.sh/getting-started/introduction) -- Installation, first workflow, and why Fabro exists - [**Defining Workflows**](https://docs.fabro.sh/workflows/stages-and-nodes) -- Node types, transitions, variables, stylesheets, and human gates -- [**Executing Workflows**](https://docs.fabro.sh/execution/run-configuration) -- Run configuration, sandboxes, checkpoints, retros, and failure handling +- [**Executing Workflows**](https://docs.fabro.sh/execution/run-configuration) -- Run configuration, sandboxes, checkpoints, observability, and failure handling - [**Tutorials**](https://docs.fabro.sh/tutorials/hello-world) -- Step-by-step guides from hello world to parallel multi-model ensembles - [**API Reference**](https://docs.fabro.sh/api-reference/overview) -- Full OpenAPI spec with authentication, SSE events, and client SDKs diff --git a/apps/fabro-web/app/app.css b/apps/fabro-web/app/app.css index c4c2eae5b..da290d54d 100644 --- a/apps/fabro-web/app/app.css +++ b/apps/fabro-web/app/app.css @@ -1,3 +1,4 @@ +@import "@xterm/xterm/css/xterm.css"; @import "tailwindcss"; @plugin "@tailwindcss/typography"; diff --git a/apps/fabro-web/app/components/editable-run-title.test.tsx b/apps/fabro-web/app/components/editable-run-title.test.tsx new file mode 100644 index 000000000..b94121512 --- /dev/null +++ b/apps/fabro-web/app/components/editable-run-title.test.tsx @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; +import { SWRConfig } from "swr"; + +import { EditableRunTitle } from "./editable-run-title"; +import { ToastProvider } from "./toast"; +import { generatedAxios } from "../lib/api-client"; + +function render(node: React.ReactNode): TestRenderer.ReactTestRenderer { + let tree: TestRenderer.ReactTestRenderer | undefined; + act(() => { + tree = TestRenderer.create( + new Map(), dedupingInterval: 0 }}> + {node} + , + ); + }); + return tree!; +} + +function instanceText(instance: TestRenderer.ReactTestInstance): string { + const parts: string[] = []; + for (const child of instance.children) { + if (typeof child === "string") parts.push(child); + else parts.push(instanceText(child)); + } + return parts.join(""); +} + +function findEditButton( + tree: TestRenderer.ReactTestRenderer, +): TestRenderer.ReactTestInstance { + return tree.root.findByProps({ "aria-label": "Edit run title" }); +} + +function findInput( + tree: TestRenderer.ReactTestRenderer, +): TestRenderer.ReactTestInstance { + return tree.root.findByProps({ "aria-label": "Run title" }); +} + +describe("EditableRunTitle", () => { + test("renders the run title with an edit affordance", () => { + const tree = render(); + expect(instanceText(findEditButton(tree))).toContain("Initial title"); + }); + + test("clicking the title swaps to an input pre-filled with the current value", () => { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + expect(findInput(tree).props.value).toBe("Initial title"); + }); + + test("Enter submits a PATCH and the input collapses back to the heading", async () => { + const submitted: unknown[] = []; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + submitted.push({ url: config.url, method: config.method, body: JSON.parse(String(config.data)) }); + return { + data: { id: "run-1", title: "Renamed title" }, + status: 200, + statusText: "OK", + headers: {}, + config, + }; + }; + + try { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + const input = findInput(tree); + act(() => { + input.props.onChange({ target: { value: "Renamed title" } }); + }); + await act(async () => { + input.props.onKeyDown({ key: "Enter", preventDefault: () => {} }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(submitted).toEqual([ + { url: "/api/v1/runs/run-1", method: "patch", body: { title: "Renamed title" } }, + ]); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); + + test("Escape exits without sending a request", () => { + let calls = 0; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + calls += 1; + return { + data: undefined, + status: 204, + statusText: "No Content", + headers: {}, + config, + }; + }; + + try { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + const input = findInput(tree); + act(() => { + input.props.onChange({ target: { value: "Discarded" } }); + }); + act(() => { + input.props.onKeyDown({ key: "Escape", preventDefault: () => {} }); + }); + + expect(calls).toBe(0); + expect(instanceText(findEditButton(tree))).toContain("Initial title"); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); + + test("submitting an empty title does not send a request", async () => { + let calls = 0; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + calls += 1; + return { + data: undefined, + status: 200, + statusText: "OK", + headers: {}, + config, + }; + }; + + try { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + const input = findInput(tree); + act(() => { + input.props.onChange({ target: { value: " " } }); + }); + await act(async () => { + input.props.onKeyDown({ key: "Enter", preventDefault: () => {} }); + await Promise.resolve(); + }); + + expect(calls).toBe(0); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); + + test("unchanged title on Enter exits without a request", async () => { + let calls = 0; + const originalAdapter = generatedAxios.defaults.adapter; + generatedAxios.defaults.adapter = async (config) => { + calls += 1; + return { + data: undefined, + status: 200, + statusText: "OK", + headers: {}, + config, + }; + }; + + try { + const tree = render(); + act(() => { + findEditButton(tree).props.onClick(); + }); + const input = findInput(tree); + await act(async () => { + input.props.onKeyDown({ key: "Enter", preventDefault: () => {} }); + await Promise.resolve(); + }); + + expect(calls).toBe(0); + expect(instanceText(findEditButton(tree))).toContain("Initial title"); + } finally { + generatedAxios.defaults.adapter = originalAdapter; + } + }); +}); diff --git a/apps/fabro-web/app/components/editable-run-title.tsx b/apps/fabro-web/app/components/editable-run-title.tsx new file mode 100644 index 000000000..8d16fbdba --- /dev/null +++ b/apps/fabro-web/app/components/editable-run-title.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from "react"; +import { PencilIcon } from "@heroicons/react/16/solid"; + +import { ApiError } from "../lib/api-client"; +import { useUpdateRunTitle } from "../lib/mutations"; +import { InlineMarkdown } from "./inline-markdown"; +import { useToast } from "./toast"; + +const TITLE_MAX_LENGTH = 100; + +function focusInputNextFrame(callback: () => void): void { + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(callback); + } else { + setTimeout(callback, 0); + } +} + +export function EditableRunTitle({ runId, title }: { runId: string; title: string }) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(title); + const submittedRef = useRef(false); + const inputRef = useRef(null); + const updateMutation = useUpdateRunTitle(runId); + const { push } = useToast(); + const isSaving = updateMutation.isMutating; + + useEffect(() => { + if (!isEditing) setDraft(title); + }, [title, isEditing]); + + const enterEdit = () => { + setDraft(title); + submittedRef.current = false; + setIsEditing(true); + focusInputNextFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }; + + const exitEdit = () => { + setIsEditing(false); + setDraft(title); + }; + + const submit = async () => { + if (submittedRef.current) return; + const trimmed = draft.trim(); + if (trimmed === title.trim()) { + exitEdit(); + return; + } + if (trimmed.length === 0) { + push({ message: "Run title can't be blank.", tone: "error" }); + inputRef.current?.focus(); + return; + } + submittedRef.current = true; + try { + await updateMutation.trigger({ title: trimmed }); + setIsEditing(false); + push({ message: "Run title updated." }); + } catch (error) { + submittedRef.current = false; + const message = error instanceof ApiError && error.message + ? error.message + : "Could not update run title."; + push({ message, tone: "error" }); + focusInputNextFrame(() => inputRef.current?.focus()); + } + }; + + if (isEditing) { + const remaining = TITLE_MAX_LENGTH - draft.length; + const showCount = remaining <= 20; + return ( +
+ setDraft(e.target.value)} + onBlur={() => void submit()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void submit(); + } else if (e.key === "Escape") { + e.preventDefault(); + submittedRef.current = true; + exitEdit(); + } + }} + className="-mx-2 block w-full rounded-md bg-panel-alt px-2 py-0.5 text-xl font-semibold text-fg outline-1 -outline-offset-1 outline-line-strong focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 disabled:opacity-60" + /> +

+ + {isSaving ? "Saving…" : "Press Enter to save · Esc to cancel"} + + {showCount && !isSaving && ( + + {remaining} left + + )} +

+
+ ); + } + + return ( +

+ +

+ ); +} diff --git a/apps/fabro-web/app/components/event-debug.tsx b/apps/fabro-web/app/components/event-debug.tsx new file mode 100644 index 000000000..67d43eaba --- /dev/null +++ b/apps/fabro-web/app/components/event-debug.tsx @@ -0,0 +1,736 @@ +import { useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { + CheckIcon, + ChevronUpDownIcon, + FunnelIcon, + MagnifyingGlassIcon, +} from "@heroicons/react/16/solid"; +import type { EventEnvelope } from "@qltysh/fabro-api-client"; + +import { Tooltip } from "./ui"; +import { formatAbsoluteTs } from "../lib/format"; + +export type DebugCategory = + | "agent" + | "command" + | "lifecycle" + | "human" + | "system"; + +export const DEBUG_CATEGORIES: readonly DebugCategory[] = [ + "agent", + "command", + "lifecycle", + "human", + "system", +] as const; + +const PREFIX_TO_CATEGORY: Record = { + agent: "agent", + command: "command", + run: "lifecycle", + stage: "lifecycle", + parallel: "lifecycle", + subgraph: "lifecycle", + edge: "lifecycle", + loop: "lifecycle", + prompt: "lifecycle", + interview: "human", +}; + +const CATEGORY_LABEL: Record = { + agent: "Agent", + command: "Command", + lifecycle: "Lifecycle", + human: "Human", + system: "System", +}; + +const CATEGORY_TONE: Record = { + agent: "bg-teal-500/15 text-teal-500", + command: "bg-mint/15 text-mint", + lifecycle: "bg-amber/15 text-amber", + human: "bg-coral/15 text-coral", + system: "bg-overlay-strong text-fg-3", +}; + +const CATEGORY_COLOR: Record = { + agent: "var(--color-teal-500)", + command: "var(--color-mint)", + lifecycle: "var(--color-amber)", + human: "var(--color-coral)", + system: "var(--color-ice-300)", +}; + +export function debugCategory(eventName: string | null | undefined): DebugCategory { + if (!eventName) return "system"; + const dot = eventName.indexOf("."); + const prefix = dot < 0 ? eventName : eventName.slice(0, dot); + return PREFIX_TO_CATEGORY[prefix] ?? "system"; +} + +export function debugCategoryLabel(category: DebugCategory): string { + return CATEGORY_LABEL[category]; +} + +export function debugCategoryTone(category: DebugCategory): string { + return CATEGORY_TONE[category]; +} + +export function debugCategoryColor(category: DebugCategory): string { + return CATEGORY_COLOR[category]; +} + +export function formatElapsed(eventTs: string, runStart: string | undefined): string { + if (!runStart) return ""; + const startMs = Date.parse(runStart); + const eventMs = Date.parse(eventTs); + if (Number.isNaN(startMs) || Number.isNaN(eventMs)) return ""; + const delta = Math.max(0, Math.floor((eventMs - startMs) / 1000)); + const hours = Math.floor(delta / 3600); + const minutes = Math.floor((delta % 3600) / 60); + const seconds = delta % 60; + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; +} + +const JSON_TOKEN_RE = + /"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?/g; + +export function highlightJson(text: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + let key = 0; + JSON_TOKEN_RE.lastIndex = 0; + while ((match = JSON_TOKEN_RE.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + const token = match[0]; + let cls: string; + if (token.startsWith('"')) { + const after = text.slice(JSON_TOKEN_RE.lastIndex); + cls = /^\s*:/.test(after) ? "text-teal-300" : "text-mint"; + } else if (token === "true" || token === "false") { + cls = "text-coral"; + } else if (token === "null") { + cls = "text-fg-muted"; + } else { + cls = "text-amber"; + } + parts.push( + + {token} + , + ); + lastIndex = JSON_TOKEN_RE.lastIndex; + } + if (lastIndex < text.length) parts.push(text.slice(lastIndex)); + return parts; +} + +export function DebugEventRow({ + event, + runStart, + selected, + onSelect, +}: { + event: EventEnvelope; + runStart: string | undefined; + selected: boolean; + onSelect: () => void; +}) { + const eventName = event.event ?? ""; + const category = debugCategory(eventName); + return ( + + ); +} + +export function DetailsPanel({ + title, + isOpen, + onClose, + children, +}: { + title: string; + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +}) { + useEffect(() => { + if (!isOpen) return; + function handleKey(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [isOpen, onClose]); + + return ( +
+
+
+

{title}

+ +
+
+ {isOpen ? children : null} +
+
+
+ ); +} + +export type EventDisplayPayload = { + event?: string | null; + [key: string]: unknown; +}; + +export function DebugEventDetailsPanel({ + event, + onClose, +}: { + event: EventDisplayPayload | null; + onClose: () => void; +}) { + return ( + + {event ? : null} + + ); +} + +function DebugEventDetails({ event }: { event: EventDisplayPayload }) { + const text = useMemo(() => JSON.stringify(event, null, 2), [event]); + const tokens = useMemo(() => highlightJson(text), [text]); + return ( +
+      {tokens}
+    
+ ); +} + +export function MultiSelectFilter({ + selected, + options, + labelOf, + onChange, + emptyMeansAll = false, +}: { + selected: T[]; + options: readonly T[]; + labelOf: (item: T) => string; + onChange: (next: T[]) => void; + emptyMeansAll?: boolean; +}) { + const allSelected = selected.length === options.length; + const summary = useMemo(() => { + if (allSelected || (emptyMeansAll && selected.length === 0)) return "All types"; + if (selected.length === 0) return "No types"; + if (selected.length <= 2) { + return options + .filter((o) => selected.includes(o)) + .map(labelOf) + .join(", "); + } + return `${selected.length} types`; + }, [allSelected, emptyMeansAll, selected, options, labelOf]); + + return ( + + + + + {options.map((option) => ( + + + + {labelOf(option)} + + ))} + + + ); +} + +export function EventSearchInput({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + return ( +
+
+ ); +} + +const STRIP_HEIGHT = 32; +const BAR_NORMAL_HEIGHT = 22; +const BAR_HOVER_HEIGHT = 26; +const BAR_SELECTED_HEIGHT = 28; +const BAR_WIDTH = 4; +const STRIP_MAX_MARKERS = 600; + +function sampleStripItems( + items: T[], + maxItems: number, + keep: (item: T) => boolean, +): T[] { + if (items.length <= maxItems) return items; + + const indices = new Set(); + for (let i = 0; i < items.length; i += 1) { + if (keep(items[i])) indices.add(i); + } + for (let i = 0; i < maxItems; i += 1) { + indices.add(Math.round((i * (items.length - 1)) / Math.max(1, maxItems - 1))); + } + + return Array.from(indices) + .sort((a, b) => a - b) + .map((index) => items[index]); +} + +function friendlyEventName(eventName: string): string { + const parts = eventName.split("."); + if (parts.length <= 1) return eventName; + return parts.slice(1).join("."); +} + +export function DebugDnaStrip({ + events, + selectedSeq, + onSelect, + runStart, +}: { + events: EventEnvelope[]; + selectedSeq: number | null; + onSelect: (seq: number) => void; + runStart: string | undefined; +}) { + const [hover, setHover] = useState<{ + seq: number; + rect: DOMRect; + } | null>(null); + const visibleEvents = useMemo( + () => sampleStripItems(events, STRIP_MAX_MARKERS, (event) => event.seq === selectedSeq), + [events, selectedSeq], + ); + const visibleEventBySeq = useMemo( + () => new Map(visibleEvents.map((event) => [event.seq, event])), + [visibleEvents], + ); + + const range = useMemo(() => { + if (events.length === 0) return null; + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + for (const event of events) { + const ms = Date.parse(event.ts); + if (Number.isNaN(ms)) continue; + if (ms < min) min = ms; + if (ms > max) max = ms; + } + if (!Number.isFinite(min) || !Number.isFinite(max)) return null; + const startCandidate = runStart ? Date.parse(runStart) : Number.NaN; + const start = Number.isFinite(startCandidate) + ? Math.min(startCandidate, min) + : min; + const duration = Math.max(1, max - start); + return { start, duration }; + }, [events, runStart]); + + if (!range) { + return ( +