diff --git a/.claude/skills/docs/references/mapping.md b/.claude/skills/docs/references/mapping.md index 0d6a0d3cf..f8b2a1081 100644 --- a/.claude/skills/docs/references/mapping.md +++ b/.claude/skills/docs/references/mapping.md @@ -23,11 +23,10 @@ Which source files affect which doc pages. Use this as guidance — also apply j | `lib/components/fabro-workflow/src/interviewer/*.rs` | `docs/public/execution/interviews.mdx` | | `lib/components/fabro-workflow/src/hook/*.rs` | `docs/public/agents/hooks.mdx` | | `lib/components/fabro-workflow/src/daytona_sandbox.rs` | `docs/public/integrations/daytona.mdx`, `docs/public/execution/environments.mdx` | -| `lib/components/fabro-agent/src/tools.rs`, `lib/components/fabro-agent/src/tool_registry.rs`, `lib/components/fabro-agent/src/tool_execution.rs` | `docs/public/agents/tools.mdx` | -| `lib/components/fabro-agent/src/v4a_patch.rs` | `docs/public/agents/tools.mdx` | -| `lib/components/fabro-agent/src/cli.rs` | `docs/public/agents/permissions.mdx` | -| `lib/components/fabro-agent/src/subagent.rs` | `docs/public/agents/subagents.mdx` | -| `lib/components/fabro-agent/src/mcp_integration.rs` | `docs/public/agents/mcp.mdx` | +| `lib/components/fabro-sandbox/src/environment.rs`, pebble's `pebble-coding-agent` tools | `docs/public/agents/tools.mdx` | +| `lib/apps/fabro-cli/src/commands/exec.rs` | `docs/public/agents/permissions.mdx` | +| pebble's `pebble-coding-agent` subagents | `docs/public/agents/subagents.mdx` | +| `lib/components/fabro-workflow/src/handler/llm/sandbox_mcp.rs`, `lib/components/fabro-mcp/src/connection_manager.rs` | `docs/public/agents/mcp.mdx` | | `lib/components/fabro-llm/src/catalog.rs`, `lib/components/fabro-llm/src/providers/*.rs` | `docs/public/core-concepts/models.mdx` | | `lib/components/fabro-slack/src/*.rs` | `docs/public/integrations/slack.mdx` | | `lib/components/fabro-mcp/src/*.rs` | `docs/public/agents/mcp.mdx` | diff --git a/.config/nextest.toml b/.config/nextest.toml index 3af76c17c..c55b0bd0d 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -15,9 +15,11 @@ leak-timeout = "500ms" filter = "package(fabro-workflow)" slow-timeout = { period = "2s", terminate-after = 3 } + # Real descendant regressions include bounded reaping and process probes. + # Leave room for their own watchdogs to run fail-safe fixture cleanup. [[profile.default.overrides]] - filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)" - slow-timeout = { period = "30s", terminate-after = 1 } + filter = "package(fabro-proc) & binary(lifecycle)" + slow-timeout = { period = "10s", terminate-after = 3 } [profile.e2e] # E2E (ignored) tests: flag SLOW after 10s, hard-kill after 30s @@ -48,6 +50,3 @@ leak-timeout = "2s" filter = "package(fabro-workflow)" slow-timeout = { period = "30s", terminate-after = 4 } - [[profile.ci.overrides]] - filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)" - slow-timeout = { period = "60s", terminate-after = 2 } diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index b3686f872..8f3423e7e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -125,13 +125,51 @@ jobs: cache-on-failure: true - uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest - run: cargo nextest run --locked --workspace --status-level slow --profile ci - # Twin-mode e2e suites. These are hermetic (in-process twin provider, no - # secrets): FABRO_TEST_MODE defaults to twin, so live-only tests - # self-skip. Scoped to the packages whose ignored tests are fully green - # in twin mode; widen as the remaining suites are fixed up for CI. - # Must not use the e2e nextest profile here: NEXTEST_PROFILE=e2e implies - # strict mode, which fails (rather than skips) live tests without keys. - - run: cargo nextest run --locked --workspace --status-level slow --profile ci --run-ignored only -E 'package(fabro-agent) + package(fabro-llm) + package(twin-openai)' + # The twin-mode ignored suites this job once ran belonged to fabro-agent, + # which pebble's coding agent replaced; the agent loop's workflow-level + # tests run in the suite above, and pebble's own suite covers the loop. + # Re-add a `--run-ignored only -E 'package(...)'` step here when a + # package has ignored suites that are fully green in twin mode. + + sandbox-plugins: + name: Sandbox plugins (stdio) + runs-on: ubuntu-24.04-x86-32-cores + permissions: + contents: read + env: + # The plugin scenarios skip when an executable or daemon is missing; + # in CI a skip is a failure. + FABRO_REQUIRE_SANDBOX_PLUGINS: "1" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: 1.97.1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + with: + cache-on-failure: true + - uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest + - run: docker pull buildpack-deps:noble + # The driver's own Host and Docker executables, installed at the rev the + # workspace pins so the plugins and the in-process providers are one + # build; the CLI scenarios find them on PATH and launch them over stdio. + - name: Install the sandbox-driver plugin executables + run: | + rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)" + test -n "$rev" + cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "$rev" sandbox-driver-host sandbox-driver-docker + # Host and Docker served as plugins through the workflow scenarios. The + # scenarios are e2e tests (ignored by default); the key-free ones run + # here, the LLM-backed ones self-skip without credentials. + - run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-cli --test it -E 'test(/host_plugin_|docker_plugin_/)' + # The stdio plugin proof (not ignored: it skips without the executable, + # which the environment above forbids) and the driver-backed Docker + # integration tests. + - run: cargo nextest run --locked --profile ci --status-level slow -p fabro-sandbox --test plugin_provider + - run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-sandbox --test docker_streaming + - run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-workflow --test it -E 'test(asset_collection_docker_sandbox)' test-macos: name: Test (macOS) diff --git a/AGENTS.md b/AGENTS.md index 17d1677b0..05ca4f0ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,17 +32,19 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24) - The packaged compose service mounts `/var/run/docker.sock` so the server can create sibling run containers on the host daemon. This is host-root-equivalent under Docker's security model; only use it in the trusted, single-tenant deployment model described by the sandbox code/docs. - Docker and Daytona are clone-based providers. When a run manifest has a GitHub origin, they clone it into the provider workspace. Present non-GitHub origins fail unless the provider has `skip_clone = true`; absent origins or `skip_clone = true` create an empty workspace without repository files. For an exact commit, the submitted branch names the working branch and the syntactically valid SHA is requested directly. No layer proves branch/SHA ancestry: a fetchable commit is checked out, an unavailable commit fails setup, and branch HEAD is never substituted. - The sandbox layer also accepts an optional exact commit for future admitted - runs. An exact commit always requires a non-empty branch. Docker initializes - an empty repository, shallow-fetches the SHA at the same depth as a branch - clone, and checks it out; Daytona uses its official SDK clone with both - `branch` and `commit_id`. Both providers then point the admitted branch at - the commit and verify HEAD, so the workspace still reports the admitted - branch name. Keep those provider transports distinct, never fall back to a - newer branch HEAD, and do not wire this capability directly from legacy - `GitContext.sha`. The sandbox layer does not verify that the commit is - reachable from the branch; admission owns that check. Current production - callers remain branch-only until the RunIntent admission cutover supplies a - validated branch/SHA pair. + runs. An exact commit always requires a non-empty branch. The sandbox driver + performs the pin the same way on every provider: it initializes an empty + repository, fetches the SHA directly at the requested depth, and attaches + the admitted branch to it, so the workspace reports the admitted branch + name. Daytona's native toolbox clone serves plain branch clones only; its + commit pin checks the branch head out first, so the driver does not use + it. A successful clone has the pin checked out; the driver's + conformance suite verifies that on every provider, and fabro does not + re-verify HEAD. Never fall back to a newer branch HEAD, and do not wire + this capability directly from legacy `GitContext.sha`. The sandbox layer + does not verify that the commit is reachable from the branch; admission + owns that check. Current production callers remain branch-only until the + RunIntent admission cutover supplies a validated branch/SHA pair. ### Release automation - `cargo dev release` — creates the next stable release tag. Use `cargo dev release --nightly` for a nightly prerelease. Use `--dry-run` to print planned commands without mutating git or running Cargo, `--skip-tests` only after running the release-mode smoke yourself, and `--release-date YYYY-MM-DD` or `FABRO_RELEASE_DATE` for deterministic version computation. @@ -122,8 +124,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as ### Rust crates (`lib/apps/`, `lib/components/`, and `lib/foundation/`) - **fabro-cli** — CLI entry point. Commands: `run`, `exec`, `serve`, `validate`, `parse`, `cp`, `model`, `doctor`, `install`, `ps`, `system prune` - **fabro-workflow** — Core workflow engine. Parses Graphviz graphs, runs stages, manages checkpoints/resume, hooks, and human-in-the-loop interactions -- **fabro-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-sandbox** — Local, Docker, and Daytona sandbox providers. `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). `RunSandbox` is also the `Environment` pebble's coding agent runs its tools through; agent stages, Ask Fabro, hook evaluators, and `fabro exec` all run on the `pebble-coding-agent` crate (pinned by rev in the workspace `Cargo.toml`). Docker is the default runtime provider and creates clone-based `/workspace` containers through the operator's Docker daemon; Daytona uses the same GitHub-only clone-source contract. Docker daemon access is host-root-equivalent and assumes trusted callers/payloads. - **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header - **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming - **fabro-api** — Auto-generated Rust types and reqwest HTTP client from OpenAPI spec (build.rs + progenitor) @@ -139,7 +140,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as - **lib/packages/fabro-api-client** — Auto-generated TypeScript Axios client from OpenAPI spec ### Key design patterns -- **Sandbox trait** — Uniform interface for local, Docker, and Daytona execution environments. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection. +- **RunSandbox** — One concrete sandbox type for local, Docker, and Daytona execution environments, over the `sandbox-driver` facets (exec, filesystem, search, git). There is no fabro-side sandbox trait; tests use `fabro_sandbox::test_support::MockSandbox` over the driver's scripted doubles. Clone-based providers use run-spec GitHub origin metadata rather than worker process cwd detection. - **Graphviz graph workflows** — Stages and transitions defined as Graphviz graph attributes - **OpenAPI-first** — `fabro-api.yaml` drives Rust type + client generation (progenitor) and TypeScript client generation (openapi-generator) - **Checkpoint/resume** — Workflows can be paused, checkpointed, and resumed diff --git a/Cargo.lock b/Cargo.lock index a32a1cb1a..0d0a8339a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,9 +235,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" @@ -660,17 +660,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "aws-smithy-eventstream" -version = "0.60.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" -dependencies = [ - "aws-smithy-types", - "bytes", - "crc32fast", -] - [[package]] name = "aws-smithy-http" version = "0.63.6" @@ -1870,9 +1859,9 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "daytona-api-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ - "reqwest 0.13.2", + "reqwest 0.13.4", "reqwest-middleware", "serde", "serde_json", @@ -1884,12 +1873,12 @@ dependencies = [ [[package]] name = "daytona-sdk" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ "daytona-api-client", "daytona-toolbox-client", "futures-util", - "reqwest 0.13.2", + "reqwest 0.13.4", "reqwest-middleware", "rustls", "serde", @@ -1904,9 +1893,9 @@ dependencies = [ [[package]] name = "daytona-toolbox-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust?rev=be2c7b7272740d47c023cac8abc9f63c1a51a511#be2c7b7272740d47c023cac8abc9f63c1a51a511" +source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc" dependencies = [ - "reqwest 0.13.2", + "reqwest 0.13.4", "reqwest-middleware", "serde", "serde_json", @@ -2257,7 +2246,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -2265,6 +2254,7 @@ dependencies = [ "fabro-types", "fabro-util", "futures", + "pebble-coding-agent", "serde_json", "shlex", "tempfile", @@ -2274,69 +2264,23 @@ dependencies = [ "tracing", ] -[[package]] -name = "fabro-agent" -version = "0.347.0-nightly.0" -dependencies = [ - "anyhow", - "async-trait", - "chrono", - "clap", - "dirs", - "fabro-auth", - "fabro-config", - "fabro-http", - "fabro-llm", - "fabro-macros", - "fabro-mcp", - "fabro-model", - "fabro-sandbox", - "fabro-static", - "fabro-template", - "fabro-test", - "fabro-types", - "fabro-util", - "fabro-vault", - "futures", - "glob", - "htmd", - "httpmock", - "insta", - "jsonschema", - "libc", - "paste", - "serde", - "serde_json", - "sha2 0.10.9", - "shell-escape", - "shlex", - "strum 0.28.0", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "toml 0.8.23", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "fabro-api" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "chrono", "fabro-automation", "fabro-config", "fabro-environment", - "fabro-model", "fabro-types", + "lithos-llm", "openapiv3", "prettyplease", "progenitor", "progenitor-client", "regress", - "reqwest 0.13.2", + "reqwest 0.13.4", + "sandbox-driver", "serde", "serde_json", "serde_yaml", @@ -2346,20 +2290,20 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", "base64", "chrono", "fabro-http", - "fabro-model", "fabro-oauth", "fabro-redact", "fabro-static", "fabro-types", "fabro-vault", "httpmock", + "lithos-llm", "serde", "serde_json", "tempfile", @@ -2371,7 +2315,7 @@ dependencies = [ [[package]] name = "fabro-automation" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2392,11 +2336,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" [[package]] name = "fabro-checkpoint" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "fabro-config", "fabro-types", @@ -2404,7 +2348,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2418,12 +2362,10 @@ dependencies = [ "cli-table", "console 0.15.11", "core-foundation 0.9.4", - "daytona-sdk", "dialoguer", "dirs", "dotenvy", "fabro-acp", - "fabro-agent", "fabro-api", "fabro-auth", "fabro-build-support", @@ -2443,7 +2385,6 @@ dependencies = [ "fabro-manifest", "fabro-mcp", "fabro-mcp-server", - "fabro-model", "fabro-oauth", "fabro-proc", "fabro-redact", @@ -2469,18 +2410,23 @@ dependencies = [ "insta", "jsonwebtoken", "libc", + "lithos-llm", "miette", "nix 0.30.1", "object_store", "openssl", "paste", + "pebble-agent", + "pebble-cli-core", + "pebble-coding-agent", "predicates", "progenitor-client", "rand 0.9.4", "regex", - "reqwest 0.13.2", + "reqwest 0.13.4", "ring", "rustls", + "sandbox-driver", "scopeguard", "semver", "serde", @@ -2506,14 +2452,13 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "bytes", "chrono", "fabro-api", "fabro-http", - "fabro-model", "fabro-static", "fabro-types", "fabro-util", @@ -2521,6 +2466,7 @@ dependencies = [ "futures", "httpmock", "libc", + "lithos-llm", "progenitor-client", "rand 0.9.4", "serde", @@ -2535,14 +2481,13 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", "clap", "dirs", "fabro-macros", - "fabro-model", "fabro-options-metadata", "fabro-proc", "fabro-static", @@ -2565,7 +2510,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -2581,7 +2526,7 @@ dependencies = [ [[package]] name = "fabro-db" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2594,7 +2539,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -2613,7 +2558,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -2627,7 +2572,7 @@ dependencies = [ [[package]] name = "fabro-environment" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2649,7 +2594,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2674,7 +2619,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -2689,18 +2634,20 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "async-trait", - "fabro-agent", "fabro-auth", "fabro-http", "fabro-llm", - "fabro-model", "fabro-redact", + "fabro-sandbox", "fabro-types", "fabro-util", "httpmock", + "lithos-llm", + "pebble-agent", + "pebble-coding-agent", "regex", "serde", "serde_json", @@ -2712,17 +2659,17 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "fabro-static", "http 1.4.0", - "reqwest 0.13.2", + "reqwest 0.13.4", "thiserror 2.0.18", ] [[package]] name = "fabro-install" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2741,7 +2688,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -2756,49 +2703,36 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", - "aws-config", - "aws-credential-types", - "aws-sigv4", - "aws-smithy-eventstream", - "aws-smithy-runtime-api", - "aws-smithy-types", - "base64", "bytes", "fabro-auth", + "fabro-config", "fabro-http", + "fabro-llm", "fabro-macros", - "fabro-model", "fabro-redact", "fabro-static", "fabro-test", "fabro-types", - "fabro-util", "futures", - "http 1.4.0", "httpmock", - "insta", - "rand 0.9.4", + "lithos-llm", "serde", "serde_json", - "sha2 0.10.9", "strum 0.28.0", "thiserror 2.0.18", "tokio", - "tokio-stream", "tokio-util", "toml 0.8.23", "tracing", - "tracing-subscriber", - "uuid", ] [[package]] name = "fabro-macros" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -2809,15 +2743,17 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", + "async-trait", "fabro-api", "fabro-config", "fabro-github", "fabro-graphviz", "fabro-template", "fabro-test", + "fabro-tool", "fabro-types", "fabro-util", "fabro-workflow", @@ -2828,32 +2764,28 @@ dependencies = [ "temp-env", "tempfile", "thiserror 2.0.18", + "tokio", "toml 0.8.23", + "tracing", + "tracing-subscriber", ] [[package]] name = "fabro-mcp" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", - "axum", - "fabro-config", - "fabro-http", "fabro-types", - "futures", + "pebble-coding-agent", "rmcp", - "serde", "serde_json", - "sse-stream", - "thiserror 2.0.18", "tokio", - "tokio-stream", "tracing", ] [[package]] name = "fabro-mcp-server" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2861,7 +2793,6 @@ dependencies = [ "fabro-client", "fabro-config", "fabro-manifest", - "fabro-model", "fabro-server", "fabro-tool", "fabro-types", @@ -2881,7 +2812,7 @@ dependencies = [ [[package]] name = "fabro-mcp-store" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "chrono", "fabro-db", @@ -2897,26 +2828,9 @@ dependencies = [ "tracing", ] -[[package]] -name = "fabro-model" -version = "0.347.0-nightly.0" -dependencies = [ - "clap", - "fabro-static", - "http 1.4.0", - "insta", - "rust-embed", - "serde", - "serde_json", - "strum 0.28.0", - "thiserror 2.0.18", - "toml 0.8.23", - "tracing", -] - [[package]] name = "fabro-oauth" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2938,7 +2852,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2946,16 +2860,20 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "cc", "libc", "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", ] [[package]] name = "fabro-redact" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2971,18 +2889,13 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", "base64", - "bollard", "chrono", - "daytona-api-client", - "daytona-sdk", - "fabro-config", "fabro-github", - "fabro-http", "fabro-proc", "fabro-redact", "fabro-static", @@ -2990,23 +2903,22 @@ dependencies = [ "fabro-types", "fabro-util", "futures", - "futures-util", - "git2", - "hex", - "hmac 0.12.1", - "httpmock", - "rand 0.9.4", - "reqwest-middleware", - "rustls", + "pebble-coding-agent", + "reqwest 0.13.4", + "sandbox-driver", + "sandbox-driver-daytona", + "sandbox-driver-daytona-config", + "sandbox-driver-docker", + "sandbox-driver-docker-config", + "sandbox-driver-host", + "sandbox-driver-protocol", + "sandbox-driver-testing", "serde", "serde_json", - "sha2 0.10.9", "strum 0.28.0", - "tar", "tempfile", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", "tokio-util", "toml 0.8.23", "tracing", @@ -3015,7 +2927,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3029,7 +2941,6 @@ dependencies = [ "cookie", "croner", "dirs", - "fabro-agent", "fabro-api", "fabro-auth", "fabro-automation", @@ -3048,7 +2959,6 @@ dependencies = [ "fabro-macros", "fabro-manifest", "fabro-mcp-store", - "fabro-model", "fabro-proc", "fabro-redact", "fabro-sandbox", @@ -3075,13 +2985,17 @@ dependencies = [ "http-body-util", "httpmock", "jsonwebtoken", + "lithos-llm", "mime_guess", "multer", "object_store", + "pebble-agent", + "pebble-coding-agent", "percent-encoding", "rand 0.9.4", "regex", "reqwest 0.12.28", + "sandbox-driver", "semver", "serde", "serde_json", @@ -3111,7 +3025,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -3133,18 +3047,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" [[package]] name = "fabro-store" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -3156,7 +3070,9 @@ dependencies = [ "futures", "hex", "insta", + "lithos-llm", "object_store", + "pebble-coding-agent", "percent-encoding", "serde", "serde_json", @@ -3175,7 +3091,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -3201,7 +3117,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -3215,7 +3131,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3240,7 +3156,7 @@ dependencies = [ [[package]] name = "fabro-tool" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3249,7 +3165,10 @@ dependencies = [ "fabro-client", "fabro-types", "fabro-util", + "fabro-workflow-version", "futures", + "httpmock", + "jsonschema", "schemars 1.2.1", "serde", "serde_json", @@ -3261,7 +3180,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -3275,15 +3194,17 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "chrono", "clap", "dirs", - "fabro-model", "fabro-types", "fabro-util", "hex", + "lithos-llm", + "pebble-coding-agent", + "sandbox-driver", "serde", "serde_json", "sha2 0.10.9", @@ -3293,12 +3214,14 @@ dependencies = [ "thiserror 2.0.18", "toml 0.8.23", "ulid", + "unicase", + "unicode-normalization", "url", ] [[package]] name = "fabro-util" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -3321,11 +3244,11 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "fabro-acp", "fabro-graphviz", - "fabro-model", + "fabro-llm", "fabro-types", "serde", "thiserror 2.0.18", @@ -3334,7 +3257,7 @@ dependencies = [ [[package]] name = "fabro-variable" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3351,7 +3274,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -3370,7 +3293,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -3380,10 +3303,10 @@ dependencies = [ "chrono", "dirs", "fabro-acp", - "fabro-agent", "fabro-api", "fabro-auth", "fabro-checkpoint", + "fabro-client", "fabro-config", "fabro-core", "fabro-dump", @@ -3396,7 +3319,6 @@ dependencies = [ "fabro-llm", "fabro-macros", "fabro-mcp", - "fabro-model", "fabro-redact", "fabro-sandbox", "fabro-static", @@ -3409,18 +3331,23 @@ dependencies = [ "fabro-validate", "fabro-vault", "fabro-workflow", + "fabro-workflow-version", "futures", "git2", "hex", "httpmock", "jsonschema", + "lithos-llm", "md5", "miette", "mime_guess", "object_store", + "pebble-agent", + "pebble-coding-agent", "predicates", "rand 0.9.4", "regex", + "sandbox-driver", "scopeguard", "serde", "serde_json", @@ -3440,7 +3367,7 @@ dependencies = [ [[package]] name = "fabro-workflow-version" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "fabro-config", "fabro-graphviz", @@ -3777,16 +3704,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.32" @@ -4199,28 +4116,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "htmd" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60ae59466542f2346e43d4a5e9b4432a1fc915b279c9fc0484e9ed7379121454" -dependencies = [ - "html5ever", - "markup5ever_rcdom", - "phf 0.13.1", -] - -[[package]] -name = "html5ever" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" -dependencies = [ - "log", - "markup5ever", - "match_token", -] - [[package]] name = "http" version = "0.2.12" @@ -4461,7 +4356,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4988,6 +4883,32 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lithos-llm" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/lithos-llm?rev=a1e3fd37b7153870411701327ac117606753fe90#a1e3fd37b7153870411701327ac117606753fe90" +dependencies = [ + "async-trait", + "aws-config", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-runtime-api", + "aws-smithy-types", + "base64", + "crc32fast", + "futures-core", + "futures-util", + "mime_guess", + "reqwest 0.13.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "toml 0.8.23", + "tracing", + "uuid", +] + [[package]] name = "litrs" version = "1.0.0" @@ -5034,12 +4955,6 @@ dependencies = [ "libc", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mac_address" version = "1.1.8" @@ -5105,40 +5020,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "markup5ever" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" -dependencies = [ - "log", - "tendril", - "web_atoms", -] - -[[package]] -name = "markup5ever_rcdom" -version = "0.35.0+unofficial" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8bcd53df4748257345b8bc156d620340ce0f015ec1c7ef1cff475543888a31d" -dependencies = [ - "html5ever", - "markup5ever", - "tendril", - "xml5ever", -] - -[[package]] -name = "match_token" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "matchers" version = "0.2.0" @@ -5353,12 +5234,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "nix" version = "0.29.0" @@ -5989,6 +5864,77 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pebble-agent" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/pebble?rev=4c0063327394cd0f1e9fee2c24541b829b93a8f7#4c0063327394cd0f1e9fee2c24541b829b93a8f7" +dependencies = [ + "async-trait", + "futures-util", + "lithos-llm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + +[[package]] +name = "pebble-cli-core" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/pebble?rev=4c0063327394cd0f1e9fee2c24541b829b93a8f7#4c0063327394cd0f1e9fee2c24541b829b93a8f7" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "clap", + "crossterm", + "fs2", + "futures-util", + "humantime", + "lithos-llm", + "pebble-agent", + "pebble-coding-agent", + "rustix", + "serde", + "serde_json", + "similar", + "tempfile", + "termimad", + "tokio", + "tokio-util", + "unicode-segmentation", + "unicode-width 0.2.2", + "uuid", +] + +[[package]] +name = "pebble-coding-agent" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/pebble?rev=4c0063327394cd0f1e9fee2c24541b829b93a8f7#4c0063327394cd0f1e9fee2c24541b829b93a8f7" +dependencies = [ + "async-trait", + "futures-util", + "lithos-llm", + "pebble-agent", + "reqwest 0.13.4", + "rmcp", + "rustix", + "sandbox-driver", + "serde", + "serde_json", + "sha2 0.10.9", + "sse-stream", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid", +] + [[package]] name = "pem" version = "3.0.6" @@ -6005,87 +5951,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros", - "phf_shared 0.13.1", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project" version = "1.1.11" @@ -6166,12 +6031,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "predicates" version = "3.1.4" @@ -6290,7 +6149,7 @@ dependencies = [ "bytes", "futures-core", "percent-encoding", - "reqwest 0.13.2", + "reqwest 0.13.4", "serde", "serde_json", "serde_urlencoded", @@ -6691,9 +6550,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -6749,7 +6608,7 @@ dependencies = [ "anyhow", "async-trait", "http 1.4.0", - "reqwest 0.13.2", + "reqwest 0.13.4", "serde", "thiserror 2.0.18", "tower-service", @@ -6789,7 +6648,7 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "reqwest 0.13.2", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", @@ -7002,6 +6861,135 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sandbox-driver" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "async-trait", + "globset", + "humantime", + "rand 0.10.1", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "sandbox-driver-daytona" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "daytona-api-client", + "daytona-sdk", + "hmac 0.12.1", + "rand 0.10.1", + "reqwest 0.13.4", + "sandbox-driver", + "sandbox-driver-daytona-config", + "sandbox-driver-docker", + "sandbox-driver-docker-config", + "sandbox-driver-protocol", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-daytona-config" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "sandbox-driver-docker-config", + "serde", + "serde_json", +] + +[[package]] +name = "sandbox-driver-docker" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "anyhow", + "async-trait", + "bollard", + "futures-util", + "sandbox-driver", + "sandbox-driver-docker-config", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tar", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-docker-config" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "sandbox-driver-host" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "anyhow", + "async-trait", + "nix 0.30.1", + "sandbox-driver", + "sandbox-driver-protocol", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sandbox-driver-protocol" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "async-trait", + "base64", + "rand 0.10.1", + "sandbox-driver", + "serde", + "serde_json", + "sha2 0.10.9", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "sandbox-driver-testing" +version = "0.1.0" +source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=a92c0db6b6a122ca9b6df75de6615544f53c0d47#a92c0db6b6a122ca9b6df75de6615544f53c0d47" +dependencies = [ + "async-trait", + "sandbox-driver", + "tokio", +] + [[package]] name = "schannel" version = "0.1.28" @@ -7439,12 +7427,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-escape" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f" - [[package]] name = "shell-words" version = "1.1.1" @@ -7484,7 +7466,7 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno 0.2.8", + "errno 0.3.14", "libc", ] @@ -7806,9 +7788,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.1" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" dependencies = [ "bytes", "futures-util", @@ -7835,31 +7817,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - [[package]] name = "stringmetrics" version = "2.2.2" @@ -8066,9 +8023,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.1", @@ -8077,17 +8034,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "termcolor" version = "1.4.1" @@ -8334,9 +8280,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -8344,6 +8290,7 @@ dependencies = [ "futures-sink", "futures-util", "hashbrown 0.15.5", + "libc", "pin-project-lite", "tokio", ] @@ -8537,9 +8484,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", @@ -8595,16 +8542,31 @@ dependencies = [ "utf-8", ] +[[package]] +name = "twin-core" +version = "0.1.5" +source = "git+https://github.com/lithoscomputer/twins?rev=ca45f0e50a6716d716aa2f638ca3cf767e88f613#ca45f0e50a6716d716aa2f638ca3cf767e88f613" +dependencies = [ + "anyhow", + "async-stream", + "axum", + "futures-util", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "twin-github" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" dependencies = [ "axum", "base64", "chrono", "fabro-http", "jsonwebtoken", - "reqwest 0.13.2", + "reqwest 0.13.4", "serde", "serde_json", "tempfile", @@ -8616,21 +8578,21 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.347.0-nightly.0" +version = "0.1.5" +source = "git+https://github.com/lithoscomputer/twins?rev=ca45f0e50a6716d716aa2f638ca3cf767e88f613#ca45f0e50a6716d716aa2f638ca3cf767e88f613" dependencies = [ "anyhow", "async-stream", "axum", - "fabro-http", - "fabro-static", "futures-util", "http 1.4.0", + "reqwest 0.13.4", "serde", "serde_json", "tokio", - "tower", "tracing", "tracing-subscriber", + "twin-core", ] [[package]] @@ -8881,13 +8843,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.4.1", + "getrandom 0.3.4", "js-sys", - "serde_core", + "serde", "wasm-bindgen", ] @@ -9116,18 +9078,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web_atoms" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" -dependencies = [ - "phf 0.11.3", - "phf_codegen", - "string_cache", - "string_cache_codegen", -] - [[package]] name = "webpki-root-certs" version = "1.0.6" @@ -9772,16 +9722,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xml5ever" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3f1e41afb31a75aef076563b0ad3ecc24f5bd9d12a72b132222664eb76b494" -dependencies = [ - "log", - "markup5ever", -] - [[package]] name = "xmlparser" version = "0.13.6" diff --git a/Cargo.toml b/Cargo.toml index d44b7713d..afb360523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ members = [ "lib/apps/*", "lib/components/*", "lib/foundation/*", - "test/twin/openai", "test/twin/github", ] default-members = ["lib/apps/fabro-cli"] @@ -11,7 +10,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.347.0-nightly.0" +version = "0.354.0-nightly.0" license = "MIT" [workspace.dependencies] @@ -28,7 +27,6 @@ serde_json = { version = "1", features = ["preserve_order"] } sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite-bundled", "migrate", "macros"] } tokio = { version = "1", features = ["full"] } reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls", "query", "form", "multipart"] } -sse-stream = "0.2" ulid = "1" uuid = { version = "1", features = ["v4", "v7", "v8"] } rand = "0.9" @@ -62,13 +60,13 @@ clap_complete = "4" jsonschema = { version = "0.42", default-features = false } chrono = { version = "0.4", features = ["clock", "serde"] } dashmap = "6" -bollard = "0.18" -tar = "0.4" cli-table = { version = "0.5", default-features = false } console = "0.15" dialoguer = "0.12" git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2", "vendored-openssl", "https"] } tracing = "0.1" +unicase = "2" +unicode-normalization = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } tracing-appender = "0.2" rmcp = { version = "1.4", default-features = false } @@ -93,12 +91,41 @@ sha2 = "0.10" hex = "0.4" insta = "1" fabro-test = { path = "lib/foundation/fabro-test" } -twin-openai = { path = "test/twin/openai" } +# Provider-neutral LLM catalog and client. Pinned to a revision until 0.x is +# published to crates.io. +lithos-llm = { git = "https://github.com/lithoscomputer/lithos-llm", rev = "a1e3fd37b7153870411701327ac117606753fe90", default-features = false } +# Deterministic OpenAI twin used by twin-mode E2E tests; the same revision +# lithos-llm verifies its codecs against. +twin-openai = { git = "https://github.com/lithoscomputer/twins", rev = "ca45f0e50a6716d716aa2f638ca3cf767e88f613" } 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 = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-sdk" } -daytona-api-client = { git = "https://github.com/brynary/daytona-sdk-rust", rev = "be2c7b7272740d47c023cac8abc9f63c1a51a511", package = "daytona-api-client" } +# sandbox-driver: the sandbox provider layer. Bundled Host, Docker, and +# Daytona providers link in-process; third-party providers run as stdio +# plugins through sandbox-driver-protocol. Pinned by rev; currently the head of +# the sandbox-driver `section-4-driver-items` branch (provider-owned scopes, the +# supervisor as provider, Host attach by directory, git retry and verbs in the +# driver, status image/snapshot/network, Daytona snapshot caching, services port +# wait and list, RFC 3339 timestamps), to move to main on merge. The CI plugin +# job installs the driver executables at the same rev, read from this file. +sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "a92c0db6b6a122ca9b6df75de6615544f53c0d47" } +# pebble: the coding agent loop fabro runs its agent stages, Ask Fabro +# sessions, hook evaluators, and `fabro exec` on. Pinned by rev to pebble +# `main`. The `PreviewUrls` trait objects fabro hands pebble's MCP servers +# only cross when fabro and pebble's `mcp` feature name the same +# sandbox-driver revision, so move the two pins together. Pebble pins the +# same lithos-llm rev as fabro, and its lockfile policy is that every shared +# crate resolves to the version lithos-llm locks. +pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "4c0063327394cd0f1e9fee2c24541b829b93a8f7" } +pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "4c0063327394cd0f1e9fee2c24541b829b93a8f7", features = ["mcp", "search-providers"] } +pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "4c0063327394cd0f1e9fee2c24541b829b93a8f7" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/apps/fabro-web/app/components/environment-form.tsx b/apps/fabro-web/app/components/environment-form.tsx index 688f0adbf..a78a6cc10 100644 --- a/apps/fabro-web/app/components/environment-form.tsx +++ b/apps/fabro-web/app/components/environment-form.tsx @@ -3,7 +3,6 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { EnvironmentApiDockerfileSourceInlineTypeEnum, EnvironmentNetworkMode, - EnvironmentProvider, } from "@qltysh/fabro-api-client"; import type { CreateEnvironmentRequest, @@ -15,6 +14,7 @@ import type { ReplaceEnvironmentRequest, } from "@qltysh/fabro-api-client"; +import { DOCKER_PROVIDER, isCloneBasedProvider } from "../lib/environment-providers"; import { Label, Panel, Row } from "./settings-panel"; import { INPUT_CLASS } from "./ui"; import { @@ -25,11 +25,15 @@ import { } from "./key-value-editor"; // Parse the `provider` query param used by the create flow into a creatable -// provider, defaulting to Docker for anything unexpected. -export function parseCreatableProvider(value: string | null): EnvironmentProvider { - return value === EnvironmentProvider.DAYTONA - ? EnvironmentProvider.DAYTONA - : EnvironmentProvider.DOCKER; +// provider, defaulting to Docker for anything that cannot back a managed +// environment. Kind names are validated server-side on create. +const PROVIDER_KIND_PATTERN = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/; + +export function parseCreatableProvider(value: string | null): string { + if (value && PROVIDER_KIND_PATTERN.test(value) && isCloneBasedProvider(value)) { + return value; + } + return DOCKER_PROVIDER; } // Environment ids are server-managed file names: lowercase, digits, hyphens. @@ -49,7 +53,7 @@ type ImageSource = "image" | "dockerfile"; export interface EnvironmentFormValues { id: string; - provider: EnvironmentProvider; + provider: string; imageSource: ImageSource; dockerRef: string; dockerfile: string; @@ -69,7 +73,7 @@ export interface EnvironmentFormValues { export const EMPTY_ENVIRONMENT_FORM: EnvironmentFormValues = { id: "", - provider: EnvironmentProvider.DOCKER, + provider: DOCKER_PROVIDER, imageSource: "image", dockerRef: "", dockerfile: "", diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx index 018184802..5ed6eca29 100644 --- a/apps/fabro-web/app/components/run-summary-panel.test.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx @@ -174,7 +174,7 @@ describe("RunSummaryPanelView", () => { const tree = render({ run: makeRun(), sandboxState: "running", - sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any, + sandboxResources: { cpu_cores: 4, memory_mb: 8 * 1024 }, }); expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB"); }); diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx index c3784a9ba..b462402a3 100644 --- a/apps/fabro-web/app/components/run-summary-panel.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.tsx @@ -80,10 +80,10 @@ function SandboxValue({ }) { const display = SANDBOX_STATE_DISPLAY[state] ?? SANDBOX_STATE_DISPLAY.unknown; const cpu = resources?.cpu_cores; - const memory = resources?.memory_bytes; + const memoryMb = resources?.memory_mb; const valueText = - cpu != null && memory != null - ? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memory)}` + cpu != null && memoryMb != null + ? `${formatCpuCores(cpu)} CPU · ${formatBytesAsMemory(memoryMb * 1024 * 1024)}` : display.label; return ( @@ -221,8 +221,8 @@ export function RunSummaryPanel({ runId }: { runId: string }) { = {}): StageCo context_window_tokens: 200_000, input_tokens: 62_000, usage_percent: 31, - count_method: StageContextWindowCountMethod.PROVIDER_API_SCALED_BREAKDOWN, - staleness: StageContextWindowStaleness.LIVE, + count_method: ContextWindowCountMethod.PROVIDER_API_SCALED_BREAKDOWN, + staleness: ContextWindowStaleness.LIVE, generated_at: new Date().toISOString(), event_seq: 42, breakdown: [ - { category: StageContextWindowCategory.SYSTEM_PROMPT, tokens: 8_000, usage_percent: 4 }, - { category: StageContextWindowCategory.TOOLS, tokens: 12_000, usage_percent: 6 }, - { category: StageContextWindowCategory.CONVERSATION, tokens: 42_000, usage_percent: 21 }, + { category: ContextWindowCategory.SYSTEM_PROMPT, tokens: 8_000, usage_percent: 4 }, + { category: ContextWindowCategory.TOOLS, tokens: 12_000, usage_percent: 6 }, + { category: ContextWindowCategory.CONVERSATION, tokens: 42_000, usage_percent: 21 }, ], warnings: [], ...overrides, @@ -139,7 +139,7 @@ describe("StageInsightsSidebar", () => { available: false, usage_percent: null, input_tokens: null, - staleness: StageContextWindowStaleness.UNAVAILABLE, + staleness: ContextWindowStaleness.UNAVAILABLE, unavailable_reason: null, }); const dom = render(makeStage(), cw); @@ -155,14 +155,14 @@ describe("StageInsightsSidebar", () => { name: "apply_patch", description: "Apply a unified diff patch", source: { kind: "native" }, - category: AgentToolCategory.WRITE, + category: ToolCategory.WRITE, invoked: true, }, { name: "grep", description: "Search file contents", source: { kind: "native" }, - category: AgentToolCategory.READ, + category: ToolCategory.READ, invoked: false, }, ], @@ -215,13 +215,33 @@ describe("StageInsightsSidebar", () => { expect(dom).toContain("Failed"); }); + test("renders a disconnected mcp server as disconnected, still counted as used", () => { + const dom = render( + makeStage({ + mcp_servers: [ + { + server_name: "github", + tool_count: 4, + status: { kind: "disconnected", error: "transport closed" }, + invoked: true, + }, + ], + }), + null, + ); + expect(dom).toContain("1/1"); + expect(dom).toContain("github"); + expect(dom).toContain("Disconnected"); + expect(dom).not.toContain("Failed"); + }); + test("shows skill activated/available ratio with source label", () => { const dom = render( makeStage({ skills: { activated: [ - { name: "frontend-design", source: AgentSkillActivationSource.SLASH }, - { name: "debug", source: AgentSkillActivationSource.TOOL }, + { name: "frontend-design", source: SkillActivationSource.SLASH }, + { name: "debug", source: SkillActivationSource.TOOL }, ], available: [ { name: "frontend-design", description: "" }, diff --git a/apps/fabro-web/app/components/stage-insights-sidebar.tsx b/apps/fabro-web/app/components/stage-insights-sidebar.tsx index 86278e1eb..65323135e 100644 --- a/apps/fabro-web/app/components/stage-insights-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-insights-sidebar.tsx @@ -7,6 +7,7 @@ import { import { ArrowPathIcon, CheckCircleIcon, + ExclamationTriangleIcon, XCircleIcon, } from "@heroicons/react/24/solid"; import { @@ -19,21 +20,21 @@ import { WrenchScrewdriverIcon, } from "@heroicons/react/24/outline"; import { - AgentSkillActivationSource, - StageContextWindowCategory, - StageContextWindowStaleness, + ContextWindowCategory, + ContextWindowStaleness, + SkillActivationSource, TodoStatus, } from "@qltysh/fabro-api-client"; import type { ActivatedSkill, - AgentSkillSummary, - AgentToolSummary, + ContextWindowBreakdownItem, McpServerProjection, + SkillSummary, StageContextWindow, - StageContextWindowBreakdownItem, StageProjection, TodoListProjection, TodoProjection, + ToolSummary, } from "@qltysh/fabro-api-client"; import { formatTokenCount } from "../lib/format"; @@ -388,7 +389,7 @@ function ContextBreakdown({ snapshot }: { snapshot: StageContextWindow | null }) if (!snapshot) { return

Context usage not yet available.

; } - if (snapshot.staleness === StageContextWindowStaleness.UNAVAILABLE) { + if (snapshot.staleness === ContextWindowStaleness.UNAVAILABLE) { return

Context usage unavailable for this stage.

; } const totalTokens = snapshot.input_tokens ?? 0; @@ -431,7 +432,7 @@ function ContextBreakdown({ snapshot }: { snapshot: StageContextWindow | null }) ); } -function nonZeroBreakdown(items: StageContextWindowBreakdownItem[]): StageContextWindowBreakdownItem[] { +function nonZeroBreakdown(items: ContextWindowBreakdownItem[]): ContextWindowBreakdownItem[] { return items.filter((i) => i.usage_percent > 0); } @@ -443,41 +444,41 @@ function nonZeroBreakdown(items: StageContextWindowBreakdownItem[]): StageContex * Palette is chosen so the typical chunks (Conversation big + System + * Tools) read as three distinct hues rather than three adjacent teals. */ -function categoryColor(category: StageContextWindowCategory): string { +function categoryColor(category: ContextWindowCategory): string { switch (category) { - case StageContextWindowCategory.SYSTEM_PROMPT: + case ContextWindowCategory.SYSTEM_PROMPT: return "var(--color-teal-700)"; - case StageContextWindowCategory.TOOLS: + case ContextWindowCategory.TOOLS: return "var(--color-amber)"; - case StageContextWindowCategory.MCP_TOOLS: + case ContextWindowCategory.MCP_TOOLS: return "var(--color-mint)"; - case StageContextWindowCategory.SKILLS: + case ContextWindowCategory.SKILLS: return "var(--color-teal-500)"; - case StageContextWindowCategory.MEMORY: + case ContextWindowCategory.MEMORY: return "var(--color-coral)"; - case StageContextWindowCategory.CONVERSATION: + case ContextWindowCategory.CONVERSATION: return "var(--color-teal-300)"; - case StageContextWindowCategory.OTHER: + case ContextWindowCategory.OTHER: default: return "var(--color-fg-muted)"; } } -function categoryLabel(category: StageContextWindowCategory): string { +function categoryLabel(category: ContextWindowCategory): string { switch (category) { - case StageContextWindowCategory.SYSTEM_PROMPT: + case ContextWindowCategory.SYSTEM_PROMPT: return "System prompt"; - case StageContextWindowCategory.TOOLS: + case ContextWindowCategory.TOOLS: return "Tools"; - case StageContextWindowCategory.MCP_TOOLS: + case ContextWindowCategory.MCP_TOOLS: return "MCP tools"; - case StageContextWindowCategory.SKILLS: + case ContextWindowCategory.SKILLS: return "Skills"; - case StageContextWindowCategory.MEMORY: + case ContextWindowCategory.MEMORY: return "Memory"; - case StageContextWindowCategory.CONVERSATION: + case ContextWindowCategory.CONVERSATION: return "Conversation"; - case StageContextWindowCategory.OTHER: + case ContextWindowCategory.OTHER: default: return "Other"; } @@ -487,7 +488,7 @@ function categoryLabel(category: StageContextWindowCategory): string { interface SkillsSectionProps { activated: ActivatedSkill[]; - available: AgentSkillSummary[]; + available: SkillSummary[]; activatedNames: Set; } @@ -517,13 +518,13 @@ function SkillsSection({ activated, available, activatedNames }: SkillsSectionPr } function SkillSourceIcon({ source }: { source: ActivatedSkill["source"] }) { - const Icon = source === AgentSkillActivationSource.SLASH ? CommandLineIcon : PuzzlePieceIcon; + const Icon = source === SkillActivationSource.SLASH ? CommandLineIcon : PuzzlePieceIcon; return ; } // ---------- Tools ---------- -function AgentToolsSection({ tools }: { tools: AgentToolSummary[] }) { +function AgentToolsSection({ tools }: { tools: ToolSummary[] }) { if (tools.length === 0) return

No tools reported.

; return (
    @@ -554,27 +555,15 @@ function McpSection({ servers }: { servers: McpServerProjection[] }) {
      {servers.map((server) => { // Dim unused servers so the eye lands on the invoked ones first; - // failed servers stay coral regardless. + // failed and disconnected servers keep their tone regardless. const nameClass = server.status.kind === "ready" && !server.invoked ? "min-w-0 flex-1 truncate text-xs text-fg-muted" : "min-w-0 flex-1 truncate text-xs text-fg-2"; return (
    • - {server.status.kind === "ready" ? ( - - ) : ( - - )} + {server.server_name} - {server.status.kind === "ready" ? ( - - {server.invoked - ? "used" - : `${server.tool_count} ${server.tool_count === 1 ? "tool" : "tools"}`} - - ) : ( - Failed - )} +
    • ); })} @@ -582,6 +571,41 @@ function McpSection({ servers }: { servers: McpServerProjection[] }) { ); } +function McpStatusIcon({ status }: { status: McpServerProjection["status"] }) { + switch (status.kind) { + case "ready": + return ; + case "disconnected": + return ( + + ); + case "failed": + return ; + } +} + +function McpStatusBadge({ server }: { server: McpServerProjection }) { + switch (server.status.kind) { + case "ready": + return ( + + {server.invoked + ? "used" + : `${server.tool_count} ${server.tool_count === 1 ? "tool" : "tools"}`} + + ); + case "disconnected": + return ( + Disconnected + ); + case "failed": + return Failed; + } +} + // ---------- helpers ---------- type IconType = (props: { className?: string }) => ReactNode; diff --git a/apps/fabro-web/app/lib/chats-runtime.test.ts b/apps/fabro-web/app/lib/chats-runtime.test.ts index fe22c2be9..b670dfcc1 100644 --- a/apps/fabro-web/app/lib/chats-runtime.test.ts +++ b/apps/fabro-web/app/lib/chats-runtime.test.ts @@ -66,8 +66,8 @@ describe("createScriptedAdapter", () => { .map((p) => p.text ?? "") .join(""); const expectedText = SCRIPTED_REPLIES[0]!.content - .filter((p) => p.kind === "text") - .map((p) => p.data.text) + .filter((p) => p.type === "text") + .map((p) => (p.type === "text" ? p.text : "")) .join(""); expect(finalText).toBe(expectedText); }); @@ -89,7 +89,7 @@ describe("createScriptedAdapter", () => { describe("toThreadMessages", () => { test("converts a user text message", () => { const out = toThreadMessages([ - { role: "user", content: [{ kind: "text", data: { text: "hi" } }] }, + { role: "user", content: [{ type: "text", text: "hi" }] }, ]); expect(out).toEqual([ { role: "user", content: [{ type: "text", text: "hi" }] }, @@ -102,16 +102,15 @@ describe("toThreadMessages", () => { role: "assistant", content: [ { - kind: "tool_call", - data: { - tool_call_id: "t1", - name: "search", - arguments: { q: "hello" }, - }, + type: "tool_call", + id: "t1", + name: "search", + input: { type: "function", arguments: { q: "hello" } }, }, { - kind: "tool_result", - data: { tool_call_id: "t1", content: { ok: true } }, + type: "tool_result", + tool_call_id: "t1", + content: [{ type: "text", text: "{\"ok\":true}" }], }, ], }, @@ -126,6 +125,6 @@ describe("toThreadMessages", () => { expect(first?.type).toBe("tool-call"); if (first?.type !== "tool-call") throw new Error("expected tool-call part"); expect(first.toolCallId).toBe("t1"); - expect(first.result).toEqual({ ok: true }); + expect(first.result).toEqual('{"ok":true}'); }); }); diff --git a/apps/fabro-web/app/lib/chats-runtime.ts b/apps/fabro-web/app/lib/chats-runtime.ts index 79cbae8ab..484d3e063 100644 --- a/apps/fabro-web/app/lib/chats-runtime.ts +++ b/apps/fabro-web/app/lib/chats-runtime.ts @@ -5,7 +5,12 @@ import type { ThreadMessageLike, } from "@assistant-ui/react"; -import type { Chat, ChatContentPart, ChatMessage } from "./chats-types"; +import type { + Chat, + ChatContentPart, + ChatMessage, + JsonValue, +} from "./chats-types"; import { pickReply } from "./chats-script"; const STREAM_CHUNK_CHARS = 28; @@ -29,29 +34,38 @@ function sleep(ms: number, signal: AbortSignal): Promise { }); } +function toolResultValue(content: readonly ChatContentPart[]): JsonValue { + const texts = content.flatMap((part) => + part.type === "text" ? [part.text] : [], + ); + return texts.length === content.length + ? texts.join("") + : (JSON.parse(JSON.stringify(content)) as JsonValue); +} + function toAssistantParts( content: readonly ChatContentPart[], ): ThreadAssistantMessagePart[] { const out: ThreadAssistantMessagePart[] = []; for (const part of content) { - if (part.kind === "text") { - out.push({ type: "text", text: part.data.text }); - } else if (part.kind === "tool_call") { + if (part.type === "text") { + out.push({ type: "text", text: part.text }); + } else if (part.type === "tool_call") { out.push({ type: "tool-call", - toolCallId: part.data.tool_call_id, - toolName: part.data.name, - args: part.data.arguments, - argsText: JSON.stringify(part.data.arguments), + toolCallId: part.id, + toolName: part.name, + args: part.input.arguments, + argsText: JSON.stringify(part.input.arguments), }); - } else if (part.kind === "tool_result") { + } else if (part.type === "tool_result") { for (let i = out.length - 1; i >= 0; i--) { const candidate = out[i]; if ( candidate?.type === "tool-call" && - candidate.toolCallId === part.data.tool_call_id + candidate.toolCallId === part.tool_call_id ) { - out[i] = { ...candidate, result: part.data.content }; + out[i] = { ...candidate, result: toolResultValue(part.content) }; break; } } @@ -71,16 +85,16 @@ export function createScriptedAdapter(args: { const accumulated: ChatContentPart[] = []; for (const part of reply.content) { - if (part.kind === "text") { - const text = part.data.text; + if (part.type === "text") { + const text = part.text; let cursor = 0; - accumulated.push({ kind: "text", data: { text: "" } }); + accumulated.push({ type: "text", text: "" }); const accIndex = accumulated.length - 1; while (cursor < text.length) { cursor = Math.min(cursor + STREAM_CHUNK_CHARS, text.length); accumulated[accIndex] = { - kind: "text", - data: { text: text.slice(0, cursor) }, + type: "text", + text: text.slice(0, cursor), }; yield buildUpdate(accumulated); if (cursor < text.length) { @@ -110,8 +124,8 @@ export function toThreadMessages( if (msg.role === "user") { const content = []; for (const part of msg.content) { - if (part.kind === "text") { - content.push({ type: "text", text: part.data.text } as const); + if (part.type === "text") { + content.push({ type: "text", text: part.text } as const); } } return { diff --git a/apps/fabro-web/app/lib/chats-script.ts b/apps/fabro-web/app/lib/chats-script.ts index 9eb39661e..52dc2d8b3 100644 --- a/apps/fabro-web/app/lib/chats-script.ts +++ b/apps/fabro-web/app/lib/chats-script.ts @@ -1,4 +1,16 @@ -import type { ChatMessage } from "./chats-types"; +import type { ChatContentPart, ChatMessage } from "./chats-types"; + +function text(value: string): ChatContentPart { + return { type: "text", text: value }; +} + +function toolResult(toolCallId: string, value: unknown): ChatContentPart { + return { + type: "tool_result", + tool_call_id: toolCallId, + content: [text(JSON.stringify(value))], + }; +} /** * Scripted assistant replies cycled through per chat. Generic content, @@ -10,176 +22,130 @@ export const SCRIPTED_REPLIES: ChatMessage[] = [ { role: "assistant", content: [ - { - kind: "text", - data: { - text: - "Hi! I'm a scripted prototype reply. A few things I can show off:\n\n" + - "- Markdown rendering (lists, **bold**, *italics*, `code`)\n" + - "- Streaming text appearing incrementally\n" + - "- Tool calls with arguments and results\n" + - "- Multi-paragraph responses with code blocks\n\n" + - "Send another message to see the next response in the bank.", - }, - }, + text( + "Hi! I'm a scripted prototype reply. A few things I can show off:\n\n" + + "- Markdown rendering (lists, **bold**, *italics*, `code`)\n" + + "- Streaming text appearing incrementally\n" + + "- Tool calls with arguments and results\n" + + "- Multi-paragraph responses with code blocks\n\n" + + "Send another message to see the next response in the bank.", + ), ], }, { role: "assistant", content: [ - { - kind: "text", - data: { - text: - "Here's a TypeScript snippet that debounces a function:\n\n" + - "```ts\n" + - "export function debounce void>(\n" + - " fn: T,\n" + - " ms: number,\n" + - "): (...args: Parameters) => void {\n" + - " let handle: ReturnType | undefined;\n" + - " return (...args) => {\n" + - " if (handle) clearTimeout(handle);\n" + - " handle = setTimeout(() => fn(...args), ms);\n" + - " };\n" + - "}\n" + - "```\n\n" + - "The trailing-edge variant is the most common; a leading-edge variant fires immediately then suppresses subsequent calls.", - }, - }, + text( + "Here's a TypeScript snippet that debounces a function:\n\n" + + "```ts\n" + + "export function debounce void>(\n" + + " fn: T,\n" + + " ms: number,\n" + + "): (...args: Parameters) => void {\n" + + " let handle: ReturnType | undefined;\n" + + " return (...args) => {\n" + + " if (handle) clearTimeout(handle);\n" + + " handle = setTimeout(() => fn(...args), ms);\n" + + " };\n" + + "}\n" + + "```\n\n" + + "The trailing-edge variant is the most common; a leading-edge variant fires immediately then suppresses subsequent calls.", + ), ], }, { role: "assistant", content: [ + text("Let me search for that real quick."), { - kind: "text", - data: { - text: "Let me search for that real quick.", - }, - }, - { - kind: "tool_call", - data: { - tool_call_id: "call_search_1", - name: "search_web", + type: "tool_call", + id: "call_search_1", + name: "search_web", + input: { + type: "function", arguments: { query: "current best practices for rate limiting an HTTP API", max_results: 5, }, }, }, - { - kind: "tool_result", - data: { - tool_call_id: "call_search_1", - content: { - results: [ - { - title: "Token bucket vs leaky bucket", - url: "https://example.com/rate-limit-algorithms", - snippet: - "Token bucket allows bursts, leaky bucket smooths traffic.", - }, - { - title: "Distributed rate limiting with Redis", - url: "https://example.com/redis-rate-limit", - snippet: - "INCR + EXPIRE is the simplest fixed-window approach.", - }, - ], + toolResult("call_search_1", { + results: [ + { + title: "Token bucket vs leaky bucket", + url: "https://example.com/rate-limit-algorithms", + snippet: "Token bucket allows bursts, leaky bucket smooths traffic.", }, - }, - }, - { - kind: "text", - data: { - text: - "\n\nTwo solid starting points. For most APIs, a Redis-backed sliding window keyed by API key gives you per-tenant fairness without a lot of moving parts. For burst tolerance, a token-bucket per route is a nice layer on top.", - }, - }, + { + title: "Distributed rate limiting with Redis", + url: "https://example.com/redis-rate-limit", + snippet: "INCR + EXPIRE is the simplest fixed-window approach.", + }, + ], + }), + text( + "\n\nTwo solid starting points. For most APIs, a Redis-backed sliding window keyed by API key gives you per-tenant fairness without a lot of moving parts. For burst tolerance, a token-bucket per route is a nice layer on top.", + ), ], }, { role: "assistant", content: [ - { - kind: "text", - data: { - text: - "## The 4-fold path of refactoring a hook\n\n" + - "When a React hook starts feeling tangled, work the corners in order:\n\n" + - "### 1. Extract pure computation\n" + - "Anything that is a function of inputs (no side effects, no state) leaves the hook entirely.\n\n" + - "### 2. Collapse derived state into `useMemo`\n" + - "State that is computable from other state shouldn't be its own state.\n\n" + - "### 3. Split orthogonal concerns into sibling hooks\n" + - "If two effects don't share dependencies, they don't belong in the same hook.\n\n" + - "### 4. Promote to a reducer\n" + - "Once there are 3+ related `useState` calls coordinating updates, `useReducer` makes the state machine explicit.\n\n" + - "> The honest test: can you write a one-sentence description of what the hook is responsible for? If not, it's doing too much.", - }, - }, + text( + "## The 4-fold path of refactoring a hook\n\n" + + "When a React hook starts feeling tangled, work the corners in order:\n\n" + + "### 1. Extract pure computation\n" + + "Anything that is a function of inputs (no side effects, no state) leaves the hook entirely.\n\n" + + "### 2. Collapse derived state into `useMemo`\n" + + "State that is computable from other state shouldn't be its own state.\n\n" + + "### 3. Split orthogonal concerns into sibling hooks\n" + + "If two effects don't share dependencies, they don't belong in the same hook.\n\n" + + "### 4. Promote to a reducer\n" + + "Once there are 3+ related `useState` calls coordinating updates, `useReducer` makes the state machine explicit.\n\n" + + "> The honest test: can you write a one-sentence description of what the hook is responsible for? If not, it's doing too much.", + ), ], }, { role: "assistant", content: [ + text("I'll compute that for you."), { - kind: "text", - data: { - text: "I'll compute that for you.", - }, - }, - { - kind: "tool_call", - data: { - tool_call_id: "call_calc_1", - name: "run_calculation", + type: "tool_call", + id: "call_calc_1", + name: "run_calculation", + input: { + type: "function", arguments: { expression: "compound_interest(principal=10000, rate=0.05, years=10)", }, }, }, - { - kind: "tool_result", - data: { - tool_call_id: "call_calc_1", - content: { - value: 16288.95, - currency: "USD", - note: "Annual compounding; rounded to cents.", - }, - }, - }, - { - kind: "text", - data: { - text: - "\n\n**$16,288.95** after 10 years. Bumping the rate to 7% would put you at roughly $19,672, and continuous compounding at 5% lands at $16,487 — so the extra two points of rate matters more than the compounding cadence.", - }, - }, + toolResult("call_calc_1", { + value: 16288.95, + currency: "USD", + note: "Annual compounding; rounded to cents.", + }), + text( + "\n\n**$16,288.95** after 10 years. Bumping the rate to 7% would put you at roughly $19,672, and continuous compounding at 5% lands at $16,487 — so the extra two points of rate matters more than the compounding cadence.", + ), ], }, { role: "assistant", content: [ - { - kind: "text", - data: { - text: - "Good question. The short answer: it depends on whether you need transactions across multiple writes.\n\n" + - "If you do — Postgres. If everything you do is single-row, SQLite is faster, simpler to operate, and easier to back up. A surprising amount of production traffic can live happily on SQLite if you accept its one-writer-at-a-time constraint.\n\n" + - "Next step: tell me about your read/write ratio and I can be more specific.", - }, - }, + text( + "Good question. The short answer: it depends on whether you need transactions across multiple writes.\n\n" + + "If you do — Postgres. If everything you do is single-row, SQLite is faster, simpler to operate, and easier to back up. A surprising amount of production traffic can live happily on SQLite if you accept its one-writer-at-a-time constraint.\n\n" + + "Next step: tell me about your read/write ratio and I can be more specific.", + ), ], }, ]; const FALLBACK_REPLY: ChatMessage = { role: "assistant", - content: [{ kind: "text", data: { text: "(No reply available.)" } }], + content: [text("(No reply available.)")], }; export function pickReply(scriptIndex: number): ChatMessage { diff --git a/apps/fabro-web/app/lib/chats-store.test.tsx b/apps/fabro-web/app/lib/chats-store.test.tsx index 1b7082b3a..f55642073 100644 --- a/apps/fabro-web/app/lib/chats-store.test.tsx +++ b/apps/fabro-web/app/lib/chats-store.test.tsx @@ -38,8 +38,8 @@ describe("chats-store reducer", () => { expect(chat?.seedMessages).toHaveLength(1); expect(chat?.seedMessages[0]?.role).toBe("user"); expect(chat?.seedMessages[0]?.content[0]).toEqual({ - kind: "text", - data: { text: "Help me with React" }, + type: "text", + text: "Help me with React", }); }); diff --git a/apps/fabro-web/app/lib/chats-store.tsx b/apps/fabro-web/app/lib/chats-store.tsx index 3c617dd1a..9619f379c 100644 --- a/apps/fabro-web/app/lib/chats-store.tsx +++ b/apps/fabro-web/app/lib/chats-store.tsx @@ -38,7 +38,7 @@ function deriveTitle(text: string): string { function userMessage(text: string): ChatMessage { return { role: "user", - content: [{ kind: "text", data: { text } }], + content: [{ type: "text", text }], }; } diff --git a/apps/fabro-web/app/lib/chats-types.ts b/apps/fabro-web/app/lib/chats-types.ts index 0272ead32..45302456c 100644 --- a/apps/fabro-web/app/lib/chats-types.ts +++ b/apps/fabro-web/app/lib/chats-types.ts @@ -1,26 +1,22 @@ /** * Stricter discriminated-union view over @qltysh/fabro-api-client's - * `CompletionContentPart` ({ kind: string; data: any }). Each variant in our - * union is assignable to the API client type at the boundary, but inside the - * chat code we get exhaustive switch checking. + * `CompletionContentPart`, the lithos `ContentPart` wire shape discriminated + * by `type`. Each variant in our union is assignable to the API client type + * at the boundary, but inside the chat code we get exhaustive switch checking. */ export type ChatContentPart = - | { kind: "text"; data: { text: string } } + | { type: "text"; text: string } | { - kind: "tool_call"; - data: { - tool_call_id: string; - name: string; - arguments: { [key: string]: JsonValue }; - }; + type: "tool_call"; + id: string; + name: string; + input: { type: "function"; arguments: { [key: string]: JsonValue } }; } | { - kind: "tool_result"; - data: { - tool_call_id: string; - content: JsonValue; - is_error?: boolean; - }; + type: "tool_result"; + tool_call_id: string; + content: ChatContentPart[]; + is_error?: boolean; }; export type JsonValue = diff --git a/apps/fabro-web/app/lib/environment-providers.ts b/apps/fabro-web/app/lib/environment-providers.ts index 8002cf6a4..300fe54cb 100644 --- a/apps/fabro-web/app/lib/environment-providers.ts +++ b/apps/fabro-web/app/lib/environment-providers.ts @@ -1,17 +1,44 @@ -import { EnvironmentProvider, type Environment } from "@qltysh/fabro-api-client"; +import type { Environment, ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; -// Providers a managed environment can be created with. `local` is a reserved, -// in-memory environment, never a managed-environment provider, so it is never -// offered. The provider is fixed at creation time and cannot be changed. -export const CREATABLE_PROVIDERS = [ - EnvironmentProvider.DOCKER, - EnvironmentProvider.DAYTONA, -] as const; +// The providers linked into the server. Any other provider kind names a +// sandbox-driver plugin the operator configured under +// `server.sandbox.providers.`. +export const LOCAL_PROVIDER = "local"; +export const DOCKER_PROVIDER = "docker"; +export const DAYTONA_PROVIDER = "daytona"; + +export const BUNDLED_PROVIDERS = [LOCAL_PROVIDER, DOCKER_PROVIDER, DAYTONA_PROVIDER] as const; + +export type ProviderSettingsMap = { [kind: string]: ServerSandboxProviderSettings }; + +// `local` runs in the caller's directory and never clones. Every other +// provider owns an isolated workspace that Fabro clones into. +export function isCloneBasedProvider(provider: string): boolean { + return provider !== LOCAL_PROVIDER; +} // Whether a server-managed environment can back Git-targeted work such as -// automations: only the clone-based (creatable) providers qualify. +// automations: only clone-based providers qualify. export function isCloneBasedEnvironment(environment: Environment): boolean { - return (CREATABLE_PROVIDERS as readonly string[]).includes(environment.provider); + return isCloneBasedProvider(environment.provider); +} + +// Providers a managed environment can be created with: every enabled +// clone-based provider. `local` is a reserved, in-memory environment, never a +// managed-environment provider, so it is never offered. +export function creatableProviders(providers: ProviderSettingsMap): string[] { + return Object.keys(providers) + .filter((kind) => isCloneBasedProvider(kind) && providers[kind]?.enabled) + .sort(compareProviderKinds); +} + +// Bundled kinds first, in their canonical order, then plugins alphabetically. +export function compareProviderKinds(left: string, right: string): number { + const rank = (kind: string) => { + const index = (BUNDLED_PROVIDERS as readonly string[]).indexOf(kind); + return index === -1 ? BUNDLED_PROVIDERS.length : index; + }; + return rank(left) - rank(right) || left.localeCompare(right); } export function providerLabel(provider: string): string { diff --git a/apps/fabro-web/app/lib/sandbox-state.ts b/apps/fabro-web/app/lib/sandbox-state.ts index 73aca46bd..6eb47020f 100644 --- a/apps/fabro-web/app/lib/sandbox-state.ts +++ b/apps/fabro-web/app/lib/sandbox-state.ts @@ -11,29 +11,31 @@ export interface SandboxStateDisplay { text: string; } +const PENDING = { dot: "bg-amber", text: "text-amber" } as const; +const QUIET = { dot: "bg-fg-muted", text: "text-fg-muted" } as const; +const GONE = { dot: "bg-coral", text: "text-coral" } as const; + /** - * Display metadata for every normalized sandbox lifecycle state. Shared by the + * Display metadata for every sandbox driver lifecycle state. Shared by the * run overview summary panel and the dedicated sandbox page so the dot color, - * label, and hover copy stay consistent. + * label, and hover copy stay consistent. A state this build does not know + * renders as `unknown`. */ export const SANDBOX_STATE_DISPLAY: Record = { unknown: { label: "Unknown", description: "The sandbox state could not be determined.", - dot: "bg-fg-muted", - text: "text-fg-muted", + ...QUIET, }, - provisioning: { - label: "Provisioning", - description: "The sandbox is being provisioned.", - dot: "bg-amber", - text: "text-amber", + creating: { + label: "Creating", + description: "The sandbox is being created.", + ...PENDING, }, starting: { label: "Starting", description: "The sandbox is starting up.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, running: { label: "Running", @@ -44,55 +46,71 @@ export const SANDBOX_STATE_DISPLAY: Record = stopping: { label: "Stopping", description: "The sandbox is shutting down.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, stopped: { label: "Stopped", description: "The sandbox is stopped.", - dot: "bg-fg-muted", - text: "text-fg-muted", + ...QUIET, + }, + pausing: { + label: "Pausing", + description: "The sandbox is being paused.", + ...PENDING, }, paused: { label: "Paused", description: "The sandbox is paused.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, - deleting: { - label: "Deleting", - description: "The sandbox is being deleted.", - dot: "bg-amber", - text: "text-amber", + resuming: { + label: "Resuming", + description: "The sandbox is resuming.", + ...PENDING, }, - deleted: { - label: "Deleted", - description: "The sandbox has been deleted.", - dot: "bg-coral", - text: "text-coral", + archiving: { + label: "Archiving", + description: "The sandbox is being archived.", + ...PENDING, }, archived: { label: "Archived", description: "The sandbox has been archived.", - dot: "bg-fg-muted", - text: "text-fg-muted", + ...QUIET, }, restoring: { label: "Restoring", description: "The sandbox is being restored.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, }, resizing: { label: "Resizing", description: "The sandbox resources are being resized.", - dot: "bg-amber", - text: "text-amber", + ...PENDING, + }, + forking: { + label: "Forking", + description: "The sandbox is being forked.", + ...PENDING, + }, + snapshotting: { + label: "Snapshotting", + description: "A snapshot of the sandbox is being taken.", + ...PENDING, + }, + deleting: { + label: "Deleting", + description: "The sandbox is being deleted.", + ...PENDING, + }, + deleted: { + label: "Deleted", + description: "The sandbox has been deleted.", + ...GONE, }, error: { label: "Error", description: "The sandbox encountered an error.", - dot: "bg-coral", - text: "text-coral", + ...GONE, }, }; diff --git a/apps/fabro-web/app/routes/run-billing.tsx b/apps/fabro-web/app/routes/run-billing.tsx index 1973ffb10..648b1796b 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -35,7 +35,7 @@ function formatUsdMicrosOrDash(usdMicros?: number | null): string { function formatModelRef(model?: BillingModelRef | null): string | null { if (!model) return null; - const speed = model.speed && model.speed !== "standard" ? ` · ${model.speed}` : ""; + const speed = model.speed ? ` · ${model.speed}` : ""; return `${model.provider}:${model.model_id}${speed}`; } diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index 958d0a217..8772f9283 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -103,14 +103,15 @@ mock.restore(); const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; function sandboxDetails( - overrides: Partial & { + overrides: { sandbox?: Partial & { runtime?: Partial>; }; + status?: Partial; } = {}, ): SandboxDetails { const sandbox = overrides.sandbox ?? {}; - const { sandbox: _sandboxOverride, ...detailOverrides } = overrides; + const status = overrides.status ?? {}; return { sandbox: { provider: "docker", @@ -126,34 +127,27 @@ function sandboxDetails( }, ...sandbox, }, - state: "running", - native_state: null, - region: null, - resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null }, - network: networkDetails(), - labels: {}, - timestamps: { created_at: null, last_activity_at: null }, - ...detailOverrides, + status: { + id: sandbox.runtime?.id ?? "", + state: "running", + provider_state: "", + error_reason: null, + resources: null, + sandbox_kind: null, + region: null, + labels: {}, + image: null, + snapshot: null, + network: null, + workspace_ownership: null, + web_url: null, + created_at: null, + updated_at: null, + ...status, + }, }; } -function networkDetails( - overrides: Partial = {}, -): SandboxDetails["network"] { - return { - egress: networkPolicy("unknown"), - ingress: networkPolicy("unknown"), - ...overrides, - }; -} - -function networkPolicy( - mode: SandboxDetails["network"]["egress"]["mode"], - cidrs: string[] = [], -): SandboxDetails["network"]["egress"] { - return { mode, cidrs }; -} - function textContent(renderer: TestRenderer.ReactTestRenderer): string { return renderer.root .findAll((node) => typeof node.type === "string") @@ -230,22 +224,18 @@ describe("RunSandbox route", () => { working_directory: "/workspace", }, }, - state: "running", - native_state: "running", - region: undefined, - resources: { - cpu_cores: 2, - memory_bytes: 4 * 1024 * 1024 * 1024, - disk_bytes: undefined, - }, - network: networkDetails({ - egress: networkPolicy("open"), - ingress: networkPolicy("blocked"), - }), - labels: { run: "abc" }, - timestamps: { - created_at: "2026-05-09T12:00:00Z", - last_activity_at: undefined, + status: { + state: "running", + provider_state: "running", + resources: { + cpu_cores: 2, + memory_mb: 4 * 1024, + disk_mb: null, + gpus: null, + }, + network: "allow_all", + labels: { run: "abc" }, + created_at: "2026-05-09T12:00:00Z", }, }); const renderer = renderRoute(); @@ -256,8 +246,8 @@ describe("RunSandbox route", () => { .filter((text): text is string => typeof text === "string"); expect(panelHeadings).toEqual(["Overview", "Resources", "Network", "Labels", "Timestamps"]); const copy = textContent(renderer); - expect(copy).toContain("Open"); - expect(copy).toContain("Blocked"); + expect(copy).toContain("Allow all"); + expect(copy).toContain("4 GiB"); }); test("links to the provider dashboard when a sandbox web URL is present", () => { @@ -269,8 +259,10 @@ describe("RunSandbox route", () => { working_directory: "/workspace", }, }, - web_url: - "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + status: { + web_url: + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + }, }); const renderer = renderRoute(); @@ -296,18 +288,12 @@ describe("RunSandbox route", () => { working_directory: "/tmp/project", }, }, - state: "unknown", - native_state: undefined, - region: undefined, - resources: { - cpu_cores: undefined, - memory_bytes: undefined, - disk_bytes: undefined, - }, - labels: {}, - timestamps: { - created_at: undefined, - last_activity_at: undefined, + status: { + state: "unknown", + resources: { cpu_cores: null, memory_mb: null, disk_mb: null, gpus: null }, + labels: {}, + created_at: null, + updated_at: null, }, }); const renderer = renderRoute(); @@ -328,35 +314,29 @@ describe("RunSandbox route", () => { expect(noLabelsCopy).toHaveLength(1); }); - test("renders unknown network policies", () => { - currentDetails = sandboxDetails({ - network: networkDetails({ - egress: networkPolicy("unknown"), - ingress: networkPolicy("unknown"), - }), - }); + test("renders an unknown network policy", () => { + currentDetails = sandboxDetails({ status: { network: null } }); const renderer = renderRoute(); const copy = textContent(renderer); expect(copy).toContain("Network"); - expect(copy).toContain("Egress"); - expect(copy).toContain("Ingress"); + expect(copy).toContain("Policy"); expect(copy).toContain("Unknown"); }); - test("renders blocked, essentials, and CIDR network policies", () => { + test("renders blocked and CIDR allow list network policies", () => { currentDetails = sandboxDetails({ - network: networkDetails({ - egress: networkPolicy("cidr_allow_list", ["10.0.0.0/8", "192.168.0.0/16"]), - ingress: networkPolicy("essentials_only"), - }), + status: { network: { cidr_allow_list: { cidrs: ["10.0.0.0/8", "192.168.0.0/16"] } } }, }); const renderer = renderRoute(); const copy = textContent(renderer); expect(copy).toContain("CIDR allow list"); expect(copy).toContain("10.0.0.0/8, 192.168.0.0/16"); - expect(copy).toContain("Essentials only"); + + currentDetails = sandboxDetails({ status: { network: "block" } }); + const blocked = renderRoute(); + expect(textContent(blocked)).toContain("Blocked"); }); test("shows the empty state when no sandbox is reported", () => { diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index d190bd5e0..c93a3f139 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -22,7 +22,7 @@ import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state"; import type { RunSandbox, SandboxDetails, - SandboxNetwork, + SandboxNetworkPolicy, SandboxResources, } from "@qltysh/fabro-api-client"; import FilesystemPanel from "./run-sandbox/filesystem-panel"; @@ -57,27 +57,47 @@ function nullableTimestamp(value: string | null | undefined): string { return value ? formatAbsoluteTs(value) : EMPTY_VALUE; } -function nullableMemory(bytes: number | null | undefined): string { - return bytes != null ? formatBytesAsMemory(bytes) : EMPTY_VALUE; +function nullableMegabytes(megabytes: number | null | undefined): string { + return megabytes != null ? formatBytesAsMemory(megabytes * 1024 * 1024) : EMPTY_VALUE; } function nullableCpu(cores: number | null | undefined): string { return cores != null ? formatCpuCores(cores) : EMPTY_VALUE; } -type SandboxNetworkPolicy = SandboxNetwork["egress"]; -type SandboxNetworkPolicyMode = SandboxNetworkPolicy["mode"]; +function nullableCount(count: number | null | undefined): string { + return count != null ? String(count) : EMPTY_VALUE; +} -const NETWORK_POLICY_DISPLAY: Record = { - unknown: "Unknown", - open: "Open", - blocked: "Blocked", - cidr_allow_list: "CIDR allow list", - essentials_only: "Essentials only", +const NETWORK_POLICY_DISPLAY: Record = { + provider_default: "Provider default", + allow_all: "Allow all", + block: "Blocked", }; -function networkPolicySummary(policy: SandboxNetworkPolicy): string { - return NETWORK_POLICY_DISPLAY[policy.mode] ?? policy.mode; +/** The policy's name, and the entries of an allow list when it carries one. */ +function describeNetworkPolicy( + policy: SandboxNetworkPolicy | null | undefined, +): { summary: string; entries: { label: string; values: string[] } | null } { + if (policy == null) { + return { summary: "Unknown", entries: null }; + } + if (typeof policy === "string") { + return { summary: NETWORK_POLICY_DISPLAY[policy] ?? policy, entries: null }; + } + if ("cidr_allow_list" in policy) { + return { + summary: "CIDR allow list", + entries: { label: "Allowed CIDRs", values: policy.cidr_allow_list.cidrs }, + }; + } + if ("domain_allow_list" in policy) { + return { + summary: "Domain allow list", + entries: { label: "Allowed domains", values: policy.domain_allow_list.domains }, + }; + } + return { summary: "Unknown", entries: null }; } interface RowProps { @@ -142,11 +162,12 @@ function Panel({ title, children }: PanelProps) { } function StatusStrip({ details }: { details: SandboxDetails }) { - const display = SANDBOX_STATE_DISPLAY[details.state] ?? SANDBOX_STATE_DISPLAY.unknown; + const status = details.status; + const display = SANDBOX_STATE_DISPLAY[status.state] ?? SANDBOX_STATE_DISPLAY.unknown; const provider = details.sandbox.provider; + const providerState = status.provider_state ?? ""; const showNative = - details.native_state && - details.native_state.toLowerCase() !== details.state.toLowerCase(); + providerState.length > 0 && providerState.toLowerCase() !== status.state.toLowerCase(); return (
      @@ -158,7 +179,7 @@ function StatusStrip({ details }: { details: SandboxDetails }) { {showNative && ( - ({details.native_state}) + ({providerState}) )}
      @@ -167,20 +188,25 @@ function StatusStrip({ details }: { details: SandboxDetails }) { function OverviewPanel({ details }: { details: SandboxDetails }) { const sandbox = details.sandbox; + const status = details.status; const runtime = sandbox.runtime; return ( - + - - {details.web_url && ( + + {status.sandbox_kind && } + {status.web_url && ( - - - + + + + {resources?.gpus != null && } ); } -function NetworkPanel({ network }: { network: SandboxNetwork }) { - const cidrRows: Array<{ label: string; policy: SandboxNetworkPolicy }> = [ - { label: "Egress CIDRs", policy: network.egress }, - { label: "Ingress CIDRs", policy: network.ingress }, - ].filter(({ policy }) => policy.mode === "cidr_allow_list"); - +function NetworkPanel({ network }: { network: SandboxNetworkPolicy | null | undefined }) { + const { summary, entries } = describeNetworkPolicy(network); return ( - - - {cidrRows.map(({ label, policy }) => ( - - ))} + + {entries && ( + + )} ); } @@ -237,11 +259,8 @@ function LabelsPanel({ labels }: { labels: { [key: string]: string } | null | un function TimestampsPanel({ details }: { details: SandboxDetails }) { return ( - - + + ); } @@ -259,9 +278,9 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) {
      - - - + + +
      ); diff --git a/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx b/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx index 38403196e..a20c95a49 100644 --- a/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/services-panel.test.tsx @@ -26,10 +26,7 @@ function makeIdlePreview(): PreviewMutationShape { } function makeServicesData(data: SandboxService[]) { - return { - data, - meta: { source: "ss" as const }, - }; + return { data }; } const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; @@ -116,46 +113,6 @@ describe("ServicesPanelView", () => { expect(titles).toHaveLength(1); }); - test("shows an iproute2 tip when services were discovered from procfs", () => { - const service: SandboxService = { - port: 3000, - addresses: ["0.0.0.0:3000"], - processes: [], - preview_supported: true, - }; - const renderer = renderView({ - servicesQuery: { - ...makeIdleQuery(), - data: { - data: [service], - meta: { source: "procfs" }, - }, - }, - previewMutation: makeIdlePreview(), - }); - - const tipLabels = renderer.root.findAll( - (node) => - node.type === "span" && - Array.isArray(node.children) && - node.children.includes("Tip:"), - ); - expect(tipLabels).toHaveLength(1); - - const commands = renderer.root.findAll( - (node) => - node.type === "code" && - Array.isArray(node.children) && - node.children.includes("apt-get install iproute2"), - ); - expect(commands).toHaveLength(1); - - const tipText = JSON.stringify(renderer.toJSON()); - expect(tipText).toContain("Install "); - expect(tipText).toContain("ss"); - expect(tipText).toContain(" in the sandbox for improved services listing:"); - }); - test("shows API error state with the error message", () => { const renderer = renderView({ servicesQuery: { diff --git a/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx b/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx index 313cac6ba..400cb6365 100644 --- a/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx +++ b/apps/fabro-web/app/routes/run-sandbox/services-panel.tsx @@ -77,7 +77,6 @@ export function ServicesPanelView({ const [previewError, setPreviewError] = useState(null); const services = servicesQuery.data?.data ?? []; - const discoverySource = servicesQuery.data?.meta.source; const queryErrorMessage = describeQueryError(servicesQuery.error); const showLoading = servicesQuery.isLoading && !servicesQuery.data; const showError = queryErrorMessage !== null && !servicesQuery.data; @@ -150,7 +149,6 @@ export function ServicesPanelView({ ) : ( <> - {discoverySource === "procfs" ? : null} - Tip:{" "} - Install ss in the sandbox - for improved services listing:{" "} - apt-get install iproute2 - - ); -} - function ServicesTable({ services, pendingPort, diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index 31a46eaef..dad3df6f5 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -90,13 +90,17 @@ describe("eventsToActivity", () => { event: "agent.message", stage_id: "verify@1", node_id: "verify", - properties: { text: "first visit reply" }, + properties: { + event: { AssistantMessage: { text: "first visit reply" } }, + }, }), envelope(4, { event: "agent.message", stage_id: "verify@2", node_id: "verify", - properties: { text: "second visit reply" }, + properties: { + event: { AssistantMessage: { text: "second visit reply" } }, + }, }), ]; @@ -204,19 +208,19 @@ describe("eventsToActivity", () => { event: "agent.tool.started", node_id: "detect-drift", properties: { - tool_call_id: "call-1", + event: { ToolCallStarted: { tool_call_id: "call-1", tool_name: "read_file", - arguments: { path: "config.toml" }, + arguments: { path: "config.toml" } } }, }, }), envelope(2, { event: "agent.tool.completed", node_id: "detect-drift", properties: { - tool_call_id: "call-1", + event: { ToolCallCompleted: { tool_call_id: "call-1", tool_name: "read_file", output: "[redis]", - is_error: false, + is_error: false } }, }, }), ]; @@ -242,13 +246,17 @@ describe("eventsToActivity", () => { event: "agent.steering.injected", stage_id: "nap@1", node_id: "nap", - properties: { text: "say hello", visit: 1 }, + properties: { + event: { SteeringInjected: { text: "say hello" } }, + }, }), envelope(3, { event: "agent.steering.injected", stage_id: "other@1", node_id: "other", - properties: { text: "wrong stage", visit: 1 }, + properties: { + event: { SteeringInjected: { text: "wrong stage" } }, + }, }), ]; @@ -410,8 +418,8 @@ describe("eventsToActivity", () => { stage_id: "simplify@1", node_id: "simplify", properties: { - text: "Done.", - billing: { input_tokens: 10, output_tokens: 5 }, + event: { AssistantMessage: { text: "Done.", + usage: { input: 10, output: 5 } } }, }, }), envelope(3, { @@ -482,7 +490,7 @@ describe("eventsToActivity", () => { event: "agent.message", stage_id: "plan@1", node_id: "plan", - properties, + properties: { event: { AssistantMessage: properties } }, }), ], "plan@1", @@ -549,7 +557,9 @@ describe("eventsToActivity", () => { envelope(2, { event: "agent.message", node_id: "detect-drift", - properties: { text: "signal" }, + properties: { + event: { AssistantMessage: { text: "signal" } }, + }, }), envelope(3, { event: "run.running", @@ -559,7 +569,9 @@ describe("eventsToActivity", () => { envelope(4, { event: "agent.message", node_id: "other-stage", - properties: { text: "wrong stage" }, + properties: { + event: { AssistantMessage: { text: "wrong stage" } }, + }, }), ]; @@ -942,9 +954,9 @@ describe("buildStageActivity pending tools", () => { stage_id: "plan@1", node_id: "plan", properties: { - tool_call_id: "call-1", + event: { ToolCallStarted: { tool_call_id: "call-1", tool_name: "shell", - arguments: { command: "cargo build" }, + arguments: { command: "cargo build" } } }, }, }), envelope(2, { @@ -952,16 +964,18 @@ describe("buildStageActivity pending tools", () => { stage_id: "plan@1", node_id: "plan", properties: { - tool_call_id: "call-2", + event: { ToolCallStarted: { tool_call_id: "call-2", tool_name: "read_file", - arguments: { file_path: "/tmp/x" }, + arguments: { file_path: "/tmp/x" } } }, }, }), envelope(3, { event: "agent.tool.completed", stage_id: "plan@1", node_id: "plan", - properties: { tool_call_id: "call-1", output: "ok" }, + properties: { + event: { ToolCallCompleted: { tool_call_id: "call-1", output: "ok" } }, + }, }), ]; expect(buildStageActivity(events, "plan@1").pendingTools).toEqual([ @@ -980,9 +994,9 @@ describe("buildStageActivity pending tools", () => { stage_id: "plan@2", node_id: "plan", properties: { - tool_call_id: "call-1", + event: { ToolCallStarted: { tool_call_id: "call-1", tool_name: "shell", - arguments: {}, + arguments: {} } }, }, }), ]; @@ -995,18 +1009,18 @@ describe("buildStageActivity pending tools", () => { event: "agent.tool.started", stage_id: "plan@1", properties: { - tool_call_id: "call-1", + event: { ToolCallStarted: { tool_call_id: "call-1", tool_name: "shell", - arguments: { command: "cargo build" }, + arguments: { command: "cargo build" } } }, }, }), envelope(2, { event: "agent.tool.started", stage_id: "plan@1", properties: { - tool_call_id: "call-2", + event: { ToolCallStarted: { tool_call_id: "call-2", tool_name: "shell", - arguments: { command: "cargo test" }, + arguments: { command: "cargo test" } } }, }, }), ]; @@ -1030,21 +1044,25 @@ describe("buildStageActivity pending tools", () => { envelope(1, { event: "agent.tool.started", stage_id: "plan@1", - properties: { tool_name: "shell", arguments: { command: "ignored" } }, + properties: { + event: { ToolCallStarted: { tool_name: "shell", arguments: { command: "ignored" } } }, + }, }), envelope(2, { event: "agent.tool.started", stage_id: "plan@1", properties: { - tool_call_id: "call-1", + event: { ToolCallStarted: { tool_call_id: "call-1", tool_name: "shell", - arguments: { command: "kept" }, + arguments: { command: "kept" } } }, }, }), envelope(3, { event: "agent.tool.completed", stage_id: "plan@1", - properties: { output: "must not clear call-1" }, + properties: { + event: { ToolCallCompleted: { output: "must not clear call-1" } }, + }, }), ]; @@ -1253,9 +1271,9 @@ describe("tool-call-only agent responses", () => { stage_id: "code@1", node_id: "code", properties: { - text: "", - billing: { input_tokens: 4200, output_tokens: 96 }, - tool_call_count: 2, + event: { AssistantMessage: { text: "", + usage: { input: 4200, output: 96 }, + tool_call_count: 2 } }, }, }), ]; @@ -1279,7 +1297,9 @@ describe("tool-call-only agent responses", () => { event: "agent.message", stage_id: "code@1", node_id: "code", - properties: { text: "", tool_call_count: 1 }, + properties: { + event: { AssistantMessage: { text: "", tool_call_count: 1 } }, + }, }), envelope(2, { event: "prompt.completed", @@ -1341,10 +1361,10 @@ describe("tool batch boundaries", () => { stage_id: STAGE, node_id: "code", properties: { - text, - billing: { input_tokens: 1000, output_tokens: 20 }, - tool_call_count: toolCallCount, - }, + event: { AssistantMessage: { text, + usage: { input: 1000, output: 20 }, + tool_call_count: toolCallCount } }, + }, }); } @@ -1362,9 +1382,9 @@ describe("tool batch boundaries", () => { stage_id: STAGE, node_id: "code", properties: { - tool_call_id: callId, + event: { ToolCallStarted: { tool_call_id: callId, tool_name: "shell", - arguments: { command }, + arguments: { command } } }, }, }), envelope(seq + 1, { @@ -1372,7 +1392,9 @@ describe("tool batch boundaries", () => { ts: endTs, stage_id: STAGE, node_id: "code", - properties: { tool_call_id: callId, tool_name: "shell", output: "ok" }, + properties: { + event: { ToolCallCompleted: { tool_call_id: callId, tool_name: "shell", output: "ok" } }, + }, }), ]; } diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 0f03c6a98..586f8a14b 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -92,6 +92,7 @@ import { getNumber, getObject, getString, + isRecord, type UnknownRecord, } from "../lib/unknown"; import type { @@ -293,6 +294,20 @@ interface PendingCommand { script: string; } +/** + * The coding agent's own payload inside an `agent.*` event: `properties.event` + * is externally tagged, `{ AssistantMessage: {...} }`, so the variant's fields + * live one level down. An event with no such payload reads as empty. + */ +function agentEventPayload(props: UnknownRecord): UnknownRecord { + const event = getObject(props, "event"); + if (!event) return {}; + for (const value of Object.values(event)) { + if (isRecord(value)) return value; + } + return {}; +} + function readTurnReasoning(props: UnknownRecord): ReasoningOutput | null { const reasoning = getObject(props, "reasoning"); if (!reasoning) return null; @@ -339,15 +354,17 @@ export function buildStageActivity( // A text-free message still marks the end of a model response — it is // the boundary between two batches of tool calls. Dropping it would // splice unrelated batches into one tool group. - const billing = (props.billing ?? {}) as UnknownRecord; + const message = agentEventPayload(props); + const usage = getObject(message, "usage") ?? {}; turns.push({ kind: "assistant", ts: e.ts, - content: getString(props, "text") ?? e.text ?? "", - inputTokens: getNumber(billing, "input_tokens") ?? 0, - outputTokens: getNumber(billing, "output_tokens") ?? 0, - toolCallCount: getNumber(props, "tool_call_count") ?? null, - reasoning: readTurnReasoning(props), + content: getString(message, "text") ?? "", + inputTokens: getNumber(usage, "input") ?? 0, + outputTokens: + (getNumber(usage, "output") ?? 0) + (getNumber(usage, "reasoning") ?? 0), + toolCallCount: getNumber(message, "tool_call_count") ?? null, + reasoning: readTurnReasoning(message), }); break; } @@ -368,7 +385,7 @@ export function buildStageActivity( break; } case "agent.steering.injected": { - const text = getString(props, "text") ?? e.text ?? ""; + const text = getString(agentEventPayload(props), "text") ?? ""; if (text) { turns.push({ kind: "steer", ts: e.ts, content: text }); } @@ -403,35 +420,33 @@ export function buildStageActivity( break; } case "agent.tool.started": { - const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; + const call = agentEventPayload(props); + const callId = getString(call, "tool_call_id") ?? e.tool_call_id ?? ""; if (!callId) break; - const args = props.arguments ?? e.arguments; + const args = call.arguments; pendingTools.set(callId, { ts: e.ts, - toolName: getString(props, "tool_name") ?? e.tool_name ?? "", + toolName: getString(call, "tool_name") ?? "", input: typeof args === "string" ? args : JSON.stringify(args ?? ""), }); break; } case "agent.tool.completed": { - const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; + const call = agentEventPayload(props); + const callId = getString(call, "tool_call_id") ?? e.tool_call_id ?? ""; if (!callId) break; const started = pendingTools.get(callId); pendingTools.delete(callId); - const output = props.output ?? e.output ?? ""; + const output = call.output ?? ""; const result = typeof output === "string" ? output : JSON.stringify(output, null, 2); turns.push({ kind: "tool", ts: started?.ts ?? e.ts, - toolName: - started?.toolName ?? - getString(props, "tool_name") ?? - e.tool_name ?? - "", + toolName: started?.toolName ?? getString(call, "tool_name") ?? "", input: started?.input ?? "", result, - isError: (props.is_error ?? e.is_error) === true, + isError: call.is_error === true, durationMs: durationBetween(started?.ts, e.ts), }); break; diff --git a/apps/fabro-web/app/routes/settings-environments.tsx b/apps/fabro-web/app/routes/settings-environments.tsx index b3059ab52..fdd13aee4 100644 --- a/apps/fabro-web/app/routes/settings-environments.tsx +++ b/apps/fabro-web/app/routes/settings-environments.tsx @@ -9,7 +9,7 @@ import type { Environment } from "@qltysh/fabro-api-client"; import { ApiError, apiData, environmentsApi } from "../lib/api-client"; import { useEnvironments, useServerSettings } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; -import { CREATABLE_PROVIDERS, providerLabel } from "../lib/environment-providers"; +import { creatableProviders, providerLabel } from "../lib/environment-providers"; import { Badge, Muted, @@ -67,9 +67,7 @@ const NEW_BUTTON_CLASS = // environment's lifetime. `local` is never offered (it's reserved/in-memory). function NewEnvironmentMenu() { const { data } = useServerSettings(); - const providers = data - ? CREATABLE_PROVIDERS.filter((provider) => data.server.sandbox.providers[provider].enabled) - : []; + const providers = data ? creatableProviders(data.server.sandbox.providers) : []; if (providers.length === 0) { return ( diff --git a/apps/fabro-web/app/routes/settings-sandboxes.tsx b/apps/fabro-web/app/routes/settings-sandboxes.tsx index d51197c73..577f81866 100644 --- a/apps/fabro-web/app/routes/settings-sandboxes.tsx +++ b/apps/fabro-web/app/routes/settings-sandboxes.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { Link } from "react-router"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ComputerDesktopIcon } from "@heroicons/react/24/outline"; -import type { ServerSandboxProvidersSettings } from "@qltysh/fabro-api-client"; +import type { ServerSandboxProviderSettings } from "@qltysh/fabro-api-client"; import { useServerSettings } from "../lib/queries"; import { Dot, @@ -12,24 +12,56 @@ import { SettingsPageIntro, } from "../components/settings-panel"; import { plural } from "../lib/plural"; +import { + DAYTONA_PROVIDER, + DOCKER_PROVIDER, + LOCAL_PROVIDER, + compareProviderKinds, + providerLabel, + type ProviderSettingsMap, +} from "../lib/environment-providers"; export function meta() { return [{ title: "Sandboxes — Fabro" }]; } -type SandboxProviderId = "local" | "docker" | "daytona"; - type SandboxProvider = { - id: SandboxProviderId; + id: string; name: string; description: string; enabled: boolean; + bundled: boolean; secretName?: string; }; const DESCRIPTION = "Runtime environments where workflow stages execute. Configured via settings.toml."; +// Display copy for the providers linked into the server. Any other kind is a +// sandbox-driver plugin configured under `server.sandbox.providers.`. +const BUNDLED_PROVIDER_COPY: Record> = { + [LOCAL_PROVIDER]: { + name: "Local", + description: "Run stages directly on the Fabro host.", + }, + [DOCKER_PROVIDER]: { + name: "Docker", + description: "Run stages in isolated Docker containers on the host daemon.", + }, + [DAYTONA_PROVIDER]: { + name: "Daytona", + description: "Run stages in cloud sandboxes managed by Daytona.", + secretName: "DAYTONA_API_KEY", + }, +}; + +function pluginDescription(settings: ServerSandboxProviderSettings): string { + const path = settings.plugin?.path; + return path + ? `Sandbox plugin executable at ${path}.` + : "Sandbox plugin executable resolved from PATH."; +} + export default function SettingsSandboxes() { const query = useServerSettings(); const settings = query.data; @@ -42,29 +74,24 @@ export default function SettingsSandboxes() { ); } -function ProvidersPanel({ settings }: { settings: ServerSandboxProvidersSettings }) { +function ProvidersPanel({ settings }: { settings: ProviderSettingsMap }) { const providers: SandboxProvider[] = useMemo( - () => [ - { - id: "local", - name: "Local", - description: "Run stages directly on the Fabro host.", - enabled: settings.local.enabled, - }, - { - id: "docker", - name: "Docker", - description: "Run stages in isolated Docker containers on the host daemon.", - enabled: settings.docker.enabled, - }, - { - id: "daytona", - name: "Daytona", - description: "Run stages in cloud sandboxes managed by Daytona.", - enabled: settings.daytona.enabled, - secretName: "DAYTONA_API_KEY", - }, - ], + () => + Object.keys(settings) + .sort(compareProviderKinds) + .map((id) => { + const entry = settings[id]; + const copy = BUNDLED_PROVIDER_COPY[id]; + return copy + ? { id, enabled: entry.enabled, bundled: true, ...copy } + : { + id, + enabled: entry.enabled, + bundled: false, + name: providerLabel(id), + description: pluginDescription(entry), + }; + }), [settings], ); @@ -138,7 +165,7 @@ function ProviderLogo({ provider }: { provider: SandboxProvider }) { "grid size-10 shrink-0 place-items-center rounded-md bg-ice-50 ring-1 ring-line-strong"; const dim = provider.enabled ? "" : "opacity-60"; - if (provider.id === "local") { + if (provider.id === LOCAL_PROVIDER) { return (