From 9027f1cc67a89f344442b3d917cb20cb1fb6cc86 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 15 May 2026 10:03:53 -0400 Subject: [PATCH 01/25] docs(tutorials): clarify what sub-workflows share with the parent run The sub-workflow tutorial implied a child workflow was an "entirely separate" engine with isolated logs, but the child reuses the parent's run ID and emits into the same event stream. Reframe the section as "What's shared and what's isolated" and correct the encapsulation bullet to scope the isolation to checkpoints and artifacts. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/public/tutorials/sub-workflow.mdx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/public/tutorials/sub-workflow.mdx b/docs/public/tutorials/sub-workflow.mdx index 48873d2d3..a6d4be0b0 100644 --- a/docs/public/tutorials/sub-workflow.mdx +++ b/docs/public/tutorials/sub-workflow.mdx @@ -67,7 +67,7 @@ fabro run docs/internal/demo/12-sub-workflow.fabro ## The house node -The `impl` node has `shape=house`, which makes it a **sub-workflow node**. Instead of running an LLM or a script, it launches an entirely separate workflow engine to execute the child workflow file: +The `impl` node has `shape=house`, which makes it a **sub-workflow node**. Instead of running an LLM or a script, it launches a separate engine to execute the child workflow file: ```dot impl [label="Implement & Test", shape=house, stack.child_workflow="implement-and-test.fabro", manager.max_cycles=50] @@ -88,9 +88,11 @@ The child workflow runs through its own start → implement → validate → gat Use `stack.child_workflow` when you want to reuse the child workflow across multiple parents. Use `stack.child_dot_source` for one-off child workflows that are specific to the parent. -## Context flow +## What's shared and what's isolated -Context flows bidirectionally between parent and child: +The child shares the parent's **run ID and event stream** — its stage events appear in the same run, so `fabro events` and `fabro inspect` see parent and child interleaved. The child gets its own **checkpoints, artifacts, and run directory**, so its working state can't pollute the parent. + +Context flows bidirectionally: 1. **Parent → child:** The child receives a clone of the parent's context. In this example, the `plan` node writes `plan.md` to disk and the child's `implement` node reads it. 2. **Child → parent:** When the child finishes, any context values it added or changed are merged back into the parent. The `review` node sees the results of the child's work. @@ -115,7 +117,7 @@ The parent polls at `manager.poll_interval` (default 45 seconds). On each poll, Sub-workflows are most valuable when: - **Reusability** — the same child workflow is used by multiple parents (e.g., a standard test-and-fix loop, a deploy pipeline, a review checklist) -- **Encapsulation** — the child runs its own engine with isolated logs and checkpoints, keeping the parent's execution trace clean +- **Encapsulation** — the child has its own checkpoints and artifacts, so its working state can't pollute the parent's - **Supervisor patterns** — the parent needs to monitor or cancel a complex child process based on external conditions For simpler cases, just add more nodes to a single workflow. Sub-workflows add a layer of indirection — use them when the benefits of reuse or encapsulation justify it. From 54c0bb4ef103ff3ab594f259ccbf84b70742fcd1 Mon Sep 17 00:00:00 2001 From: "fabro-releases[bot]" Date: Sat, 16 May 2026 09:45:35 +0000 Subject: [PATCH 02/25] Bump version to 0.235.0-nightly.0 --- Cargo.lock | 92 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e389e20f..a197b510b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1580,7 +1580,7 @@ dependencies = [ [[package]] name = "fabro-acp" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", @@ -1602,7 +1602,7 @@ dependencies = [ [[package]] name = "fabro-agent" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1641,7 +1641,7 @@ dependencies = [ [[package]] name = "fabro-api" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "fabro-auth" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -1687,11 +1687,11 @@ dependencies = [ [[package]] name = "fabro-build-support" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" [[package]] name = "fabro-checkpoint" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "chrono", "fabro-config", @@ -1707,7 +1707,7 @@ dependencies = [ [[package]] name = "fabro-cli" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1808,7 +1808,7 @@ dependencies = [ [[package]] name = "fabro-client" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1837,7 +1837,7 @@ dependencies = [ [[package]] name = "fabro-config" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -1865,7 +1865,7 @@ dependencies = [ [[package]] name = "fabro-core" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "async-trait", "fabro-types", @@ -1880,7 +1880,7 @@ dependencies = [ [[package]] name = "fabro-dev" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -1899,7 +1899,7 @@ dependencies = [ [[package]] name = "fabro-devcontainer" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "fabro-http", "fabro-static", @@ -1916,7 +1916,7 @@ dependencies = [ [[package]] name = "fabro-dump" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "bytes", @@ -1930,7 +1930,7 @@ dependencies = [ [[package]] name = "fabro-github" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -1952,7 +1952,7 @@ dependencies = [ [[package]] name = "fabro-graphviz" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "fabro-types", @@ -1966,7 +1966,7 @@ dependencies = [ [[package]] name = "fabro-hooks" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "async-trait", "fabro-agent", @@ -1990,7 +1990,7 @@ dependencies = [ [[package]] name = "fabro-http" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "fabro-static", "http", @@ -2000,7 +2000,7 @@ dependencies = [ [[package]] name = "fabro-install" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2015,7 +2015,7 @@ dependencies = [ [[package]] name = "fabro-interview" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "async-trait", "dialoguer", @@ -2030,7 +2030,7 @@ dependencies = [ [[package]] name = "fabro-llm" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2063,7 +2063,7 @@ dependencies = [ [[package]] name = "fabro-macros" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "clap", "fabro-options-metadata", @@ -2074,7 +2074,7 @@ dependencies = [ [[package]] name = "fabro-manifest" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "fabro-api", @@ -2092,7 +2092,7 @@ dependencies = [ [[package]] name = "fabro-mcp" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "fabro-config", @@ -2108,7 +2108,7 @@ dependencies = [ [[package]] name = "fabro-mcp-server" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "chrono", @@ -2131,7 +2131,7 @@ dependencies = [ [[package]] name = "fabro-model" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "fabro-static", "insta", @@ -2145,7 +2145,7 @@ dependencies = [ [[package]] name = "fabro-oauth" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "axum", @@ -2167,7 +2167,7 @@ dependencies = [ [[package]] name = "fabro-options-metadata" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "serde", "serde_json", @@ -2175,7 +2175,7 @@ dependencies = [ [[package]] name = "fabro-proc" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "cc", "libc", @@ -2184,7 +2184,7 @@ dependencies = [ [[package]] name = "fabro-redact" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "aho-corasick", "ref-cast", @@ -2200,7 +2200,7 @@ dependencies = [ [[package]] name = "fabro-sandbox" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2243,7 +2243,7 @@ dependencies = [ [[package]] name = "fabro-server" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2325,7 +2325,7 @@ dependencies = [ [[package]] name = "fabro-slack" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "fabro-http", "fabro-interview", @@ -2346,18 +2346,18 @@ dependencies = [ [[package]] name = "fabro-spa" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "rust-embed", ] [[package]] name = "fabro-static" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" [[package]] name = "fabro-store" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "async-trait", "bytes", @@ -2384,7 +2384,7 @@ dependencies = [ [[package]] name = "fabro-telemetry" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "base64", @@ -2410,7 +2410,7 @@ dependencies = [ [[package]] name = "fabro-template" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "fabro-util", @@ -2422,7 +2422,7 @@ dependencies = [ [[package]] name = "fabro-test" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "assert_cmd", "axum", @@ -2445,7 +2445,7 @@ dependencies = [ [[package]] name = "fabro-tracker" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "async-trait", @@ -2459,7 +2459,7 @@ dependencies = [ [[package]] name = "fabro-types" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "chrono", "clap", @@ -2480,7 +2480,7 @@ dependencies = [ [[package]] name = "fabro-util" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "console 0.15.11", @@ -2500,7 +2500,7 @@ dependencies = [ [[package]] name = "fabro-validate" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "fabro-graphviz", "fabro-model", @@ -2512,7 +2512,7 @@ dependencies = [ [[package]] name = "fabro-vault" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "chrono", "fabro-types", @@ -2524,7 +2524,7 @@ dependencies = [ [[package]] name = "fabro-workflow" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "assert_cmd", @@ -7301,7 +7301,7 @@ dependencies = [ [[package]] name = "twin-github" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "axum", "base64", @@ -7320,7 +7320,7 @@ dependencies = [ [[package]] name = "twin-openai" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" dependencies = [ "anyhow", "async-stream", diff --git a/Cargo.toml b/Cargo.toml index 1ccdb3caa..6a510c6fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.234.0-nightly.0" +version = "0.235.0-nightly.0" license = "MIT" [workspace.dependencies] From 9768651b523c748ae9c716bc64348e69f4eef947 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 05:18:21 -0700 Subject: [PATCH 03/25] feat(model): add opt-in Ollama catalog provider (#268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds Ollama as a disabled-by-default built-in catalog provider backed entirely by provider TOML. Enabling `[llm.providers.ollama] enabled = true` exposes the bundled `qwen3-coder` sample model through the existing OpenAI-compatible adapter, while other local Ollama models still require explicit model blocks until fabro-sh/fabro#267 adds discovery. The docs now show the opt-in setting and note that local users can set `OLLAMA_API_KEY=ollama` for Ollama's OpenAI-compatible endpoint. ## Verification - `cargo nextest run -p fabro-model` - `cargo nextest run -p fabro-cli cmd::model` - `cargo +nightly-2026-04-14 fmt --check --all` --- [![Compound Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 via [Codex](https://openai.com/codex) --------- Co-authored-by: roALAB1 <233429779+roALAB1@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- docs/public/core-concepts/models.mdx | 11 +++++++ lib/crates/fabro-cli/tests/it/cmd/model.rs | 8 ++--- lib/crates/fabro-client/src/client.rs | 11 ++----- lib/crates/fabro-model/src/catalog.rs | 29 +++++++++++++++++++ .../src/catalog/providers/ollama.toml | 26 +++++++++++++++++ 5 files changed, 73 insertions(+), 12 deletions(-) create mode 100644 lib/crates/fabro-model/src/catalog/providers/ollama.toml diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index 71b469545..1b52c0d58 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -98,6 +98,17 @@ Header values and credentials are typed references, not raw secrets. Use `env: +### Ollama + +Fabro ships an Ollama provider definition that is disabled by default. Enable it in settings when you want Fabro to route through a local Ollama server: + +```toml title="settings.toml" +[llm.providers.ollama] +enabled = true +``` + +Enabling the provider alone does not expose any models — until #267 adds auto-discovery, add explicit `[llm.models.]` blocks for each Ollama model you have pulled locally. Ollama's OpenAI-compatible endpoint accepts any bearer token, so local users can set `OLLAMA_API_KEY=ollama`. + ## Default models When no model or provider is specified, Fabro checks configured provider credentials and chooses the first configured provider by catalog priority. If no provider credentials are configured, it uses the catalog's global default model. Each provider has a default model: diff --git a/lib/crates/fabro-cli/tests/it/cmd/model.rs b/lib/crates/fabro-cli/tests/it/cmd/model.rs index a725d42e2..94d418d41 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model.rs @@ -144,16 +144,16 @@ fn list_query_case_insensitive() { } #[test] -fn list_invalid_provider_errors() { +fn list_unknown_provider_returns_empty() { let context = test_context!(); let mut cmd = context.model(); cmd.args(["list", "--provider", "not-a-provider"]); fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 + success: true + exit_code: 0 ----- stdout ----- + MODEL PROVIDER ALIASES CONTEXT COST SPEED ----- stderr ----- - × unknown provider: not-a-provider "); } diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index 3654fd740..edc020338 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -9,7 +9,7 @@ use bytes::Bytes; use fabro_api::types; use fabro_http::header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE}; use fabro_http::multipart::{Form, Part}; -use fabro_model::{Model, ModelTestMode, Provider}; +use fabro_model::{Model, ModelTestMode, ProviderId}; use fabro_types::settings::run::MergeStrategy; use fabro_types::{ ArtifactUpload, EventEnvelope, RunBlobId, RunEvent, RunId, RunProjection, RunSummary, StageId, @@ -608,17 +608,12 @@ impl Client { provider: Option<&str>, query: Option<&str>, ) -> Result> { - let provider = provider - .map(|provider| { - provider - .parse::() - .map_err(|_| anyhow!("unknown provider: {provider}")) - }) - .transpose()?; + let provider = provider.map(ProviderId::new); let mut offset = 0u64; let mut models = Vec::new(); loop { + let provider = provider.clone(); let response = self .send_api(|client| async move { let mut request = client.list_models().page_limit(100u64).page_offset(offset); diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index 06dd0f054..a8542ad26 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -1596,6 +1596,35 @@ effort = false assert_eq!(model.provider, ProviderId::new("acme")); } + #[test] + fn builtin_ollama_provider_is_opt_in() { + let ollama = ProviderId::new("ollama"); + let builtin = Catalog::builtin(); + + assert!(builtin.provider(&ollama).is_none()); + assert!(builtin.list(Some(&ollama)).is_empty()); + + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r" +[providers.ollama] +enabled = true +", + )) + .expect("enabled Ollama override should build from the built-in provider settings"); + + let provider = catalog + .provider(&ollama) + .expect("enabled Ollama provider should be present"); + assert_eq!(provider.adapter, "openai_compatible"); + assert_eq!( + provider.base_url.as_deref(), + Some("http://localhost:11434/v1") + ); + + assert!(catalog.list(Some(&ollama)).is_empty()); + assert!(catalog.default_for_provider(&ollama).is_none()); + } + #[test] fn builtin_get_by_id() { let m = Catalog::builtin().get("claude-opus-4-6").unwrap(); diff --git a/lib/crates/fabro-model/src/catalog/providers/ollama.toml b/lib/crates/fabro-model/src/catalog/providers/ollama.toml new file mode 100644 index 000000000..c3be4ed37 --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/ollama.toml @@ -0,0 +1,26 @@ +[providers.ollama] +display_name = "Ollama" +adapter = "openai_compatible" +base_url = "http://localhost:11434/v1" +credentials = ["credential:ollama", "env:OLLAMA_API_KEY"] +priority = 30 +enabled = false + +# Example model. Uncomment after `ollama pull qwen3.5` (and `enabled = true` +# above) to expose it through the OpenAI-compatible adapter. +# +# [models."qwen3.5"] +# provider = "ollama" +# api_id = "qwen3.5:latest" +# display_name = "Qwen3.5" +# family = "qwen3.5" +# default = true +# aliases = ["ollama-qwen3.5"] +# +# [models."qwen3.5".limits] +# context_window = 32768 +# +# [models."qwen3.5".features] +# tools = true +# vision = false +# reasoning = false From d52a2ccbe2df498fec0670b9773864ee1fe106e3 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 05:36:31 -0700 Subject: [PATCH 04/25] feat: add opt-in LiteLLM TOML provider (#269) ## Summary - Add a disabled built-in `litellm` provider fragment backed by the OpenAI-compatible adapter and local proxy defaults. - Document how to enable LiteLLM in `settings.toml`, configure credentials, and declare explicit LiteLLM-routed models. - Register the LiteLLM integration page and cross-link it from the model and settings docs. ## Validation - `cargo test -p fabro-model` - `cargo test -p fabro-config` - `jq empty docs/public/docs.json` - `rg -n 'aliases = \["openai_compatible", "openai-compatible"\]|llm\.discovery|FABRO_LITELLM|litellm_api_key_env|x-litellm-' lib/crates/fabro-model/src/catalog/providers/litellm.toml docs/public/integrations/litellm.mdx docs/public/core-concepts/models.mdx docs/public/reference/user-configuration.mdx` returned no matches --------- Co-authored-by: Mark Ferraz --- docs/public/core-concepts/models.mdx | 24 ++++ docs/public/docs.json | 1 + docs/public/integrations/litellm.mdx | 133 ++++++++++++++++++ .../src/catalog/providers/litellm.toml | 29 ++++ 4 files changed, 187 insertions(+) create mode 100644 docs/public/integrations/litellm.mdx create mode 100644 lib/crates/fabro-model/src/catalog/providers/litellm.toml diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index 1b52c0d58..ed31b4377 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -90,6 +90,30 @@ output_cost_per_mtok = 16.00 cache_input_cost_per_mtok = 0.60 ``` +For [LiteLLM](/integrations/litellm), Fabro ships a disabled provider entry. Enable it in settings and declare the models your proxy exposes: + +```toml title="settings.toml" +[llm.providers.litellm] +enabled = true +base_url = "http://localhost:4000/v1" + +[llm.models."litellm-gpt-5"] +provider = "litellm" +api_id = "gpt-5" +display_name = "LiteLLM GPT-5" +family = "litellm" +default = true + +[llm.models."litellm-gpt-5".limits] +context_window = 128000 +max_output = 8192 + +[llm.models."litellm-gpt-5".features] +tools = true +vision = false +reasoning = false +``` + `api_id` is the model name sent to the provider API. Omit it when the Fabro model ID and provider model ID are the same. Header values and credentials are typed references, not raw secrets. Use `env:` or `credential:` for provider credentials, and `{ env = "NAME" }`, `{ credential = "id" }`, or `{ literal = "value" }` for extra headers. diff --git a/docs/public/docs.json b/docs/public/docs.json index fc3d932dc..977006732 100644 --- a/docs/public/docs.json +++ b/docs/public/docs.json @@ -93,6 +93,7 @@ "pages": [ "integrations/github", "integrations/daytona", + "integrations/litellm", "integrations/slack", "integrations/brave-search" ] diff --git a/docs/public/integrations/litellm.mdx b/docs/public/integrations/litellm.mdx new file mode 100644 index 000000000..f39712079 --- /dev/null +++ b/docs/public/integrations/litellm.mdx @@ -0,0 +1,133 @@ +--- +title: "LiteLLM" +description: "Route Fabro models through a LiteLLM proxy" +--- + +[LiteLLM](https://docs.litellm.ai/) can run as an OpenAI-compatible proxy in front of many model providers. Fabro includes a disabled `litellm` provider entry so you can opt in from `settings.toml` without changing Fabro code. + +## Prerequisites + +- A running LiteLLM proxy reachable from the Fabro process +- At least one LiteLLM model name you want Fabro to route to +- A LiteLLM key or placeholder key available to Fabro + +Fabro's built-in LiteLLM provider points at `http://localhost:4000/v1`. Change `base_url` if your proxy is hosted elsewhere. + +## Enable the provider + +Add the provider override and one or more model entries to `~/.fabro/settings.toml`: + +```toml title="settings.toml" +_version = 1 + +[llm.providers.litellm] +enabled = true +base_url = "http://localhost:4000/v1" + +[llm.models."litellm-gpt-5"] +provider = "litellm" +api_id = "gpt-5" +display_name = "LiteLLM GPT-5" +family = "litellm" +default = true + +[llm.models."litellm-gpt-5".limits] +context_window = 128000 +max_output = 8192 + +[llm.models."litellm-gpt-5".features] +tools = true +vision = false +reasoning = false +``` + +`api_id` is the model name Fabro sends to LiteLLM. It should match a model name configured in your LiteLLM proxy. + +## Configure credentials + +The LiteLLM provider checks `credential:litellm` first, then `LITELLM_API_KEY` from the Fabro process environment. + +For a server-owned secret: + +```bash +fabro secret set litellm sk-proxy-key +``` + +For a process environment variable: + +```bash +export LITELLM_API_KEY=sk-proxy-key +``` + +If your local LiteLLM proxy does not enforce authentication, use a placeholder value such as `anything`; the OpenAI-compatible client still needs a credential value. + +## Use LiteLLM models + +Once the provider is enabled and at least one model is declared, use the Fabro model ID like any other catalog model: + +```bash +fabro model list --provider litellm +fabro model test --model litellm-gpt-5 +fabro run workflow.fabro --model litellm-gpt-5 +``` + +In workflow stylesheets: + +```dot title="workflow.fabro" +digraph Example { + graph [ + model_stylesheet=" + * { model: litellm-gpt-5; } + " + ] + + start [shape=Mdiamond, label="Start"] + work [label="Work", prompt="Use the configured LiteLLM model."] + exit [shape=Msquare, label="Exit"] + + start -> work -> exit +} +``` + +## Declaring more models + +Declare each LiteLLM-routed model explicitly so Fabro knows its provider, context window, tool support, and routing defaults: + +```toml title="settings.toml" +[llm.models."litellm-fast"] +provider = "litellm" +api_id = "fast-model" +display_name = "LiteLLM Fast" +family = "litellm" +aliases = ["fast"] + +[llm.models."litellm-fast".limits] +context_window = 64000 +max_output = 4096 + +[llm.models."litellm-fast".features] +tools = true +vision = false +reasoning = false +``` + +Only one model for a provider should set `default = true`. + +## Troubleshooting + +**"No API key configured"** — Set `credential:litellm` with `fabro secret set litellm ...` or export `LITELLM_API_KEY` in the Fabro process environment. + +**Connection refused** — Confirm the LiteLLM proxy is running and that `base_url` is reachable from the Fabro process. For Docker deployments, `localhost` means the Fabro container unless you point it at a host or service name. + +**Unknown model from LiteLLM** — Check that the model's `api_id` matches the model name configured in LiteLLM, then run `fabro model test --model `. + +## Further reading + + + + How Fabro routes model IDs, providers, and fallbacks. + + + Full reference for `[llm.providers.]` and `[llm.models.]`. + + diff --git a/lib/crates/fabro-model/src/catalog/providers/litellm.toml b/lib/crates/fabro-model/src/catalog/providers/litellm.toml new file mode 100644 index 000000000..3acb6e852 --- /dev/null +++ b/lib/crates/fabro-model/src/catalog/providers/litellm.toml @@ -0,0 +1,29 @@ +[providers.litellm] +display_name = "LiteLLM" +adapter = "openai_compatible" +base_url = "http://localhost:4000/v1" +credentials = ["credential:litellm", "env:LITELLM_API_KEY"] +priority = 50 +enabled = false + +# To enable LiteLLM, add entries like these to settings.toml: +# +# [llm.providers.litellm] +# enabled = true +# base_url = "http://localhost:4000/v1" +# +# [llm.models."litellm-gpt-5"] +# provider = "litellm" +# api_id = "gpt-5" +# display_name = "LiteLLM GPT-5" +# family = "litellm" +# default = true +# +# [llm.models."litellm-gpt-5".limits] +# context_window = 128000 +# max_output = 8192 +# +# [llm.models."litellm-gpt-5".features] +# tools = true +# vision = false +# reasoning = false From be993cb608b1b1a8bb50de20f8ae8b2d177a7642 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 05:37:00 -0700 Subject: [PATCH 05/25] feat(server): expose health check at /api/v1/health (#279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Mount the existing `/health` handler at `/api/v1/health` so callers using a uniform `/api/v1` base no longer have to special-case the root path. The root `/health` route is unchanged and remains the canonical probe target. - Add the new path to the OpenAPI spec (`operationId: getApiHealth`, `Discovery` tag, reusing `HealthResponse`), and regenerate the TypeScript client so `DiscoveryApi.getApiHealth()` is exposed alongside `getHealth()`. - Split the old `moved_routes_not_at_root_of_api_prefix` test into a focused `api_v1_root_is_not_routed` and a new `health_responds_at_versioned_path` that asserts `200` + `{"status":"ok"}` under the versioned prefix. ## Test plan - [x] `cargo build --workspace` (verifies the OpenAPI spec regenerates cleanly via `fabro-api` build.rs) - [x] `cargo nextest run -p fabro-server` (545 tests pass, including OpenAPI conformance and the new routing assertions) - [x] `cd lib/packages/fabro-api-client && bun run generate` (regenerated client exposes `getApiHealth`) - [ ] Manual: `fabro server start` then `curl -s http://localhost:/api/v1/health` and `curl -s http://localhost:/health` both return `{"status":"ok"}` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) --- docs/public/api-reference/fabro-api.yaml | 17 +++ .../fabro-server/src/server/handler/system.rs | 1 + .../fabro-server/tests/it/api/routing.rs | 32 +++-- .../fabro-api-client/src/api/auth-api.ts | 27 ++-- .../fabro-api-client/src/api/discovery-api.ts | 61 +++++++++ .../src/api/human-in-the-loop-api.ts | 91 ++++++------- .../fabro-api-client/src/api/models-api.ts | 3 +- .../src/api/run-outputs-api.ts | 18 +-- .../fabro-api-client/src/api/runs-api.ts | 122 +++++++++--------- .../src/models/auth-session.ts | 4 +- .../src/models/auth-sessions-response.ts | 3 +- .../src/models/automation-ref.ts | 3 +- .../src/models/billing-by-model.ts | 3 +- .../src/models/billing-model-ref.ts | 5 +- .../src/models/billing-speed.ts | 5 +- .../src/models/checkpoint-record.ts | 3 +- .../fabro-api-client/src/models/conclusion.ts | 5 +- .../src/models/daytona-settings.ts | 3 +- .../src/models/delete-run-response.ts | 3 +- .../src/models/delete-run-sandbox.ts | 5 +- .../src/models/docker-settings.ts | 3 +- .../src/models/model-features.ts | 5 +- .../fabro-api-client/src/models/model.ts | 5 +- .../src/models/paginated-board-run-list.ts | 3 +- .../src/models/paginated-run-commit-list.ts | 3 +- .../src/models/paginated-run-file-list.ts | 5 +- .../src/models/paginated-run-list.ts | 3 +- .../src/models/pending-interview-record.ts | 3 +- .../models/pull-request-details-timestamps.ts | 3 +- .../src/models/pull-request-details.ts | 3 +- .../src/models/pull-request.ts | 4 +- .../src/models/reasoning-effort-feature.ts | 5 +- .../src/models/repository-ref.ts | 4 +- .../src/models/run-billing-stage.ts | 5 +- .../src/models/run-billing-summary.ts | 3 +- .../src/models/run-branch-settings.ts | 3 +- .../src/models/run-clone-settings.ts | 3 +- .../src/models/run-commit-parent.ts | 3 +- .../src/models/run-commit-person.ts | 3 +- .../fabro-api-client/src/models/run-commit.ts | 3 +- .../src/models/run-commits-meta.ts | 4 +- .../fabro-api-client/src/models/run-diff.ts | 3 +- .../src/models/run-lifecycle.ts | 5 +- .../fabro-api-client/src/models/run-links.ts | 3 +- .../src/models/run-meta-branch-settings.ts | 3 +- .../fabro-api-client/src/models/run-model.ts | 3 +- .../src/models/run-namespace.ts | 3 +- .../fabro-api-client/src/models/run-origin.ts | 4 +- .../src/models/run-projection.ts | 5 +- .../src/models/run-sandbox-runtime.ts | 5 +- .../src/models/run-sandbox-settings.ts | 5 +- .../src/models/run-sandbox.ts | 5 +- .../fabro-api-client/src/models/run-spec.ts | 3 +- .../fabro-api-client/src/models/run-status.ts | 6 +- .../src/models/run-timestamps.ts | 3 +- .../fabro-api-client/src/models/run.ts | 3 +- .../src/models/sandbox-details.ts | 5 +- .../src/models/sandbox-provider.ts | 5 +- .../src/models/sandbox-resources.ts | 3 +- .../sandbox-service-discovery-source.ts | 5 +- .../src/models/sandbox-service-list-meta.ts | 5 +- .../models/sandbox-service-list-response.ts | 3 +- .../src/models/sandbox-service.ts | 3 +- .../src/models/sandbox-state.ts | 5 +- .../src/models/sandbox-timestamps.ts | 3 +- .../src/models/ssh-access-request.ts | 3 +- .../src/models/ssh-access-response.ts | 3 +- .../src/models/stage-projection.ts | 5 +- .../src/models/stage-summary.ts | 3 +- .../src/models/start-record.ts | 3 +- .../src/models/vnc-preview-response.ts | 3 +- .../src/models/workflow-ref.ts | 3 +- 72 files changed, 404 insertions(+), 205 deletions(-) diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 52f2e5bbd..d17032350 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -554,6 +554,23 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/health: + get: + operationId: getApiHealth + tags: [Discovery] + summary: Health Check (API) + description: > + Returns service health status under the versioned API prefix. Mirrors + `/health` for callers that prefer a uniform `/api/v1` base. + security: [] + responses: + "200": + description: Service is healthy + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + /api/v1/health/diagnostics: post: operationId: runDiagnostics diff --git a/lib/crates/fabro-server/src/server/handler/system.rs b/lib/crates/fabro-server/src/server/handler/system.rs index 785f86d11..c7f18cd4a 100644 --- a/lib/crates/fabro-server/src/server/handler/system.rs +++ b/lib/crates/fabro-server/src/server/handler/system.rs @@ -13,6 +13,7 @@ use super::super::{ pub(super) fn routes() -> Router> { Router::new() .route("/repos/github/{owner}/{name}", get(get_github_repo)) + .route("/health", get(health)) .route("/health/diagnostics", post(run_diagnostics)) .route("/settings", get(get_server_settings)) .route("/system/info", get(get_system_info)) diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index af7903d51..c3793295c 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -112,18 +112,30 @@ async fn install_routes_are_absent_in_normal_mode() { } #[tokio::test] -async fn moved_routes_not_at_root_of_api_prefix() { +async fn api_v1_root_is_not_routed() { let app = fabro_server::test_support::build_test_router(test_app_state()); - for path in ["/api/v1/health", "/api/v1/"] { - let req = Request::builder() - .method("GET") - .uri(path) - .body(Body::empty()) - .unwrap(); - let response = app.clone().oneshot(req).await.unwrap(); - response_status(response, StatusCode::NOT_FOUND, format!("GET {path}")).await; - } + let req = Request::builder() + .method("GET") + .uri("/api/v1/") + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + response_status(response, StatusCode::NOT_FOUND, "GET /api/v1/").await; +} + +#[tokio::test] +async fn health_responds_at_versioned_path() { + let app = fabro_server::test_support::build_test_router(test_app_state()); + + let req = Request::builder() + .method("GET") + .uri("/api/v1/health") + .body(Body::empty()) + .unwrap(); + let response = app.oneshot(req).await.unwrap(); + let body = response_json(response, StatusCode::OK, "GET /api/v1/health").await; + assert_eq!(body["status"], "ok"); } #[tokio::test] diff --git a/lib/packages/fabro-api-client/src/api/auth-api.ts b/lib/packages/fabro-api-client/src/api/auth-api.ts index 20a55a4fa..afc5bd1e0 100644 --- a/lib/packages/fabro-api-client/src/api/auth-api.ts +++ b/lib/packages/fabro-api-client/src/api/auth-api.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -45,7 +45,7 @@ export const AuthApiAxiosParamCreator = function (configuration?: Configuration) /** * Revokes an active CLI session chain. Browser sessions are not revocable in this API version. * @summary Revoke an authenticated session - * @param {string} id + * @param {string} id * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -187,7 +187,7 @@ export const AuthApiAxiosParamCreator = function (configuration?: Configuration) /** * Creates a browser session from an enabled development token. * @summary Login with development token - * @param {DevTokenLoginRequest} devTokenLoginRequest + * @param {DevTokenLoginRequest} devTokenLoginRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -222,7 +222,7 @@ export const AuthApiAxiosParamCreator = function (configuration?: Configuration) /** * Enables or disables demo-mode routing for the current browser session. * @summary Toggle browser demo mode - * @param {DemoToggleRequest} demoToggleRequest + * @param {DemoToggleRequest} demoToggleRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -272,7 +272,7 @@ export const AuthApiFp = function(configuration?: Configuration) { /** * Revokes an active CLI session chain. Browser sessions are not revocable in this API version. * @summary Revoke an authenticated session - * @param {string} id + * @param {string} id * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -321,7 +321,7 @@ export const AuthApiFp = function(configuration?: Configuration) { /** * Creates a browser session from an enabled development token. * @summary Login with development token - * @param {DevTokenLoginRequest} devTokenLoginRequest + * @param {DevTokenLoginRequest} devTokenLoginRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -334,7 +334,7 @@ export const AuthApiFp = function(configuration?: Configuration) { /** * Enables or disables demo-mode routing for the current browser session. * @summary Toggle browser demo mode - * @param {DemoToggleRequest} demoToggleRequest + * @param {DemoToggleRequest} demoToggleRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -356,7 +356,7 @@ export const AuthApiFactory = function (configuration?: Configuration, basePath? /** * Revokes an active CLI session chain. Browser sessions are not revocable in this API version. * @summary Revoke an authenticated session - * @param {string} id + * @param {string} id * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -393,7 +393,7 @@ export const AuthApiFactory = function (configuration?: Configuration, basePath? /** * Creates a browser session from an enabled development token. * @summary Login with development token - * @param {DevTokenLoginRequest} devTokenLoginRequest + * @param {DevTokenLoginRequest} devTokenLoginRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -403,7 +403,7 @@ export const AuthApiFactory = function (configuration?: Configuration, basePath? /** * Enables or disables demo-mode routing for the current browser session. * @summary Toggle browser demo mode - * @param {DemoToggleRequest} demoToggleRequest + * @param {DemoToggleRequest} demoToggleRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -420,7 +420,7 @@ export class AuthApi extends BaseAPI { /** * Revokes an active CLI session chain. Browser sessions are not revocable in this API version. * @summary Revoke an authenticated session - * @param {string} id + * @param {string} id * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -461,7 +461,7 @@ export class AuthApi extends BaseAPI { /** * Creates a browser session from an enabled development token. * @summary Login with development token - * @param {DevTokenLoginRequest} devTokenLoginRequest + * @param {DevTokenLoginRequest} devTokenLoginRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -472,7 +472,7 @@ export class AuthApi extends BaseAPI { /** * Enables or disables demo-mode routing for the current browser session. * @summary Toggle browser demo mode - * @param {DemoToggleRequest} demoToggleRequest + * @param {DemoToggleRequest} demoToggleRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -480,3 +480,4 @@ export class AuthApi extends BaseAPI { return AuthApiFp(this.configuration).toggleDemo(demoToggleRequest, options).then((request) => request(this.axios, this.basePath)); } } + diff --git a/lib/packages/fabro-api-client/src/api/discovery-api.ts b/lib/packages/fabro-api-client/src/api/discovery-api.ts index e8f950075..a201b9be7 100644 --- a/lib/packages/fabro-api-client/src/api/discovery-api.ts +++ b/lib/packages/fabro-api-client/src/api/discovery-api.ts @@ -36,6 +36,36 @@ import type { UserResponse } from '../models'; */ export const DiscoveryApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * Returns service health status under the versioned API prefix. Mirrors `/health` for callers that prefer a uniform `/api/v1` base. + * @summary Health Check (API) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getApiHealth: async (options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/api/v1/health`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Returns service health status. Used by load balancers and monitoring. * @summary Health Check @@ -207,6 +237,18 @@ export const DiscoveryApiAxiosParamCreator = function (configuration?: Configura export const DiscoveryApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = DiscoveryApiAxiosParamCreator(configuration) return { + /** + * Returns service health status under the versioned API prefix. Mirrors `/health` for callers that prefer a uniform `/api/v1` base. + * @summary Health Check (API) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getApiHealth(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getApiHealth(options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['DiscoveryApi.getApiHealth']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Returns service health status. Used by load balancers and monitoring. * @summary Health Check @@ -276,6 +318,15 @@ export const DiscoveryApiFp = function(configuration?: Configuration) { export const DiscoveryApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = DiscoveryApiFp(configuration) return { + /** + * Returns service health status under the versioned API prefix. Mirrors `/health` for callers that prefer a uniform `/api/v1` base. + * @summary Health Check (API) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getApiHealth(options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getApiHealth(options).then((request) => request(axios, basePath)); + }, /** * Returns service health status. Used by load balancers and monitoring. * @summary Health Check @@ -328,6 +379,16 @@ export const DiscoveryApiFactory = function (configuration?: Configuration, base * DiscoveryApi - object-oriented interface */ export class DiscoveryApi extends BaseAPI { + /** + * Returns service health status under the versioned API prefix. Mirrors `/health` for callers that prefer a uniform `/api/v1` base. + * @summary Health Check (API) + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getApiHealth(options?: RawAxiosRequestConfig) { + return DiscoveryApiFp(this.configuration).getApiHealth(options).then((request) => request(this.axios, this.basePath)); + } + /** * Returns service health status. Used by load balancers and monitoring. * @summary Health Check diff --git a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts index f3977a508..1b299db54 100644 --- a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts +++ b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -54,7 +54,7 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf * Creates a command for connecting to the run\'s sandbox environment. Daytona runs return a time-limited SSH command; Docker runs return a local docker exec command. * @summary Sandbox Access Command * @param {string} id Unique run identifier (ULID). - * @param {SshAccessRequest} sshAccessRequest + * @param {SshAccessRequest} sshAccessRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -139,7 +139,7 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL * @param {string} id Unique run identifier (ULID). - * @param {PreviewUrlRequest} previewUrlRequest + * @param {PreviewUrlRequest} previewUrlRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -184,7 +184,7 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf * Downloads a file from the run\'s sandbox environment. * @summary Download Sandbox File * @param {string} id Unique run identifier (ULID). - * @param {string} path + * @param {string} path * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -228,7 +228,7 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf }; }, /** - * Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round. + * Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round. * @summary Interrupt Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -321,8 +321,8 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf * Lists directory entries from the run\'s sandbox environment. * @summary List Sandbox Files * @param {string} id Unique run identifier (ULID). - * @param {string} path - * @param {number} [depth] + * @param {string} path + * @param {number} [depth] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -413,8 +413,8 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf * Uploads a file into the run\'s sandbox environment. * @summary Upload Sandbox File * @param {string} id Unique run identifier (ULID). - * @param {string} path - * @param {File} body + * @param {string} path + * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -502,10 +502,10 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf }; }, /** - * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session. + * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session. * @summary Steer Run * @param {string} id Unique run identifier (ULID). - * @param {SteerRunRequest} steerRunRequest + * @param {SteerRunRequest} steerRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -551,7 +551,7 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf * @summary Submit Run Answer * @param {string} id Unique run identifier (ULID). * @param {string} qid Unique identifier of a pending question. - * @param {SubmitAnswerRequest} submitAnswerRequest + * @param {SubmitAnswerRequest} submitAnswerRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -608,7 +608,7 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { * Creates a command for connecting to the run\'s sandbox environment. Daytona runs return a time-limited SSH command; Docker runs return a local docker exec command. * @summary Sandbox Access Command * @param {string} id Unique run identifier (ULID). - * @param {SshAccessRequest} sshAccessRequest + * @param {SshAccessRequest} sshAccessRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -635,7 +635,7 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL * @param {string} id Unique run identifier (ULID). - * @param {PreviewUrlRequest} previewUrlRequest + * @param {PreviewUrlRequest} previewUrlRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -649,7 +649,7 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { * Downloads a file from the run\'s sandbox environment. * @summary Download Sandbox File * @param {string} id Unique run identifier (ULID). - * @param {string} path + * @param {string} path * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -660,7 +660,7 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round. + * Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round. * @summary Interrupt Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -691,8 +691,8 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { * Lists directory entries from the run\'s sandbox environment. * @summary List Sandbox Files * @param {string} id Unique run identifier (ULID). - * @param {string} path - * @param {number} [depth] + * @param {string} path + * @param {number} [depth] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -719,8 +719,8 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { * Uploads a file into the run\'s sandbox environment. * @summary Upload Sandbox File * @param {string} id Unique run identifier (ULID). - * @param {string} path - * @param {File} body + * @param {string} path + * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -744,10 +744,10 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session. + * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session. * @summary Steer Run * @param {string} id Unique run identifier (ULID). - * @param {SteerRunRequest} steerRunRequest + * @param {SteerRunRequest} steerRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -762,7 +762,7 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { * @summary Submit Run Answer * @param {string} id Unique run identifier (ULID). * @param {string} qid Unique identifier of a pending question. - * @param {SubmitAnswerRequest} submitAnswerRequest + * @param {SubmitAnswerRequest} submitAnswerRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -785,7 +785,7 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, * Creates a command for connecting to the run\'s sandbox environment. Daytona runs return a time-limited SSH command; Docker runs return a local docker exec command. * @summary Sandbox Access Command * @param {string} id Unique run identifier (ULID). - * @param {SshAccessRequest} sshAccessRequest + * @param {SshAccessRequest} sshAccessRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -806,7 +806,7 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL * @param {string} id Unique run identifier (ULID). - * @param {PreviewUrlRequest} previewUrlRequest + * @param {PreviewUrlRequest} previewUrlRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -817,7 +817,7 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, * Downloads a file from the run\'s sandbox environment. * @summary Download Sandbox File * @param {string} id Unique run identifier (ULID). - * @param {string} path + * @param {string} path * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -825,7 +825,7 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, return localVarFp.getSandboxFile(id, path, options).then((request) => request(axios, basePath)); }, /** - * Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round. + * Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round. * @summary Interrupt Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -850,8 +850,8 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, * Lists directory entries from the run\'s sandbox environment. * @summary List Sandbox Files * @param {string} id Unique run identifier (ULID). - * @param {string} path - * @param {number} [depth] + * @param {string} path + * @param {number} [depth] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -872,8 +872,8 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, * Uploads a file into the run\'s sandbox environment. * @summary Upload Sandbox File * @param {string} id Unique run identifier (ULID). - * @param {string} path - * @param {File} body + * @param {string} path + * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -891,10 +891,10 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, return localVarFp.retrieveRunSandbox(id, options).then((request) => request(axios, basePath)); }, /** - * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session. + * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session. * @summary Steer Run * @param {string} id Unique run identifier (ULID). - * @param {SteerRunRequest} steerRunRequest + * @param {SteerRunRequest} steerRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -906,7 +906,7 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, * @summary Submit Run Answer * @param {string} id Unique run identifier (ULID). * @param {string} qid Unique identifier of a pending question. - * @param {SubmitAnswerRequest} submitAnswerRequest + * @param {SubmitAnswerRequest} submitAnswerRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -924,7 +924,7 @@ export class HumanInTheLoopApi extends BaseAPI { * Creates a command for connecting to the run\'s sandbox environment. Daytona runs return a time-limited SSH command; Docker runs return a local docker exec command. * @summary Sandbox Access Command * @param {string} id Unique run identifier (ULID). - * @param {SshAccessRequest} sshAccessRequest + * @param {SshAccessRequest} sshAccessRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -947,7 +947,7 @@ export class HumanInTheLoopApi extends BaseAPI { * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL * @param {string} id Unique run identifier (ULID). - * @param {PreviewUrlRequest} previewUrlRequest + * @param {PreviewUrlRequest} previewUrlRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -959,7 +959,7 @@ export class HumanInTheLoopApi extends BaseAPI { * Downloads a file from the run\'s sandbox environment. * @summary Download Sandbox File * @param {string} id Unique run identifier (ULID). - * @param {string} path + * @param {string} path * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -968,7 +968,7 @@ export class HumanInTheLoopApi extends BaseAPI { } /** - * Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round. + * Interrupt the active API-mode agent round without sending steering text. The agent keeps its steering lease and waits for a later steer message before starting another LLM round. * @summary Interrupt Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -995,8 +995,8 @@ export class HumanInTheLoopApi extends BaseAPI { * Lists directory entries from the run\'s sandbox environment. * @summary List Sandbox Files * @param {string} id Unique run identifier (ULID). - * @param {string} path - * @param {number} [depth] + * @param {string} path + * @param {number} [depth] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1019,8 +1019,8 @@ export class HumanInTheLoopApi extends BaseAPI { * Uploads a file into the run\'s sandbox environment. * @summary Upload Sandbox File * @param {string} id Unique run identifier (ULID). - * @param {string} path - * @param {File} body + * @param {string} path + * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1040,10 +1040,10 @@ export class HumanInTheLoopApi extends BaseAPI { } /** - * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session. + * Send a mid-run steering message to the live agent session(s) of a running run. Set `interrupt=true` to atomically interrupt the active API-mode agent round first, then deliver this message as the next user turn. Without `interrupt=true`, the message is appended to the steering queue and may buffer until the next API-mode agent session. * @summary Steer Run * @param {string} id Unique run identifier (ULID). - * @param {SteerRunRequest} steerRunRequest + * @param {SteerRunRequest} steerRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1056,7 +1056,7 @@ export class HumanInTheLoopApi extends BaseAPI { * @summary Submit Run Answer * @param {string} id Unique run identifier (ULID). * @param {string} qid Unique identifier of a pending question. - * @param {SubmitAnswerRequest} submitAnswerRequest + * @param {SubmitAnswerRequest} submitAnswerRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1064,3 +1064,4 @@ export class HumanInTheLoopApi extends BaseAPI { return HumanInTheLoopApiFp(this.configuration).submitRunAnswer(id, qid, submitAnswerRequest, options).then((request) => request(this.axios, this.basePath)); } } + diff --git a/lib/packages/fabro-api-client/src/api/models-api.ts b/lib/packages/fabro-api-client/src/api/models-api.ts index 1f119b1d1..df5eaf19a 100644 --- a/lib/packages/fabro-api-client/src/api/models-api.ts +++ b/lib/packages/fabro-api-client/src/api/models-api.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -240,3 +240,4 @@ export class ModelsApi extends BaseAPI { return ModelsApiFp(this.configuration).testModel(id, mode, options).then((request) => request(this.axios, this.basePath)); } } + diff --git a/lib/packages/fabro-api-client/src/api/run-outputs-api.ts b/lib/packages/fabro-api-client/src/api/run-outputs-api.ts index ca9c328db..35c2e283e 100644 --- a/lib/packages/fabro-api-client/src/api/run-outputs-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-outputs-api.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -35,7 +35,7 @@ import type { RunBilling } from '../models'; export const RunOutputsApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness. + * Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness. * @summary List Run Commits * @param {string} id Unique run identifier (ULID). * @param {number} [limit] Maximum number of commits to return. Defaults to 100 and is capped at 100. @@ -80,7 +80,7 @@ export const RunOutputsApiAxiosParamCreator = function (configuration?: Configur }; }, /** - * Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`. + * Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`. * @summary List Run Files Changed * @param {string} id Unique run identifier (ULID). * @param {number} [pageLimit] Maximum number of items to return per page. @@ -194,7 +194,7 @@ export const RunOutputsApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = RunOutputsApiAxiosParamCreator(configuration) return { /** - * Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness. + * Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness. * @summary List Run Commits * @param {string} id Unique run identifier (ULID). * @param {number} [limit] Maximum number of commits to return. Defaults to 100 and is capped at 100. @@ -208,7 +208,7 @@ export const RunOutputsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`. + * Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`. * @summary List Run Files Changed * @param {string} id Unique run identifier (ULID). * @param {number} [pageLimit] Maximum number of items to return per page. @@ -248,7 +248,7 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas const localVarFp = RunOutputsApiFp(configuration) return { /** - * Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness. + * Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness. * @summary List Run Commits * @param {string} id Unique run identifier (ULID). * @param {number} [limit] Maximum number of commits to return. Defaults to 100 and is capped at 100. @@ -259,7 +259,7 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas return localVarFp.listRunCommits(id, limit, options).then((request) => request(axios, basePath)); }, /** - * Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`. + * Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`. * @summary List Run Files Changed * @param {string} id Unique run identifier (ULID). * @param {number} [pageLimit] Maximum number of items to return per page. @@ -291,7 +291,7 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas */ export class RunOutputsApi extends BaseAPI { /** - * Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness. + * Returns commits on the run branch since the run\'s base SHA, sourced directly from sandbox Git. The list uses first-parent chronological history and is capped by `limit` (default and maximum: 100). Commit data is Git-authoritative; Fabro-generated commit messages are not required for correctness. * @summary List Run Commits * @param {string} id Unique run identifier (ULID). * @param {number} [limit] Maximum number of commits to return. Defaults to 100 and is capped at 100. @@ -303,7 +303,7 @@ export class RunOutputsApi extends BaseAPI { } /** - * Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`. + * Returns the set of file changes produced by a run as a list of before/after diffs. While the run\'s sandbox is reachable, diffs are resolved live against the sandbox working tree at the current HEAD. Degraded responses keep the same `data: FileDiff[]` shape. File contents are null on every entry; non-sensitive non-flagged entries include `unified_patch`, while sensitive / binary / symlink / submodule / truncated entries render through the same placeholder flags used by the live path. Responses are bounded by per-file (256 KiB / 20k lines), per-run aggregate (5 MiB), and per-request (200 files) caps. Files exceeding a cap are returned with `truncated: true` and empty `contents`. Sensitive paths (credentials, keys) are elided with `sensitive: true` and empty `contents`. * @summary List Run Files Changed * @param {string} id Unique run identifier (ULID). * @param {number} [pageLimit] Maximum number of items to return per page. diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index 2e90b30c3..634b53a9d 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -71,7 +71,7 @@ import type { ValidateResponse } from '../models'; export const RunsApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal. + * Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal. * @summary Archive Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -193,7 +193,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) /** * Creates a new workflow run in `submitted` status from a self-contained manifest. * @summary Create Run - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -235,7 +235,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) * Creates a pull request for a completed run on GitHub and persists the record on the server. * @summary Create Run Pull Request * @param {string} id Unique run identifier (ULID). - * @param {CreateRunPullRequestRequest} createRunPullRequestRequest + * @param {CreateRunPullRequestRequest} createRunPullRequestRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -322,10 +322,10 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) }; }, /** - * Creates a new run from a checkpoint of the source run. The source run is left untouched. + * Creates a new run from a checkpoint of the source run. The source run is left untouched. * @summary Fork Run * @param {string} id Unique run identifier (ULID). - * @param {ForkRequest} [forkRequest] + * @param {ForkRequest} [forkRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -405,7 +405,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) }; }, /** - * Returns checkpoint timeline entries from durable run-store checkpoints. Metadata branches are write-only archives and are not read by this endpoint. + * Returns checkpoint timeline entries from durable run-store checkpoints. Metadata branches are write-only archives and are not read by this endpoint. * @summary Get Run Timeline * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -550,7 +550,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) * Merges the stored pull request for a run on GitHub. * @summary Merge Run Pull Request * @param {string} id Unique run identifier (ULID). - * @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest + * @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -634,7 +634,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) /** * Validates and renders a workflow manifest as SVG without creating a run. * @summary Render Workflow Graph - * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest + * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -841,10 +841,10 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) }; }, /** - * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. + * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. * @summary Rewind Run * @param {string} id Unique run identifier (ULID). - * @param {RewindRequest} [rewindRequest] + * @param {RewindRequest} [rewindRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -886,7 +886,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) /** * Validates runtime readiness for a workflow manifest without creating a run. * @summary Validate Workflow Manifest - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -928,7 +928,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) * Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. * @summary Start Run * @param {string} id Unique run identifier (ULID). - * @param {StartRunRequest} [startRunRequest] + * @param {StartRunRequest} [startRunRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -968,7 +968,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) }; }, /** - * Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active. + * Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active. * @summary Unarchive Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1051,7 +1051,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) * Updates mutable run metadata. Title updates are allowed for all run states, including archived runs. * @summary Update Run * @param {string} id Unique run identifier (ULID). - * @param {UpdateRunRequest} updateRunRequest + * @param {UpdateRunRequest} updateRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1095,7 +1095,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) /** * Validates workflow structure and diagnostics without runtime readiness checks. * @summary Validate Workflow Manifest - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1143,7 +1143,7 @@ export const RunsApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = RunsApiAxiosParamCreator(configuration) return { /** - * Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal. + * Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal. * @summary Archive Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1184,7 +1184,7 @@ export const RunsApiFp = function(configuration?: Configuration) { /** * Creates a new workflow run in `submitted` status from a self-contained manifest. * @summary Create Run - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1198,7 +1198,7 @@ export const RunsApiFp = function(configuration?: Configuration) { * Creates a pull request for a completed run on GitHub and persists the record on the server. * @summary Create Run Pull Request * @param {string} id Unique run identifier (ULID). - * @param {CreateRunPullRequestRequest} createRunPullRequestRequest + * @param {CreateRunPullRequestRequest} createRunPullRequestRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1223,10 +1223,10 @@ export const RunsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Creates a new run from a checkpoint of the source run. The source run is left untouched. + * Creates a new run from a checkpoint of the source run. The source run is left untouched. * @summary Fork Run * @param {string} id Unique run identifier (ULID). - * @param {ForkRequest} [forkRequest] + * @param {ForkRequest} [forkRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1250,7 +1250,7 @@ export const RunsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Returns checkpoint timeline entries from durable run-store checkpoints. Metadata branches are write-only archives and are not read by this endpoint. + * Returns checkpoint timeline entries from durable run-store checkpoints. Metadata branches are write-only archives and are not read by this endpoint. * @summary Get Run Timeline * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1296,7 +1296,7 @@ export const RunsApiFp = function(configuration?: Configuration) { * Merges the stored pull request for a run on GitHub. * @summary Merge Run Pull Request * @param {string} id Unique run identifier (ULID). - * @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest + * @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1322,7 +1322,7 @@ export const RunsApiFp = function(configuration?: Configuration) { /** * Validates and renders a workflow manifest as SVG without creating a run. * @summary Render Workflow Graph - * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest + * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1386,10 +1386,10 @@ export const RunsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. + * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. * @summary Rewind Run * @param {string} id Unique run identifier (ULID). - * @param {RewindRequest} [rewindRequest] + * @param {RewindRequest} [rewindRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1402,7 +1402,7 @@ export const RunsApiFp = function(configuration?: Configuration) { /** * Validates runtime readiness for a workflow manifest without creating a run. * @summary Validate Workflow Manifest - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1416,7 +1416,7 @@ export const RunsApiFp = function(configuration?: Configuration) { * Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. * @summary Start Run * @param {string} id Unique run identifier (ULID). - * @param {StartRunRequest} [startRunRequest] + * @param {StartRunRequest} [startRunRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1427,7 +1427,7 @@ export const RunsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active. + * Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active. * @summary Unarchive Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1456,7 +1456,7 @@ export const RunsApiFp = function(configuration?: Configuration) { * Updates mutable run metadata. Title updates are allowed for all run states, including archived runs. * @summary Update Run * @param {string} id Unique run identifier (ULID). - * @param {UpdateRunRequest} updateRunRequest + * @param {UpdateRunRequest} updateRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1469,7 +1469,7 @@ export const RunsApiFp = function(configuration?: Configuration) { /** * Validates workflow structure and diagnostics without runtime readiness checks. * @summary Validate Workflow Manifest - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1489,7 +1489,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? const localVarFp = RunsApiFp(configuration) return { /** - * Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal. + * Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal. * @summary Archive Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1521,7 +1521,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? /** * Creates a new workflow run in `submitted` status from a self-contained manifest. * @summary Create Run - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1532,7 +1532,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? * Creates a pull request for a completed run on GitHub and persists the record on the server. * @summary Create Run Pull Request * @param {string} id Unique run identifier (ULID). - * @param {CreateRunPullRequestRequest} createRunPullRequestRequest + * @param {CreateRunPullRequestRequest} createRunPullRequestRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1551,10 +1551,10 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? return localVarFp.deleteRun(id, force, options).then((request) => request(axios, basePath)); }, /** - * Creates a new run from a checkpoint of the source run. The source run is left untouched. + * Creates a new run from a checkpoint of the source run. The source run is left untouched. * @summary Fork Run * @param {string} id Unique run identifier (ULID). - * @param {ForkRequest} [forkRequest] + * @param {ForkRequest} [forkRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1572,7 +1572,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? return localVarFp.getRunPullRequest(id, options).then((request) => request(axios, basePath)); }, /** - * Returns checkpoint timeline entries from durable run-store checkpoints. Metadata branches are write-only archives and are not read by this endpoint. + * Returns checkpoint timeline entries from durable run-store checkpoints. Metadata branches are write-only archives and are not read by this endpoint. * @summary Get Run Timeline * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1609,7 +1609,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? * Merges the stored pull request for a run on GitHub. * @summary Merge Run Pull Request * @param {string} id Unique run identifier (ULID). - * @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest + * @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1629,7 +1629,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? /** * Validates and renders a workflow manifest as SVG without creating a run. * @summary Render Workflow Graph - * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest + * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1678,10 +1678,10 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? return localVarFp.retrieveRunGraphSource(id, options).then((request) => request(axios, basePath)); }, /** - * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. + * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. * @summary Rewind Run * @param {string} id Unique run identifier (ULID). - * @param {RewindRequest} [rewindRequest] + * @param {RewindRequest} [rewindRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1691,7 +1691,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? /** * Validates runtime readiness for a workflow manifest without creating a run. * @summary Validate Workflow Manifest - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1702,7 +1702,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? * Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. * @summary Start Run * @param {string} id Unique run identifier (ULID). - * @param {StartRunRequest} [startRunRequest] + * @param {StartRunRequest} [startRunRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1710,7 +1710,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? return localVarFp.startRun(id, startRunRequest, options).then((request) => request(axios, basePath)); }, /** - * Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active. + * Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active. * @summary Unarchive Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1733,7 +1733,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? * Updates mutable run metadata. Title updates are allowed for all run states, including archived runs. * @summary Update Run * @param {string} id Unique run identifier (ULID). - * @param {UpdateRunRequest} updateRunRequest + * @param {UpdateRunRequest} updateRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1743,7 +1743,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? /** * Validates workflow structure and diagnostics without runtime readiness checks. * @summary Validate Workflow Manifest - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1758,7 +1758,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? */ export class RunsApi extends BaseAPI { /** - * Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal. + * Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal. * @summary Archive Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1793,7 +1793,7 @@ export class RunsApi extends BaseAPI { /** * Creates a new workflow run in `submitted` status from a self-contained manifest. * @summary Create Run - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1805,7 +1805,7 @@ export class RunsApi extends BaseAPI { * Creates a pull request for a completed run on GitHub and persists the record on the server. * @summary Create Run Pull Request * @param {string} id Unique run identifier (ULID). - * @param {CreateRunPullRequestRequest} createRunPullRequestRequest + * @param {CreateRunPullRequestRequest} createRunPullRequestRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1826,10 +1826,10 @@ export class RunsApi extends BaseAPI { } /** - * Creates a new run from a checkpoint of the source run. The source run is left untouched. + * Creates a new run from a checkpoint of the source run. The source run is left untouched. * @summary Fork Run * @param {string} id Unique run identifier (ULID). - * @param {ForkRequest} [forkRequest] + * @param {ForkRequest} [forkRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1849,7 +1849,7 @@ export class RunsApi extends BaseAPI { } /** - * Returns checkpoint timeline entries from durable run-store checkpoints. Metadata branches are write-only archives and are not read by this endpoint. + * Returns checkpoint timeline entries from durable run-store checkpoints. Metadata branches are write-only archives and are not read by this endpoint. * @summary Get Run Timeline * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1889,7 +1889,7 @@ export class RunsApi extends BaseAPI { * Merges the stored pull request for a run on GitHub. * @summary Merge Run Pull Request * @param {string} id Unique run identifier (ULID). - * @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest + * @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1911,7 +1911,7 @@ export class RunsApi extends BaseAPI { /** * Validates and renders a workflow manifest as SVG without creating a run. * @summary Render Workflow Graph - * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest + * @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1965,10 +1965,10 @@ export class RunsApi extends BaseAPI { } /** - * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. + * Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed. * @summary Rewind Run * @param {string} id Unique run identifier (ULID). - * @param {RewindRequest} [rewindRequest] + * @param {RewindRequest} [rewindRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1979,7 +1979,7 @@ export class RunsApi extends BaseAPI { /** * Validates runtime readiness for a workflow manifest without creating a run. * @summary Validate Workflow Manifest - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1991,7 +1991,7 @@ export class RunsApi extends BaseAPI { * Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable. * @summary Start Run * @param {string} id Unique run identifier (ULID). - * @param {StartRunRequest} [startRunRequest] + * @param {StartRunRequest} [startRunRequest] * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -2000,7 +2000,7 @@ export class RunsApi extends BaseAPI { } /** - * Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active. + * Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active. * @summary Unarchive Run * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -2025,7 +2025,7 @@ export class RunsApi extends BaseAPI { * Updates mutable run metadata. Title updates are allowed for all run states, including archived runs. * @summary Update Run * @param {string} id Unique run identifier (ULID). - * @param {UpdateRunRequest} updateRunRequest + * @param {UpdateRunRequest} updateRunRequest * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -2036,7 +2036,7 @@ export class RunsApi extends BaseAPI { /** * Validates workflow structure and diagnostics without runtime readiness checks. * @summary Validate Workflow Manifest - * @param {RunManifest} runManifest + * @param {RunManifest} runManifest * @param {*} [options] Override http request option. * @throws {RequiredError} */ diff --git a/lib/packages/fabro-api-client/src/models/auth-session.ts b/lib/packages/fabro-api-client/src/models/auth-session.ts index d57f6df59..05d9c815d 100644 --- a/lib/packages/fabro-api-client/src/models/auth-session.ts +++ b/lib/packages/fabro-api-client/src/models/auth-session.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -34,3 +34,5 @@ export const AuthSessionKindEnum = { } as const; export type AuthSessionKindEnum = typeof AuthSessionKindEnum[keyof typeof AuthSessionKindEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/auth-sessions-response.ts b/lib/packages/fabro-api-client/src/models/auth-sessions-response.ts index 82c01fb1d..fbf898951 100644 --- a/lib/packages/fabro-api-client/src/models/auth-sessions-response.ts +++ b/lib/packages/fabro-api-client/src/models/auth-sessions-response.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -20,3 +20,4 @@ import type { AuthSession } from './auth-session'; export interface AuthSessionsResponse { 'sessions': Array; } + diff --git a/lib/packages/fabro-api-client/src/models/automation-ref.ts b/lib/packages/fabro-api-client/src/models/automation-ref.ts index 46465579c..f965a18d0 100644 --- a/lib/packages/fabro-api-client/src/models/automation-ref.ts +++ b/lib/packages/fabro-api-client/src/models/automation-ref.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -18,3 +18,4 @@ export interface AutomationRef { 'id': string; 'name': string | null; } + diff --git a/lib/packages/fabro-api-client/src/models/billing-by-model.ts b/lib/packages/fabro-api-client/src/models/billing-by-model.ts index 0678eae9e..0acbe2e91 100644 --- a/lib/packages/fabro-api-client/src/models/billing-by-model.ts +++ b/lib/packages/fabro-api-client/src/models/billing-by-model.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -31,3 +31,4 @@ export interface BillingByModel { 'stages': number; 'billing': BilledTokenCounts; } + diff --git a/lib/packages/fabro-api-client/src/models/billing-model-ref.ts b/lib/packages/fabro-api-client/src/models/billing-model-ref.ts index 5b12fa237..c7b12207c 100644 --- a/lib/packages/fabro-api-client/src/models/billing-model-ref.ts +++ b/lib/packages/fabro-api-client/src/models/billing-model-ref.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -28,3 +28,6 @@ export interface BillingModelRef { 'model_id': string; 'speed'?: BillingSpeed | null; } + + + diff --git a/lib/packages/fabro-api-client/src/models/billing-speed.ts b/lib/packages/fabro-api-client/src/models/billing-speed.ts index 3cad0e643..127a5eb0b 100644 --- a/lib/packages/fabro-api-client/src/models/billing-speed.ts +++ b/lib/packages/fabro-api-client/src/models/billing-speed.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -24,3 +24,6 @@ export const BillingSpeed = { } as const; export type BillingSpeed = typeof BillingSpeed[keyof typeof BillingSpeed]; + + + diff --git a/lib/packages/fabro-api-client/src/models/checkpoint-record.ts b/lib/packages/fabro-api-client/src/models/checkpoint-record.ts index 9177008eb..7dc4bfa42 100644 --- a/lib/packages/fabro-api-client/src/models/checkpoint-record.ts +++ b/lib/packages/fabro-api-client/src/models/checkpoint-record.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -28,3 +28,4 @@ export interface CheckpointRecord { 'checkpoint': RunCheckpoint; 'diff': RunDiff; } + diff --git a/lib/packages/fabro-api-client/src/models/conclusion.ts b/lib/packages/fabro-api-client/src/models/conclusion.ts index b25c1b32f..02d6e2d16 100644 --- a/lib/packages/fabro-api-client/src/models/conclusion.ts +++ b/lib/packages/fabro-api-client/src/models/conclusion.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -43,3 +43,6 @@ export interface Conclusion { 'total_retries': number; 'diff': RunDiff; } + + + diff --git a/lib/packages/fabro-api-client/src/models/daytona-settings.ts b/lib/packages/fabro-api-client/src/models/daytona-settings.ts index 4e3127313..0a1e08340 100644 --- a/lib/packages/fabro-api-client/src/models/daytona-settings.ts +++ b/lib/packages/fabro-api-client/src/models/daytona-settings.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -26,3 +26,4 @@ export interface DaytonaSettings { 'snapshot': DaytonaSnapshotSettings | null; 'network': DaytonaNetworkLayer | null; } + diff --git a/lib/packages/fabro-api-client/src/models/delete-run-response.ts b/lib/packages/fabro-api-client/src/models/delete-run-response.ts index 696f161ca..54e38ae3f 100644 --- a/lib/packages/fabro-api-client/src/models/delete-run-response.ts +++ b/lib/packages/fabro-api-client/src/models/delete-run-response.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -25,3 +25,4 @@ export interface DeleteRunResponse { 'sandbox_preserved': boolean; 'sandbox': DeleteRunSandbox; } + diff --git a/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts b/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts index cc8410346..5c224237d 100644 --- a/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts +++ b/lib/packages/fabro-api-client/src/models/delete-run-sandbox.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -21,3 +21,6 @@ export interface DeleteRunSandbox { 'provider': SandboxProvider; 'id': string; } + + + diff --git a/lib/packages/fabro-api-client/src/models/docker-settings.ts b/lib/packages/fabro-api-client/src/models/docker-settings.ts index 0b9b74593..07829de3a 100644 --- a/lib/packages/fabro-api-client/src/models/docker-settings.ts +++ b/lib/packages/fabro-api-client/src/models/docker-settings.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -21,3 +21,4 @@ export interface DockerSettings { 'cpu_quota': number | null; 'env_vars': { [key: string]: string; }; } + diff --git a/lib/packages/fabro-api-client/src/models/model-features.ts b/lib/packages/fabro-api-client/src/models/model-features.ts index b495aa069..833f385d8 100644 --- a/lib/packages/fabro-api-client/src/models/model-features.ts +++ b/lib/packages/fabro-api-client/src/models/model-features.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -44,3 +44,6 @@ export interface ModelFeatures { */ 'effort': boolean; } + + + diff --git a/lib/packages/fabro-api-client/src/models/model.ts b/lib/packages/fabro-api-client/src/models/model.ts index 4b03f415b..eb534564b 100644 --- a/lib/packages/fabro-api-client/src/models/model.ts +++ b/lib/packages/fabro-api-client/src/models/model.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -67,7 +67,8 @@ export interface Model { */ 'default': boolean; /** - * Whether credential material is present for this model\'s provider on the server (vault entry or environment variable). Does NOT imply the credential is valid or that requests will succeed; call `POST /models/{id}/test` to verify usability. + * Whether credential material is present for this model\'s provider on the server (vault entry or environment variable). Does NOT imply the credential is valid or that requests will succeed; call `POST /models/{id}/test` to verify usability. */ 'configured': boolean; } + diff --git a/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts b/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts index 82be66feb..3fb53d459 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts +++ b/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -31,3 +31,4 @@ export interface PaginatedBoardRunList { 'data': Array; 'meta': PaginationMeta; } + diff --git a/lib/packages/fabro-api-client/src/models/paginated-run-commit-list.ts b/lib/packages/fabro-api-client/src/models/paginated-run-commit-list.ts index 1290d80d2..c3f93c6be 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-run-commit-list.ts +++ b/lib/packages/fabro-api-client/src/models/paginated-run-commit-list.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -27,3 +27,4 @@ export interface PaginatedRunCommitList { 'data': Array; 'meta': RunCommitsMeta; } + diff --git a/lib/packages/fabro-api-client/src/models/paginated-run-file-list.ts b/lib/packages/fabro-api-client/src/models/paginated-run-file-list.ts index 1d84791af..9f9eaf440 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-run-file-list.ts +++ b/lib/packages/fabro-api-client/src/models/paginated-run-file-list.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -21,9 +21,10 @@ import type { FileDiff } from './file-diff'; import type { RunFilesMeta } from './run-files-meta'; /** - * List of file diffs produced by a run, with metadata describing truncation and degraded-response state. Naturally bounded: at most 200 files per response. Consumers should inspect `meta.truncated` rather than assuming `data.length` equals the run\'s total change count. + * List of file diffs produced by a run, with metadata describing truncation and degraded-response state. Naturally bounded: at most 200 files per response. Consumers should inspect `meta.truncated` rather than assuming `data.length` equals the run\'s total change count. */ export interface PaginatedRunFileList { 'data': Array; 'meta': RunFilesMeta; } + diff --git a/lib/packages/fabro-api-client/src/models/paginated-run-list.ts b/lib/packages/fabro-api-client/src/models/paginated-run-list.ts index 5aa510d61..d36007fa3 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-run-list.ts +++ b/lib/packages/fabro-api-client/src/models/paginated-run-list.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -27,3 +27,4 @@ export interface PaginatedRunList { 'data': Array; 'meta': PaginationMeta; } + diff --git a/lib/packages/fabro-api-client/src/models/pending-interview-record.ts b/lib/packages/fabro-api-client/src/models/pending-interview-record.ts index 8d98b95b8..ec90b6579 100644 --- a/lib/packages/fabro-api-client/src/models/pending-interview-record.ts +++ b/lib/packages/fabro-api-client/src/models/pending-interview-record.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -24,3 +24,4 @@ export interface PendingInterviewRecord { 'question': InterviewQuestionRecord; 'started_at': string; } + diff --git a/lib/packages/fabro-api-client/src/models/pull-request-details-timestamps.ts b/lib/packages/fabro-api-client/src/models/pull-request-details-timestamps.ts index 79a699d36..5ca3e0a71 100644 --- a/lib/packages/fabro-api-client/src/models/pull-request-details-timestamps.ts +++ b/lib/packages/fabro-api-client/src/models/pull-request-details-timestamps.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -18,3 +18,4 @@ export interface PullRequestDetailsTimestamps { 'created_at': string; 'updated_at': string; } + diff --git a/lib/packages/fabro-api-client/src/models/pull-request-details.ts b/lib/packages/fabro-api-client/src/models/pull-request-details.ts index 009cd9e0c..104b9a94f 100644 --- a/lib/packages/fabro-api-client/src/models/pull-request-details.ts +++ b/lib/packages/fabro-api-client/src/models/pull-request-details.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -44,3 +44,4 @@ export interface PullRequestDetails { 'author': PullRequestUser; 'timestamps': PullRequestDetailsTimestamps; } + diff --git a/lib/packages/fabro-api-client/src/models/pull-request.ts b/lib/packages/fabro-api-client/src/models/pull-request.ts index d7cd74504..109ac4aac 100644 --- a/lib/packages/fabro-api-client/src/models/pull-request.ts +++ b/lib/packages/fabro-api-client/src/models/pull-request.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -33,3 +33,5 @@ export const PullRequestProviderEnum = { } as const; export type PullRequestProviderEnum = typeof PullRequestProviderEnum[keyof typeof PullRequestProviderEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts b/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts index 774b00841..b3c303299 100644 --- a/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts +++ b/lib/packages/fabro-api-client/src/models/reasoning-effort-feature.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -24,3 +24,6 @@ export const ReasoningEffortFeature = { } as const; export type ReasoningEffortFeature = typeof ReasoningEffortFeature[keyof typeof ReasoningEffortFeature]; + + + diff --git a/lib/packages/fabro-api-client/src/models/repository-ref.ts b/lib/packages/fabro-api-client/src/models/repository-ref.ts index 1c9652e82..492078034 100644 --- a/lib/packages/fabro-api-client/src/models/repository-ref.ts +++ b/lib/packages/fabro-api-client/src/models/repository-ref.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -30,3 +30,5 @@ export const RepositoryRefProviderEnum = { } as const; export type RepositoryRefProviderEnum = typeof RepositoryRefProviderEnum[keyof typeof RepositoryRefProviderEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/run-billing-stage.ts b/lib/packages/fabro-api-client/src/models/run-billing-stage.ts index 5cef24796..8ea3052fe 100644 --- a/lib/packages/fabro-api-client/src/models/run-billing-stage.ts +++ b/lib/packages/fabro-api-client/src/models/run-billing-stage.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -43,3 +43,6 @@ export interface RunBillingStage { 'started_at'?: string | null; 'state'?: StageState | null; } + + + diff --git a/lib/packages/fabro-api-client/src/models/run-billing-summary.ts b/lib/packages/fabro-api-client/src/models/run-billing-summary.ts index 4e0cb5ee7..41221d873 100644 --- a/lib/packages/fabro-api-client/src/models/run-billing-summary.ts +++ b/lib/packages/fabro-api-client/src/models/run-billing-summary.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -17,3 +17,4 @@ export interface RunBillingSummary { 'total_usd_micros': number | null; } + diff --git a/lib/packages/fabro-api-client/src/models/run-branch-settings.ts b/lib/packages/fabro-api-client/src/models/run-branch-settings.ts index 1805c3f2b..cd99e2e62 100644 --- a/lib/packages/fabro-api-client/src/models/run-branch-settings.ts +++ b/lib/packages/fabro-api-client/src/models/run-branch-settings.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -18,3 +18,4 @@ export interface RunBranchSettings { 'enabled': boolean; 'push': boolean; } + diff --git a/lib/packages/fabro-api-client/src/models/run-clone-settings.ts b/lib/packages/fabro-api-client/src/models/run-clone-settings.ts index 4c4b6a657..940d9c20f 100644 --- a/lib/packages/fabro-api-client/src/models/run-clone-settings.ts +++ b/lib/packages/fabro-api-client/src/models/run-clone-settings.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -17,3 +17,4 @@ export interface RunCloneSettings { 'enabled': boolean; } + diff --git a/lib/packages/fabro-api-client/src/models/run-commit-parent.ts b/lib/packages/fabro-api-client/src/models/run-commit-parent.ts index 1cc1cea61..9d524e8f6 100644 --- a/lib/packages/fabro-api-client/src/models/run-commit-parent.ts +++ b/lib/packages/fabro-api-client/src/models/run-commit-parent.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -21,3 +21,4 @@ export interface RunCommitParent { 'sha': string; 'short_sha': string; } + diff --git a/lib/packages/fabro-api-client/src/models/run-commit-person.ts b/lib/packages/fabro-api-client/src/models/run-commit-person.ts index 6a33f0ce6..bc23e30c2 100644 --- a/lib/packages/fabro-api-client/src/models/run-commit-person.ts +++ b/lib/packages/fabro-api-client/src/models/run-commit-person.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -22,3 +22,4 @@ export interface RunCommitPerson { 'email': string; 'date': string | null; } + diff --git a/lib/packages/fabro-api-client/src/models/run-commit.ts b/lib/packages/fabro-api-client/src/models/run-commit.ts index f0b033ffc..b81460c54 100644 --- a/lib/packages/fabro-api-client/src/models/run-commit.ts +++ b/lib/packages/fabro-api-client/src/models/run-commit.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -35,3 +35,4 @@ export interface RunCommit { 'trailers': { [key: string]: string; }; 'tree_sha': string | null; } + diff --git a/lib/packages/fabro-api-client/src/models/run-commits-meta.ts b/lib/packages/fabro-api-client/src/models/run-commits-meta.ts index 06730f348..d44b0a30c 100644 --- a/lib/packages/fabro-api-client/src/models/run-commits-meta.ts +++ b/lib/packages/fabro-api-client/src/models/run-commits-meta.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -31,3 +31,5 @@ export const RunCommitsMetaSourceEnum = { } as const; export type RunCommitsMetaSourceEnum = typeof RunCommitsMetaSourceEnum[keyof typeof RunCommitsMetaSourceEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/run-diff.ts b/lib/packages/fabro-api-client/src/models/run-diff.ts index 6b740259f..8b8757baa 100644 --- a/lib/packages/fabro-api-client/src/models/run-diff.ts +++ b/lib/packages/fabro-api-client/src/models/run-diff.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -24,3 +24,4 @@ export interface RunDiff { 'patch'?: string | null; 'summary'?: DiffSummary | null; } + diff --git a/lib/packages/fabro-api-client/src/models/run-lifecycle.ts b/lib/packages/fabro-api-client/src/models/run-lifecycle.ts index 3c66a6ac2..aafe4629b 100644 --- a/lib/packages/fabro-api-client/src/models/run-lifecycle.ts +++ b/lib/packages/fabro-api-client/src/models/run-lifecycle.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -31,3 +31,6 @@ export interface RunLifecycle { 'archived': boolean; 'archived_at': string | null; } + + + diff --git a/lib/packages/fabro-api-client/src/models/run-links.ts b/lib/packages/fabro-api-client/src/models/run-links.ts index ac0336dd7..0789c6c03 100644 --- a/lib/packages/fabro-api-client/src/models/run-links.ts +++ b/lib/packages/fabro-api-client/src/models/run-links.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -17,3 +17,4 @@ export interface RunLinks { 'web': string | null; } + diff --git a/lib/packages/fabro-api-client/src/models/run-meta-branch-settings.ts b/lib/packages/fabro-api-client/src/models/run-meta-branch-settings.ts index 950734a44..8159a8e09 100644 --- a/lib/packages/fabro-api-client/src/models/run-meta-branch-settings.ts +++ b/lib/packages/fabro-api-client/src/models/run-meta-branch-settings.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -18,3 +18,4 @@ export interface RunMetaBranchSettings { 'enabled': boolean; 'push': boolean; } + diff --git a/lib/packages/fabro-api-client/src/models/run-model.ts b/lib/packages/fabro-api-client/src/models/run-model.ts index 261182e70..d2f9b0c72 100644 --- a/lib/packages/fabro-api-client/src/models/run-model.ts +++ b/lib/packages/fabro-api-client/src/models/run-model.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -18,3 +18,4 @@ export interface RunModel { 'provider': string | null; 'name': string; } + diff --git a/lib/packages/fabro-api-client/src/models/run-namespace.ts b/lib/packages/fabro-api-client/src/models/run-namespace.ts index 655714f13..985810109 100644 --- a/lib/packages/fabro-api-client/src/models/run-namespace.ts +++ b/lib/packages/fabro-api-client/src/models/run-namespace.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -94,3 +94,4 @@ export interface RunNamespace { 'artifacts': ArtifactsSettings; 'integrations': RunIntegrationsSettings; } + diff --git a/lib/packages/fabro-api-client/src/models/run-origin.ts b/lib/packages/fabro-api-client/src/models/run-origin.ts index fe4a05773..b323ebe0b 100644 --- a/lib/packages/fabro-api-client/src/models/run-origin.ts +++ b/lib/packages/fabro-api-client/src/models/run-origin.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -23,3 +23,5 @@ export const RunOriginKindEnum = { } as const; export type RunOriginKindEnum = typeof RunOriginKindEnum[keyof typeof RunOriginKindEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/run-projection.ts b/lib/packages/fabro-api-client/src/models/run-projection.ts index ad7f63d83..624251dfb 100644 --- a/lib/packages/fabro-api-client/src/models/run-projection.ts +++ b/lib/packages/fabro-api-client/src/models/run-projection.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -74,3 +74,6 @@ export interface RunProjection { */ 'stages': { [key: string]: StageProjection; }; } + + + diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-runtime.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-runtime.ts index f03e7c092..6f4eab9e6 100644 --- a/lib/packages/fabro-api-client/src/models/run-sandbox-runtime.ts +++ b/lib/packages/fabro-api-client/src/models/run-sandbox-runtime.ts @@ -5,13 +5,15 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ + + export interface RunSandboxRuntime { 'id': string; 'working_directory': string; @@ -23,3 +25,4 @@ export interface RunSandboxRuntime { 'primary_repo_path'?: string | null; 'primary_repo_link'?: string | null; } + diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-settings.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-settings.ts index 4fe24f6be..bfe260bac 100644 --- a/lib/packages/fabro-api-client/src/models/run-sandbox-settings.ts +++ b/lib/packages/fabro-api-client/src/models/run-sandbox-settings.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -32,3 +32,6 @@ export interface RunSandboxSettings { 'docker': DockerSettings | null; 'daytona': DaytonaSettings | null; } + + + diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox.ts b/lib/packages/fabro-api-client/src/models/run-sandbox.ts index a06c1ad64..e7a9e0115 100644 --- a/lib/packages/fabro-api-client/src/models/run-sandbox.ts +++ b/lib/packages/fabro-api-client/src/models/run-sandbox.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -29,3 +29,6 @@ export interface RunSandbox { 'snapshot': string | null; 'runtime': RunSandboxRuntime | null; } + + + diff --git a/lib/packages/fabro-api-client/src/models/run-spec.ts b/lib/packages/fabro-api-client/src/models/run-spec.ts index 2be7312b7..1ff38727d 100644 --- a/lib/packages/fabro-api-client/src/models/run-spec.ts +++ b/lib/packages/fabro-api-client/src/models/run-spec.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -43,3 +43,4 @@ export interface RunSpec { 'git'?: GitContext | null; 'fork_source_ref'?: ForkSourceRef | null; } + diff --git a/lib/packages/fabro-api-client/src/models/run-status.ts b/lib/packages/fabro-api-client/src/models/run-status.ts index 3ca628bba..153f875c1 100644 --- a/lib/packages/fabro-api-client/src/models/run-status.ts +++ b/lib/packages/fabro-api-client/src/models/run-status.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -52,6 +52,8 @@ import type { RunStatusSucceeded } from './run-status-succeeded'; /** * @type RunStatus - * Execution status of a run. Archive state is represented separately on `RunLifecycle.archived` so terminal status payloads remain intact. + * Execution status of a run. Archive state is represented separately on `RunLifecycle.archived` so terminal status payloads remain intact. */ export type RunStatus = { kind: 'blocked' } & RunStatusBlocked | { kind: 'dead' } & RunStatusDead | { kind: 'failed' } & RunStatusFailed | { kind: 'paused' } & RunStatusPaused | { kind: 'queued' } & RunStatusQueued | { kind: 'removing' } & RunStatusRemoving | { kind: 'running' } & RunStatusRunning | { kind: 'starting' } & RunStatusStarting | { kind: 'submitted' } & RunStatusSubmitted | { kind: 'succeeded' } & RunStatusSucceeded; + + diff --git a/lib/packages/fabro-api-client/src/models/run-timestamps.ts b/lib/packages/fabro-api-client/src/models/run-timestamps.ts index 5debf5a4a..18ef61885 100644 --- a/lib/packages/fabro-api-client/src/models/run-timestamps.ts +++ b/lib/packages/fabro-api-client/src/models/run-timestamps.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -22,3 +22,4 @@ export interface RunTimestamps { 'duration_ms'?: number | null; 'elapsed_secs'?: number | null; } + diff --git a/lib/packages/fabro-api-client/src/models/run.ts b/lib/packages/fabro-api-client/src/models/run.ts index dca15afaf..b93a79dc1 100644 --- a/lib/packages/fabro-api-client/src/models/run.ts +++ b/lib/packages/fabro-api-client/src/models/run.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -81,3 +81,4 @@ export interface Run { 'superseded_by': string | null; 'links': RunLinks; } + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-details.ts b/lib/packages/fabro-api-client/src/models/sandbox-details.ts index 0ff2bf212..c3a917272 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-details.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-details.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -47,3 +47,6 @@ export interface SandboxDetails { 'labels': { [key: string]: string; }; 'timestamps': SandboxTimestamps; } + + + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-provider.ts b/lib/packages/fabro-api-client/src/models/sandbox-provider.ts index ba15168e1..370b44c1c 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-provider.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-provider.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -25,3 +25,6 @@ export const SandboxProvider = { } as const; export type SandboxProvider = typeof SandboxProvider[keyof typeof SandboxProvider]; + + + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-resources.ts b/lib/packages/fabro-api-client/src/models/sandbox-resources.ts index 662cdb3de..c584edaba 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-resources.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-resources.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -31,3 +31,4 @@ export interface SandboxResources { */ 'disk_bytes'?: number; } + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service-discovery-source.ts b/lib/packages/fabro-api-client/src/models/sandbox-service-discovery-source.ts index 442f173e0..78024461f 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-service-discovery-source.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-service-discovery-source.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -24,3 +24,6 @@ export const SandboxServiceDiscoverySource = { } as const; export type SandboxServiceDiscoverySource = typeof SandboxServiceDiscoverySource[keyof typeof SandboxServiceDiscoverySource]; + + + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service-list-meta.ts b/lib/packages/fabro-api-client/src/models/sandbox-service-list-meta.ts index eeba349a3..4d45b0489 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-service-list-meta.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-service-list-meta.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -23,3 +23,6 @@ import type { SandboxServiceDiscoverySource } from './sandbox-service-discovery- export interface SandboxServiceListMeta { 'source': SandboxServiceDiscoverySource; } + + + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts b/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts index 1494ec5b7..86071eba6 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-service-list-response.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -27,3 +27,4 @@ export interface SandboxServiceListResponse { 'data': Array; 'meta': SandboxServiceListMeta; } + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-service.ts b/lib/packages/fabro-api-client/src/models/sandbox-service.ts index fffdbd642..3b4c246ed 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-service.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-service.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -35,3 +35,4 @@ export interface SandboxService { */ 'preview_supported': boolean; } + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-state.ts b/lib/packages/fabro-api-client/src/models/sandbox-state.ts index 38f785751..9867fe19d 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-state.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-state.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -35,3 +35,6 @@ export const SandboxState = { } as const; export type SandboxState = typeof SandboxState[keyof typeof SandboxState]; + + + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-timestamps.ts b/lib/packages/fabro-api-client/src/models/sandbox-timestamps.ts index 83937e370..5785d1a74 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-timestamps.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-timestamps.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -27,3 +27,4 @@ export interface SandboxTimestamps { */ 'last_activity_at'?: string; } + diff --git a/lib/packages/fabro-api-client/src/models/ssh-access-request.ts b/lib/packages/fabro-api-client/src/models/ssh-access-request.ts index 9f75dc437..f4bd5fdfc 100644 --- a/lib/packages/fabro-api-client/src/models/ssh-access-request.ts +++ b/lib/packages/fabro-api-client/src/models/ssh-access-request.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -23,3 +23,4 @@ export interface SshAccessRequest { */ 'ttl_minutes': number; } + diff --git a/lib/packages/fabro-api-client/src/models/ssh-access-response.ts b/lib/packages/fabro-api-client/src/models/ssh-access-response.ts index 4cbe63f52..9638bcf03 100644 --- a/lib/packages/fabro-api-client/src/models/ssh-access-response.ts +++ b/lib/packages/fabro-api-client/src/models/ssh-access-response.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -23,3 +23,4 @@ export interface SshAccessResponse { */ 'command': string; } + diff --git a/lib/packages/fabro-api-client/src/models/stage-projection.ts b/lib/packages/fabro-api-client/src/models/stage-projection.ts index 7021a8f9f..87f3830ed 100644 --- a/lib/packages/fabro-api-client/src/models/stage-projection.ts +++ b/lib/packages/fabro-api-client/src/models/stage-projection.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -73,3 +73,6 @@ export interface StageProjection { */ 'state': StageState; } + + + diff --git a/lib/packages/fabro-api-client/src/models/stage-summary.ts b/lib/packages/fabro-api-client/src/models/stage-summary.ts index 527268c3f..8a811521b 100644 --- a/lib/packages/fabro-api-client/src/models/stage-summary.ts +++ b/lib/packages/fabro-api-client/src/models/stage-summary.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -24,3 +24,4 @@ export interface StageSummary { 'billing_usd_micros'?: number | null; 'retries': number; } + diff --git a/lib/packages/fabro-api-client/src/models/start-record.ts b/lib/packages/fabro-api-client/src/models/start-record.ts index 7882a9789..a6f53654e 100644 --- a/lib/packages/fabro-api-client/src/models/start-record.ts +++ b/lib/packages/fabro-api-client/src/models/start-record.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -22,3 +22,4 @@ export interface StartRecord { 'run_branch'?: string | null; 'base_sha'?: string | null; } + diff --git a/lib/packages/fabro-api-client/src/models/vnc-preview-response.ts b/lib/packages/fabro-api-client/src/models/vnc-preview-response.ts index aed16f52a..1fa77e63c 100644 --- a/lib/packages/fabro-api-client/src/models/vnc-preview-response.ts +++ b/lib/packages/fabro-api-client/src/models/vnc-preview-response.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -35,3 +35,4 @@ export interface VncPreviewResponse { */ 'expires_in_secs': number; } + diff --git a/lib/packages/fabro-api-client/src/models/workflow-ref.ts b/lib/packages/fabro-api-client/src/models/workflow-ref.ts index 9233a5396..5eccc32c3 100644 --- a/lib/packages/fabro-api-client/src/models/workflow-ref.ts +++ b/lib/packages/fabro-api-client/src/models/workflow-ref.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -18,3 +18,4 @@ export interface WorkflowRef { 'slug': string | null; 'name': string; } + From f790a47da470bf9c3d7abcb92635aeb55a9a2654 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 07:16:18 -0700 Subject: [PATCH 06/25] feat(sandbox): surface provider links and network policy (#275) ## Summary - Add provider dashboard URL reporting to `SandboxDetails`, including Daytona dashboard links and a Sandbox tab provider link. - Extend `SandboxDetails` with required provider-neutral public network policy for egress and ingress allow/block rules. - Populate local, Docker, and Daytona network policies from provider details when Fabro can assert them, otherwise default to explicit `unknown` policy. - Update OpenAPI, Rust API replacements, generated TypeScript client models, server/API tests, and the Sandbox tab `Network` panel. ## Notes - This reports policy only; it does not probe live connectivity. - The network model intentionally excludes ports, previews, IP addresses, DNS, routes, Docker network IDs, and service discovery. - Older persisted/API JSON still deserializes through the Rust serde default for `network`. ## Verification - `cargo build -p fabro-api` - `cargo nextest run -p fabro-types -p fabro-api -p fabro-sandbox -p fabro-server sandbox_details` - `cargo test -p fabro-sandbox details --features docker,daytona` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` - `cd apps/fabro-web && bun test app/routes/run-sandbox.test.tsx` - `cd apps/fabro-web && bun run typecheck` - `cd apps/fabro-web && bun run build` - `cd lib/packages/fabro-api-client && bun run typecheck` - `git diff --check` ## Post-Deploy Monitoring & Validation - Log queries/search terms: `Failed to inspect Docker container`, `Daytona sandbox is not initialized`, `missing runtime metadata`, `GET /api/v1/runs/*/sandbox`, `Sandbox unavailable`. - Metrics/dashboards to watch: API 5xx/error rate for `GET /api/v1/runs/{id}/sandbox`, frontend error reporting for the Sandbox tab, and provider reconnect/inspect failures. - Expected healthy signals: Sandbox tab loads with Overview, Resources, Network, Labels, and Timestamps; Daytona sandboxes show provider links; local/ambiguous network policy shows `Unknown`; Docker `network_mode = none` shows `Blocked`. - Failure signals and rollback trigger: sandbox details deserialization errors, missing `network` crashes, sustained sandbox endpoint 5xx increase, or blank Sandbox tab after deploy. Roll back this PR or hide the Network panel if API/client shape issues appear. - Validation window and owner: first 24 hours after deploy, release owner/on-call. ## Compound Engineering - Implemented with OpenAI Codex CLI on GPT-5. --- .../fabro-web/app/routes/run-sandbox.test.tsx | 95 ++++++++- apps/fabro-web/app/routes/run-sandbox.tsx | 84 +++++++- docs/public/api-reference/fabro-api.yaml | 43 ++++ .../2026-05-15-sandbox-network-details.md | 109 ++++++++++ lib/crates/fabro-api/build.rs | 11 + lib/crates/fabro-api/src/lib.rs | 3 +- .../tests/sandbox_details_round_trip.rs | 31 ++- lib/crates/fabro-sandbox/src/daytona/mod.rs | 4 +- lib/crates/fabro-sandbox/src/details.rs | 134 ++++++++++++- .../src/server/handler/sandbox.rs | 2 + lib/crates/fabro-types/src/lib.rs | 5 +- lib/crates/fabro-types/src/sandbox_details.rs | 188 ++++++++++++++++++ .../fabro-types/tests/sandbox_model_serde.rs | 15 +- .../src/.openapi-generator/FILES | 3 + .../fabro-api-client/src/models/index.ts | 3 + .../src/models/sandbox-details.ts | 8 + .../src/models/sandbox-network-policy-mode.ts | 29 +++ .../src/models/sandbox-network-policy.ts | 29 +++ .../src/models/sandbox-network.ts | 26 +++ 19 files changed, 801 insertions(+), 21 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-15-sandbox-network-details.md create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-network.ts diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index cde374135..1ae03e0e6 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -113,12 +113,38 @@ function sandboxDetails( 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 }, ...overrides, }; } +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") + .flatMap((node) => node.children) + .filter((child): child is string => typeof child === "string") + .join(" "); +} + function renderRoute(initialPath: string = "/runs/run_1/sandbox") { let renderer!: TestRenderer.ReactTestRenderer; act(() => { @@ -176,6 +202,10 @@ describe("RunSandbox route", () => { 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", @@ -188,7 +218,37 @@ describe("RunSandbox route", () => { .findAll((node) => node.type === "h3") .map((node) => node.children.find((child) => typeof child === "string")) .filter((text): text is string => typeof text === "string"); - expect(panelHeadings).toEqual(["Overview", "Resources", "Labels", "Timestamps"]); + expect(panelHeadings).toEqual(["Overview", "Resources", "Network", "Labels", "Timestamps"]); + const copy = textContent(renderer); + expect(copy).toContain("Open"); + expect(copy).toContain("Blocked"); + }); + + test("links to the provider dashboard when a sandbox web URL is present", () => { + currentDetails = sandboxDetails({ + sandbox: { + provider: "daytona", + runtime: { + id: "ad65029a-2d01-421e-8936-49451653fcd9", + working_directory: "/workspace", + }, + }, + web_url: + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + }); + const renderer = renderRoute(); + + const providerLinks = renderer.root.findAll( + (node) => + node.type === "a" && + node.props.href === + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + ); + expect(providerLinks).toHaveLength(1); + expect(providerLinks[0]?.props.target).toBe("_blank"); + expect(providerLinks[0]?.props.rel).toBe("noopener noreferrer"); + const linkText = providerLinks[0]?.findByType("span"); + expect(linkText?.children).toContain("Open in Daytona"); }); test("renders without crashing when most fields are null", () => { @@ -232,6 +292,37 @@ describe("RunSandbox route", () => { expect(noLabelsCopy).toHaveLength(1); }); + test("renders unknown network policies", () => { + currentDetails = sandboxDetails({ + network: networkDetails({ + egress: networkPolicy("unknown"), + ingress: networkPolicy("unknown"), + }), + }); + const renderer = renderRoute(); + + const copy = textContent(renderer); + expect(copy).toContain("Network"); + expect(copy).toContain("Egress"); + expect(copy).toContain("Ingress"); + expect(copy).toContain("Unknown"); + }); + + test("renders blocked, essentials, and CIDR 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"), + }), + }); + 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"); + }); + test("shows the empty state when no sandbox is reported", () => { currentDetails = null; const renderer = renderRoute(); @@ -300,7 +391,7 @@ describe("RunSandbox route", () => { .findAll((node) => node.type === "h3") .map((node) => node.children.find((child) => typeof child === "string")) .filter((text): text is string => typeof text === "string"); - expect(panelHeadings).toEqual(["Overview", "Resources", "Labels", "Timestamps"]); + expect(panelHeadings).toEqual(["Overview", "Resources", "Network", "Labels", "Timestamps"]); const tabs = renderer.root.findAll( (node) => node.type === "button" && node.props.role === "tab", diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index f660bf65d..8cc0dbc24 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -1,11 +1,17 @@ import { useMemo } from "react"; import { useSearchParams } from "react-router"; +import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import TerminalView, { TERMINAL_DOCK_CLEARANCE_CLASS } from "../components/terminal-view"; import { EmptyState, ErrorState } from "../components/state"; import { formatAbsoluteTs } from "../lib/format"; import { useRunSandboxDetails } from "../lib/queries"; -import type { SandboxDetails, SandboxResources, SandboxState } from "@qltysh/fabro-api-client"; +import type { + SandboxDetails, + SandboxNetwork, + SandboxResources, + SandboxState, +} from "@qltysh/fabro-api-client"; import FilesystemPanel from "./run-sandbox/filesystem-panel"; import ServicesPanel from "./run-sandbox/services-panel"; import VncPanel from "./run-sandbox/vnc-panel"; @@ -81,16 +87,39 @@ function nullableCpu(cores: number | null | undefined): string { return cores != null ? formatCpuCores(cores) : EMPTY_VALUE; } +type SandboxNetworkPolicy = SandboxNetwork["egress"]; +type SandboxNetworkPolicyMode = SandboxNetworkPolicy["mode"]; + +const NETWORK_POLICY_DISPLAY: Record = { + unknown: "Unknown", + open: "Open", + blocked: "Blocked", + cidr_allow_list: "CIDR allow list", + essentials_only: "Essentials only", +}; + +function networkPolicySummary(policy: SandboxNetworkPolicy): string { + return NETWORK_POLICY_DISPLAY[policy.mode] ?? policy.mode; +} + interface RowProps { label: string; - value: string; + value: React.ReactNode; valueClassName?: string; } +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + function Row({ label, value, valueClassName }: RowProps) { return ( -
- {label} + {value} -
+ + ); +} + +function LinkRow({ label, href, text }: { label: string; href: string; text: string }) { + return ( + + + {text} + + ); } @@ -154,6 +199,17 @@ function OverviewPanel({ details }: { details: SandboxDetails }) { value={details.region ? details.region : sandbox.provider === "docker" ? "local" : EMPTY_VALUE} /> + {details.web_url && ( + + )} ); } @@ -168,6 +224,23 @@ function ResourcesPanel({ resources }: { resources: SandboxResources }) { ); } +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"); + + return ( + + + + {cidrRows.map(({ label, policy }) => ( + + ))} + + ); +} + function LabelsPanel({ labels }: { labels: { [key: string]: string } | null | undefined }) { const entries = labels ? Object.entries(labels) : []; return ( @@ -209,6 +282,7 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) { + diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index d17032350..89455db4a 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -8098,6 +8098,43 @@ components: minimum: 0 description: Disk size in bytes. Null when unavailable. + SandboxNetworkPolicyMode: + description: Provider-neutral public-network policy for one direction. + type: string + enum: + - unknown + - open + - blocked + - cidr_allow_list + - essentials_only + + SandboxNetworkPolicy: + description: Public-network policy for one direction. + type: object + required: + - mode + - cidrs + properties: + mode: + $ref: "#/components/schemas/SandboxNetworkPolicyMode" + cidrs: + type: array + items: + type: string + description: CIDR entries when `mode` is `cidr_allow_list`; empty for other modes. + + SandboxNetwork: + description: Provider-neutral public-network policy for sandbox egress and ingress. + type: object + required: + - egress + - ingress + properties: + egress: + $ref: "#/components/schemas/SandboxNetworkPolicy" + ingress: + $ref: "#/components/schemas/SandboxNetworkPolicy" + SandboxTimestamps: description: Lifecycle timestamps for a sandbox. Fields are nullable when the provider does not surface a value. type: object @@ -8118,6 +8155,7 @@ components: - sandbox - state - resources + - network - labels - timestamps properties: @@ -8131,8 +8169,13 @@ components: region: type: ["string", "null"] description: Provider region or target. Null for local-style providers. + web_url: + type: ["string", "null"] + description: Provider dashboard URL for this sandbox when available. resources: $ref: "#/components/schemas/SandboxResources" + network: + $ref: "#/components/schemas/SandboxNetwork" labels: type: object additionalProperties: diff --git a/docs/superpowers/plans/2026-05-15-sandbox-network-details.md b/docs/superpowers/plans/2026-05-15-sandbox-network-details.md new file mode 100644 index 000000000..6715c59e7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-sandbox-network-details.md @@ -0,0 +1,109 @@ +# Sandbox Network Details Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add required provider-neutral public network policy data to `SandboxDetails` and show it clearly on the Sandbox tab. + +**Architecture:** Extend the shared `fabro-types` sandbox-details model with a required `network` object that separates egress and ingress policy. Populate it from provider inspection where available, default to explicit `unknown` policies when Fabro cannot assert the policy, and reuse the shared types through OpenAPI, Rust API generation, and the generated TypeScript client. + +**Tech Stack:** Rust, serde, OpenAPI/progenitor, TypeScript Axios client, React, Bun tests, cargo nextest. + +--- + +## Summary + +Add public-network policy reporting to `SandboxDetails`. The model reports only high-level public-network policy for egress and ingress; it does not include ports, previews, IP addresses, DNS, routes, Docker network IDs, or service discovery. + +## Key Changes + +- Add shared domain/API types in `fabro-types` and re-export them from `fabro-api`: + + ```rust + pub struct SandboxNetwork { + pub egress: SandboxNetworkPolicy, + pub ingress: SandboxNetworkPolicy, + } + + pub struct SandboxNetworkPolicy { + pub mode: SandboxNetworkPolicyMode, + pub cidrs: Vec, + } + + #[serde(rename_all = "snake_case")] + pub enum SandboxNetworkPolicyMode { + Unknown, + Open, + Blocked, + CidrAllowList, + EssentialsOnly, + } + ``` + +- Add required `network: SandboxNetwork` to `SandboxDetails`. +- Serialize `network` always. +- Give `network` a serde default of egress/ingress `unknown` so older persisted/API JSON can still deserialize. +- Update OpenAPI with required `network` and schemas for the new types. +- Add `fabro-api` replacement mappings for the new shared types. +- Regenerate the TypeScript API client after the OpenAPI/Rust type changes. + +## Provider Mapping + +- Daytona: + - `network_block_all == true` maps egress to `blocked`. + - Non-empty `network_allow_list` maps egress to `cidr_allow_list`, with comma-separated CIDRs trimmed. + - `network_block_all == false` and no allow list maps egress to `unknown`, because current SDK fields do not distinguish full default access from Daytona tier-managed essentials-only behavior. + - Ingress maps to `unknown`. +- Docker: + - If `inspect.host_config.network_mode == "none"`, map egress and ingress to `blocked`. + - Other Docker modes map to `unknown`. +- Local: + - Egress and ingress map to `unknown`. + +## Web UI + +- Add a compact `Network` panel to the Sandbox tab after `Resources`. +- Show two primary rows: `Egress` and `Ingress`. +- Use concise summaries: + - `Unknown` + - `Open` + - `Blocked` + - `Essentials only` + - `CIDR allow list` +- When a direction has `mode == "cidr_allow_list"`, add one extra row for that CIDR list, comma-separated and styled consistently with existing detail rows. + +## Implementation Tasks + +- [x] Add `SandboxNetwork`, `SandboxNetworkPolicy`, and `SandboxNetworkPolicyMode` to `lib/crates/fabro-types/src/sandbox_details.rs`. +- [x] Add constructors/default helpers for `unknown`, `open`, `blocked`, `allow_cidrs`, and `essentials_only` policies to keep provider mapping code readable. +- [x] Add `network: SandboxNetwork` to every `SandboxDetails` construction site. +- [x] Update local sandbox details to return `SandboxNetwork::unknown()`. +- [x] Update Docker sandbox details to inspect `host_config.network_mode` and emit blocked policy only for `"none"`, otherwise unknown. +- [x] Update Daytona sandbox details to derive egress policy from `network_block_all` and `network_allow_list`, and ingress as unknown. +- [x] Update OpenAPI schemas and `fabro-api/build.rs` replacements for the new shared types. +- [x] Run `cargo build -p fabro-api` so Rust API generation validates the schema. +- [x] Regenerate the TypeScript client with `cd lib/packages/fabro-api-client && bun run generate`. +- [x] Add the Sandbox tab `Network` panel and formatting helpers in `apps/fabro-web/app/routes/run-sandbox.tsx`. +- [x] Update tests and snapshots affected by the now-required `network` field. + +## Test Plan + +- Update `fabro-types` serde tests to prove `network` serializes, defaults to unknown when absent, and supports `open`, `blocked`, `cidr_allow_list`, and `essentials_only`. +- Update `fabro-api` round-trip/type-identity tests for the new shared types and OpenAPI JSON shape. +- Add `fabro-sandbox` mapper tests for Daytona block-all, Daytona CIDR allow list, Daytona ambiguous default, local unknown, Docker `network_mode = none`, and Docker non-`none` unknown. +- Update server sandbox-details tests for the required `network` field. +- Update `apps/fabro-web` Sandbox tab tests to expect the `Network` panel and verify unknown, blocked, essentials-only, and CIDR allow-list display. +- Run: + + ```sh + cargo nextest run -p fabro-types -p fabro-api -p fabro-sandbox -p fabro-server sandbox_details + cd apps/fabro-web && bun test app/routes/run-sandbox.test.tsx + cd apps/fabro-web && bun run typecheck + ``` + +## Assumptions + +- This is policy/reporting data, not live connectivity probing. +- No IPs, listening services, port mappings, preview URLs, DNS, routes, or Docker network IDs are added to `SandboxDetails`. +- The `essentials_only` mode is included in the API now, but v1 only emits it when Fabro can assert it from provider data. +- Current Daytona SDK fields do not distinguish full default access from org-tier essentials-only access. +- Daytona behavior follows the current docs: `networkBlockAll` takes precedence, `networkAllowList` is IPv4 CIDR-only, and essential services are Daytona-managed. diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 1ecf7aaa1..eb0e53dd3 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -395,6 +395,17 @@ fn main() { ("SandboxProvider", "fabro_types::SandboxProvider", &[]), ("RunSandbox", "fabro_types::RunSandbox", &[]), ("SandboxDetails", "fabro_types::SandboxDetails", &[]), + ("SandboxNetwork", "fabro_types::SandboxNetwork", &[]), + ( + "SandboxNetworkPolicy", + "fabro_types::SandboxNetworkPolicy", + &[], + ), + ( + "SandboxNetworkPolicyMode", + "fabro_types::SandboxNetworkPolicyMode", + &[], + ), ("SandboxService", "fabro_types::SandboxService", &[]), ( "SandboxServiceDiscoverySource", diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index c72597ac5..c2312ae11 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -37,7 +37,8 @@ pub mod types { IdpIdentity, InterviewOption, InterviewQuestionRecord, PendingInterviewRecord, PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, QuestionType, RepositoryRef, Run, RunClientProvenance, RunEvent, RunFailure, RunProjection, RunProvenance, RunSandbox, - RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxProvider, SandboxResources, + RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxNetwork, + SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, StageCompletion, StageHandler, StageOutcome, StageProjection, StageState, SystemActorKind, UserPrincipal, WorkflowSettings, diff --git a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs index 88919dc29..97485b4c1 100644 --- a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs +++ b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs @@ -3,13 +3,15 @@ use std::collections::BTreeMap; use chrono::{TimeZone, Utc}; use fabro_api::types::{ - SandboxDetails as ApiSandboxDetails, SandboxProvider as ApiSandboxProvider, + SandboxDetails as ApiSandboxDetails, SandboxNetwork as ApiSandboxNetwork, + SandboxNetworkPolicy as ApiSandboxNetworkPolicy, + SandboxNetworkPolicyMode as ApiSandboxNetworkPolicyMode, SandboxProvider as ApiSandboxProvider, SandboxResources as ApiSandboxResources, SandboxState as ApiSandboxState, SandboxTimestamps as ApiSandboxTimestamps, }; use fabro_types::{ - RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxProvider, SandboxResources, SandboxState, - SandboxTimestamps, + RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, + SandboxNetworkPolicyMode, SandboxProvider, SandboxResources, SandboxState, SandboxTimestamps, }; use serde_json::json; @@ -20,6 +22,9 @@ fn sandbox_details_reuses_domain_types() { assert_same_type::(); assert_same_type::(); assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); } #[test] @@ -45,11 +50,19 @@ fn sandbox_details_json_matches_openapi_shape() { state: SandboxState::Running, native_state: Some("running".to_string()), region: None, + web_url: Some( + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" + .to_string(), + ), resources: SandboxResources { cpu_cores: Some(2.0), memory_bytes: Some(4 * 1024 * 1024 * 1024), disk_bytes: None, }, + network: SandboxNetwork { + egress: SandboxNetworkPolicy::open(), + ingress: SandboxNetworkPolicy::blocked(), + }, labels: BTreeMap::from([("run".to_string(), "abc".to_string())]), timestamps: SandboxTimestamps { created_at: Some(created_at), @@ -74,10 +87,21 @@ fn sandbox_details_json_matches_openapi_shape() { }, "state": "running", "native_state": "running", + "web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", "resources": { "cpu_cores": 2.0, "memory_bytes": 4_294_967_296_u64, }, + "network": { + "egress": { + "mode": "open", + "cidrs": [] + }, + "ingress": { + "mode": "blocked", + "cidrs": [] + } + }, "labels": { "run": "abc" }, @@ -128,6 +152,7 @@ fn sandbox_details_deserializes_when_optional_fields_are_absent() { assert!(details.native_state.is_none()); assert!(details.labels.is_empty()); assert_eq!(details.resources, SandboxResources::default()); + assert_eq!(details.network, SandboxNetwork::unknown()); assert_eq!(details.timestamps, SandboxTimestamps::default()); } diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index c3f1e550b..1b794c413 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -36,6 +36,8 @@ pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; pub(crate) const REPOS_ROOT: &str = "/repos"; const DEFAULT_SNAPSHOT: &str = "daytona-medium"; pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api"; +pub(crate) const DAYTONA_DASHBOARD_SANDBOXES_URL: &str = + "https://app.daytona.io/dashboard/sandboxes"; const FABRO_SANDBOX_USER_AGENT: &str = concat!("fabro-sandbox/", env!("CARGO_PKG_VERSION")); const DAYTONA_PROBE_TIMEOUT: Duration = Duration::from_secs(20); /// Upper bound on `DaytonaSession::close` so a stalled Daytona REST call cannot @@ -1033,7 +1035,7 @@ impl Sandbox for DaytonaSandbox { name: Some(sandbox_name), cpu: Some(sandbox_cpu), memory: Some(sandbox_memory), - url: Some("https://app.daytona.io/dashboard/sandboxes".into()), + url: Some(DAYTONA_DASHBOARD_SANDBOXES_URL.into()), }); Ok(()) diff --git a/lib/crates/fabro-sandbox/src/details.rs b/lib/crates/fabro-sandbox/src/details.rs index 6b158b262..fe5612691 100644 --- a/lib/crates/fabro-sandbox/src/details.rs +++ b/lib/crates/fabro-sandbox/src/details.rs @@ -4,8 +4,8 @@ use anyhow::Result; #[cfg(any(feature = "docker", feature = "daytona"))] use chrono::{DateTime, Utc}; use fabro_types::{ - RunId, RunSandbox, SandboxDetails, SandboxProvider, SandboxResources, SandboxState, - SandboxTimestamps, + RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProvider, SandboxResources, + SandboxState, SandboxTimestamps, }; /// Inspect the sandbox identified by `record` and return provider-neutral @@ -50,7 +50,9 @@ fn local_details(record: &RunSandbox) -> SandboxDetails { state: SandboxState::Running, native_state: None, region: None, + web_url: None, resources: SandboxResources::default(), + network: SandboxNetwork::unknown(), labels: BTreeMap::new(), timestamps: SandboxTimestamps::default(), } @@ -72,7 +74,8 @@ mod docker { use bollard::container::InspectContainerOptions; use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig}; use fabro_types::{ - RunId, RunSandbox, SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps, + RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources, + SandboxState, SandboxTimestamps, }; use super::parse_rfc3339_utc; @@ -117,6 +120,7 @@ mod docker { .and_then(|bytes| u64::try_from(bytes).ok()), disk_bytes: None, }; + let network = docker_network(host_config); let labels: BTreeMap = inspect .config @@ -136,7 +140,9 @@ mod docker { state: normalized_state, native_state, region: None, + web_url: None, resources, + network, labels, timestamps: SandboxTimestamps { created_at, @@ -145,6 +151,19 @@ mod docker { } } + fn docker_network(host_config: Option<&HostConfig>) -> SandboxNetwork { + match host_config.and_then(|host| host.network_mode.as_deref()) { + Some("none") => { + let blocked = SandboxNetworkPolicy::blocked(); + SandboxNetwork { + egress: blocked.clone(), + ingress: blocked, + } + } + _ => SandboxNetwork::unknown(), + } + } + pub(super) fn docker_cpu_cores(host_config: &HostConfig) -> Option { let quota = host_config.cpu_quota?; let period = host_config.cpu_period?; @@ -175,7 +194,9 @@ mod docker { #[cfg(test)] mod tests { use bollard::models::HostConfig; - use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider}; + use fabro_types::{ + RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, SandboxProvider, + }; use super::*; @@ -254,6 +275,33 @@ mod docker { assert_eq!(details.resources.memory_bytes, Some(2_147_483_648)); } + #[test] + fn network_mode_none_blocks_ingress_and_egress() { + let inspect = ContainerInspectResponse { + host_config: Some(HostConfig { + network_mode: Some("none".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let details = map_docker_inspect(inspect, &record()); + assert_eq!(details.network.egress, SandboxNetworkPolicy::blocked()); + assert_eq!(details.network.ingress, SandboxNetworkPolicy::blocked()); + } + + #[test] + fn non_none_network_mode_is_unknown() { + let inspect = ContainerInspectResponse { + host_config: Some(HostConfig { + network_mode: Some("bridge".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let details = map_docker_inspect(inspect, &record()); + assert_eq!(details.network, SandboxNetwork::unknown()); + } + #[test] fn record_identity_is_carried_through() { let inspect = ContainerInspectResponse { @@ -350,11 +398,12 @@ mod daytona { use anyhow::{Context, Result, anyhow}; use daytona_api_client::models::SandboxState as DaytonaState; use fabro_types::{ - RunSandbox, SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps, + RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources, + SandboxState, SandboxTimestamps, }; use super::parse_rfc3339_utc; - use crate::daytona::DaytonaSandbox; + use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox}; pub(super) async fn daytona_details( record: &RunSandbox, @@ -418,7 +467,12 @@ mod daytona { state: normalized_state, native_state, region, + web_url: Some(daytona_dashboard_url(&sandbox.id)), resources, + network: daytona_network( + sandbox.network_block_all, + sandbox.network_allow_list.as_deref(), + ), labels, timestamps: SandboxTimestamps { created_at: sandbox.created_at.as_deref().and_then(parse_rfc3339_utc), @@ -443,6 +497,36 @@ mod daytona { Some(bytes) } + fn daytona_dashboard_url(sandbox_id: &str) -> String { + format!("{DAYTONA_DASHBOARD_SANDBOXES_URL}?sandboxId={sandbox_id}") + } + + fn daytona_network( + network_block_all: bool, + network_allow_list: Option<&str>, + ) -> SandboxNetwork { + let egress = if network_block_all { + SandboxNetworkPolicy::blocked() + } else { + let cidrs = network_allow_list + .into_iter() + .flat_map(|allow_list| allow_list.split(',')) + .map(str::trim) + .filter(|cidr| !cidr.is_empty()); + let cidrs: Vec<_> = cidrs.collect(); + if cidrs.is_empty() { + SandboxNetworkPolicy::unknown() + } else { + SandboxNetworkPolicy::allow_cidrs(cidrs) + } + }; + + SandboxNetwork { + egress, + ingress: SandboxNetworkPolicy::unknown(), + } + } + pub(super) fn normalize_daytona_state(state: DaytonaState) -> SandboxState { match state { DaytonaState::Creating @@ -540,6 +624,43 @@ mod daytona { fn gibibytes_to_bytes_returns_none_for_zero() { assert_eq!(gibibytes_to_bytes(0.0), None); } + + #[test] + fn daytona_dashboard_url_uses_sandbox_id_query_param() { + assert_eq!( + daytona_dashboard_url("ad65029a-2d01-421e-8936-49451653fcd9"), + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", + ); + } + + #[test] + fn network_block_all_blocks_egress_and_leaves_ingress_unknown() { + let network = daytona_network(true, Some("10.0.0.0/8")); + assert_eq!(network.egress, SandboxNetworkPolicy::blocked()); + assert_eq!(network.ingress, SandboxNetworkPolicy::unknown()); + } + + #[test] + fn network_allow_list_maps_to_cidr_allow_list() { + let network = daytona_network(false, Some("10.0.0.0/8, 192.168.0.0/16 ")); + assert_eq!( + network.egress, + SandboxNetworkPolicy::allow_cidrs(["10.0.0.0/8", "192.168.0.0/16"]) + ); + assert_eq!(network.ingress, SandboxNetworkPolicy::unknown()); + } + + #[test] + fn empty_network_allow_list_is_unknown() { + let network = daytona_network(false, Some(" , ")); + assert_eq!(network, SandboxNetwork::unknown()); + } + + #[test] + fn default_daytona_network_is_unknown() { + let network = daytona_network(false, None); + assert_eq!(network, SandboxNetwork::unknown()); + } } } @@ -575,6 +696,7 @@ mod tests { assert!(details.sandbox.image.is_none()); assert!(details.labels.is_empty()); assert_eq!(details.resources, SandboxResources::default()); + assert_eq!(details.network, SandboxNetwork::unknown()); assert_eq!(details.timestamps, SandboxTimestamps::default()); } } diff --git a/lib/crates/fabro-server/src/server/handler/sandbox.rs b/lib/crates/fabro-server/src/server/handler/sandbox.rs index cbe882fff..ca192c550 100644 --- a/lib/crates/fabro-server/src/server/handler/sandbox.rs +++ b/lib/crates/fabro-server/src/server/handler/sandbox.rs @@ -1405,6 +1405,8 @@ mod retrieve_sandbox_tests { assert!(body.get("name").is_none()); assert!(body.get("identifier").is_none()); assert!(body["resources"].is_object()); + assert_eq!(body["network"]["egress"]["mode"], "unknown"); + assert_eq!(body["network"]["ingress"]["mode"], "unknown"); assert!(body["timestamps"].is_object()); } diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 7b11683b5..57154fd44 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -93,7 +93,10 @@ pub type RunSummary = Run; pub type PullRequestRecord = PullRequest; pub type PullRequestDetail = PullRequestDetails; pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title}; -pub use sandbox_details::{SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps}; +pub use sandbox_details::{ + SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, + SandboxResources, SandboxState, SandboxTimestamps, +}; pub use sandbox_provider::SandboxProvider; pub use sandbox_services::{ SandboxService, SandboxServiceDiscoverySource, SandboxServiceListMeta, diff --git a/lib/crates/fabro-types/src/sandbox_details.rs b/lib/crates/fabro-types/src/sandbox_details.rs index eb9e8f04c..ecbf21a12 100644 --- a/lib/crates/fabro-types/src/sandbox_details.rs +++ b/lib/crates/fabro-types/src/sandbox_details.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use chrono::{DateTime, Utc}; +use serde::de::Error as _; use serde::{Deserialize, Serialize}; use crate::RunSandbox; @@ -13,7 +14,11 @@ pub struct SandboxDetails { pub native_state: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub region: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_url: Option, pub resources: SandboxResources, + #[serde(default)] + pub network: SandboxNetwork, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub labels: BTreeMap, pub timestamps: SandboxTimestamps, @@ -47,6 +52,123 @@ pub struct SandboxResources { pub disk_bytes: Option, } +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct SandboxNetwork { + pub egress: SandboxNetworkPolicy, + pub ingress: SandboxNetworkPolicy, +} + +impl SandboxNetwork { + pub fn unknown() -> Self { + Self::default() + } +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize)] +pub struct SandboxNetworkPolicy { + mode: SandboxNetworkPolicyMode, + cidrs: Vec, +} + +impl SandboxNetworkPolicy { + pub fn unknown() -> Self { + Self::default() + } + + pub fn mode(&self) -> SandboxNetworkPolicyMode { + self.mode + } + + pub fn cidrs(&self) -> &[String] { + &self.cidrs + } + + pub fn open() -> Self { + Self { + mode: SandboxNetworkPolicyMode::Open, + cidrs: Vec::new(), + } + } + + pub fn blocked() -> Self { + Self { + mode: SandboxNetworkPolicyMode::Blocked, + cidrs: Vec::new(), + } + } + + pub fn allow_cidrs(cidrs: I) -> Self + where + I: IntoIterator, + S: Into, + { + let cidrs: Vec = cidrs.into_iter().map(Into::into).collect(); + if cidrs.is_empty() { + return Self::unknown(); + } + Self { + mode: SandboxNetworkPolicyMode::CidrAllowList, + cidrs, + } + } + + pub fn essentials_only() -> Self { + Self { + mode: SandboxNetworkPolicyMode::EssentialsOnly, + cidrs: Vec::new(), + } + } +} + +impl<'de> Deserialize<'de> for SandboxNetworkPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + #[serde(default)] + mode: SandboxNetworkPolicyMode, + #[serde(default)] + cidrs: Vec, + } + + let wire = Wire::deserialize(deserializer)?; + match wire.mode { + SandboxNetworkPolicyMode::CidrAllowList => { + if wire.cidrs.is_empty() { + return Err(D::Error::custom( + "cidr_allow_list network policy requires at least one CIDR", + )); + } + Ok(Self::allow_cidrs(wire.cidrs)) + } + mode => { + if !wire.cidrs.is_empty() { + return Err(D::Error::custom( + "network policy CIDRs are only valid for cidr_allow_list mode", + )); + } + Ok(Self { + mode, + cidrs: Vec::new(), + }) + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SandboxNetworkPolicyMode { + #[default] + Unknown, + Open, + Blocked, + CidrAllowList, + EssentialsOnly, +} + #[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] pub struct SandboxTimestamps { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -84,11 +206,19 @@ mod tests { state: SandboxState::Running, native_state: Some("running".to_string()), region: None, + web_url: Some( + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" + .to_string(), + ), resources: SandboxResources { cpu_cores: Some(2.0), memory_bytes: Some(4 * 1024 * 1024 * 1024), disk_bytes: None, }, + network: SandboxNetwork { + egress: SandboxNetworkPolicy::allow_cidrs(["10.0.0.0/8"]), + ingress: SandboxNetworkPolicy::unknown(), + }, labels: BTreeMap::from([("run".to_string(), "abc".to_string())]), timestamps: SandboxTimestamps { created_at: Some(Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap()), @@ -109,10 +239,21 @@ mod tests { }, "state": "running", "native_state": "running", + "web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9", "resources": { "cpu_cores": 2.0, "memory_bytes": 4_294_967_296_u64, }, + "network": { + "egress": { + "mode": "cidr_allow_list", + "cidrs": ["10.0.0.0/8"] + }, + "ingress": { + "mode": "unknown", + "cidrs": [] + } + }, "labels": { "run": "abc" }, @@ -162,9 +303,56 @@ mod tests { assert!(details.sandbox.image.is_none()); assert!(details.labels.is_empty()); assert_eq!(details.resources, SandboxResources::default()); + assert_eq!(details.network, SandboxNetwork::unknown()); assert_eq!(details.timestamps, SandboxTimestamps::default()); } + #[test] + fn network_policy_helpers_cover_supported_modes() { + assert_eq!( + SandboxNetworkPolicy::unknown().mode(), + SandboxNetworkPolicyMode::Unknown + ); + assert_eq!( + SandboxNetworkPolicy::open().mode(), + SandboxNetworkPolicyMode::Open + ); + assert_eq!( + SandboxNetworkPolicy::blocked().mode(), + SandboxNetworkPolicyMode::Blocked + ); + assert_eq!( + SandboxNetworkPolicy::allow_cidrs(["192.168.0.0/16", "10.0.0.0/8"]).cidrs(), + ["192.168.0.0/16".to_string(), "10.0.0.0/8".to_string()] + ); + assert_eq!( + SandboxNetworkPolicy::essentials_only().mode(), + SandboxNetworkPolicyMode::EssentialsOnly, + ); + } + + #[test] + fn network_policy_deserialization_rejects_empty_cidr_allow_list() { + assert!( + serde_json::from_value::(json!({ + "mode": "cidr_allow_list", + "cidrs": [] + })) + .is_err() + ); + } + + #[test] + fn network_policy_deserialization_rejects_cidrs_for_non_cidr_mode() { + assert!( + serde_json::from_value::(json!({ + "mode": "open", + "cidrs": ["10.0.0.0/8"] + })) + .is_err() + ); + } + #[test] fn state_serializes_each_variant_in_snake_case() { fn check(state: SandboxState, expected: &str) { diff --git a/lib/crates/fabro-types/tests/sandbox_model_serde.rs b/lib/crates/fabro-types/tests/sandbox_model_serde.rs index 306e308ab..a6609aa5a 100644 --- a/lib/crates/fabro-types/tests/sandbox_model_serde.rs +++ b/lib/crates/fabro-types/tests/sandbox_model_serde.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; use chrono::{TimeZone, Utc}; use fabro_types::{ - RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxProvider, SandboxResources, SandboxState, - SandboxTimestamps, + RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxProvider, + SandboxResources, SandboxState, SandboxTimestamps, }; use serde_json::json; @@ -70,11 +70,16 @@ fn sandbox_details_requires_canonical_id_and_working_directory() { state: SandboxState::Running, native_state: Some("started".to_string()), region: Some("us".to_string()), + web_url: Some( + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" + .to_string(), + ), resources: SandboxResources { cpu_cores: Some(2.0), memory_bytes: Some(4 * 1024 * 1024 * 1024), disk_bytes: None, }, + network: SandboxNetwork::unknown(), labels: BTreeMap::from([("run".to_string(), "abc".to_string())]), timestamps: SandboxTimestamps { created_at: Some(Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap()), @@ -95,6 +100,12 @@ fn sandbox_details_requires_canonical_id_and_working_directory() { "/home/daytona/workspace" ); assert_eq!(value["sandbox"]["runtime"]["repos_root"], "/repos"); + assert_eq!( + value["web_url"], + "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" + ); + assert_eq!(value["network"]["egress"]["mode"], "unknown"); + assert_eq!(value["network"]["ingress"]["mode"], "unknown"); assert!(value.get("name").is_none()); assert!(value.get("identifier").is_none()); } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index b99bcfd58..8a8f087bd 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -302,6 +302,9 @@ models/run.ts models/sandbox-details.ts models/sandbox-file-entry.ts models/sandbox-file-list-response.ts +models/sandbox-network-policy-mode.ts +models/sandbox-network-policy.ts +models/sandbox-network.ts models/sandbox-provider.ts models/sandbox-resources.ts models/sandbox-service-discovery-source.ts diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index f4b7cd730..209d99af7 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -279,6 +279,9 @@ export * from './run-timings'; export * from './sandbox-details'; export * from './sandbox-file-entry'; export * from './sandbox-file-list-response'; +export * from './sandbox-network'; +export * from './sandbox-network-policy'; +export * from './sandbox-network-policy-mode'; export * from './sandbox-provider'; export * from './sandbox-resources'; export * from './sandbox-service'; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-details.ts b/lib/packages/fabro-api-client/src/models/sandbox-details.ts index c3a917272..d1a5ef41d 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-details.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-details.ts @@ -18,6 +18,9 @@ import type { RunSandbox } from './run-sandbox'; // May contain unused imports in some cases // @ts-ignore +import type { SandboxNetwork } from './sandbox-network'; +// May contain unused imports in some cases +// @ts-ignore import type { SandboxResources } from './sandbox-resources'; // May contain unused imports in some cases // @ts-ignore @@ -40,7 +43,12 @@ export interface SandboxDetails { * Provider region or target. Null for local-style providers. */ 'region'?: string | null; + /** + * Provider dashboard URL for this sandbox when available. + */ + 'web_url'?: string | null; 'resources': SandboxResources; + 'network': SandboxNetwork; /** * Provider-reported labels. */ diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts new file mode 100644 index 000000000..0579ee087 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Provider-neutral public-network policy for one direction. + */ + +export const SandboxNetworkPolicyMode = { + UNKNOWN: 'unknown', + OPEN: 'open', + BLOCKED: 'blocked', + CIDR_ALLOW_LIST: 'cidr_allow_list', + ESSENTIALS_ONLY: 'essentials_only' +} as const; + +export type SandboxNetworkPolicyMode = typeof SandboxNetworkPolicyMode[keyof typeof SandboxNetworkPolicyMode]; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts new file mode 100644 index 000000000..da49f0397 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxNetworkPolicyMode } from './sandbox-network-policy-mode'; + +/** + * Public-network policy for one direction. + */ +export interface SandboxNetworkPolicy { + 'mode': SandboxNetworkPolicyMode; + /** + * CIDR entries when `mode` is `cidr_allow_list`; empty for other modes. + */ + 'cidrs': Array; +} diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network.ts b/lib/packages/fabro-api-client/src/models/sandbox-network.ts new file mode 100644 index 000000000..91b5d8e1d --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-network.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxNetworkPolicy } from './sandbox-network-policy'; + +/** + * Provider-neutral public-network policy for sandbox egress and ingress. + */ +export interface SandboxNetwork { + 'egress': SandboxNetworkPolicy; + 'ingress': SandboxNetworkPolicy; +} From b0847180b66018d00fd686e54ef6d5f027c06667 Mon Sep 17 00:00:00 2001 From: David Bock Date: Sat, 16 May 2026 10:49:14 -0400 Subject: [PATCH 07/25] Fix custom provider resolution in exec and model list (#276) ## Summary - allow `fabro exec` to use configured custom provider IDs from the resolved LLM catalog - route direct exec sessions through the same catalog-aware provider/profile resolution used by workflow runs - stop `fabro-client::list_models` from rejecting non-built-in provider filters client-side - update CLI snapshots and add regression tests for custom-provider exec and model listing ## Repro With a configured provider like: ```toml [llm.providers.bedrock] adapter = "openai_compatible" base_url = "https://.../v1" [cli.exec.model] provider = "bedrock" name = "bedrock-claude-sonnet-4-6" ``` these paths diverged: - `fabro run ... --model bedrock-claude-sonnet-4-6` worked - `fabro model list` showed `bedrock-*` models - `fabro exec "..."` failed with `unknown provider: bedrock` - `fabro model test --provider bedrock` failed with the same client-side error ## Root cause There were two separate built-in-only assumptions: 1. `fabro-agent` direct CLI paths parsed provider strings into the built-in `Provider` enum and built a default catalog, so configured provider IDs from `settings.toml` were invisible. 2. `fabro-client::list_models()` parsed the optional provider filter into the same built-in enum before calling the server, so custom provider filters never reached the API. ## Validation - `cargo check -p fabro-cli -p fabro-agent -p fabro-client` - `cargo test -p fabro-agent resolve_provider_accepts_custom_catalog_provider -- --nocapture` - `cargo test -p fabro-client list_models_allows_custom_provider_filters -- --nocapture` - `cargo test -p fabro-cli exec_accepts_configured_custom_provider_from_settings -- --nocapture` - `cargo test -p fabro-cli list_invalid_provider_errors -- --nocapture` - `cargo test -p fabro-cli help -- --nocapture` --------- Co-authored-by: Bryan Helmkamp --- lib/crates/fabro-agent/src/cli.rs | 285 ++++++++++++++---- .../fabro-agent/src/profiles/anthropic.rs | 20 +- lib/crates/fabro-agent/src/profiles/gemini.rs | 20 +- lib/crates/fabro-agent/src/profiles/openai.rs | 28 +- lib/crates/fabro-agent/tests/it/guardrails.rs | 2 +- .../fabro-agent/tests/it/parity_matrix.rs | 9 +- lib/crates/fabro-cli/src/commands/exec.rs | 17 +- lib/crates/fabro-cli/tests/it/cmd/exec.rs | 65 +++- lib/crates/fabro-client/src/client.rs | 29 +- lib/crates/fabro-model/src/adapter.rs | 112 +++++-- .../fabro-workflow/src/handler/llm/api.rs | 43 +-- .../fabro-workflow/src/operations/start.rs | 17 +- 12 files changed, 500 insertions(+), 147 deletions(-) diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 46f335529..1dae73d50 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -19,7 +19,7 @@ use fabro_llm::provider::StreamEventStream; use fabro_llm::types::{Request, Response}; use fabro_mcp::config::McpServerSettings; use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, ModelHandle, Provider}; +use fabro_model::{AgentProfileKind, Catalog, ModelHandle, Provider, ProviderId, adapter}; use fabro_util::terminal::Styles; use fabro_vault::Vault; use tokio::io::{AsyncWriteExt, stdout}; @@ -41,7 +41,7 @@ pub struct AgentArgs { /// Task prompt pub prompt: String, - /// LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception) + /// LLM provider (built-in or configured provider ID) #[arg(long)] pub provider: Option, @@ -205,10 +205,34 @@ fn build_tool_approval( }) } -fn summarizer_model_id(provider: Provider) -> ModelHandle { +fn summarizer_model_id( + provider: &ResolvedProvider, + selected_model: &str, + catalog: &Catalog, +) -> ModelHandle { + if Provider::from_id(&provider.provider_id).is_none() { + if let Some(model) = catalog.default_for_provider(&provider.provider_id) { + return ModelHandle::ByName { + provider: provider.provider_id.clone(), + model: model.id.clone(), + }; + } + return ModelHandle::ByName { + provider: provider.provider_id.clone(), + model: selected_model.to_string(), + }; + } + + if let Some(model) = catalog.probe_for_provider(provider.provider) { + return ModelHandle::ByName { + provider: provider.provider_id.clone(), + model: model.id.clone(), + }; + } + ModelHandle::ByName { - provider: provider.id(), - model: match provider { + provider: provider.provider_id.clone(), + model: match provider.provider { Provider::OpenAi | Provider::OpenAiCompatible => "gpt-4o-mini", Provider::Gemini => "gemini-2.0-flash", Provider::Anthropic => "claude-haiku-4-5", @@ -221,46 +245,76 @@ fn summarizer_model_id(provider: Provider) -> ModelHandle { } } -fn build_summarizer(provider: Provider, llm_client: Client) -> WebFetchSummarizer { +fn build_summarizer(model_id: ModelHandle, llm_client: Client) -> WebFetchSummarizer { WebFetchSummarizer { - client: llm_client, - model_id: summarizer_model_id(provider), + client: llm_client, + model_id, } } fn build_profile( - provider: Provider, + provider: &ResolvedProvider, model: &str, summarizer: Option, catalog: Arc, ) -> Box { - match provider { - Provider::OpenAi => { - Box::new(OpenAiProfile::with_summarizer(model, summarizer).with_catalog(catalog)) - } - Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => Box::new( + match provider.profile_kind { + AgentProfileKind::OpenAi => Box::new( OpenAiProfile::with_summarizer(model, summarizer) - .with_provider(provider) + .with_identity(provider.provider, provider.provider_id.clone()) + .with_catalog(catalog), + ), + AgentProfileKind::Gemini => Box::new( + GeminiProfile::with_summarizer(model, summarizer) + .with_identity(provider.provider, provider.provider_id.clone()) + .with_catalog(catalog), + ), + AgentProfileKind::Anthropic => Box::new( + AnthropicProfile::with_summarizer(model, summarizer) + .with_identity(provider.provider, provider.provider_id.clone()) .with_catalog(catalog), ), - Provider::Gemini => { - Box::new(GeminiProfile::with_summarizer(model, summarizer).with_catalog(catalog)) - } - Provider::Anthropic => { - Box::new(AnthropicProfile::with_summarizer(model, summarizer).with_catalog(catalog)) - } } } -fn parse_provider(args: &AgentArgs) -> anyhow::Result { - let provider_str = args.provider.as_deref().unwrap_or("anthropic"); - provider_str - .parse() - .map_err(|_| anyhow::anyhow!("unknown provider: {provider_str}")) +#[derive(Debug, Clone)] +struct ResolvedProvider { + provider: Provider, + provider_id: ProviderId, + profile_kind: AgentProfileKind, +} + +fn resolve_provider(args: &AgentArgs, catalog: &Catalog) -> anyhow::Result { + let provider_id = ProviderId::new(args.provider.as_deref().unwrap_or("anthropic")); + if let Some(catalog_provider) = catalog.provider(&provider_id) { + let metadata = adapter::get(&catalog_provider.adapter).ok_or_else(|| { + anyhow::anyhow!( + "provider '{}' uses unknown adapter '{}'", + catalog_provider.id, + catalog_provider.adapter + ) + })?; + let profile_kind = metadata.default_profile; + let provider = adapter::profile_provider_for_provider_id( + &catalog_provider.id, + &catalog_provider.adapter, + ); + return Ok(ResolvedProvider { + provider, + provider_id: catalog_provider.id.clone(), + profile_kind, + }); + } + + let provider = Provider::from_id(&provider_id) + .ok_or_else(|| anyhow::anyhow!("unknown provider: {}", provider_id.as_str()))?; + let profile_kind = adapter::default_profile_for_provider_id(&provider_id); + + Ok(ResolvedProvider { + provider, + provider_id, + profile_kind, + }) } fn standalone_llm_source() -> Arc { @@ -273,16 +327,15 @@ fn standalone_llm_source() -> Arc { } } -fn ensure_provider_registered(client: &Client, provider: Provider) -> anyhow::Result<()> { - if client - .provider_names() - .iter() - .any(|name| *name == <&'static str>::from(provider)) - { +fn ensure_provider_registered(client: &Client, provider_id: &ProviderId) -> anyhow::Result<()> { + if client.has_provider(provider_id.as_str()) { return Ok(()); } - anyhow::bail!("LLM credentials not configured for provider '{provider}'"); + anyhow::bail!( + "LLM credentials not configured for provider '{}'", + provider_id.as_str() + ); } fn format_tool_args(args: &serde_json::Value, cwd: &str) -> String { @@ -445,7 +498,11 @@ pub async fn run_with_args( mcp_servers: Vec, ) -> anyhow::Result<()> { let llm_source = standalone_llm_source(); - run_with_args_and_source(args, llm_source, mcp_servers).await + let catalog = Arc::new( + Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) + .context("failed to build standalone agent LLM catalog")?, + ); + run_with_args_and_source_and_catalog(args, llm_source, mcp_servers, catalog).await } #[allow( @@ -458,16 +515,28 @@ pub async fn run_with_args_and_source( llm_source: Arc, mcp_servers: Vec, ) -> anyhow::Result<()> { - let provider = parse_provider(&args)?; let catalog = Arc::new( Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) .context("failed to build standalone agent LLM catalog")?, ); + run_with_args_and_source_and_catalog(args, llm_source, mcp_servers, catalog).await +} + +#[allow( + clippy::print_stdout, + clippy::print_stderr, + reason = "Assistant output stays on stdout while prompts and diagnostics use stderr." +)] +pub async fn run_with_args_and_source_and_catalog( + args: AgentArgs, + llm_source: Arc, + mcp_servers: Vec, + catalog: Arc, +) -> anyhow::Result<()> { let client = Client::from_source(llm_source.as_ref(), Arc::clone(&catalog)) .await .context("Failed to create LLM client")?; - ensure_provider_registered(&client, provider)?; - run_with_args_and_client(args, client, mcp_servers).await + run_with_args_and_client_and_catalog(args, client, mcp_servers, catalog).await } #[allow( @@ -476,16 +545,34 @@ pub async fn run_with_args_and_source( reason = "Assistant output stays on stdout while prompts and diagnostics use stderr." )] pub async fn run_with_args_and_client( + args: AgentArgs, + client: Client, + mcp_servers: Vec, +) -> anyhow::Result<()> { + let catalog = Arc::new( + Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) + .context("failed to build standalone agent LLM catalog")?, + ); + run_with_args_and_client_and_catalog(args, client, mcp_servers, catalog).await +} + +#[allow( + clippy::print_stdout, + clippy::print_stderr, + reason = "Assistant output stays on stdout while prompts and diagnostics use stderr." +)] +pub async fn run_with_args_and_client_and_catalog( args: AgentArgs, mut client: Client, mcp_servers: Vec, + catalog: Arc, ) -> anyhow::Result<()> { // Resolve color support once, leak to get 'static lifetime for use across // threads let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let provider = parse_provider(&args)?; - ensure_provider_registered(&client, provider)?; + let provider = resolve_provider(&args, catalog.as_ref())?; + ensure_provider_registered(&client, &provider.provider_id)?; if args.verbose { client.add_middleware(Arc::new(VerboseMiddleware { styles })); @@ -494,27 +581,25 @@ pub async fn run_with_args_and_client( } // Resolve model and build profile - let catalog = Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .context("failed to build standalone agent LLM catalog")?, - ); let model = if let Some(model) = args.model.clone() { model } else { catalog - .default_for_provider(&provider.id()) + .default_for_provider(&provider.provider_id) .map(|model| model.id.clone()) .ok_or_else(|| { anyhow::anyhow!( - "provider '{provider}' has no default model in the catalog; pass --model explicitly" + "provider '{}' has no default model in the catalog; pass --model explicitly", + provider.provider_id.as_str() ) })? }; eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}"))); + let summarizer_model = summarizer_model_id(&provider, &model, catalog.as_ref()); let mut profile = build_profile( - provider, + &provider, &model, - Some(build_summarizer(provider, client.clone())), + Some(build_summarizer(summarizer_model.clone(), client.clone())), Arc::clone(&catalog), ); @@ -552,10 +637,15 @@ pub async fn run_with_args_and_client( let factory_catalog = Arc::clone(&catalog); let factory_env = Arc::clone(&env); let factory_hooks = config.tool_hooks.clone(); + let factory_provider = provider.clone(); + let factory_summarizer_model = summarizer_model.clone(); let factory: SessionFactory = Arc::new(move || { - let child_summarizer = Some(build_summarizer(provider, factory_client.clone())); + let child_summarizer = Some(build_summarizer( + factory_summarizer_model.clone(), + factory_client.clone(), + )); let child_profile: Arc = Arc::from(build_profile( - provider, + &factory_provider, &factory_model, child_summarizer, Arc::clone(&factory_catalog), @@ -756,6 +846,7 @@ mod tests { use std::collections::HashMap; use fabro_model::Provider; + use fabro_model::catalog::ProviderCatalogSettings; use serde_json::json; use super::*; @@ -860,22 +951,33 @@ mod tests { Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()) } + fn resolved_builtin(provider: Provider) -> ResolvedProvider { + let provider_id = provider.id(); + ResolvedProvider { + provider, + provider_id: provider_id.clone(), + profile_kind: adapter::default_profile_for_provider_id(&provider_id), + } + } + #[test] fn build_profile_anthropic() { - let profile = build_profile(Provider::Anthropic, "model", None, test_catalog()); + let provider = resolved_builtin(Provider::Anthropic); + let profile = build_profile(&provider, "model", None, test_catalog()); assert_eq!(profile.provider(), Provider::Anthropic); } #[test] fn build_profile_openai() { - let profile = build_profile(Provider::OpenAi, "model", None, test_catalog()); + let provider = resolved_builtin(Provider::OpenAi); + let profile = build_profile(&provider, "model", None, test_catalog()); assert_eq!(profile.provider(), Provider::OpenAi); } #[test] fn ensure_provider_registered_reports_missing_credentials() { let client = Client::new(HashMap::new(), None, vec![]); - let error = ensure_provider_registered(&client, Provider::Anthropic).unwrap_err(); + let error = ensure_provider_registered(&client, &Provider::Anthropic.id()).unwrap_err(); assert_eq!( error.to_string(), "LLM credentials not configured for provider 'anthropic'" @@ -884,15 +986,82 @@ mod tests { #[test] fn build_profile_gemini() { - let profile = build_profile(Provider::Gemini, "model", None, test_catalog()); + let provider = resolved_builtin(Provider::Gemini); + let profile = build_profile(&provider, "model", None, test_catalog()); assert_eq!(profile.provider(), Provider::Gemini); } + #[test] + fn resolve_provider_accepts_custom_catalog_provider() { + let mut settings = LlmCatalogSettings::default(); + settings + .providers + .insert("bedrock".to_string(), ProviderCatalogSettings { + display_name: Some("Bedrock".to_string()), + adapter: Some("openai_compatible".to_string()), + base_url: Some("https://example.invalid/v1".to_string()), + ..ProviderCatalogSettings::default() + }); + let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); + let args = AgentArgs { + prompt: "test".to_string(), + provider: Some("bedrock".to_string()), + model: None, + permissions: None, + auto_approve: false, + debug: false, + verbose: false, + skills_dir: None, + output_format: None, + }; + + let resolved = resolve_provider(&args, &catalog).unwrap(); + assert_eq!(resolved.provider_id, ProviderId::new("bedrock")); + assert_eq!(resolved.provider, Provider::OpenAiCompatible); + assert_eq!(resolved.profile_kind, AgentProfileKind::OpenAi); + } + + #[test] + fn summarizer_model_id_uses_selected_model_for_custom_provider_without_catalog_default() { + let mut settings = LlmCatalogSettings::default(); + settings + .providers + .insert("bedrock".to_string(), ProviderCatalogSettings { + display_name: Some("Bedrock".to_string()), + adapter: Some("openai_compatible".to_string()), + base_url: Some("https://example.invalid/v1".to_string()), + ..ProviderCatalogSettings::default() + }); + let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); + let args = AgentArgs { + prompt: "test".to_string(), + provider: Some("bedrock".to_string()), + model: Some("bedrock-claude-sonnet-4-6".to_string()), + permissions: None, + auto_approve: false, + debug: false, + verbose: false, + skills_dir: None, + output_format: None, + }; + let resolved = resolve_provider(&args, &catalog).unwrap(); + + let model_id = summarizer_model_id( + &resolved, + args.model.as_deref().expect("explicit model"), + &catalog, + ); + + assert_eq!(model_id.provider(), &ProviderId::new("bedrock")); + assert_eq!(model_id.model_id(), "bedrock-claude-sonnet-4-6"); + } + // subagent tool registration tests #[test] fn build_profile_can_register_subagent_tools() { - let mut profile = build_profile(Provider::Anthropic, "model", None, test_catalog()); + let provider = resolved_builtin(Provider::Anthropic); + let mut profile = build_profile(&provider, "model", None, test_catalog()); let manager = Arc::new(AsyncMutex::new(SubAgentManager::new(1))); let factory: SessionFactory = Arc::new(|| { panic!("factory should not be called in this test"); diff --git a/lib/crates/fabro-agent/src/profiles/anthropic.rs b/lib/crates/fabro-agent/src/profiles/anthropic.rs index 379fe7c07..ed1ea4149 100644 --- a/lib/crates/fabro-agent/src/profiles/anthropic.rs +++ b/lib/crates/fabro-agent/src/profiles/anthropic.rs @@ -51,7 +51,14 @@ impl AnthropicProfile { #[must_use] pub fn with_provider(mut self, provider: Provider) -> Self { self.base.provider = provider; - self.base.provider_id = provider.id(); + self + } + + /// Override the provider identity and provider ID together. + #[must_use] + pub fn with_identity(mut self, provider: Provider, provider_id: ProviderId) -> Self { + self.base.provider = provider; + self.base.provider_id = provider_id; self } @@ -211,6 +218,17 @@ mod tests { assert_eq!(profile.model(), "claude-sonnet-4-20250514"); } + #[test] + fn with_provider_preserves_explicit_provider_id() { + let custom_id = ProviderId::new("acme-anthropic"); + let profile = AnthropicProfile::new("model") + .with_provider_id(custom_id.clone()) + .with_provider(Provider::Anthropic); + + assert_eq!(profile.provider(), Provider::Anthropic); + assert_eq!(profile.provider_id(), custom_id); + } + #[test] fn anthropic_context_window_from_catalog() { let profile = AnthropicProfile::new("claude-opus-4-6").with_catalog(test_catalog()); diff --git a/lib/crates/fabro-agent/src/profiles/gemini.rs b/lib/crates/fabro-agent/src/profiles/gemini.rs index 9ba16df92..eb9c2fe9c 100644 --- a/lib/crates/fabro-agent/src/profiles/gemini.rs +++ b/lib/crates/fabro-agent/src/profiles/gemini.rs @@ -52,7 +52,14 @@ impl GeminiProfile { #[must_use] pub fn with_provider(mut self, provider: Provider) -> Self { self.base.provider = provider; - self.base.provider_id = provider.id(); + self + } + + /// Override the provider identity and provider ID together. + #[must_use] + pub fn with_identity(mut self, provider: Provider, provider_id: ProviderId) -> Self { + self.base.provider = provider; + self.base.provider_id = provider_id; self } @@ -253,6 +260,17 @@ mod tests { assert_eq!(profile.model(), "gemini-2.0-flash"); } + #[test] + fn with_provider_preserves_explicit_provider_id() { + let custom_id = ProviderId::new("acme-gemini"); + let profile = GeminiProfile::new("model") + .with_provider_id(custom_id.clone()) + .with_provider(Provider::Gemini); + + assert_eq!(profile.provider(), Provider::Gemini); + assert_eq!(profile.provider_id(), custom_id); + } + #[test] fn gemini_context_window_from_catalog() { let profile = GeminiProfile::new("gemini-3.1-pro-preview").with_catalog(test_catalog()); diff --git a/lib/crates/fabro-agent/src/profiles/openai.rs b/lib/crates/fabro-agent/src/profiles/openai.rs index 90e382045..c09246547 100644 --- a/lib/crates/fabro-agent/src/profiles/openai.rs +++ b/lib/crates/fabro-agent/src/profiles/openai.rs @@ -49,7 +49,14 @@ impl OpenAiProfile { #[must_use] pub fn with_provider(mut self, provider: Provider) -> Self { self.base.provider = provider; - self.base.provider_id = provider.id(); + self + } + + /// Override the provider identity and provider ID together. + #[must_use] + pub fn with_identity(mut self, provider: Provider, provider_id: ProviderId) -> Self { + self.base.provider = provider; + self.base.provider_id = provider_id; self } @@ -244,6 +251,17 @@ mod tests { assert_eq!(profile.model(), "o3-mini"); } + #[test] + fn with_provider_preserves_explicit_provider_id() { + let custom_id = ProviderId::new("bedrock"); + let profile = OpenAiProfile::new("model") + .with_provider_id(custom_id.clone()) + .with_provider(Provider::OpenAiCompatible); + + assert_eq!(profile.provider(), Provider::OpenAiCompatible); + assert_eq!(profile.provider_id(), custom_id); + } + #[test] fn openai_system_prompt_contains_env_context() { let profile = OpenAiProfile::new("o3-mini"); @@ -333,7 +351,7 @@ mod tests { #[test] fn kimi_provider_prompt_uses_catalog_display_name() { let profile = OpenAiProfile::new("kimi-k2.5") - .with_provider(Provider::Kimi) + .with_identity(Provider::Kimi, Provider::Kimi.id()) .with_catalog(test_catalog()); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); @@ -344,7 +362,7 @@ mod tests { #[test] fn zai_provider_prompt_uses_catalog_display_name() { let profile = OpenAiProfile::new("glm-4.7") - .with_provider(Provider::Zai) + .with_identity(Provider::Zai, Provider::Zai.id()) .with_catalog(test_catalog()); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); @@ -354,7 +372,7 @@ mod tests { #[test] fn minimax_provider_prompt_uses_catalog_display_name() { let profile = OpenAiProfile::new("minimax-m2.5") - .with_provider(Provider::Minimax) + .with_identity(Provider::Minimax, Provider::Minimax.id()) .with_catalog(test_catalog()); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); @@ -364,7 +382,7 @@ mod tests { #[test] fn inception_provider_prompt_uses_catalog_display_name() { let profile = OpenAiProfile::new("mercury-2") - .with_provider(Provider::Inception) + .with_identity(Provider::Inception, Provider::Inception.id()) .with_catalog(test_catalog()); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); diff --git a/lib/crates/fabro-agent/tests/it/guardrails.rs b/lib/crates/fabro-agent/tests/it/guardrails.rs index 985cb7bc4..4a990856c 100644 --- a/lib/crates/fabro-agent/tests/it/guardrails.rs +++ b/lib/crates/fabro-agent/tests/it/guardrails.rs @@ -27,7 +27,7 @@ fn profile_context_window_matches_catalog_for_default_models() { | Provider::Inception | Provider::OpenAiCompatible => Box::new( OpenAiProfile::new(model) - .with_provider(provider) + .with_identity(provider, provider.id()) .with_catalog(Arc::clone(&catalog)), ), Provider::Gemini => { diff --git a/lib/crates/fabro-agent/tests/it/parity_matrix.rs b/lib/crates/fabro-agent/tests/it/parity_matrix.rs index 8a795ac24..31f27880d 100644 --- a/lib/crates/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/crates/fabro-agent/tests/it/parity_matrix.rs @@ -66,9 +66,10 @@ fn build_profile(provider: Provider, model: &str, client: &Client) -> Box { - Box::new(OpenAiProfile::with_summarizer(model, summarizer).with_provider(provider)) - } + | Provider::OpenAiCompatible => Box::new( + OpenAiProfile::with_summarizer(model, summarizer) + .with_identity(provider, provider.id()), + ), Provider::Gemini => Box::new(GeminiProfile::with_summarizer(model, summarizer)), } } @@ -106,7 +107,7 @@ async fn make_session( | Provider::Inception | Provider::OpenAiCompatible => Arc::new( OpenAiProfile::with_summarizer(&factory_model, summarizer) - .with_provider(provider), + .with_identity(provider, provider.id()), ), Provider::Gemini => { Arc::new(GeminiProfile::with_summarizer(&factory_model, summarizer)) diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index f832ae215..0e118d2a0 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -2,7 +2,9 @@ use std::collections::HashMap; use std::sync::Arc; use anyhow::{Context as _, Result as AnyResult}; -use fabro_agent::cli::{OutputFormat, run_with_args_and_client, run_with_args_and_source}; +use fabro_agent::cli::{ + OutputFormat, run_with_args_and_client_and_catalog, run_with_args_and_source_and_catalog, +}; use fabro_llm::client::Client; use fabro_llm::error::{ Error as LlmError, ProviderErrorDetail, ProviderErrorKind, error_from_status_code, @@ -13,6 +15,7 @@ use fabro_llm::types::{ FinishReason, Message, Request, Response as LlmResponse, StreamEvent, TokenCounts, }; use fabro_mcp::config::McpServerSettings; +use fabro_model::ProviderId; use fabro_types::settings::InterpString; use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; use fabro_util::exit::{self, ErrorExt, ExitClass}; @@ -320,23 +323,29 @@ pub(crate) async fn execute(mut args: ExecArgs, ctx: &CommandContext) -> AnyResu .provider .clone() .unwrap_or_else(|| "anthropic".to_string()); + let catalog = ctx.catalog()?; + let provider_id = ProviderId::from(provider_name.as_str()); + let adapter_provider_name = catalog + .provider(&provider_id) + .map_or(provider_name.as_str(), |provider| provider.id.as_str()); let server_client = server_client::connect_server_target(&target).await?; let adapter = Arc::new(AuthenticatedFabroServerAdapter::new( server_client, - &provider_name, + adapter_provider_name, )); let mut client = Client::new(HashMap::new(), None, vec![]); client .register_provider(adapter) .await .context("Failed to register fabro server adapter")?; - run_with_args_and_client(args.agent, client, mcp_servers) + run_with_args_and_client_and_catalog(args.agent, client, mcp_servers, catalog) .await .map_err(classify_server_agent_auth)?; } else { tracing::info!(transport = "direct", "Agent session starting"); let llm_source = ctx.llm_source().await?; - run_with_args_and_source(args.agent, llm_source, mcp_servers).await?; + let catalog = ctx.catalog()?; + run_with_args_and_source_and_catalog(args.agent, llm_source, mcp_servers, catalog).await?; } Ok(()) diff --git a/lib/crates/fabro-cli/tests/it/cmd/exec.rs b/lib/crates/fabro-cli/tests/it/cmd/exec.rs index 56c07735b..89ee38adc 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/exec.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/exec.rs @@ -41,7 +41,7 @@ fn help() { Options: --json Output as JSON [env: FABRO_JSON=] --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] - --provider LLM provider (anthropic, openai, gemini, kimi, zai, minimax, inception) + --provider LLM provider (built-in or configured provider ID) --model Model name (defaults per provider) --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --permissions Permission level for tool execution [possible values: read-only, read-write, full] @@ -116,6 +116,32 @@ fn exec_uses_user_config_defaults() { "); } +#[test] +fn exec_accepts_configured_custom_provider_from_settings() { + let context = test_context!(); + context.write_home( + ".fabro/settings.toml", + "_version = 1\n\n[llm.providers.bedrock]\nadapter = \"openai_compatible\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[cli.exec.model]\nprovider = \"bedrock\"\nname = \"bedrock-claude-sonnet-4-6\"\n", + ); + + let mut cmd = context.exec_cmd(); + cmd.arg("test prompt"); + cmd.env_clear(); + preserve_coverage_env!(cmd); + cmd.env("HOME", &context.home_dir); + cmd.env("FABRO_STORAGE_DIR", &context.storage_dir); + cmd.env("FABRO_NO_UPGRADE_CHECK", "true") + .env("FABRO_HTTP_PROXY_POLICY", "disabled"); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + × LLM credentials not configured for provider 'bedrock' + "); +} + #[test] fn exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution() { let context = test_context!(); @@ -155,6 +181,43 @@ fn exec_server_target_uses_remote_transport_instead_of_local_api_key_resolution( ); } +#[test] +fn exec_server_target_accepts_configured_custom_provider_from_settings() { + let context = test_context!(); + context.write_home( + ".fabro/settings.toml", + "_version = 1\n\n[llm.providers.bedrock]\nadapter = \"openai_compatible\"\nbase_url = \"https://bedrock.example.invalid/v1\"\n\n[cli.exec.model]\nprovider = \"bedrock\"\nname = \"bedrock-claude-sonnet-4-6\"\n", + ); + let server = MockServer::start(); + server.mock(|when, then| { + when.method("POST").path("/api/v1/completions"); + then.status(400).body("custom-server-routed-marker"); + }); + + let mut cmd = context.exec_cmd(); + cmd.env_clear(); + preserve_coverage_env!(cmd); + cmd.env("HOME", &context.home_dir); + cmd.env("FABRO_NO_UPGRADE_CHECK", "true") + .env("FABRO_HTTP_PROXY_POLICY", "disabled"); + cmd.args([ + "--server", + &format!("{}/api/v1", server.base_url()), + "test prompt", + ]); + + let output = cmd.assert().failure().get_output().clone(); + let stderr = String::from_utf8(output.stderr).expect("valid utf8"); + assert!( + stderr.contains("custom-server-routed-marker"), + "expected remote server failure marker, got: {stderr}" + ); + assert!( + !stderr.contains("unknown provider: bedrock"), + "exec should resolve custom providers from settings for remote transport: {stderr}" + ); +} + #[test] fn exec_configured_server_target_alone_does_not_reroute_exec() { let context = test_context!(); diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index edc020338..818faddea 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -1678,7 +1678,7 @@ mod tests { use chrono::Duration as ChronoDuration; use fabro_util::exit; - use httpmock::Method::POST; + use httpmock::Method::{GET, POST}; use httpmock::MockServer; use serde_json::json; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -1811,6 +1811,33 @@ mod tests { server.await.unwrap(); } + #[tokio::test] + async fn list_models_allows_custom_provider_filters() { + let server = MockServer::start_async().await; + let mock = server + .mock_async(|when, then| { + when.method(GET) + .path("/api/v1/models") + .query_param("provider", "bedrock"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [], + "meta": { "has_more": false } + }) + .to_string(), + ); + }) + .await; + + let client = Client::new_no_proxy(&server.url("")).unwrap(); + let models = client.list_models(Some("bedrock"), None).await.unwrap(); + + mock.assert_async().await; + assert!(models.is_empty()); + } + async fn oauth_client( server: &MockServer, ) -> (tempfile::TempDir, Client, AuthStore, ServerTarget) { diff --git a/lib/crates/fabro-model/src/adapter.rs b/lib/crates/fabro-model/src/adapter.rs index d9b3ddd80..f6b94cbaa 100644 --- a/lib/crates/fabro-model/src/adapter.rs +++ b/lib/crates/fabro-model/src/adapter.rs @@ -59,13 +59,16 @@ pub struct AdapterControlCapabilities { pub struct AdapterMetadata { /// Stable adapter key referenced from `[llm.providers.] adapter = /// "..."`. - pub key: &'static str, + pub key: &'static str, /// Default agent profile dispatched for providers that use this adapter. - pub default_profile: AgentProfileKind, + pub default_profile: AgentProfileKind, + /// Provider behavior to use when the catalog provider ID is not a built-in + /// provider enum variant. + pub fallback_provider: Provider, /// How API keys for this adapter are converted into auth headers. - pub api_key_header: ApiKeyHeaderPolicy, + pub api_key_header: ApiKeyHeaderPolicy, /// Native control values the adapter can transmit. - pub controls: AdapterControlCapabilities, + pub controls: AdapterControlCapabilities, } /// Every reasoning-effort variant. Re-exposed as a const slice so static @@ -76,10 +79,11 @@ const FAST_SPEEDS: &[Speed] = &[Speed::Fast]; /// Anthropic — `anthropic` adapter. pub const ANTHROPIC: AdapterMetadata = AdapterMetadata { - key: "anthropic", - default_profile: AgentProfileKind::Anthropic, - api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-api-key" }, - controls: AdapterControlCapabilities { + key: "anthropic", + default_profile: AgentProfileKind::Anthropic, + fallback_provider: Provider::Anthropic, + api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-api-key" }, + controls: AdapterControlCapabilities { native_reasoning_effort: FULL_REASONING_EFFORTS, additional_speeds: FAST_SPEEDS, }, @@ -87,10 +91,11 @@ pub const ANTHROPIC: AdapterMetadata = AdapterMetadata { /// OpenAI — `openai` adapter. pub const OPENAI: AdapterMetadata = AdapterMetadata { - key: "openai", - default_profile: AgentProfileKind::OpenAi, - api_key_header: ApiKeyHeaderPolicy::Bearer, - controls: AdapterControlCapabilities { + key: "openai", + default_profile: AgentProfileKind::OpenAi, + fallback_provider: Provider::OpenAi, + api_key_header: ApiKeyHeaderPolicy::Bearer, + controls: AdapterControlCapabilities { native_reasoning_effort: FULL_REASONING_EFFORTS, additional_speeds: &[], }, @@ -98,12 +103,13 @@ pub const OPENAI: AdapterMetadata = AdapterMetadata { /// Google Gemini — `gemini` adapter. pub const GEMINI: AdapterMetadata = AdapterMetadata { - key: "gemini", - default_profile: AgentProfileKind::Gemini, - api_key_header: ApiKeyHeaderPolicy::Custom { + key: "gemini", + default_profile: AgentProfileKind::Gemini, + fallback_provider: Provider::Gemini, + api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-goog-api-key", }, - controls: AdapterControlCapabilities { + controls: AdapterControlCapabilities { native_reasoning_effort: FULL_REASONING_EFFORTS, additional_speeds: &[], }, @@ -113,10 +119,11 @@ pub const GEMINI: AdapterMetadata = AdapterMetadata { /// Routes through the OpenAI agent profile but accepts arbitrary `base_url` /// per provider settings. pub const OPENAI_COMPATIBLE: AdapterMetadata = AdapterMetadata { - key: "openai_compatible", - default_profile: AgentProfileKind::OpenAi, - api_key_header: ApiKeyHeaderPolicy::Bearer, - controls: AdapterControlCapabilities { + key: "openai_compatible", + default_profile: AgentProfileKind::OpenAi, + fallback_provider: Provider::OpenAiCompatible, + api_key_header: ApiKeyHeaderPolicy::Bearer, + controls: AdapterControlCapabilities { // `openai_compatible` providers vary widely; the catalog requires // models declaring `features.reasoning_effort = "levels"` to // enumerate exactly which effort values their endpoint accepts. @@ -158,6 +165,24 @@ pub fn default_for_provider_id(provider: &ProviderId) -> &'static str { } } +/// Default agent profile for a provider ID. +#[must_use] +pub fn default_profile_for_provider_id(provider: &ProviderId) -> AgentProfileKind { + get(default_for_provider_id(provider)) + .expect("default adapter key must be registered") + .default_profile +} + +/// Agent-facing provider enum to use for a catalog provider. +#[must_use] +pub fn profile_provider_for_provider_id(provider_id: &ProviderId, adapter_key: &str) -> Provider { + Provider::from_id(provider_id).unwrap_or_else(|| { + get(adapter_key) + .expect("catalog provider adapter key must be registered") + .fallback_provider + }) +} + #[cfg(test)] mod tests { use super::*; @@ -208,6 +233,53 @@ mod tests { assert_eq!(OPENAI_COMPATIBLE.default_profile, AgentProfileKind::OpenAi); } + #[test] + fn adapters_declare_fallback_provider_for_custom_provider_ids() { + assert_eq!(ANTHROPIC.fallback_provider, Provider::Anthropic); + assert_eq!(OPENAI.fallback_provider, Provider::OpenAi); + assert_eq!(GEMINI.fallback_provider, Provider::Gemini); + assert_eq!( + OPENAI_COMPATIBLE.fallback_provider, + Provider::OpenAiCompatible + ); + } + + #[test] + fn default_profile_for_builtin_provider_ids_uses_adapter_metadata() { + assert_eq!( + default_profile_for_provider_id(&ProviderId::anthropic()), + AgentProfileKind::Anthropic + ); + assert_eq!( + default_profile_for_provider_id(&ProviderId::openai()), + AgentProfileKind::OpenAi + ); + assert_eq!( + default_profile_for_provider_id(&ProviderId::gemini()), + AgentProfileKind::Gemini + ); + } + + #[test] + fn profile_provider_for_provider_id_preserves_builtins() { + assert_eq!( + profile_provider_for_provider_id(&ProviderId::new("kimi"), OPENAI_COMPATIBLE.key,), + Provider::Kimi + ); + } + + #[test] + fn profile_provider_for_custom_provider_maps_openai_compatible_adapter() { + assert_eq!( + profile_provider_for_provider_id(&ProviderId::new("bedrock"), OPENAI_COMPATIBLE.key), + Provider::OpenAiCompatible + ); + assert_eq!( + profile_provider_for_provider_id(&ProviderId::new("openrouter"), OPENAI.key), + Provider::OpenAi + ); + } + #[test] fn every_adapter_supports_full_native_reasoning_effort() { for adapter in ALL_ADAPTERS { diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 5f38a56e5..9a618a08a 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -177,38 +177,22 @@ fn build_profile( match profile_kind { AgentProfileKind::OpenAi => Box::new( OpenAiProfile::new(model) - .with_provider(provider) - .with_provider_id(provider_id) + .with_identity(provider, provider_id) .with_catalog(catalog), ), AgentProfileKind::Gemini => Box::new( GeminiProfile::new(model) - .with_provider(provider) - .with_provider_id(provider_id) + .with_identity(provider, provider_id) .with_catalog(catalog), ), AgentProfileKind::Anthropic => Box::new( AnthropicProfile::new(model) - .with_provider(provider) - .with_provider_id(provider_id) + .with_identity(provider, provider_id) .with_catalog(catalog), ), } } -fn default_profile_kind(provider: Provider) -> AgentProfileKind { - match provider { - Provider::Anthropic => AgentProfileKind::Anthropic, - Provider::Gemini => AgentProfileKind::Gemini, - Provider::OpenAi - | Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => AgentProfileKind::OpenAi, - } -} - pub(super) fn effective_request_controls( catalog: &Catalog, run_model_controls: &RunModelControls, @@ -288,19 +272,6 @@ fn legacy_reasoning_effort_default(catalog: &Catalog, model: &str) -> Option Provider { - Provider::from_id(provider_id).unwrap_or(match (profile_kind, adapter) { - (AgentProfileKind::Anthropic, _) => Provider::Anthropic, - (AgentProfileKind::Gemini, _) => Provider::Gemini, - (AgentProfileKind::OpenAi, "openai_compatible") => Provider::OpenAiCompatible, - (AgentProfileKind::OpenAi, _) => Provider::OpenAi, - }) -} - /// Shared state for tracking file modifications from agent tool calls. struct FileTracking { /// Maps tool_call_id → file_path for in-flight write/edit calls. @@ -416,7 +387,7 @@ impl AgentApiBackend { model, provider, provider.id(), - default_profile_kind(provider), + adapter::default_profile_for_provider_id(&provider.id()), fallback_chain, source, steering_hub, @@ -534,11 +505,7 @@ impl AgentApiBackend { )) })?; Ok(ProviderContext { - provider: profile_provider_for_catalog_provider( - &provider.id, - profile_kind, - &provider.adapter, - ), + provider: adapter::profile_provider_for_provider_id(&provider.id, &provider.adapter), provider_id: provider.id.clone(), profile_kind, }) diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 9ab9a61be..22c24caee 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use fabro_auth::{CredentialSource, EnvCredentialSource, VaultCredentialSource}; use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_mcp::config::{McpServerSettings, McpTransport}; -use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, Provider, ProviderId, adapter}; +use fabro_model::{Catalog, FallbackTarget, ProviderId, adapter}; use fabro_sandbox::config::{ DaytonaNetwork, DaytonaSnapshotSettings, DaytonaVolumeMount, DockerfileSource as SandboxDockerfileSource, @@ -356,9 +356,8 @@ impl RunSession { catalog_provider.adapter, )) })?; - let provider_enum = Provider::from_id(&provider_id).unwrap_or_else(|| { - profile_provider_for_custom_provider(profile_kind, &catalog_provider.adapter) - }); + let provider_enum = + adapter::profile_provider_for_provider_id(&provider_id, &catalog_provider.adapter); let fallback_chain = resolve_fallback_chain(catalog.as_ref(), &provider_id, &model, &resolved.model); @@ -492,15 +491,6 @@ async fn configured_providers_for_start( source.configured_providers(catalog).await } -fn profile_provider_for_custom_provider(profile_kind: AgentProfileKind, adapter: &str) -> Provider { - match (profile_kind, adapter) { - (AgentProfileKind::Anthropic, _) => Provider::Anthropic, - (AgentProfileKind::Gemini, _) => Provider::Gemini, - (AgentProfileKind::OpenAi, "openai_compatible") => Provider::OpenAiCompatible, - (AgentProfileKind::OpenAi, _) => Provider::OpenAi, - } -} - fn resolve_interp(value: &InterpString) -> String { value .resolve(process_env_var) @@ -1131,6 +1121,7 @@ mod tests { DaytonaSandboxLayer, DaytonaVolumeLayer, RunCloneLayer, RunExecutionLayer, RunLayer, RunSandboxLayer, WorkflowSettingsBuilder, }; + use fabro_model::Provider; use fabro_model::catalog::LlmCatalogSettings; use fabro_store::Database; use fabro_types::settings::ModelRef; From d09e6cde33879ac5c6a42f52a29c1029fd7e5a43 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 09:47:27 -0700 Subject: [PATCH 08/25] feat(pr): support GitHub pull request associations (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds event-sourced pull request association management for runs while preserving Fabro-created PR creation. A run can now store a current GitHub PR association, replace it by linking another GitHub PR URL, and remove it through an unlink event. ## What Changed - Added `pull_request.linked` and `pull_request.unlinked` events, projection replay support, and optional PR metadata fields in shared pull request records. - Added API, server, and client support for `PUT /runs/{id}/pull_request` and `DELETE /runs/{id}/pull_request`; linking accepts GitHub PR URLs, infers owner/repo/number, and captures live GitHub title and branch metadata when available. - Added `fabro pr link` and `fabro pr unlink`, updated `fabro pr view`, and kept create/merge/close behavior guarded to GitHub PRs with usable coordinates. - Updated web UI rendering and internal event docs so stored PR links display cleanly when live GitHub details are unavailable. ## Testing - `cargo +nightly-2026-04-14 fmt --check --all` - `git diff --check` - `cargo build -p fabro-api` - `cargo nextest run -p fabro-types -p fabro-store -p fabro-server -p fabro-cli` - `bun run typecheck` in `lib/packages/fabro-api-client` - `bun run typecheck` in `apps/fabro-web` - `bun test` in `apps/fabro-web` Refs https://github.com/fabro-sh/fabro/issues/235 --- [![Compound Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 via [Codex](https://openai.com/codex) --------- Co-authored-by: Haroldo Olivieri <6575718+haroldolivieri@users.noreply.github.com> --- .../app/components/pull-request-chip.test.tsx | 23 ++ .../app/components/pull-request-chip.tsx | 2 +- apps/fabro-web/app/data/runs.test.ts | 16 +- apps/fabro-web/app/lib/board-events.ts | 3 + apps/fabro-web/app/lib/run-events.ts | 3 + apps/fabro-web/app/routes/automation-runs.tsx | 2 +- apps/fabro-web/app/routes/run-detail.test.ts | 9 +- apps/fabro-web/app/routes/run-detail.tsx | 2 +- apps/fabro-web/app/routes/runs.tsx | 4 +- docs/internal/events.md | 43 +++ docs/public/api-reference/fabro-api.yaml | 206 +++++++++---- docs/public/reference/cli.mdx | 43 +++ lib/crates/fabro-api/build.rs | 17 ++ lib/crates/fabro-api/src/lib.rs | 16 +- .../tests/pull_request_round_trip.rs | 138 +++++---- .../fabro-api/tests/run_summary_round_trip.rs | 23 +- lib/crates/fabro-cli/src/args.rs | 26 ++ .../fabro-cli/src/commands/pr/create.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/link.rs | 36 +++ lib/crates/fabro-cli/src/commands/pr/mod.rs | 9 +- .../fabro-cli/src/commands/pr/unlink.rs | 30 ++ lib/crates/fabro-cli/src/commands/pr/view.rs | 49 +-- .../fabro-cli/src/commands/run/events.rs | 50 ++++ .../fabro-cli/src/commands/run/output.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/mod.rs | 2 + lib/crates/fabro-cli/tests/it/cmd/pr.rs | 2 + .../fabro-cli/tests/it/cmd/pr_create.rs | 22 +- lib/crates/fabro-cli/tests/it/cmd/pr_link.rs | 124 ++++++++ .../fabro-cli/tests/it/cmd/pr_unlink.rs | 74 +++++ lib/crates/fabro-cli/tests/it/cmd/pr_view.rs | 121 ++++++-- lib/crates/fabro-client/src/client.rs | 37 +++ lib/crates/fabro-server/src/server.rs | 21 +- .../fabro-server/src/server/handler/events.rs | 16 +- .../src/server/handler/pull_requests.rs | 264 +++++++++++----- lib/crates/fabro-server/src/server/tests.rs | 283 +++++++++++++----- lib/crates/fabro-store/src/run_state.rs | 94 +++++- lib/crates/fabro-types/src/lib.rs | 9 +- lib/crates/fabro-types/src/pull_request.rs | 276 +++++++++++++++-- lib/crates/fabro-types/src/run_event/misc.rs | 12 +- lib/crates/fabro-types/src/run_event/mod.rs | 82 +++++ lib/crates/fabro-types/src/run_summary.rs | 6 +- .../fabro-workflow/src/event/convert.rs | 10 + lib/crates/fabro-workflow/src/event/events.rs | 36 ++- lib/crates/fabro-workflow/src/event/names.rs | 2 + lib/crates/fabro-workflow/src/pipeline/mod.rs | 4 +- .../src/pipeline/pull_request.rs | 47 +-- lib/crates/fabro-workflow/src/pull_request.rs | 4 +- .../src/.openapi-generator/FILES | 6 + .../fabro-api-client/src/api/runs-api.ts | 178 ++++++++++- .../fabro-api-client/src/models/index.ts | 6 + .../models/link-run-pull-request-request.ts | 26 ++ .../src/models/pull-request-details-status.ts | 29 ++ ...pull-request-details-unavailable-reason.ts | 30 ++ .../src/models/pull-request-details.ts | 19 +- .../src/models/pull-request-link.ts | 29 ++ .../src/models/pull-request-meta.ts | 32 ++ .../src/models/pull-request-response.ts | 30 ++ .../src/models/pull-request.ts | 25 +- .../src/models/run-projection.ts | 5 +- .../fabro-api-client/src/models/run.ts | 4 +- .../src/models/sandbox-network-policy-mode.ts | 5 +- .../src/models/sandbox-network-policy.ts | 5 +- .../src/models/sandbox-network.ts | 3 +- 63 files changed, 2207 insertions(+), 527 deletions(-) create mode 100644 apps/fabro-web/app/components/pull-request-chip.test.tsx create mode 100644 lib/crates/fabro-cli/src/commands/pr/link.rs create mode 100644 lib/crates/fabro-cli/src/commands/pr/unlink.rs create mode 100644 lib/crates/fabro-cli/tests/it/cmd/pr_link.rs create mode 100644 lib/crates/fabro-cli/tests/it/cmd/pr_unlink.rs create mode 100644 lib/packages/fabro-api-client/src/models/link-run-pull-request-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/pull-request-details-status.ts create mode 100644 lib/packages/fabro-api-client/src/models/pull-request-details-unavailable-reason.ts create mode 100644 lib/packages/fabro-api-client/src/models/pull-request-link.ts create mode 100644 lib/packages/fabro-api-client/src/models/pull-request-meta.ts create mode 100644 lib/packages/fabro-api-client/src/models/pull-request-response.ts diff --git a/apps/fabro-web/app/components/pull-request-chip.test.tsx b/apps/fabro-web/app/components/pull-request-chip.test.tsx new file mode 100644 index 000000000..d5716157e --- /dev/null +++ b/apps/fabro-web/app/components/pull-request-chip.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; + +import { PullRequestChip } from "./pull-request-chip"; + +describe("PullRequestChip", () => { + test("renders a linked pull request number", () => { + let renderer: TestRenderer.ReactTestRenderer | undefined; + act(() => { + renderer = TestRenderer.create( + , + ); + }); + + const link = renderer!.root.findByType("a"); + const rendered = JSON.stringify(renderer!.toJSON()); + expect(link.props.href).toBe("https://github.com/acme/widgets/pull/42"); + expect(rendered).toContain("#42"); + }); +}); diff --git a/apps/fabro-web/app/components/pull-request-chip.tsx b/apps/fabro-web/app/components/pull-request-chip.tsx index 8b561f6c7..a43621de9 100644 --- a/apps/fabro-web/app/components/pull-request-chip.tsx +++ b/apps/fabro-web/app/components/pull-request-chip.tsx @@ -18,7 +18,7 @@ export function PullRequestChip({ const content = ( <> - #{number} + {`#${number}`} {children} ); diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index dfe10d055..92db7c8a8 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -68,14 +68,10 @@ describe("mapRunListItem", () => { title: "Server supplied title", ...withStatus({ kind: "paused", prior_block: null }), pull_request: { - number: 123, - html_url: "https://github.com/fabro-sh/fabro/pull/123", - provider: "github", owner: "fabro-sh", repo: "fabro", - base_branch: "main", - head_branch: "fabro/run/demo", - title: "Add run PR chip", + number: 123, + html_url: "https://github.com/fabro-sh/fabro/pull/123", }, }); const item = mapRunListItem(summary); @@ -102,14 +98,10 @@ describe("mapRunSummaryToRunItem", () => { test("maps canonical run summary to RunItem", () => { const summary = makeRun({ pull_request: { - html_url: "https://github.com/fabro-sh/fabro/pull/456", - number: 456, - provider: "github", owner: "fabro-sh", repo: "fabro", - base_branch: "main", - head_branch: "fabro/run/demo", - title: "Add run PR chip", + number: 456, + html_url: "https://github.com/fabro-sh/fabro/pull/456", }, }); const item = mapRunSummaryToRunItem(summary); diff --git a/apps/fabro-web/app/lib/board-events.ts b/apps/fabro-web/app/lib/board-events.ts index 97c12bfaa..77e44f520 100644 --- a/apps/fabro-web/app/lib/board-events.ts +++ b/apps/fabro-web/app/lib/board-events.ts @@ -39,6 +39,9 @@ const BOARD_STATUS_EVENTS = new Set([ "interview.completed", "interview.timeout", "interview.interrupted", + "pull_request.created", + "pull_request.linked", + "pull_request.unlinked", ]); const subscriptions = new Map(); diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index a8dda4926..665cf4189 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -47,6 +47,9 @@ const RUN_SUMMARY_EVENTS = new Set([ "run.archived", "run.unarchived", "run.title.updated", + "pull_request.created", + "pull_request.linked", + "pull_request.unlinked", ]); const STAGE_EVENTS = new Set([ "stage.started", diff --git a/apps/fabro-web/app/routes/automation-runs.tsx b/apps/fabro-web/app/routes/automation-runs.tsx index 75cb6e3cd..0ad5dc291 100644 --- a/apps/fabro-web/app/routes/automation-runs.tsx +++ b/apps/fabro-web/app/routes/automation-runs.tsx @@ -56,7 +56,7 @@ function RunRow({ run }: { run: RunWithStatus }) { - {run.number != null && ( + {run.pullRequestUrl && run.number != null && ( {run.checks != null && } diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index 028e9a844..26c803ffe 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -105,7 +105,7 @@ function makeRunSummary( }, billing: null, diff: diffSummary, - pull_request: pullRequest ? { provider: "github", ...pullRequest } : null, + pull_request: pullRequest, current_question: null, superseded_by: null, links: { web: null }, @@ -449,13 +449,10 @@ describe("RunDetail full-height child routes", () => { const renderer = await renderRunDetail({ initialEntry: "/runs/run_1", pullRequest: { - html_url: "https://github.com/fabro-sh/fabro/pull/123", - number: 123, owner: "fabro-sh", repo: "fabro", - base_branch: "main", - head_branch: "fabro/run/demo", - title: "Add run PR chip", + number: 123, + html_url: "https://github.com/fabro-sh/fabro/pull/123", }, }); diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index b8d694843..3b409a66a 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -337,7 +337,7 @@ export default function RunDetail({ params }: { params: { id: string } }) { )} - {run.number != null && run.pullRequestUrl && ( + {run.pullRequestUrl && run.number != null && ( )} - {pr.number != null && ( + {pr.pullRequestUrl && pr.number != null && ( - {run.number != null && ( + {run.pullRequestUrl && run.number != null && ( {run.checks != null && } diff --git a/docs/internal/events.md b/docs/internal/events.md index f548196d6..f9e99fd89 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -2042,6 +2042,49 @@ These legacy events may appear in older run logs. Current CLI backend runs do no | `pr_number` | number | Pull request number | | `draft` | boolean | Whether the PR is a draft | +### `pull_request.linked` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "pull_request.linked", + "properties": { + "pull_request": { + "provider": "github", + "html_url": "https://github.com/org/repo/pull/42", + "number": 42, + "owner": "org", + "repo": "repo", + "title": "Review deployment chart" + } + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `pull_request` | object | Stored GitHub pull request association. `title`, `base_branch`, and `head_branch` may be included when live GitHub metadata is available. | + +### `pull_request.unlinked` + +```json +{ + "id": "...", "ts": "...", "run_id": "...", + "event": "pull_request.unlinked", + "properties": { + "pull_request": { + "provider": "github", + "html_url": "https://github.com/org/repo/pull/42", + "number": 42 + } + } +} +``` + +| Property | Type | Description | +|----------|------|-------------| +| `pull_request` | object | Pull request association removed from the run. | + ### `pull_request.failed` ```json diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 89455db4a..03ba33463 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -1745,7 +1745,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/PullRequest" + $ref: "#/components/schemas/PullRequestLink" "400": description: Pull request creation does not apply to this run headers: @@ -1793,22 +1793,28 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - get: - operationId: getRunPullRequest + put: + operationId: linkRunPullRequest tags: [Runs] - summary: Get Run Pull Request - description: Returns the stored pull request record for a run plus live GitHub details. + summary: Link Run Pull Request + description: Links or replaces the GitHub pull request association for a run without modifying the remote pull request. parameters: - $ref: "#/components/parameters/RunId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LinkRunPullRequestRequest" responses: "200": - description: Pull request detail + description: Pull request linked content: application/json: schema: - $ref: "#/components/schemas/PullRequestDetails" + $ref: "#/components/schemas/PullRequestLink" "400": - description: Pull request lookup does not apply to this run + description: Pull request link request is invalid headers: x-request-id: $ref: "#/components/headers/XRequestId" @@ -1816,6 +1822,29 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + "404": + description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + operationId: unlinkRunPullRequest + tags: [Runs] + summary: Unlink Run Pull Request + description: Removes Fabro's stored pull request association for a run without modifying the remote pull request. + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Pull request unlinked + content: + application/json: + schema: + $ref: "#/components/schemas/PullRequestLink" "404": description: Run or stored pull request record not found headers: @@ -1825,17 +1854,22 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorResponse" - "502": - description: Stored pull request record exists but GitHub could not find it - headers: - x-request-id: - $ref: "#/components/headers/XRequestId" + get: + operationId: getRunPullRequest + tags: [Runs] + summary: Get Run Pull Request + description: Returns the stored pull request record for a run plus live GitHub details when available. + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Pull request detail content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - "503": - description: GitHub integration is unavailable on the server + $ref: "#/components/schemas/PullRequestResponse" + "404": + description: Run or stored pull request record not found headers: x-request-id: $ref: "#/components/headers/XRequestId" @@ -6595,8 +6629,9 @@ components: - $ref: "#/components/schemas/RunSandbox" - type: "null" pull_request: - type: ["object", "null"] - additionalProperties: true + oneOf: + - $ref: "#/components/schemas/PullRequestLink" + - type: "null" superseded_by: type: ["string", "null"] pending_interviews: @@ -6684,7 +6719,7 @@ components: - type: "null" pull_request: oneOf: - - $ref: "#/components/schemas/PullRequest" + - $ref: "#/components/schemas/PullRequestLink" - type: "null" current_question: oneOf: @@ -7071,44 +7106,82 @@ components: description: Error message. example: "Stage 'apply-changes' exceeded maximum retries." - PullRequest: - description: Persisted record of a pull request created for a run. + PullRequestLink: + description: Minimal GitHub pull request link associated with a run. type: object required: - - provider - - html_url - - number - owner - repo - - base_branch - - head_branch - - title + - number + - html_url properties: - provider: - type: string - enum: [github] - html_url: - type: string - format: uri - example: https://github.com/fabro-sh/fabro/pull/123 - number: - type: integer - example: 123 owner: type: string example: fabro-sh repo: type: string example: fabro - base_branch: + number: + type: integer + example: 123 + html_url: type: string - example: main - head_branch: - type: string - example: fabro/run/demo - title: - type: string - example: Move PR commands server-side + format: uri + description: Computed GitHub web URL for the pull request. + example: https://github.com/fabro-sh/fabro/pull/123 + + PullRequest: + description: Stored pull request link plus optional live GitHub details. + type: object + required: + - link + properties: + link: + $ref: "#/components/schemas/PullRequestLink" + details: + oneOf: + - $ref: "#/components/schemas/PullRequestDetails" + - type: "null" + + PullRequestMeta: + description: Metadata for live GitHub detail retrieval. + type: object + required: + - details_status + properties: + details_status: + $ref: "#/components/schemas/PullRequestDetailsStatus" + details_unavailable_reason: + oneOf: + - $ref: "#/components/schemas/PullRequestDetailsUnavailableReason" + - type: "null" + + PullRequestResponse: + description: Pull request link and optional live GitHub details for a run. + type: object + required: + - data + - meta + properties: + data: + $ref: "#/components/schemas/PullRequest" + meta: + $ref: "#/components/schemas/PullRequestMeta" + + PullRequestDetailsStatus: + description: Whether live GitHub pull request details are present. + type: string + enum: + - available + - unavailable + + PullRequestDetailsUnavailableReason: + description: Why live GitHub pull request details are unavailable. + type: string + enum: + - integration_unavailable + - not_found + - fetch_failed PullRequestUser: description: GitHub user summary for a pull request. @@ -7131,25 +7204,27 @@ components: example: fabro/run/demo PullRequestDetails: - description: Stored pull request record plus live GitHub-enriched fields. + description: Live pull request fields retrieved successfully from GitHub. type: object required: - - pull_request + - title - state - draft - merged - - merged_at - - mergeable - additions - deletions - changed_files - - comments - - checks - author + - head_branch + - base_branch - timestamps properties: - pull_request: - $ref: "#/components/schemas/PullRequest" + title: + type: string + example: Move PR commands server-side + body: + type: ["string", "null"] + example: Detailed description state: type: string example: open @@ -7175,15 +7250,14 @@ components: changed_files: type: integer example: 5 - comments: - type: integer - example: 4 - checks: - type: array - items: - $ref: "#/components/schemas/CheckRun" author: $ref: "#/components/schemas/PullRequestUser" + head_branch: + type: string + example: fabro/run/demo + base_branch: + type: string + example: main timestamps: type: object required: [created_at, updated_at] @@ -7210,6 +7284,18 @@ components: description: Optional model override for generating the pull request description. example: claude-sonnet-4-6 + LinkRunPullRequestRequest: + description: Request body for linking an existing GitHub pull request to a run. + type: object + required: + - html_url + properties: + html_url: + type: string + format: uri + description: GitHub pull request URL to associate with the run. Must use the form `https://github.com/{owner}/{repo}/pull/{number}`. + example: https://github.com/fabro-sh/fabro/pull/123 + MergeMethod: description: GitHub merge method for a pull request. type: string diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index 7d5bfc2a7..434293abb 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -641,7 +641,9 @@ fabro pr [OPTIONS] | --- | --- | | `fabro pr close` | Close a pull request | | `fabro pr create` | Create a pull request from a completed run | +| `fabro pr link` | Link or replace the GitHub pull request associated with a run | | `fabro pr merge` | Merge a pull request | +| `fabro pr unlink` | Unlink the pull request associated with a run | | `fabro pr view` | View pull request details | #### `fabro pr close` @@ -686,6 +688,27 @@ fabro pr create [OPTIONS] | `--model ` | LLM model for generating PR description | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | +#### `fabro pr link` + +Link or replace the GitHub pull request associated with a run + +```bash +fabro pr link [OPTIONS] +``` + +#### Arguments + +| Name | Description | +| --- | --- | +| `RUN_ID` | Run ID or prefix | +| `URL` | GitHub pull request URL to associate with the run | + +#### Options + +| Option | Description | +| --- | --- | +| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | + #### `fabro pr merge` Merge a pull request @@ -707,6 +730,26 @@ fabro pr merge [OPTIONS] | `--method ` | Merge method: merge, squash, or rebase
Values: `merge`, `squash`, `rebase`
Default: `squash` | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | +#### `fabro pr unlink` + +Unlink the pull request associated with a run + +```bash +fabro pr unlink [OPTIONS] +``` + +#### Arguments + +| Name | Description | +| --- | --- | +| `RUN_ID` | Run ID or prefix | + +#### Options + +| Option | Description | +| --- | --- | +| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | + #### `fabro pr view` View pull request details diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index eb0e53dd3..dbe3773d8 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -378,7 +378,24 @@ fn main() { ("RunEvent", "fabro_types::RunEvent", &[]), ("EventEnvelope", "fabro_types::EventEnvelope", &[]), ("PullRequest", "fabro_types::PullRequest", &[]), + ("PullRequestLink", "fabro_types::PullRequestLink", &[]), ("PullRequestDetails", "fabro_types::PullRequestDetails", &[]), + ("PullRequestMeta", "fabro_types::PullRequestMeta", &[]), + ( + "PullRequestResponse", + "fabro_types::PullRequestResponse", + &[], + ), + ( + "PullRequestDetailsStatus", + "fabro_types::PullRequestDetailsStatus", + &[], + ), + ( + "PullRequestDetailsUnavailableReason", + "fabro_types::PullRequestDetailsUnavailableReason", + &[], + ), ("RunSandboxRuntime", "fabro_types::RunSandboxRuntime", &[]), ("PullRequestUser", "fabro_types::PullRequestUser", &[]), ("PullRequestRef", "fabro_types::PullRequestRef", &[]), diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index c2312ae11..d76ddd4dc 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -35,13 +35,15 @@ pub mod types { AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, DiffStats, DiffSummary, DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureSignature, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord, PendingInterviewRecord, - PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, QuestionType, RepositoryRef, - Run, RunClientProvenance, RunEvent, RunFailure, RunProjection, RunProvenance, RunSandbox, - RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxNetwork, - SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources, - SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps, - SecretMetadata, SecretType, ServerSettings, StageCompletion, StageHandler, StageOutcome, - StageProjection, StageState, SystemActorKind, UserPrincipal, WorkflowSettings, + PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus, + PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, + QuestionType, RepositoryRef, Run, RunClientProvenance, RunEvent, RunFailure, RunProjection, + RunProvenance, RunSandbox, RunSandboxRuntime, RunServerProvenance, SandboxDetails, + SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, + SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, + SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, StageCompletion, + StageHandler, StageOutcome, StageProjection, StageState, SystemActorKind, UserPrincipal, + WorkflowSettings, }; pub use crate::generated::types::*; diff --git a/lib/crates/fabro-api/tests/pull_request_round_trip.rs b/lib/crates/fabro-api/tests/pull_request_round_trip.rs index 3ed430fb3..98ca72c92 100644 --- a/lib/crates/fabro-api/tests/pull_request_round_trip.rs +++ b/lib/crates/fabro-api/tests/pull_request_round_trip.rs @@ -2,43 +2,30 @@ use std::any::{TypeId, type_name}; use fabro_api::types::MergeRunPullRequestRequest; use fabro_types::settings::run::MergeStrategy; -use fabro_types::{PullRequestDetail, PullRequestRecord}; +use fabro_types::{PullRequest, PullRequestLink, PullRequestResponse}; use serde_json::json; #[test] -fn pull_request_detail_reuses_domain_record_type() { - let detail: PullRequestDetail = serde_json::from_value(json!({ - "pull_request": { - "provider": "github", - "html_url": "https://github.com/fabro-sh/fabro/pull/123", - "number": 123, - "owner": "fabro-sh", - "repo": "fabro", - "base_branch": "main", - "head_branch": "fabro/run/demo", - "title": "Move PR commands server-side" +fn pull_request_response_reuses_domain_types() { + let response: PullRequestResponse = serde_json::from_value(json!({ + "data": { + "link": { + "owner": "fabro-sh", + "repo": "fabro", + "number": 123, + "html_url": "https://github.com/fabro-sh/fabro/pull/123" + }, + "details": null }, - "state": "closed", - "draft": false, - "merged": true, - "merged_at": "2026-04-23T15:45:00Z", - "mergeable": false, - "additions": 234, - "deletions": 67, - "changed_files": 5, - "comments": 3, - "checks": [], - "author": { - "login": "octocat" - }, - "timestamps": { - "created_at": "2026-04-23T15:40:00Z", - "updated_at": "2026-04-23T15:45:00Z" + "meta": { + "details_status": "unavailable", + "details_unavailable_reason": "integration_unavailable" } })) - .expect("detail should deserialize"); + .expect("response should deserialize"); - assert_same_type_as_pull_request_record(&detail.pull_request); + assert_same_type_as_pull_request(&response.data); + assert_same_type_as_pull_request_link(&response.data.link); } #[test] @@ -63,69 +50,80 @@ fn merge_strategy_json_matches_openapi_shape() { } #[test] -fn pull_request_record_json_matches_openapi_shape() { +fn pull_request_link_json_matches_openapi_shape() { let fixture = json!({ - "provider": "github", - "html_url": "https://github.com/fabro-sh/fabro/pull/123", - "number": 123, "owner": "fabro-sh", "repo": "fabro", - "base_branch": "main", - "head_branch": "fabro/run/demo", - "title": "Move PR commands server-side" + "number": 123, + "html_url": "https://github.com/fabro-sh/fabro/pull/123" }); - let domain_record: PullRequestRecord = - serde_json::from_value(fixture.clone()).expect("domain record should deserialize"); + let domain_record: PullRequestLink = + serde_json::from_value(fixture.clone()).expect("domain link should deserialize"); assert_eq!(serde_json::to_value(domain_record).unwrap(), fixture); } #[test] -fn pull_request_detail_json_matches_openapi_shape() { +fn pull_request_response_json_matches_openapi_shape() { let fixture = json!({ - "pull_request": { - "provider": "github", - "html_url": "https://github.com/fabro-sh/fabro/pull/123", - "number": 123, - "owner": "fabro-sh", - "repo": "fabro", - "base_branch": "main", - "head_branch": "fabro/run/demo", - "title": "Move PR commands server-side" + "data": { + "link": { + "owner": "fabro-sh", + "repo": "fabro", + "number": 123, + "html_url": "https://github.com/fabro-sh/fabro/pull/123" + }, + "details": { + "title": "Move PR commands server-side", + "body": "Detailed description", + "state": "closed", + "draft": false, + "merged": true, + "merged_at": "2026-04-23T15:45:00Z", + "mergeable": false, + "additions": 234, + "deletions": 67, + "changed_files": 5, + "author": { + "login": "octocat" + }, + "head_branch": "fabro/run/demo", + "base_branch": "main", + "timestamps": { + "created_at": "2026-04-23T15:40:00Z", + "updated_at": "2026-04-23T15:45:00Z" + } + } }, - "state": "closed", - "draft": false, - "merged": true, - "merged_at": "2026-04-23T15:45:00Z", - "mergeable": false, - "additions": 234, - "deletions": 67, - "changed_files": 5, - "comments": 3, - "checks": [], - "author": { - "login": "octocat" - }, - "timestamps": { - "created_at": "2026-04-23T15:40:00Z", - "updated_at": "2026-04-23T15:45:00Z" + "meta": { + "details_status": "available" } }); - let detail: PullRequestDetail = - serde_json::from_value(fixture.clone()).expect("detail should deserialize"); + let detail: PullRequestResponse = + serde_json::from_value(fixture.clone()).expect("response should deserialize"); assert_eq!(serde_json::to_value(detail).unwrap(), fixture); } -fn assert_same_type_as_pull_request_record(_: &T) { +fn assert_same_type_as_pull_request(_: &T) { assert_eq!( TypeId::of::(), - TypeId::of::(), + TypeId::of::(), "{} should be the same type as {}", type_name::(), - type_name::() + type_name::() + ); +} + +fn assert_same_type_as_pull_request_link(_: &T) { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() ); } diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs index fc1ab4a79..153bec293 100644 --- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs @@ -5,7 +5,7 @@ use chrono::{TimeZone, Utc}; use fabro_api::types::{RepositoryRef as ApiRepositoryRef, RunSummary as ApiRunSummary}; use fabro_types::status::{RunStatus, SuccessReason}; use fabro_types::{ - DiffSummary, PullRequest, RepositoryProvider, RepositoryRef, RunBillingSummary, RunId, + DiffSummary, PullRequestLink, RepositoryProvider, RepositoryRef, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin, RunSummary, RunTimestamps, WorkflowRef, }; use serde_json::json; @@ -68,15 +68,10 @@ fn run_summary_json_matches_openapi_shape() { additions: 12, deletions: 4, }), - pull_request: Some(PullRequest { - provider: "github".to_string(), - html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(), - number: 123, - owner: "fabro-sh".to_string(), - repo: "fabro".to_string(), - base_branch: "main".to_string(), - head_branch: "fabro/run/demo".to_string(), - title: "Add run PR chip".to_string(), + pull_request: Some(PullRequestLink { + owner: "fabro-sh".to_string(), + repo: "fabro".to_string(), + number: 123, }), current_question: None, superseded_by: None, @@ -137,14 +132,10 @@ fn run_summary_json_matches_openapi_shape() { "deletions": 4 }, "pull_request": { - "provider": "github", - "html_url": "https://github.com/fabro-sh/fabro/pull/123", - "number": 123, "owner": "fabro-sh", "repo": "fabro", - "base_branch": "main", - "head_branch": "fabro/run/demo", - "title": "Add run PR chip" + "number": 123, + "html_url": "https://github.com/fabro-sh/fabro/pull/123" }, "current_question": null, "superseded_by": null, diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 3116d4e0c..26fdeb1f1 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -888,6 +888,26 @@ pub(crate) struct PrViewArgs { pub(crate) run_id: String, } +#[derive(Args)] +pub(crate) struct PrLinkArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Run ID or prefix + pub(crate) run_id: String, + /// GitHub pull request URL to associate with the run + pub(crate) url: String, +} + +#[derive(Args)] +pub(crate) struct PrUnlinkArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Run ID or prefix + pub(crate) run_id: String, +} + #[derive(Args)] pub(crate) struct PrMergeArgs { #[command(flatten)] @@ -1284,6 +1304,8 @@ impl Commands { }, Self::Pr(ns) => match &ns.command { PrCommand::Create(_) => "pr create", + PrCommand::Link(_) => "pr link", + PrCommand::Unlink(_) => "pr unlink", PrCommand::View(_) => "pr view", PrCommand::Merge(_) => "pr merge", PrCommand::Close(_) => "pr close", @@ -1335,6 +1357,10 @@ pub(crate) struct PrNamespace { pub(crate) enum PrCommand { /// Create a pull request from a completed run Create(PrCreateArgs), + /// Link or replace the GitHub pull request associated with a run + Link(PrLinkArgs), + /// Unlink the pull request associated with a run + Unlink(PrUnlinkArgs), /// View pull request details View(PrViewArgs), /// Merge a pull request diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index b79398310..0d846539c 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -22,7 +22,7 @@ pub(super) async fn create_command(args: PrCreateArgs, base_ctx: &CommandContext if ctx.json_output() { print_json_pretty(&record)?; } else { - fabro_util::printout!(ctx.printer(), "{}", record.html_url); + fabro_util::printout!(ctx.printer(), "{}", record.html_url()); } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/pr/link.rs b/lib/crates/fabro-cli/src/commands/pr/link.rs new file mode 100644 index 000000000..1e0db9eed --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/pr/link.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use fabro_types::PullRequestRecord; +use tracing::info; + +use crate::args::PrLinkArgs; +use crate::command_context::CommandContext; +use crate::shared::print_json_pretty; + +pub(super) async fn link_command(args: PrLinkArgs, base_ctx: &CommandContext) -> Result<()> { + let (ctx, client, run_id) = + super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + let record = client.link_run_pull_request(&run_id, args.url).await?; + + info!( + pr_url = %record.html_url(), + number = record.number, + "Linked pull request" + ); + + if ctx.json_output() { + print_json_pretty(&record)?; + } else { + fabro_util::printout!( + ctx.printer(), + "Linked pull request: {} ({})", + record.html_url(), + record_label(&record) + ); + } + + Ok(()) +} + +fn record_label(record: &PullRequestRecord) -> String { + format!("github #{}", record.number) +} diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index f0d310acc..a88976663 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -1,6 +1,8 @@ mod close; mod create; +mod link; mod merge; +mod unlink; mod view; use std::sync::Arc; @@ -15,6 +17,8 @@ use crate::command_context::CommandContext; pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Result<()> { match ns.command { PrCommand::Create(args) => Box::pin(create::create_command(args, base_ctx)).await, + PrCommand::Link(args) => link::link_command(args, base_ctx).await, + PrCommand::Unlink(args) => unlink::unlink_command(args, base_ctx).await, PrCommand::View(args) => view::view_command(args, base_ctx).await, PrCommand::Merge(args) => merge::merge_command(args, base_ctx).await, PrCommand::Close(args) => close::close_command(args, base_ctx).await, @@ -28,6 +32,9 @@ async fn resolve_run_for_pr( ) -> Result<(CommandContext, Arc, RunId)> { let ctx = base_ctx.with_target(server)?; let client = ctx.server().await?; - let run_id = client.resolve_run(selector).await?.id; + let run_id = match selector.parse::() { + Ok(run_id) => run_id, + Err(_) => client.resolve_run(selector).await?.id, + }; Ok((ctx, client, run_id)) } diff --git a/lib/crates/fabro-cli/src/commands/pr/unlink.rs b/lib/crates/fabro-cli/src/commands/pr/unlink.rs new file mode 100644 index 000000000..a6cc363e9 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/pr/unlink.rs @@ -0,0 +1,30 @@ +use anyhow::Result; +use tracing::info; + +use crate::args::PrUnlinkArgs; +use crate::command_context::CommandContext; +use crate::shared::print_json_pretty; + +pub(super) async fn unlink_command(args: PrUnlinkArgs, base_ctx: &CommandContext) -> Result<()> { + let (ctx, client, run_id) = + super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + let record = client.unlink_run_pull_request(&run_id).await?; + + info!( + pr_url = %record.html_url(), + number = record.number, + "Unlinked pull request" + ); + + if ctx.json_output() { + print_json_pretty(&record)?; + } else { + fabro_util::printout!( + ctx.printer(), + "Unlinked pull request: {}", + record.html_url() + ); + } + + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index 4838b62ff..933dccb95 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -9,12 +9,14 @@ pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> let (ctx, client, run_id) = super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; let detail = client.get_run_pull_request(&run_id).await?; - let pull_request = &detail.pull_request; + let pull_request = &detail.data.link; + let github_details = detail.data.details.as_ref(); info!( number = pull_request.number, owner = %pull_request.owner, repo = %pull_request.repo, + details_status = %detail.meta.details_status, "Viewing pull request" ); @@ -24,30 +26,35 @@ pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> } let printer = ctx.printer(); - fabro_util::printout!(printer, "#{} {}", pull_request.number, pull_request.title); - let state_display = if detail.merged { + let title = github_details.map_or("Pull request", |details| details.title.as_str()); + fabro_util::printout!(printer, "#{} {title}", pull_request.number); + let state_display = if github_details.is_some_and(|details| details.merged) { "merged" - } else if detail.draft { + } else if github_details.is_some_and(|details| details.draft) { "draft" } else { - &detail.state + github_details.map_or("", |details| details.state.as_str()) }; - fabro_util::printout!(printer, "State: {state_display}"); - fabro_util::printout!(printer, "URL: {}", pull_request.html_url); - fabro_util::printout!( - printer, - "Branch: {} -> {}", - pull_request.head_branch, - pull_request.base_branch - ); - fabro_util::printout!(printer, "Author: {}", detail.author.login); - fabro_util::printout!( - printer, - "Changes: +{} -{} ({} files)", - detail.additions, - detail.deletions, - detail.changed_files - ); + if !state_display.is_empty() { + fabro_util::printout!(printer, "State: {state_display}"); + } + fabro_util::printout!(printer, "URL: {}", pull_request.html_url()); + if let Some(reason) = detail.meta.details_unavailable_reason { + fabro_util::printout!(printer, "Details: unavailable ({reason})"); + } + if let Some(details) = github_details { + let head_branch = &details.head_branch; + let base_branch = &details.base_branch; + fabro_util::printout!(printer, "Branch: {head_branch} -> {base_branch}"); + fabro_util::printout!(printer, "Author: {}", details.author.login); + fabro_util::printout!( + printer, + "Changes: +{} -{} ({} files)", + details.additions, + details.deletions, + details.changed_files + ); + } Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/run/events.rs b/lib/crates/fabro-cli/src/commands/run/events.rs index b6cc09f1c..2f3d1857d 100644 --- a/lib/crates/fabro-cli/src/commands/run/events.rs +++ b/lib/crates/fabro-cli/src/commands/run/events.rs @@ -766,6 +766,18 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O url, )) } + "pull_request.linked" => Some(format_pull_request_record_event( + envelope, + styles, + &ts, + "PR linked:", + )), + "pull_request.unlinked" => Some(format_pull_request_record_event( + envelope, + styles, + &ts, + "PR unlinked:", + )), "pull_request.failed" => { let error = prop_str_field(envelope, "error").unwrap_or("unknown error"); Some(format!( @@ -797,6 +809,24 @@ fn prop_str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str prop_field(value, key)?.as_str() } +fn format_pull_request_record_event( + envelope: &serde_json::Value, + styles: &Styles, + ts: &str, + label: &str, +) -> String { + let url = prop_field(envelope, "pull_request") + .and_then(|record| record.get("html_url")) + .and_then(serde_json::Value::as_str) + .unwrap_or("?"); + format!( + "{} {} {}", + styles.dim.apply_to(ts), + styles.bold.apply_to(label), + url, + ) +} + fn format_timestamp(ts: &str) -> String { ts.parse::>() .map_or_else(|_| ts.to_string(), |dt| dt.format("%H:%M:%S").to_string()) @@ -1119,6 +1149,26 @@ mod tests { assert!(result.contains("Draft PR:"), "got: {result}"); } + #[test] + fn pretty_pull_request_linked() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"pull_request.linked","properties":{"pull_request":{"owner":"owner","repo":"repo","number":42,"html_url":"https://github.com/owner/repo/pull/42"}}}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("PR linked:"), "got: {result}"); + assert!( + result.contains("https://github.com/owner/repo/pull/42"), + "got: {result}" + ); + } + + #[test] + fn pretty_pull_request_unlinked() { + let styles = no_color_styles(); + let line = r#"{"ts":"2026-01-01T14:25:00Z","event":"pull_request.unlinked","properties":{"pull_request":{"owner":"owner","repo":"repo","number":42,"html_url":"https://github.com/owner/repo/pull/42"}}}"#; + let result = format_event_pretty(line, &styles).unwrap(); + assert!(result.contains("PR unlinked:"), "got: {result}"); + } + #[test] fn pretty_pull_request_failed() { let styles = no_color_styles(); diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index 9278dd9b4..049f81212 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -139,7 +139,7 @@ pub(crate) async fn print_run_summary_with_client( let pr_url = run_state .pull_request .as_ref() - .map(|record: &PullRequestRecord| record.html_url.clone()); + .map(PullRequestRecord::html_url); let Some(conclusion) = conclusion else { return Ok(()); }; diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 0dd5da69b..68b8e4da7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -28,7 +28,9 @@ mod parse; mod pr; mod pr_close; mod pr_create; +mod pr_link; mod pr_merge; +mod pr_unlink; mod pr_view; mod preflight; mod provider; diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr.rs b/lib/crates/fabro-cli/tests/it/cmd/pr.rs index 50129994f..de97b712d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr.rs @@ -15,6 +15,8 @@ fn help() { Commands: create Create a pull request from a completed run + link Link or replace the GitHub pull request associated with a run + unlink Unlink the pull request associated with a run view View pull request details merge Merge a pull request close Close a pull request diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs index 8c3cfc784..50b615309 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs @@ -73,13 +73,10 @@ fn pr_create_uses_server_endpoint_and_prints_url() { then.status(200) .header("Content-Type", "application/json") .json_body(serde_json::json!({ - "html_url": "https://github.com/fabro-sh/fabro/pull/123", - "number": 123, "owner": "fabro-sh", "repo": "fabro", - "base_branch": "main", - "head_branch": "fabro/run/demo", - "title": "Map the constellations" + "number": 123, + "html_url": "https://github.com/fabro-sh/fabro/pull/123" })); }); @@ -122,13 +119,10 @@ fn pr_create_passes_force_and_model_to_server() { then.status(200) .header("Content-Type", "application/json") .json_body(serde_json::json!({ - "html_url": "https://github.com/fabro-sh/fabro/pull/123", - "number": 123, "owner": "fabro-sh", "repo": "fabro", - "base_branch": "main", - "head_branch": "fabro/run/demo", - "title": "Map the constellations" + "number": 123, + "html_url": "https://github.com/fabro-sh/fabro/pull/123" })); }); @@ -150,14 +144,10 @@ fn pr_create_passes_force_and_model_to_server() { exit_code: 0 ----- stdout ----- { - "provider": "github", - "html_url": "https://github.com/fabro-sh/fabro/pull/123", - "number": 123, "owner": "fabro-sh", "repo": "fabro", - "base_branch": "main", - "head_branch": "fabro/run/demo", - "title": "Map the constellations" + "number": 123, + "html_url": "https://github.com/fabro-sh/fabro/pull/123" } ----- stderr ----- "#); diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_link.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_link.rs new file mode 100644 index 000000000..11314626b --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_link.rs @@ -0,0 +1,124 @@ +use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; + +use super::support::mock_resolved_run; +use crate::support::unique_run_id; + +#[test] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["pr", "link", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Link or replace the GitHub pull request associated with a run + + Usage: fabro pr link [OPTIONS] + + Arguments: + Run ID or prefix + GitHub pull request URL to associate with the run + + Options: + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + ----- stderr ----- + "); +} + +#[test] +fn pr_link_uses_server_endpoint_and_prints_linked_record() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + + let resolve_mock = mock_resolved_run(&server, "nightly-build", &run_id); + let link_mock = server.mock(|when, then| { + when.method("PUT") + .path(format!("/api/v1/runs/{run_id}/pull_request")) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "html_url": "https://github.com/acme/widgets/pull/42" + })); + then.status(200) + .header("Content-Type", "application/json") + .json_body(serde_json::json!({ + "owner": "acme", + "repo": "widgets", + "number": 42, + "html_url": "https://github.com/acme/widgets/pull/42" + })); + }); + + let mut cmd = context.command(); + cmd.args([ + "pr", + "link", + "--server", + &server.base_url(), + "nightly-build", + "https://github.com/acme/widgets/pull/42", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Linked pull request: https://github.com/acme/widgets/pull/42 (github #42) + ----- stderr ----- + "); + + resolve_mock.assert(); + link_mock.assert(); +} + +#[test] +fn pr_link_skips_resolve_endpoint_for_full_run_id() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + + let link_mock = server.mock(|when, then| { + when.method("PUT") + .path(format!("/api/v1/runs/{run_id}/pull_request")) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "html_url": "https://github.com/acme/widgets/pull/42" + })); + then.status(200) + .header("Content-Type", "application/json") + .json_body(serde_json::json!({ + "owner": "acme", + "repo": "widgets", + "number": 42, + "html_url": "https://github.com/acme/widgets/pull/42" + })); + }); + + let mut cmd = context.command(); + cmd.args([ + "pr", + "link", + "--server", + &server.base_url(), + &run_id, + "https://github.com/acme/widgets/pull/42", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Linked pull request: https://github.com/acme/widgets/pull/42 (github #42) + ----- stderr ----- + "); + + link_mock.assert(); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_unlink.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_unlink.rs new file mode 100644 index 000000000..df21a14c0 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_unlink.rs @@ -0,0 +1,74 @@ +use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; + +use super::support::mock_resolved_run; +use crate::support::unique_run_id; + +#[test] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["pr", "unlink", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Unlink the pull request associated with a run + + Usage: fabro pr unlink [OPTIONS] + + Arguments: + Run ID or prefix + + Options: + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + ----- stderr ----- + "); +} + +#[test] +fn pr_unlink_uses_server_endpoint_and_prints_removed_url() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + + let resolve_mock = mock_resolved_run(&server, "nightly-build", &run_id); + let unlink_mock = server.mock(|when, then| { + when.method("DELETE") + .path(format!("/api/v1/runs/{run_id}/pull_request")); + then.status(200) + .header("Content-Type", "application/json") + .json_body(serde_json::json!({ + "owner": "acme", + "repo": "widgets", + "number": 42, + "html_url": "https://github.com/acme/widgets/pull/42" + })); + }); + + let mut cmd = context.command(); + cmd.args([ + "pr", + "unlink", + "--server", + &server.base_url(), + "nightly-build", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Unlinked pull request: https://github.com/acme/widgets/pull/42 + ----- stderr ----- + "); + + resolve_mock.assert(); + unlink_mock.assert(); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs index ee88bf2df..4c716c8ea 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs @@ -103,11 +103,13 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() { cmd.args(["pr", "view", &run.run_id]); fabro_snapshot!(context.filters(), cmd, @" - success: false - exit_code: 1 + success: true + exit_code: 0 ----- stdout ----- + #123 Pull request + URL: https://github.com/fabro-sh/fabro/pull/123 + Details: unavailable (integration_unavailable) ----- stderr ----- - × GitHub integration unavailable on server. "); } @@ -125,35 +127,37 @@ fn pr_view_uses_server_pull_request_endpoint_and_renders_merged_state() { .header("Content-Type", "application/json") .body( serde_json::json!({ - "pull_request": { - "provider": "github", - "html_url": "https://github.com/fabro-sh/fabro/pull/123", - "number": 123, - "owner": "fabro-sh", - "repo": "fabro", - "base_branch": "main", - "head_branch": "fabro/run/demo", - "title": "Map the constellations" + "data": { + "link": { + "owner": "fabro-sh", + "repo": "fabro", + "number": 123, + "html_url": "https://github.com/fabro-sh/fabro/pull/123" + }, + "details": { + "title": "Map the constellations", + "body": "Detailed description", + "state": "closed", + "draft": false, + "merged": true, + "merged_at": "2026-04-06T12:30:00Z", + "mergeable": false, + "additions": 10, + "deletions": 3, + "changed_files": 2, + "author": { + "login": "testuser" + }, + "head_branch": "fabro/run/demo", + "base_branch": "main", + "timestamps": { + "created_at": "2026-04-05T12:00:00Z", + "updated_at": "2026-04-06T12:30:00Z" + } + } }, - "number": 123, - "title": "Map the constellations", - "body": "Detailed description", - "state": "closed", - "draft": false, - "merged": true, - "merged_at": "2026-04-06T12:30:00Z", - "mergeable": false, - "additions": 10, - "deletions": 3, - "changed_files": 2, - "comments": 0, - "checks": [], - "author": { - "login": "testuser" - }, - "timestamps": { - "created_at": "2026-04-05T12:00:00Z", - "updated_at": "2026-04-06T12:30:00Z" + "meta": { + "details_status": "available" } }) .to_string(), @@ -185,3 +189,58 @@ fn pr_view_uses_server_pull_request_endpoint_and_renders_merged_state() { resolve_mock.assert(); detail_mock.assert(); } + +#[test] +fn pr_view_renders_unavailable_details_reason() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + + let resolve_mock = mock_resolved_run(&server, "nightly-build", &run_id); + let detail_mock = server.mock(|when, then| { + when.method("GET") + .path(format!("/api/v1/runs/{run_id}/pull_request")); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": { + "link": { + "owner": "acme", + "repo": "widgets", + "number": 42, + "html_url": "https://github.com/acme/widgets/pull/42" + }, + "details": null + }, + "meta": { + "details_status": "unavailable", + "details_unavailable_reason": "fetch_failed" + } + }) + .to_string(), + ); + }); + + let mut cmd = context.command(); + cmd.args([ + "pr", + "view", + "--server", + &server.base_url(), + "nightly-build", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + #42 Pull request + URL: https://github.com/acme/widgets/pull/42 + Details: unavailable (fetch_failed) + ----- stderr ----- + "); + + resolve_mock.assert(); + detail_mock.assert(); +} diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index 818faddea..557893c20 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -1045,6 +1045,43 @@ impl Client { Ok(response.into_inner()) } + pub async fn link_run_pull_request( + &self, + run_id: &RunId, + html_url: String, + ) -> Result { + let body = types::LinkRunPullRequestRequest { html_url }; + let response = self + .send_api(|client| async move { + client + .link_run_pull_request() + .id(run_id.to_string()) + .body(body.clone()) + .send() + .await + }) + .await + .map_err(add_pr_upgrade_hint)?; + convert_type(response.into_inner()) + } + + pub async fn unlink_run_pull_request( + &self, + run_id: &RunId, + ) -> Result { + let response = self + .send_api(|client| async move { + client + .unlink_run_pull_request() + .id(run_id.to_string()) + .send() + .await + }) + .await + .map_err(add_pr_upgrade_hint)?; + convert_type(response.into_inner()) + } + pub async fn merge_run_pull_request( &self, run_id: &RunId, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 94d503588..e878985ce 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -28,16 +28,17 @@ pub use fabro_api::types::{ CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, CreateRunPullRequestRequest, CreateSecretRequest, DeleteRunResponse, DeleteRunSandbox, DeleteSecretRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, ForkRequest, - ForkResponse, MergeRunPullRequestRequest, MergeRunPullRequestResponse, ModelReference, - PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest, - PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, - RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RewindRequest, RewindResponse, - RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, - RunError, RunManifest, RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, - SandboxService, SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, - StageState, StartRunRequest, SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, - SystemRepairRunIssue, SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, - VncPreviewResponse, WriteBlobResponse, + ForkResponse, LinkRunPullRequestRequest, MergeRunPullRequestRequest, + MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList, + PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, PruneRunEntry, + PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, + RewindRequest, RewindResponse, RunArtifactEntry, RunArtifactListResponse, RunBilling, + RunBillingStage, RunBillingTotals, RunError, RunManifest, RunStage, SandboxDetails, + SandboxFileEntry, SandboxFileListResponse, SandboxService, SandboxServiceListResponse, + SshAccessRequest, SshAccessResponse, StageHandler, StageState, StartRunRequest, + SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRepairRunIssue, + SystemRepairRunsResponse, SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, + WriteBlobResponse, }; use fabro_auth::{ CredentialSource, VaultCredentialSource, auth_issue_message, parse_credential_secret, diff --git a/lib/crates/fabro-server/src/server/handler/events.rs b/lib/crates/fabro-server/src/server/handler/events.rs index 40f5e81d0..17e6ad930 100644 --- a/lib/crates/fabro-server/src/server/handler/events.rs +++ b/lib/crates/fabro-server/src/server/handler/events.rs @@ -382,14 +382,16 @@ async fn attach_run_events( /// (e.g. "archive only from terminal") that a direct event append would /// bypass. Other run-lifecycle events flow through this endpoint legitimately: /// the worker subprocess emits state transitions during execution. -fn denied_lifecycle_event_name(body: &EventBody) -> Option<&'static str> { +fn denied_lifecycle_event_name(body: &EventBody) -> Option<&str> { match body { - EventBody::RunArchived(_) => Some("run.archived"), - EventBody::RunUnarchived(_) => Some("run.unarchived"), - EventBody::RunTitleUpdated(_) => Some("run.title.updated"), - EventBody::RunCancelRequested(_) => Some("run.cancel.requested"), - EventBody::RunPauseRequested(_) => Some("run.pause.requested"), - EventBody::RunUnpauseRequested(_) => Some("run.unpause.requested"), + EventBody::RunArchived(_) + | EventBody::RunUnarchived(_) + | EventBody::RunTitleUpdated(_) + | EventBody::RunCancelRequested(_) + | EventBody::RunPauseRequested(_) + | EventBody::RunUnpauseRequested(_) + | EventBody::PullRequestLinked(_) + | EventBody::PullRequestUnlinked(_) => Some(body.event_name()), _ => None, } } diff --git a/lib/crates/fabro-server/src/server/handler/pull_requests.rs b/lib/crates/fabro-server/src/server/handler/pull_requests.rs index 31a393965..b996fa760 100644 --- a/lib/crates/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/crates/fabro-server/src/server/handler/pull_requests.rs @@ -2,16 +2,19 @@ use std::sync::Arc; use super::super::{ ApiError, AppState, CloseRunPullRequestResponse, CreateRunPullRequestRequest, IntoResponse, - Json, MergeRunPullRequestRequest, MergeRunPullRequestResponse, PullRequestRecord, - RequireRunScoped, Response, Router, RunId, State, StatusCode, get, lock_pull_request_create, - post, pull_request, warn, workflow_event, + Json, LinkRunPullRequestRequest, MergeRunPullRequestRequest, MergeRunPullRequestResponse, + PullRequestRecord, RequireRunScoped, Response, Router, RunId, State, StatusCode, get, + lock_pull_request_create, post, pull_request, warn, workflow_event, }; pub(super) fn routes() -> Router> { Router::new() .route( "/runs/{id}/pull_request", - get(get_run_pull_request).post(create_run_pull_request), + get(get_run_pull_request) + .post(create_run_pull_request) + .put(link_run_pull_request) + .delete(unlink_run_pull_request), ) .route( "/runs/{id}/pull_request/merge", @@ -49,6 +52,19 @@ fn parse_github_owner_repo_from_url(url: &str, kind: &str) -> Result<(String, St fabro_github::parse_github_owner_repo(url).map_err(|err| ApiError::bad_request(err.to_string())) } +fn pull_request_record_from_link_request( + body: &LinkRunPullRequestRequest, +) -> Result { + PullRequestRecord::from_github_url(body.html_url.trim()).map_err(|err| { + let code = if err.contains("GitHub pull request URL") { + "unsupported_pull_request_provider" + } else { + "invalid_pull_request_url" + }; + ApiError::with_code(StatusCode::BAD_REQUEST, err, code) + }) +} + fn load_server_github_credentials( state: &AppState, ) -> Result { @@ -92,23 +108,26 @@ fn server_github_context<'a>( )) } -fn github_pull_request_not_found_error(record: &PullRequestRecord) -> ApiError { +fn github_pull_request_not_found_error(number: u64) -> ApiError { ApiError::with_code( StatusCode::BAD_GATEWAY, - format!("Pull request #{} was deleted on GitHub.", record.number), + format!("Pull request #{number} was deleted on GitHub."), "github_not_found", ) } struct PullRequestGithubContext { record: PullRequestRecord, + owner: String, + repo: String, + number: u64, creds: fabro_github::GitHubCredentials, } -async fn load_pull_request_github_context( +async fn load_pull_request_record( state: &Arc, id: &RunId, -) -> Result { +) -> Result { let run_store = state .store .open_run_reader(id) @@ -118,16 +137,33 @@ async fn load_pull_request_github_context( .state() .await .map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; - let record = run_state.pull_request.ok_or_else(|| { + run_state.pull_request.ok_or_else(|| { ApiError::with_code( StatusCode::NOT_FOUND, format!("No pull request found in store. Create one first with: fabro pr create {id}"), "no_stored_record", ) - })?; - parse_github_owner_repo_from_url(&record.html_url, "pull request URL")?; + }) +} + +fn github_coordinates_for_record(record: &PullRequestRecord) -> (String, String, u64) { + (record.owner.clone(), record.repo.clone(), record.number) +} + +async fn load_pull_request_github_context( + state: &Arc, + id: &RunId, +) -> Result { + let record = load_pull_request_record(state, id).await?; + let (owner, repo, number) = github_coordinates_for_record(&record); let creds = load_server_github_credentials(state.as_ref())?; - Ok(PullRequestGithubContext { record, creds }) + Ok(PullRequestGithubContext { + record, + owner, + repo, + number, + creds, + }) } struct RunPrInputs<'a> { @@ -144,7 +180,7 @@ impl<'a> RunPrInputs<'a> { if let Some(record) = run_state.pull_request.as_ref() { return Err(ApiError::with_code( StatusCode::CONFLICT, - format!("Pull request already exists at {}", record.html_url), + format!("Pull request already exists at {}", record.html_url()), "pull_request_exists", )); } @@ -216,6 +252,38 @@ impl<'a> RunPrInputs<'a> { } } +fn unavailable_pull_request_response( + record: PullRequestRecord, + reason: fabro_types::PullRequestDetailsUnavailableReason, +) -> fabro_types::PullRequestDetail { + fabro_types::PullRequestDetail { + data: fabro_types::PullRequest { + link: record, + details: None, + }, + meta: fabro_types::PullRequestMeta { + details_status: fabro_types::PullRequestDetailsStatus::Unavailable, + details_unavailable_reason: Some(reason), + }, + } +} + +fn available_pull_request_response( + record: PullRequestRecord, + details: fabro_types::PullRequestDetails, +) -> fabro_types::PullRequestDetail { + fabro_types::PullRequestDetail { + data: fabro_types::PullRequest { + link: record, + details: Some(details), + }, + meta: fabro_types::PullRequestMeta { + details_status: fabro_types::PullRequestDetailsStatus::Available, + details_unavailable_reason: None, + }, + } +} + async fn create_run_pull_request( RequireRunScoped(id): RequireRunScoped, State(state): State>, @@ -273,8 +341,8 @@ async fn create_run_pull_request( conclusion: Some(inputs.conclusion), run_state: Some(&run_state), }; - let pull_request = match pull_request::maybe_open_pull_request(request).await { - Ok(Some(record)) => record, + let created_pull_request = match pull_request::maybe_open_pull_request(request).await { + Ok(Some(created)) => created, Ok(None) => { return ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, @@ -285,7 +353,68 @@ async fn create_run_pull_request( Err(err) => return ApiError::new(StatusCode::BAD_GATEWAY, err).into_response(), }; - let event = workflow_event::Event::pull_request_created(&pull_request, true); + let event = workflow_event::Event::pull_request_created( + &created_pull_request.link, + &created_pull_request.base_branch, + &created_pull_request.head_branch, + &created_pull_request.title, + true, + ); + if let Err(err) = workflow_event::append_event(&run_store, &id, &event).await { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } + + Json(created_pull_request.link).into_response() +} + +async fn link_run_pull_request( + RequireRunScoped(id): RequireRunScoped, + State(state): State>, + Json(body): Json, +) -> Response { + let pull_request = match pull_request_record_from_link_request(&body) { + Ok(record) => record, + Err(err) => return err.into_response(), + }; + let Ok(run_store) = state.store.open_run(&id).await else { + return ApiError::not_found("Run not found.").into_response(); + }; + let event = workflow_event::Event::PullRequestLinked { + pull_request: pull_request.clone(), + }; + if let Err(err) = workflow_event::append_event(&run_store, &id, &event).await { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } + + Json(pull_request).into_response() +} + +async fn unlink_run_pull_request( + RequireRunScoped(id): RequireRunScoped, + State(state): State>, +) -> Response { + let _create_guard = lock_pull_request_create(&state.pull_request_create_locks, &id).await; + let Ok(run_store) = state.store.open_run(&id).await else { + return ApiError::not_found("Run not found.").into_response(); + }; + let run_state = match run_store.state().await { + Ok(run_state) => run_state, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + let Some(pull_request) = run_state.pull_request else { + return ApiError::with_code( + StatusCode::NOT_FOUND, + format!("No pull request found in store. Create one first with: fabro pr create {id}"), + "no_stored_record", + ) + .into_response(); + }; + let event = workflow_event::Event::PullRequestUnlinked { + pull_request: pull_request.clone(), + }; if let Err(err) = workflow_event::append_event(&run_store, &id, &event).await { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); } @@ -297,46 +426,52 @@ async fn get_run_pull_request( RequireRunScoped(id): RequireRunScoped, State(state): State>, ) -> Response { - let ctx = match load_pull_request_github_context(&state, &id).await { - Ok(ctx) => ctx, + let record = match load_pull_request_record(&state, &id).await { + Ok(record) => record, Err(err) => return err.into_response(), }; - let github = match server_github_context(state.as_ref(), &ctx.creds) { + let (owner, repo, number) = github_coordinates_for_record(&record); + let creds = match load_server_github_credentials(state.as_ref()) { + Ok(creds) => creds, + Err(err) => { + warn!(error = ?err, "Returning stored pull request without live GitHub details"); + return Json(unavailable_pull_request_response( + record, + fabro_types::PullRequestDetailsUnavailableReason::IntegrationUnavailable, + )) + .into_response(); + } + }; + let github = match server_github_context(state.as_ref(), &creds) { Ok(github) => github, - Err(err) => return err.into_response(), + Err(err) => { + warn!(error = ?err, "Returning stored pull request without live GitHub details"); + return Json(unavailable_pull_request_response( + record, + fabro_types::PullRequestDetailsUnavailableReason::IntegrationUnavailable, + )) + .into_response(); + } }; - match fabro_github::get_pull_request( - &github, - &ctx.record.owner, - &ctx.record.repo, - ctx.record.number, - ) - .await - { - Ok(github) => Json(fabro_types::PullRequestDetail { - pull_request: ctx.record, - state: github.state, - draft: github.draft, - merged: github.merged, - merged_at: github.merged_at, - mergeable: github.mergeable, - additions: github.additions, - deletions: github.deletions, - changed_files: github.changed_files, - comments: 0, - checks: Vec::new(), - author: github.user, - timestamps: fabro_types::PullRequestTimestamps { - created_at: github.created_at, - updated_at: github.updated_at, - }, - }) - .into_response(), + match fabro_github::get_pull_request(&github, &owner, &repo, number).await { + Ok(github) => Json(available_pull_request_response(record, github.into())).into_response(), Err(fabro_github::PullRequestApiError::NotFound { .. }) => { - github_pull_request_not_found_error(&ctx.record).into_response() + warn!("Returning stored pull request because GitHub no longer has the PR"); + Json(unavailable_pull_request_response( + record, + fabro_types::PullRequestDetailsUnavailableReason::NotFound, + )) + .into_response() + } + Err(err) => { + warn!(error = %err, "Returning stored pull request without live GitHub details"); + Json(unavailable_pull_request_response( + record, + fabro_types::PullRequestDetailsUnavailableReason::FetchFailed, + )) + .into_response() } - Err(err) => ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(), } } @@ -354,24 +489,18 @@ async fn merge_run_pull_request( Err(err) => return err.into_response(), }; - match fabro_github::merge_pull_request( - &github, - &ctx.record.owner, - &ctx.record.repo, - ctx.record.number, - body.method, - ) - .await + match fabro_github::merge_pull_request(&github, &ctx.owner, &ctx.repo, ctx.number, body.method) + .await { Ok(()) => Json(MergeRunPullRequestResponse { - number: i64::try_from(ctx.record.number) + number: i64::try_from(ctx.number) .expect("stored pull request number should fit in i64"), - html_url: ctx.record.html_url, + html_url: ctx.record.html_url(), method: body.method, }) .into_response(), Err(fabro_github::PullRequestApiError::NotFound { .. }) => { - github_pull_request_not_found_error(&ctx.record).into_response() + github_pull_request_not_found_error(ctx.number).into_response() } Err(err) => ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(), } @@ -390,22 +519,15 @@ async fn close_run_pull_request( Err(err) => return err.into_response(), }; - match fabro_github::close_pull_request( - &github, - &ctx.record.owner, - &ctx.record.repo, - ctx.record.number, - ) - .await - { + match fabro_github::close_pull_request(&github, &ctx.owner, &ctx.repo, ctx.number).await { Ok(()) => Json(CloseRunPullRequestResponse { - number: i64::try_from(ctx.record.number) + number: i64::try_from(ctx.number) .expect("stored pull request number should fit in i64"), - html_url: ctx.record.html_url, + html_url: ctx.record.html_url(), }) .into_response(), Err(fabro_github::PullRequestApiError::NotFound { .. }) => { - github_pull_request_not_found_error(&ctx.record).into_response() + github_pull_request_not_found_error(ctx.number).into_response() } Err(err) => ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(), } diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index e3d0304a9..27faff8bf 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -3721,6 +3721,17 @@ async fn create_run_with_pull_request_record( .await; } +async fn create_run_with_linked_pull_request_record( + state: &Arc, + run_id: RunId, + pull_request: PullRequestRecord, +) { + create_durable_run_with_events(state, run_id, &[workflow_event::Event::PullRequestLinked { + pull_request, + }]) + .await; +} + async fn create_completed_run_ready_for_pull_request( state: &Arc, run_id: RunId, @@ -4971,12 +4982,13 @@ async fn get_run_pull_request_returns_live_detail_from_github() { .unwrap(); let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["pull_request"]["number"], 42); - assert_eq!(body["pull_request"]["owner"], "acme"); - assert_eq!(body["state"], "closed"); - assert_eq!(body["merged"], true); - assert_eq!(body["pull_request"]["head_branch"], "feature"); - assert_eq!(body["pull_request"]["base_branch"], "main"); + assert_eq!(body["data"]["link"]["number"], 42); + assert_eq!(body["data"]["link"]["owner"], "acme"); + assert_eq!(body["data"]["details"]["state"], "closed"); + assert_eq!(body["data"]["details"]["merged"], true); + assert_eq!(body["data"]["details"]["head_branch"], "feature"); + assert_eq!(body["data"]["details"]["base_branch"], "main"); + assert_eq!(body["meta"]["details_status"], "available"); github_mock.assert(); } @@ -5002,35 +5014,140 @@ async fn get_run_pull_request_returns_not_found_when_record_missing() { } #[tokio::test] -async fn get_run_pull_request_rejects_non_github_record_url() { - let (state, app, run_id) = pr_test_app(Some("ghu_test"), None); +async fn link_run_pull_request_links_github_pr_from_any_repo_and_updates_state() { + let (_state, app, run_id) = pr_test_app_with_minimal_run(None, None).await; - create_run_with_pull_request_record( - &state, - run_id, - "https://gitlab.com/acme/widgets/-/merge_requests/42", - 42, - "Fix the bug", - ) - .await; + let response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(api(&format!("/runs/{run_id}/pull_request"))) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "html_url": "https://github.com/other/repo/pull/987" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::OK).await; + + assert_eq!(body["html_url"], "https://github.com/other/repo/pull/987"); + assert_eq!(body["owner"], "other"); + assert_eq!(body["repo"], "repo"); + assert_eq!(body["number"], 987); + + let state_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}/state"))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let state_body = response_json!(state_response, StatusCode::OK).await; + assert_eq!( + state_body["pull_request"]["html_url"], + "https://github.com/other/repo/pull/987" + ); +} + +#[tokio::test] +async fn link_run_pull_request_rejects_non_github_url() { + let (_state, app, run_id) = pr_test_app_with_minimal_run(None, None).await; let response = app .oneshot( Request::builder() - .method("GET") + .method("PUT") .uri(api(&format!("/runs/{run_id}/pull_request"))) - .body(Body::empty()) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "html_url": "https://gitlab.com/acme/widgets/-/merge_requests/42" + }) + .to_string(), + )) .unwrap(), ) .await .unwrap(); let body = response_json!(response, StatusCode::BAD_REQUEST).await; - assert_eq!(body["errors"][0]["code"], "unsupported_host"); + assert_eq!( + body["errors"][0]["code"], + "unsupported_pull_request_provider" + ); } #[tokio::test] -async fn get_run_pull_request_returns_service_unavailable_without_github_credentials() { +async fn unlink_run_pull_request_appends_event_and_clears_projected_state() { + let (state, app, run_id) = pr_test_app_with_minimal_run(None, None).await; + let link_response = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(api(&format!("/runs/{run_id}/pull_request"))) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "html_url": "https://github.com/acme/widgets/pull/42" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + response_json!(link_response, StatusCode::OK).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(api(&format!("/runs/{run_id}/pull_request"))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::OK).await; + + assert_eq!(body["html_url"], "https://github.com/acme/widgets/pull/42"); + + let state_response = app + .oneshot( + Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}/state"))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let state_body = response_json!(state_response, StatusCode::OK).await; + assert!(state_body["pull_request"].is_null()); + + let run_id = run_id.parse::().unwrap(); + let run_store = state.store.open_run_reader(&run_id).await.unwrap(); + let events = run_store.list_events().await.unwrap(); + assert!(events.iter().any(|event| { + event.event.event_name() == "pull_request.unlinked" + && event.event.properties().unwrap()["pull_request"]["html_url"] + == "https://github.com/acme/widgets/pull/42" + })); +} + +#[tokio::test] +async fn get_run_pull_request_returns_stored_github_association_without_github_credentials() { let (state, app, run_id) = pr_test_app(None, None); create_run_with_pull_request_record( @@ -5052,13 +5169,23 @@ async fn get_run_pull_request_returns_service_unavailable_without_github_credent ) .await .unwrap(); - let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await; + let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["errors"][0]["code"], "integration_unavailable"); + assert_eq!(body["data"]["link"]["number"], 42); + assert_eq!( + body["data"]["link"]["html_url"], + "https://github.com/acme/widgets/pull/42" + ); + assert!(body["data"]["details"].is_null()); + assert_eq!(body["meta"]["details_status"], "unavailable"); + assert_eq!( + body["meta"]["details_unavailable_reason"], + "integration_unavailable" + ); } #[tokio::test] -async fn get_run_pull_request_returns_bad_gateway_when_github_pr_is_missing() { +async fn get_run_pull_request_returns_stored_github_association_when_github_pr_is_missing() { let github = MockServer::start(); let github_mock = github.mock(|when, then| { when.method("GET") @@ -5089,9 +5216,16 @@ async fn get_run_pull_request_returns_bad_gateway_when_github_pr_is_missing() { ) .await .unwrap(); - let body = response_json!(response, StatusCode::BAD_GATEWAY).await; + let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["errors"][0]["code"], "github_not_found"); + assert_eq!(body["data"]["link"]["number"], 42); + assert_eq!( + body["data"]["link"]["html_url"], + "https://github.com/acme/widgets/pull/42" + ); + assert!(body["data"]["details"].is_null()); + assert_eq!(body["meta"]["details_status"], "unavailable"); + assert_eq!(body["meta"]["details_unavailable_reason"], "not_found"); github_mock.assert(); } @@ -5199,7 +5333,8 @@ async fn create_run_pull_request_creates_and_persists_record() { .unwrap(); let state_body = response_json!(state_response, StatusCode::OK).await; assert_eq!(state_body["pull_request"]["number"], 42); - assert!(state_body["pull_request"]["title"].as_str().is_some()); + assert_eq!(state_body["pull_request"]["owner"], "acme"); + assert_eq!(state_body["pull_request"]["repo"], "widgets"); response_mock.assert_async().await; create_mock.assert(); @@ -5439,35 +5574,6 @@ async fn merge_run_pull_request_rejects_invalid_method() { assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); } -#[tokio::test] -async fn merge_run_pull_request_rejects_non_github_record_url() { - let (state, app, run_id) = pr_test_app(Some("ghu_test"), None); - - create_run_with_pull_request_record( - &state, - run_id, - "https://gitlab.com/acme/widgets/-/merge_requests/42", - 42, - "Fix the bug", - ) - .await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/pull_request/merge"))) - .header("content-type", "application/json") - .body(Body::from(json!({ "method": "squash" }).to_string())) - .unwrap(), - ) - .await - .unwrap(); - let body = response_json!(response, StatusCode::BAD_REQUEST).await; - - assert_eq!(body["errors"][0]["code"], "unsupported_host"); -} - #[tokio::test] async fn merge_run_pull_request_returns_service_unavailable_without_github_credentials() { let (state, app, run_id) = pr_test_app(None, None); @@ -5497,6 +5603,45 @@ async fn merge_run_pull_request_returns_service_unavailable_without_github_crede assert_eq!(body["errors"][0]["code"], "integration_unavailable"); } +#[tokio::test] +async fn merge_run_pull_request_uses_stored_link_coordinates() { + let github = MockServer::start(); + let github_mock = github.mock(|when, then| { + when.method("PUT") + .path("/repos/acme/widgets/pulls/42/merge") + .header("authorization", "Bearer ghu_test") + .json_body(json!({ "merge_method": "squash" })); + then.status(200) + .header("content-type", "application/json") + .body(json!({}).to_string()); + }); + let (state, app, run_id) = pr_test_app(Some("ghu_test"), Some(github.base_url())); + + create_run_with_linked_pull_request_record(&state, run_id, PullRequestRecord { + owner: "acme".to_string(), + repo: "widgets".to_string(), + number: 42, + }) + .await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(api(&format!("/runs/{run_id}/pull_request/merge"))) + .header("content-type", "application/json") + .body(Body::from(json!({ "method": "squash" }).to_string())) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json!(response, StatusCode::OK).await; + + assert_eq!(body["number"], 42); + assert_eq!(body["html_url"], "https://github.com/acme/widgets/pull/42"); + github_mock.assert(); +} + #[tokio::test] async fn close_run_pull_request_returns_not_found_when_record_missing() { let (_state, app, run_id) = pr_test_app_with_minimal_run(Some("ghu_test"), None).await; @@ -5516,34 +5661,6 @@ async fn close_run_pull_request_returns_not_found_when_record_missing() { assert_eq!(body["errors"][0]["code"], "no_stored_record"); } -#[tokio::test] -async fn close_run_pull_request_rejects_non_github_record_url() { - let (state, app, run_id) = pr_test_app(Some("ghu_test"), None); - - create_run_with_pull_request_record( - &state, - run_id, - "https://gitlab.com/acme/widgets/-/merge_requests/42", - 42, - "Fix the bug", - ) - .await; - - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri(api(&format!("/runs/{run_id}/pull_request/close"))) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let body = response_json!(response, StatusCode::BAD_REQUEST).await; - - assert_eq!(body["errors"][0]["code"], "unsupported_host"); -} - #[tokio::test] async fn close_run_pull_request_returns_service_unavailable_without_github_credentials() { let (state, app, run_id) = pr_test_app(None, None); diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 2137d26eb..62a8966dd 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -238,16 +238,17 @@ impl RunProjectionReducer for RunProjection { } EventBody::PullRequestCreated(props) => { self.pull_request = Some(PullRequestRecord { - provider: "github".to_string(), - html_url: props.pr_url.clone(), - number: props.pr_number, - owner: props.owner.clone(), - repo: props.repo.clone(), - base_branch: props.base_branch.clone(), - head_branch: props.head_branch.clone(), - title: props.title.clone(), + owner: props.owner.clone(), + repo: props.repo.clone(), + number: props.pr_number, }); } + EventBody::PullRequestLinked(props) => { + self.pull_request = Some(props.pull_request.clone()); + } + EventBody::PullRequestUnlinked(_) => { + self.pull_request = None; + } EventBody::InterviewStarted(props) => { if props.question_id.is_empty() { return Ok(()); @@ -951,8 +952,9 @@ mod tests { use fabro_types::{ BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint, CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph, - Outcome, QuestionType, RunBlobId, RunControlAction, RunDiff, RunEvent, RunSpec, RunStatus, - StageOutcome, StageState, SuccessReason, WorkflowSettings, first_event_seq, fixtures, + Outcome, PullRequestRecord, QuestionType, RunBlobId, RunControlAction, RunDiff, RunEvent, + RunSpec, RunStatus, StageOutcome, StageState, SuccessReason, WorkflowSettings, + first_event_seq, fixtures, }; use serde_json::json; @@ -2490,7 +2492,7 @@ mod tests { .as_ref() .expect("projection should store pull request"); assert_eq!( - pull_request.html_url, + pull_request.html_url(), "https://github.com/fabro-sh/fabro/pull/123" ); assert_eq!(pull_request.number, 123); @@ -2499,6 +2501,76 @@ mod tests { assert_eq!(summary.pull_request, state.pull_request); } + #[test] + fn pull_request_linked_replaces_and_unlinked_clears_projection() { + use fabro_types::run_event::{ + PullRequestCreatedProps, PullRequestLinkedProps, PullRequestUnlinkedProps, + }; + + let mut state = running_projection(); + let github_pull_request = PullRequestRecord { + owner: "fabro-sh".to_string(), + repo: "fabro".to_string(), + number: 123, + }; + let replacement_pull_request = PullRequestRecord { + owner: "acme".to_string(), + repo: "widgets".to_string(), + number: 42, + }; + + state + .apply_event(&test_event( + 1, + EventBody::PullRequestCreated(PullRequestCreatedProps { + pr_url: github_pull_request.html_url(), + pr_number: github_pull_request.number, + owner: github_pull_request.owner.clone(), + repo: github_pull_request.repo.clone(), + base_branch: "main".to_string(), + head_branch: "fabro/run/demo".to_string(), + title: "Add run PR chip".to_string(), + draft: false, + }), + None, + )) + .unwrap(); + assert_eq!(state.pull_request, Some(github_pull_request.clone())); + + state + .apply_event(&test_event( + 2, + EventBody::PullRequestLinked(PullRequestLinkedProps { + pull_request: replacement_pull_request.clone(), + }), + None, + )) + .unwrap(); + assert_eq!(state.pull_request, Some(replacement_pull_request.clone())); + + state + .apply_event(&test_event( + 3, + EventBody::PullRequestUnlinked(PullRequestUnlinkedProps { + pull_request: replacement_pull_request, + }), + None, + )) + .unwrap(); + assert_eq!(state.pull_request, None); + + state + .apply_event(&test_event( + 4, + EventBody::PullRequestLinked(PullRequestLinkedProps { + pull_request: github_pull_request.clone(), + }), + None, + )) + .unwrap(); + assert_eq!(state.pull_request, Some(github_pull_request)); + } + #[test] fn run_unarchived_restores_prior_status() { use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps}; diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 57154fd44..8e6bdf055 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -65,8 +65,9 @@ pub use outcome::{ }; pub use principal::{AuthMethod, Principal, SystemActorKind, UserPrincipal}; pub use pull_request::{ - CheckRun, CheckRunStatus, PullRequest, PullRequestDetails, PullRequestGithubDetail, - PullRequestRef, PullRequestTimestamps, PullRequestUser, + CheckRun, CheckRunStatus, PullRequest, PullRequestDetails, PullRequestDetailsStatus, + PullRequestDetailsUnavailableReason, PullRequestGithubDetail, PullRequestLink, PullRequestMeta, + PullRequestRef, PullRequestResponse, PullRequestTimestamps, PullRequestUser, }; pub use repository::{RepositoryProvider, RepositoryRef}; pub type RepositoryReference = RepositoryRef; @@ -90,8 +91,8 @@ pub use run_summary::{ RunOriginKind, RunTimestamps, WorkflowRef, }; pub type RunSummary = Run; -pub type PullRequestRecord = PullRequest; -pub type PullRequestDetail = PullRequestDetails; +pub type PullRequestRecord = PullRequestLink; +pub type PullRequestDetail = PullRequestResponse; pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title}; pub use sandbox_details::{ SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, diff --git a/lib/crates/fabro-types/src/pull_request.rs b/lib/crates/fabro-types/src/pull_request.rs index 54f8fb00c..0bafd9baa 100644 --- a/lib/crates/fabro-types/src/pull_request.rs +++ b/lib/crates/fabro-types/src/pull_request.rs @@ -1,21 +1,179 @@ -use serde::{Deserialize, Serialize}; +use serde::de::Error as DeError; +use serde::ser::SerializeStruct; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; -/// Record of a pull request created for a workflow run. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PullRequest { - #[serde(default = "github_provider")] - pub provider: String, - pub html_url: String, - pub number: u64, - pub owner: String, - pub repo: String, - pub base_branch: String, - pub head_branch: String, - pub title: String, +/// Minimal GitHub pull request reference stored on a workflow run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PullRequestLink { + pub owner: String, + pub repo: String, + pub number: u64, } -fn github_provider() -> String { - "github".to_string() +impl PullRequestLink { + #[must_use] + pub fn html_url(&self) -> String { + format!( + "https://github.com/{}/{}/pull/{}", + self.owner, self.repo, self.number + ) + } + + pub fn from_github_url(url: &str) -> Result { + github_pull_request_link_from_url(url) + } +} + +impl Serialize for PullRequestLink { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("PullRequestLink", 4)?; + state.serialize_field("owner", &self.owner)?; + state.serialize_field("repo", &self.repo)?; + state.serialize_field("number", &self.number)?; + state.serialize_field("html_url", &self.html_url())?; + state.end() + } +} + +impl<'de> Deserialize<'de> for PullRequestLink { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + #[serde(default)] + provider: Option, + #[serde(default)] + html_url: Option, + #[serde(default)] + owner: Option, + #[serde(default)] + repo: Option, + #[serde(default)] + number: Option, + } + + let wire = Wire::deserialize(deserializer)?; + if wire + .provider + .as_deref() + .is_some_and(|provider| provider != "github") + { + return Err(D::Error::custom( + "pull request links must reference github.com pull requests", + )); + } + + let link = + if let (Some(owner), Some(repo), Some(number)) = (wire.owner, wire.repo, wire.number) { + Self { + owner, + repo, + number, + } + } else { + let html_url = wire + .html_url + .as_deref() + .ok_or_else(|| D::Error::custom("missing pull request owner/repo/number"))?; + github_pull_request_link_from_url(html_url).map_err(D::Error::custom)? + }; + + if let Some(html_url) = wire.html_url { + let url_link = + github_pull_request_link_from_url(&html_url).map_err(D::Error::custom)?; + if url_link != link { + return Err(D::Error::custom( + "pull request html_url does not match owner/repo/number", + )); + } + } + + Ok(link) + } +} + +#[expect( + clippy::disallowed_types, + reason = "Pull request links are public github.com URLs stored for display and coordinate inference." +)] +fn github_pull_request_link_from_url(raw_url: &str) -> Result { + let parsed = + url::Url::parse(raw_url).map_err(|err| format!("Invalid pull request URL: {err}"))?; + if parsed.scheme() != "https" || parsed.host_str() != Some("github.com") { + return Err( + "Pull request link must be a GitHub pull request URL like https://github.com/owner/repo/pull/123." + .to_string(), + ); + } + let segments = parsed + .path_segments() + .map(|segments| { + segments + .filter(|segment| !segment.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + let [owner, repo, "pull", number] = segments.as_slice() else { + return Err( + "Pull request link must use https://github.com/owner/repo/pull/123.".to_string(), + ); + }; + let number = number + .parse() + .map_err(|_| "Pull request URL number must be an unsigned integer.".to_string())?; + Ok(PullRequestLink { + owner: (*owner).to_string(), + repo: (*repo).to_string(), + number, + }) +} + +/// Stored pull request link plus optional live GitHub details. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequest { + pub link: PullRequestLink, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// Response metadata for `GET /runs/{id}/pull_request`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestMeta { + pub details_status: PullRequestDetailsStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details_unavailable_reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestResponse { + pub data: PullRequest, + pub meta: PullRequestMeta, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display, strum::EnumString, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum PullRequestDetailsStatus { + Available, + Unavailable, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display, strum::EnumString, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum PullRequestDetailsUnavailableReason { + IntegrationUnavailable, + NotFound, + FetchFailed, } /// GitHub user summary for a pull request. @@ -55,11 +213,12 @@ pub struct PullRequestGithubDetail { pub updated_at: String, } -/// Stored pull request record plus live GitHub fields, returned by the -/// `GET /runs/{id}/pull_request` endpoint. +/// Live GitHub pull request fields returned only after a successful GitHub API +/// fetch. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PullRequestDetails { - pub pull_request: PullRequest, + pub title: String, + pub body: Option, pub state: String, pub draft: bool, pub merged: bool, @@ -68,12 +227,36 @@ pub struct PullRequestDetails { pub additions: u64, pub deletions: u64, pub changed_files: u64, - pub comments: u64, - pub checks: Vec, pub author: PullRequestUser, + pub head_branch: String, + pub base_branch: String, pub timestamps: PullRequestTimestamps, } +impl From for PullRequestDetails { + fn from(detail: PullRequestGithubDetail) -> Self { + Self { + title: detail.title, + body: detail.body, + state: detail.state, + draft: detail.draft, + merged: detail.merged, + merged_at: detail.merged_at, + mergeable: detail.mergeable, + additions: detail.additions, + deletions: detail.deletions, + changed_files: detail.changed_files, + author: detail.user, + head_branch: detail.head.ref_name, + base_branch: detail.base.ref_name, + timestamps: PullRequestTimestamps { + created_at: detail.created_at, + updated_at: detail.updated_at, + }, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PullRequestTimestamps { pub created_at: String, @@ -96,3 +279,56 @@ pub enum CheckRunStatus { Completed, Unknown, } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn pull_request_link_serializes_computed_html_url() { + let link = PullRequestLink { + owner: "fabro-sh".to_string(), + repo: "fabro".to_string(), + number: 270, + }; + + assert_eq!( + serde_json::to_value(link).unwrap(), + json!({ + "owner": "fabro-sh", + "repo": "fabro", + "number": 270, + "html_url": "https://github.com/fabro-sh/fabro/pull/270" + }) + ); + } + + #[test] + fn pull_request_link_accepts_legacy_github_record() { + let link: PullRequestLink = serde_json::from_value(json!({ + "provider": "github", + "html_url": "https://github.com/fabro-sh/fabro/pull/270", + "number": 270, + "owner": "fabro-sh", + "repo": "fabro", + "title": "ignored live metadata" + })) + .unwrap(); + + assert_eq!(link.owner, "fabro-sh"); + assert_eq!(link.repo, "fabro"); + assert_eq!(link.number, 270); + } + + #[test] + fn pull_request_link_rejects_legacy_external_record() { + let result = serde_json::from_value::(json!({ + "provider": "external", + "html_url": "https://gitlab.com/acme/widgets/-/merge_requests/42" + })); + + assert!(result.is_err()); + } +} diff --git a/lib/crates/fabro-types/src/run_event/misc.rs b/lib/crates/fabro-types/src/run_event/misc.rs index 22c1f340e..0ce664db0 100644 --- a/lib/crates/fabro-types/src/run_event/misc.rs +++ b/lib/crates/fabro-types/src/run_event/misc.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize, de}; use serde_json::Value; use super::ExecOutputTail; -use crate::CommandTermination; +use crate::{CommandTermination, PullRequestLink}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InterviewOption { @@ -378,6 +378,16 @@ pub struct PullRequestCreatedProps { pub draft: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestLinkedProps { + pub pull_request: PullRequestLink, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PullRequestUnlinkedProps { + pub pull_request: PullRequestLink, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PullRequestFailedProps { pub error: String, diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index 2c46231ed..5ceedebdf 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -300,6 +300,10 @@ pub enum EventBody { AgentAcpTimedOut(AgentAcpTimedOutProps), #[serde(rename = "pull_request.created")] PullRequestCreated(PullRequestCreatedProps), + #[serde(rename = "pull_request.linked")] + PullRequestLinked(PullRequestLinkedProps), + #[serde(rename = "pull_request.unlinked")] + PullRequestUnlinked(PullRequestUnlinkedProps), #[serde(rename = "pull_request.failed")] PullRequestFailed(PullRequestFailedProps), #[serde(rename = "devcontainer.resolved")] @@ -497,6 +501,8 @@ impl EventBody { Self::AgentAcpCancelled(_) => "agent.acp.cancelled", Self::AgentAcpTimedOut(_) => "agent.acp.timed_out", Self::PullRequestCreated(_) => "pull_request.created", + Self::PullRequestLinked(_) => "pull_request.linked", + Self::PullRequestUnlinked(_) => "pull_request.unlinked", Self::PullRequestFailed(_) => "pull_request.failed", Self::DevcontainerResolved(_) => "devcontainer.resolved", Self::DevcontainerLifecycleStarted(_) => "devcontainer.lifecycle.started", @@ -648,6 +654,8 @@ fn is_known_event_name(event: &str) -> bool { | "agent.acp.cancelled" | "agent.acp.timed_out" | "pull_request.created" + | "pull_request.linked" + | "pull_request.unlinked" | "pull_request.failed" | "devcontainer.resolved" | "devcontainer.lifecycle.started" @@ -1488,6 +1496,80 @@ mod tests { )); } + #[test] + fn pull_request_linked_round_trips_json() { + let event = RunEvent { + id: "evt_pr_linked".to_string(), + ts: DateTime::parse_from_rfc3339("2026-05-15T12:00:00.000Z") + .unwrap() + .with_timezone(&Utc), + run_id: fixtures::RUN_1, + node_id: None, + node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, + session_id: None, + parent_session_id: None, + tool_call_id: None, + actor: None, + body: EventBody::PullRequestLinked(PullRequestLinkedProps { + pull_request: crate::PullRequestLink { + owner: "acme".to_string(), + repo: "widgets".to_string(), + number: 42, + }, + }), + }; + + let value = event.to_value().unwrap(); + assert_eq!(value["event"], "pull_request.linked"); + assert_eq!( + value["properties"]["pull_request"]["html_url"], + "https://github.com/acme/widgets/pull/42" + ); + + let parsed = RunEvent::from_value(value).unwrap(); + assert_eq!(parsed, event); + } + + #[test] + fn pull_request_unlinked_round_trips_json() { + let event = RunEvent { + id: "evt_pr_unlinked".to_string(), + ts: DateTime::parse_from_rfc3339("2026-05-15T12:05:00.000Z") + .unwrap() + .with_timezone(&Utc), + run_id: fixtures::RUN_1, + node_id: None, + node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, + session_id: None, + parent_session_id: None, + tool_call_id: None, + actor: None, + body: EventBody::PullRequestUnlinked(PullRequestUnlinkedProps { + pull_request: crate::PullRequestLink { + owner: "acme".to_string(), + repo: "widgets".to_string(), + number: 42, + }, + }), + }; + + let value = event.to_value().unwrap(); + assert_eq!(value["event"], "pull_request.unlinked"); + assert_eq!( + value["properties"]["pull_request"]["number"], + serde_json::json!(42) + ); + + let parsed = RunEvent::from_value(value).unwrap(); + assert_eq!(parsed, event); + } + #[test] fn retired_sandbox_snapshot_events_deserialize_as_unknown() { for (event_name, expected_properties) in [ diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs index f58c1b936..e4e64f30c 100644 --- a/lib/crates/fabro-types/src/run_summary.rs +++ b/lib/crates/fabro-types/src/run_summary.rs @@ -4,8 +4,8 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{ - DiffSummary, InterviewQuestionRecord, Principal, PullRequest, RepositoryRef, RunControlAction, - RunId, RunSandbox, RunStatus, + DiffSummary, InterviewQuestionRecord, Principal, PullRequestLink, RepositoryRef, + RunControlAction, RunId, RunSandbox, RunStatus, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -34,7 +34,7 @@ pub struct Run { #[serde(default)] pub diff: Option, #[serde(default)] - pub pull_request: Option, + pub pull_request: Option, #[serde(default)] pub current_question: Option, #[serde(default)] diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index c00119caa..3fff23db7 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -1168,6 +1168,16 @@ fn event_body_from_event(event: &Event) -> EventBody { title: title.clone(), draft: *draft, }), + Event::PullRequestLinked { pull_request } => { + EventBody::PullRequestLinked(fabro_types::PullRequestLinkedProps { + pull_request: pull_request.clone(), + }) + } + Event::PullRequestUnlinked { pull_request } => { + EventBody::PullRequestUnlinked(fabro_types::PullRequestUnlinkedProps { + pull_request: pull_request.clone(), + }) + } Event::PullRequestFailed { error } => { EventBody::PullRequestFailed(fabro_types::PullRequestFailedProps { error: error.clone(), diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index 46c6147c3..a57d43020 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -648,6 +648,12 @@ pub enum Event { title: String, draft: bool, }, + PullRequestLinked { + pull_request: PullRequestRecord, + }, + PullRequestUnlinked { + pull_request: PullRequestRecord, + }, PullRequestFailed { error: String, }, @@ -709,15 +715,21 @@ impl Event { } } - pub fn pull_request_created(record: &PullRequestRecord, draft: bool) -> Self { + pub fn pull_request_created( + record: &PullRequestRecord, + base_branch: &str, + head_branch: &str, + title: &str, + draft: bool, + ) -> Self { Self::PullRequestCreated { - pr_url: record.html_url.clone(), + pr_url: record.html_url(), pr_number: record.number, owner: record.owner.clone(), repo: record.repo.clone(), - base_branch: record.base_branch.clone(), - head_branch: record.head_branch.clone(), - title: record.title.clone(), + base_branch: base_branch.to_string(), + head_branch: head_branch.to_string(), + title: title.to_string(), draft, } } @@ -1426,6 +1438,20 @@ impl Event { } => { info!(pr_url = %pr_url, pr_number, draft, owner, repo, "Pull request created"); } + Self::PullRequestLinked { pull_request } => { + info!( + pr_url = %pull_request.html_url(), + pr_number = pull_request.number, + "Pull request linked" + ); + } + Self::PullRequestUnlinked { pull_request } => { + info!( + pr_url = %pull_request.html_url(), + pr_number = pull_request.number, + "Pull request unlinked" + ); + } Self::PullRequestFailed { error, .. } => { error!(error = %error, "Pull request creation failed"); } diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs index 1607975f5..22d8b05cd 100644 --- a/lib/crates/fabro-workflow/src/event/names.rs +++ b/lib/crates/fabro-workflow/src/event/names.rs @@ -139,6 +139,8 @@ pub fn event_name(event: &Event) -> &'static str { Event::AgentAcpCancelled { .. } => "agent.acp.cancelled", Event::AgentAcpTimedOut { .. } => "agent.acp.timed_out", Event::PullRequestCreated { .. } => "pull_request.created", + Event::PullRequestLinked { .. } => "pull_request.linked", + Event::PullRequestUnlinked { .. } => "pull_request.unlinked", Event::PullRequestFailed { .. } => "pull_request.failed", Event::DevcontainerResolved { .. } => "devcontainer.resolved", Event::DevcontainerLifecycleStarted { .. } => "devcontainer.lifecycle.started", diff --git a/lib/crates/fabro-workflow/src/pipeline/mod.rs b/lib/crates/fabro-workflow/src/pipeline/mod.rs index b6d6899d0..faa876788 100644 --- a/lib/crates/fabro-workflow/src/pipeline/mod.rs +++ b/lib/crates/fabro-workflow/src/pipeline/mod.rs @@ -18,8 +18,8 @@ pub use initialize::initialize; pub use parse::parse; pub(crate) use persist::persist; pub use pull_request::{ - AutoMergeOptions, OpenPullRequestRequest, PrContent, build_pr_content, maybe_open_pull_request, - pull_request, + AutoMergeOptions, CreatedPullRequest, OpenPullRequestRequest, PrContent, build_pr_content, + maybe_open_pull_request, pull_request, }; pub use transform::transform; pub use types::{ diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 4aaf37cda..537caec0d 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -472,13 +472,21 @@ pub struct OpenPullRequestRequest<'a> { pub run_state: Option<&'a RunProjection>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreatedPullRequest { + pub link: PullRequestRecord, + pub title: String, + pub base_branch: String, + pub head_branch: String, +} + /// Optionally open a pull request after a successful workflow run. /// -/// Returns `Ok(Some(PullRequestRecord))` if a PR was created, `Ok(None)` if +/// Returns `Ok(Some(CreatedPullRequest))` if a PR was created, `Ok(None)` if /// the diff was empty, or `Err` on failure. pub async fn maybe_open_pull_request( req: OpenPullRequestRequest<'_>, -) -> Result, String> { +) -> Result, String> { if req.diff.is_empty() { debug!("Empty diff, skipping pull request creation"); return Ok(None); @@ -541,18 +549,18 @@ pub async fn maybe_open_pull_request( } } - let record = PullRequestRecord { - provider: "github".to_string(), - html_url: created.html_url, - number: created.number, + let link = PullRequestRecord { owner, repo, - base_branch: req.base_branch.to_string(), - head_branch: req.head_branch.to_string(), - title, + number: created.number, }; - Ok(Some(record)) + Ok(Some(CreatedPullRequest { + link, + title, + base_branch: req.base_branch.to_string(), + head_branch: req.head_branch.to_string(), + })) } /// PULL_REQUEST phase: optionally create a pull request after finalize. @@ -615,11 +623,15 @@ pub async fn pull_request(concluded: Concluded, options: &PullRequestOptions) -> }) .await { - Ok(Some(record)) => { - services - .emitter - .emit(&Event::pull_request_created(&record, pr_cfg.draft)); - pr_url = Some(record.html_url.clone()); + Ok(Some(created)) => { + services.emitter.emit(&Event::pull_request_created( + &created.link, + &created.base_branch, + &created.head_branch, + &created.title, + pr_cfg.draft, + )); + pr_url = Some(created.link.html_url()); } Ok(None) => {} Err(e) => { @@ -1977,8 +1989,9 @@ mod tests { .expect("PR creation should succeed"); let record = result.expect("PR record should be Some"); - assert_eq!(record.title.chars().count(), 72); - assert!(record.title.ends_with('\u{2026}')); + let title = record.title; + assert_eq!(title.chars().count(), 72); + assert!(title.ends_with('\u{2026}')); harness.assert_mocks_called_once().await; } } diff --git a/lib/crates/fabro-workflow/src/pull_request.rs b/lib/crates/fabro-workflow/src/pull_request.rs index 3b65dfcbc..66aab1d82 100644 --- a/lib/crates/fabro-workflow/src/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pull_request.rs @@ -1,4 +1,4 @@ pub use crate::pipeline::{ - AutoMergeOptions, OpenPullRequestRequest, PrContent, PullRequestRecord, build_pr_content, - maybe_open_pull_request, + AutoMergeOptions, CreatedPullRequest, OpenPullRequestRequest, PrContent, PullRequestRecord, + build_pr_content, maybe_open_pull_request, }; diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 8a8f087bd..281863d55 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -146,6 +146,7 @@ models/interview-option.ts models/interview-provider-settings.ts models/interview-question-record.ts models/ip-allow-entry.ts +models/link-run-pull-request-request.ts models/literal-ip-allow-entry.ts models/log-destination.ts models/manifest-args.ts @@ -215,9 +216,14 @@ models/project-namespace.ts models/prune-run-entry.ts models/prune-runs-request.ts models/prune-runs-response.ts +models/pull-request-details-status.ts models/pull-request-details-timestamps.ts +models/pull-request-details-unavailable-reason.ts models/pull-request-details.ts +models/pull-request-link.ts +models/pull-request-meta.ts models/pull-request-ref.ts +models/pull-request-response.ts models/pull-request-settings.ts models/pull-request-user.ts models/pull-request.ts diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index 634b53a9d..32677953b 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -34,6 +34,8 @@ import type { ForkRequest } from '../models'; // @ts-ignore import type { ForkResponse } from '../models'; // @ts-ignore +import type { LinkRunPullRequestRequest } from '../models'; +// @ts-ignore import type { MergeRunPullRequestRequest } from '../models'; // @ts-ignore import type { MergeRunPullRequestResponse } from '../models'; @@ -44,9 +46,9 @@ import type { PaginatedRunList } from '../models'; // @ts-ignore import type { PreflightResponse } from '../models'; // @ts-ignore -import type { PullRequest } from '../models'; +import type { PullRequestLink } from '../models'; // @ts-ignore -import type { PullRequestDetails } from '../models'; +import type { PullRequestResponse } from '../models'; // @ts-ignore import type { RenderWorkflowGraphRequest } from '../models'; // @ts-ignore @@ -365,7 +367,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) }; }, /** - * Returns the stored pull request record for a run plus live GitHub details. + * Returns the stored pull request record for a run plus live GitHub details when available. * @summary Get Run Pull Request * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -444,6 +446,51 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Links or replaces the GitHub pull request association for a run without modifying the remote pull request. + * @summary Link Run Pull Request + * @param {string} id Unique run identifier (ULID). + * @param {LinkRunPullRequestRequest} linkRunPullRequestRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + linkRunPullRequest: async (id: string, linkRunPullRequestRequest: LinkRunPullRequestRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('linkRunPullRequest', 'id', id) + // verify required parameter 'linkRunPullRequestRequest' is not null or undefined + assertParamExists('linkRunPullRequest', 'linkRunPullRequestRequest', linkRunPullRequestRequest) + const localVarPath = `/api/v1/runs/{id}/pull_request` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(linkRunPullRequestRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves. Archived runs are hidden by default; pass `include_archived=true` to include them under the `archived` column. * @summary List Board Runs @@ -1007,6 +1054,46 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Removes Fabro\'s stored pull request association for a run without modifying the remote pull request. + * @summary Unlink Run Pull Request + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + unlinkRunPullRequest: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('unlinkRunPullRequest', 'id', id) + const localVarPath = `/api/v1/runs/{id}/pull_request` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Resumes a paused run. Returns 409 if the run is not paused. * @summary Unpause Run @@ -1202,7 +1289,7 @@ export const RunsApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.createRunPullRequest(id, createRunPullRequestRequest, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunsApi.createRunPullRequest']?.[localVarOperationServerIndex]?.url; @@ -1237,13 +1324,13 @@ export const RunsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Returns the stored pull request record for a run plus live GitHub details. + * Returns the stored pull request record for a run plus live GitHub details when available. * @summary Get Run Pull Request * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getRunPullRequest(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getRunPullRequest(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getRunPullRequest(id, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunsApi.getRunPullRequest']?.[localVarOperationServerIndex]?.url; @@ -1262,6 +1349,20 @@ export const RunsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunsApi.getRunTimeline']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Links or replaces the GitHub pull request association for a run without modifying the remote pull request. + * @summary Link Run Pull Request + * @param {string} id Unique run identifier (ULID). + * @param {LinkRunPullRequestRequest} linkRunPullRequestRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async linkRunPullRequest(id: string, linkRunPullRequestRequest: LinkRunPullRequestRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.linkRunPullRequest(id, linkRunPullRequestRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunsApi.linkRunPullRequest']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves. Archived runs are hidden by default; pass `include_archived=true` to include them under the `archived` column. * @summary List Board Runs @@ -1439,6 +1540,19 @@ export const RunsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunsApi.unarchiveRun']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Removes Fabro\'s stored pull request association for a run without modifying the remote pull request. + * @summary Unlink Run Pull Request + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async unlinkRunPullRequest(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.unlinkRunPullRequest(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunsApi.unlinkRunPullRequest']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Resumes a paused run. Returns 409 if the run is not paused. * @summary Unpause Run @@ -1536,7 +1650,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? * @param {*} [options] Override http request option. * @throws {RequiredError} */ - createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): AxiosPromise { + createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.createRunPullRequest(id, createRunPullRequestRequest, options).then((request) => request(axios, basePath)); }, /** @@ -1562,13 +1676,13 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? return localVarFp.forkRun(id, forkRequest, options).then((request) => request(axios, basePath)); }, /** - * Returns the stored pull request record for a run plus live GitHub details. + * Returns the stored pull request record for a run plus live GitHub details when available. * @summary Get Run Pull Request * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getRunPullRequest(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + getRunPullRequest(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.getRunPullRequest(id, options).then((request) => request(axios, basePath)); }, /** @@ -1581,6 +1695,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? getRunTimeline(id: string, options?: RawAxiosRequestConfig): AxiosPromise> { return localVarFp.getRunTimeline(id, options).then((request) => request(axios, basePath)); }, + /** + * Links or replaces the GitHub pull request association for a run without modifying the remote pull request. + * @summary Link Run Pull Request + * @param {string} id Unique run identifier (ULID). + * @param {LinkRunPullRequestRequest} linkRunPullRequestRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + linkRunPullRequest(id: string, linkRunPullRequestRequest: LinkRunPullRequestRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.linkRunPullRequest(id, linkRunPullRequestRequest, options).then((request) => request(axios, basePath)); + }, /** * Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves. Archived runs are hidden by default; pass `include_archived=true` to include them under the `archived` column. * @summary List Board Runs @@ -1719,6 +1844,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? unarchiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.unarchiveRun(id, options).then((request) => request(axios, basePath)); }, + /** + * Removes Fabro\'s stored pull request association for a run without modifying the remote pull request. + * @summary Unlink Run Pull Request + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + unlinkRunPullRequest(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.unlinkRunPullRequest(id, options).then((request) => request(axios, basePath)); + }, /** * Resumes a paused run. Returns 409 if the run is not paused. * @summary Unpause Run @@ -1838,7 +1973,7 @@ export class RunsApi extends BaseAPI { } /** - * Returns the stored pull request record for a run plus live GitHub details. + * Returns the stored pull request record for a run plus live GitHub details when available. * @summary Get Run Pull Request * @param {string} id Unique run identifier (ULID). * @param {*} [options] Override http request option. @@ -1859,6 +1994,18 @@ export class RunsApi extends BaseAPI { return RunsApiFp(this.configuration).getRunTimeline(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * Links or replaces the GitHub pull request association for a run without modifying the remote pull request. + * @summary Link Run Pull Request + * @param {string} id Unique run identifier (ULID). + * @param {LinkRunPullRequestRequest} linkRunPullRequestRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public linkRunPullRequest(id: string, linkRunPullRequestRequest: LinkRunPullRequestRequest, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).linkRunPullRequest(id, linkRunPullRequestRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves. Archived runs are hidden by default; pass `include_archived=true` to include them under the `archived` column. * @summary List Board Runs @@ -2010,6 +2157,17 @@ export class RunsApi extends BaseAPI { return RunsApiFp(this.configuration).unarchiveRun(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * Removes Fabro\'s stored pull request association for a run without modifying the remote pull request. + * @summary Unlink Run Pull Request + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public unlinkRunPullRequest(id: string, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).unlinkRunPullRequest(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * Resumes a paused run. Returns 409 if the run is not paused. * @summary Unpause Run diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 209d99af7..9b74efb79 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -123,6 +123,7 @@ export * from './interview-option'; export * from './interview-provider-settings'; export * from './interview-question-record'; export * from './ip-allow-entry'; +export * from './link-run-pull-request-request'; export * from './literal-ip-allow-entry'; export * from './log-destination'; export * from './manifest-args'; @@ -194,8 +195,13 @@ export * from './prune-runs-request'; export * from './prune-runs-response'; export * from './pull-request'; export * from './pull-request-details'; +export * from './pull-request-details-status'; export * from './pull-request-details-timestamps'; +export * from './pull-request-details-unavailable-reason'; +export * from './pull-request-link'; +export * from './pull-request-meta'; export * from './pull-request-ref'; +export * from './pull-request-response'; export * from './pull-request-settings'; export * from './pull-request-user'; export * from './question-type'; diff --git a/lib/packages/fabro-api-client/src/models/link-run-pull-request-request.ts b/lib/packages/fabro-api-client/src/models/link-run-pull-request-request.ts new file mode 100644 index 000000000..0d6b00f50 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/link-run-pull-request-request.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Request body for linking an existing GitHub pull request to a run. + */ +export interface LinkRunPullRequestRequest { + /** + * GitHub pull request URL to associate with the run. Must use the form `https://github.com/{owner}/{repo}/pull/{number}`. + */ + 'html_url': string; +} + diff --git a/lib/packages/fabro-api-client/src/models/pull-request-details-status.ts b/lib/packages/fabro-api-client/src/models/pull-request-details-status.ts new file mode 100644 index 000000000..c2103a0ac --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pull-request-details-status.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Whether live GitHub pull request details are present. + */ + +export const PullRequestDetailsStatus = { + AVAILABLE: 'available', + UNAVAILABLE: 'unavailable' +} as const; + +export type PullRequestDetailsStatus = typeof PullRequestDetailsStatus[keyof typeof PullRequestDetailsStatus]; + + + diff --git a/lib/packages/fabro-api-client/src/models/pull-request-details-unavailable-reason.ts b/lib/packages/fabro-api-client/src/models/pull-request-details-unavailable-reason.ts new file mode 100644 index 000000000..e1b46ad81 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pull-request-details-unavailable-reason.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Why live GitHub pull request details are unavailable. + */ + +export const PullRequestDetailsUnavailableReason = { + INTEGRATION_UNAVAILABLE: 'integration_unavailable', + NOT_FOUND: 'not_found', + FETCH_FAILED: 'fetch_failed' +} as const; + +export type PullRequestDetailsUnavailableReason = typeof PullRequestDetailsUnavailableReason[keyof typeof PullRequestDetailsUnavailableReason]; + + + diff --git a/lib/packages/fabro-api-client/src/models/pull-request-details.ts b/lib/packages/fabro-api-client/src/models/pull-request-details.ts index 104b9a94f..69dd2da87 100644 --- a/lib/packages/fabro-api-client/src/models/pull-request-details.ts +++ b/lib/packages/fabro-api-client/src/models/pull-request-details.ts @@ -13,12 +13,6 @@ */ -// May contain unused imports in some cases -// @ts-ignore -import type { CheckRun } from './check-run'; -// May contain unused imports in some cases -// @ts-ignore -import type { PullRequest } from './pull-request'; // May contain unused imports in some cases // @ts-ignore import type { PullRequestDetailsTimestamps } from './pull-request-details-timestamps'; @@ -27,21 +21,22 @@ import type { PullRequestDetailsTimestamps } from './pull-request-details-timest import type { PullRequestUser } from './pull-request-user'; /** - * Stored pull request record plus live GitHub-enriched fields. + * Live pull request fields retrieved successfully from GitHub. */ export interface PullRequestDetails { - 'pull_request': PullRequest; + 'title': string; + 'body'?: string | null; 'state': string; 'draft': boolean; 'merged': boolean; - 'merged_at': string | null; - 'mergeable': boolean | null; + 'merged_at'?: string | null; + 'mergeable'?: boolean | null; 'additions': number; 'deletions': number; 'changed_files': number; - 'comments': number; - 'checks': Array; 'author': PullRequestUser; + 'head_branch': string; + 'base_branch': string; 'timestamps': PullRequestDetailsTimestamps; } diff --git a/lib/packages/fabro-api-client/src/models/pull-request-link.ts b/lib/packages/fabro-api-client/src/models/pull-request-link.ts new file mode 100644 index 000000000..bec270523 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pull-request-link.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Minimal GitHub pull request link associated with a run. + */ +export interface PullRequestLink { + 'owner': string; + 'repo': string; + 'number': number; + /** + * Computed GitHub web URL for the pull request. + */ + 'html_url': string; +} + diff --git a/lib/packages/fabro-api-client/src/models/pull-request-meta.ts b/lib/packages/fabro-api-client/src/models/pull-request-meta.ts new file mode 100644 index 000000000..286395ca7 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pull-request-meta.ts @@ -0,0 +1,32 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { PullRequestDetailsStatus } from './pull-request-details-status'; +// May contain unused imports in some cases +// @ts-ignore +import type { PullRequestDetailsUnavailableReason } from './pull-request-details-unavailable-reason'; + +/** + * Metadata for live GitHub detail retrieval. + */ +export interface PullRequestMeta { + 'details_status': PullRequestDetailsStatus; + 'details_unavailable_reason'?: PullRequestDetailsUnavailableReason | null; +} + + + diff --git a/lib/packages/fabro-api-client/src/models/pull-request-response.ts b/lib/packages/fabro-api-client/src/models/pull-request-response.ts new file mode 100644 index 000000000..8ace88585 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pull-request-response.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { PullRequest } from './pull-request'; +// May contain unused imports in some cases +// @ts-ignore +import type { PullRequestMeta } from './pull-request-meta'; + +/** + * Pull request link and optional live GitHub details for a run. + */ +export interface PullRequestResponse { + 'data': PullRequest; + 'meta': PullRequestMeta; +} + diff --git a/lib/packages/fabro-api-client/src/models/pull-request.ts b/lib/packages/fabro-api-client/src/models/pull-request.ts index 109ac4aac..8feb9a009 100644 --- a/lib/packages/fabro-api-client/src/models/pull-request.ts +++ b/lib/packages/fabro-api-client/src/models/pull-request.ts @@ -13,25 +13,18 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { PullRequestDetails } from './pull-request-details'; +// May contain unused imports in some cases +// @ts-ignore +import type { PullRequestLink } from './pull-request-link'; /** - * Persisted record of a pull request created for a run. + * Stored pull request link plus optional live GitHub details. */ export interface PullRequest { - 'provider': PullRequestProviderEnum; - 'html_url': string; - 'number': number; - 'owner': string; - 'repo': string; - 'base_branch': string; - 'head_branch': string; - 'title': string; + 'link': PullRequestLink; + 'details'?: PullRequestDetails | null; } -export const PullRequestProviderEnum = { - GITHUB: 'github' -} as const; - -export type PullRequestProviderEnum = typeof PullRequestProviderEnum[keyof typeof PullRequestProviderEnum]; - - diff --git a/lib/packages/fabro-api-client/src/models/run-projection.ts b/lib/packages/fabro-api-client/src/models/run-projection.ts index 624251dfb..2c93eb48e 100644 --- a/lib/packages/fabro-api-client/src/models/run-projection.ts +++ b/lib/packages/fabro-api-client/src/models/run-projection.ts @@ -24,6 +24,9 @@ import type { Conclusion } from './conclusion'; import type { PendingInterviewRecord } from './pending-interview-record'; // May contain unused imports in some cases // @ts-ignore +import type { PullRequestLink } from './pull-request-link'; +// May contain unused imports in some cases +// @ts-ignore import type { RunControlAction } from './run-control-action'; // May contain unused imports in some cases // @ts-ignore @@ -66,7 +69,7 @@ export interface RunProjection { 'checkpoints': Array; 'conclusion'?: Conclusion | null; 'sandbox'?: RunSandbox | null; - 'pull_request'?: { [key: string]: any; } | null; + 'pull_request'?: PullRequestLink | null; 'superseded_by'?: string | null; 'pending_interviews': { [key: string]: PendingInterviewRecord; }; /** diff --git a/lib/packages/fabro-api-client/src/models/run.ts b/lib/packages/fabro-api-client/src/models/run.ts index b93a79dc1..0849c7118 100644 --- a/lib/packages/fabro-api-client/src/models/run.ts +++ b/lib/packages/fabro-api-client/src/models/run.ts @@ -24,7 +24,7 @@ import type { DiffSummary } from './diff-summary'; import type { Principal } from './principal'; // May contain unused imports in some cases // @ts-ignore -import type { PullRequest } from './pull-request'; +import type { PullRequestLink } from './pull-request-link'; // May contain unused imports in some cases // @ts-ignore import type { RepositoryRef } from './repository-ref'; @@ -76,7 +76,7 @@ export interface Run { 'timestamps': RunTimestamps; 'billing': RunBillingSummary | null; 'diff': DiffSummary | null; - 'pull_request': PullRequest | null; + 'pull_request': PullRequestLink | null; 'current_question': RunQuestion | null; 'superseded_by': string | null; 'links': RunLinks; diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts index 0579ee087..73f54e184 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy-mode.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -27,3 +27,6 @@ export const SandboxNetworkPolicyMode = { } as const; export type SandboxNetworkPolicyMode = typeof SandboxNetworkPolicyMode[keyof typeof SandboxNetworkPolicyMode]; + + + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts b/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts index da49f0397..514a32a0d 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-network-policy.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -27,3 +27,6 @@ export interface SandboxNetworkPolicy { */ 'cidrs': Array; } + + + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-network.ts b/lib/packages/fabro-api-client/src/models/sandbox-network.ts index 91b5d8e1d..f0f9cf4a0 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-network.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-network.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -24,3 +24,4 @@ export interface SandboxNetwork { 'egress': SandboxNetworkPolicy; 'ingress': SandboxNetworkPolicy; } + From 87950295bd9553c11c6ff085614049072054b099 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 10:13:41 -0700 Subject: [PATCH 09/25] refactor(llm): split provider identity from adapters (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR separates provider identity from adapter behavior across the LLM stack. Provider IDs now represent catalog rows and provider metadata, while adapter/profile routing owns protocol behavior for Anthropic, OpenAI, Gemini, and OpenAI-compatible providers. ## Changes - Replace the shared `fabro_model::Provider` enum with open-ended `ProviderId` catalog identity and typed `AdapterKind` metadata. - Route auth, CLI, ACP, workflow, memory selection, profile construction, and LLM client registration through catalog provider rows instead of provider-ID fallbacks. - Move API-key URL/header/env metadata into provider catalog/auth flows and require configured provider rows for credential-backed clients. - Simplify billing to `algorithm`-tagged OpenAI, Anthropic, and Gemini shapes; OpenAI-compatible adapters bill through the OpenAI algorithm. - Remove greenfield compatibility paths for old provider aliases, legacy provider-tagged billing JSON, and the `openai_compatible` pseudo-provider env fallback. - Update fixtures and tests to exercise catalog-driven Kimi/Zai/Minimax/Inception/custom OpenAI-compatible routing. ## Validation - `cargo test --no-run -p fabro-model -p fabro-auth -p fabro-agent -p fabro-workflow -p fabro-server -p fabro-llm -p fabro-api -p fabro-cli -p fabro-store -p fabro-static` - `cargo nextest run -p fabro-model -p fabro-auth -p fabro-agent -p fabro-workflow -p fabro-server --no-fail-fast` - `cargo nextest run -p fabro-llm -p fabro-api -p fabro-cli -p fabro-store -p fabro-static --no-fail-fast` - `cargo +nightly-2026-04-14 fmt --check --all` - `git diff --check` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` --- [![Compound Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 via [Codex](https://openai.com/codex) --- docs/public/api-reference/fabro-api.yaml | 5 - docs/public/reference/user-configuration.mdx | 2 - lib/crates/fabro-agent/src/agent_profile.rs | 13 +- lib/crates/fabro-agent/src/cli.rs | 283 ++++++------- lib/crates/fabro-agent/src/memory.rs | 35 +- .../fabro-agent/src/profiles/anthropic.rs | 43 +- lib/crates/fabro-agent/src/profiles/gemini.rs | 42 +- lib/crates/fabro-agent/src/profiles/mod.rs | 14 +- lib/crates/fabro-agent/src/profiles/openai.rs | 55 +-- lib/crates/fabro-agent/src/session.rs | 8 +- lib/crates/fabro-agent/src/test_support.rs | 14 +- lib/crates/fabro-agent/src/tools.rs | 5 +- lib/crates/fabro-agent/src/types.rs | 4 +- lib/crates/fabro-agent/tests/it/guardrails.rs | 30 +- .../fabro-agent/tests/it/parity_matrix.rs | 168 ++++---- lib/crates/fabro-api/src/lib.rs | 2 +- .../tests/model_features_round_trip.rs | 2 - .../fabro-api/tests/model_round_trip.rs | 6 +- .../fabro-api/tests/provider_round_trip.rs | 11 +- lib/crates/fabro-auth/src/context.rs | 6 +- lib/crates/fabro-auth/src/credential.rs | 10 +- lib/crates/fabro-auth/src/env_source.rs | 34 +- lib/crates/fabro-auth/src/resolve.rs | 179 ++++---- .../fabro-auth/src/strategies/api_key.rs | 38 +- .../fabro-auth/src/strategies/codex_device.rs | 2 +- lib/crates/fabro-auth/src/strategy.rs | 27 +- lib/crates/fabro-auth/src/vault_ext.rs | 8 +- lib/crates/fabro-auth/src/vault_source.rs | 37 +- lib/crates/fabro-cli/src/args.rs | 4 +- lib/crates/fabro-cli/src/commands/install.rs | 111 +++-- lib/crates/fabro-cli/src/commands/model.rs | 44 +- .../fabro-cli/src/commands/provider/login.rs | 8 +- .../src/commands/run/run_progress/mod.rs | 33 +- .../fabro-cli/src/commands/run/runner.rs | 4 +- lib/crates/fabro-cli/src/main.rs | 20 +- .../fabro-cli/src/shared/provider_auth.rs | 189 ++++++--- lib/crates/fabro-cli/tests/it/cmd/doctor.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/model.rs | 6 +- .../fabro-cli/tests/it/cmd/model_test.rs | 5 +- lib/crates/fabro-cli/tests/it/cmd/run.rs | 4 +- lib/crates/fabro-cli/tests/it/workflow/acp.rs | 4 +- .../fabro-cli/tests/it/workflow/hooks.rs | 4 +- .../fabro-cli/tests/it/workflow/real_cli.rs | 12 +- lib/crates/fabro-config/src/builders.rs | 3 +- lib/crates/fabro-config/src/layers/llm.rs | 6 +- .../src/commands/docs_options_reference.rs | 2 - lib/crates/fabro-hooks/src/executor.rs | 3 +- lib/crates/fabro-hooks/src/runner.rs | 12 +- lib/crates/fabro-llm/src/adapter_registry.rs | 86 +--- lib/crates/fabro-llm/src/client.rs | 40 +- lib/crates/fabro-llm/src/lib.rs | 2 +- lib/crates/fabro-llm/src/model_test.rs | 5 +- lib/crates/fabro-llm/src/provider.rs | 2 +- .../src/providers/openai_compatible.rs | 1 - lib/crates/fabro-model/src/adapter.rs | 241 +++++------ lib/crates/fabro-model/src/billing.rs | 272 +++++-------- lib/crates/fabro-model/src/catalog.rs | 383 ++++-------------- .../src/catalog/providers/anthropic.toml | 1 + .../src/catalog/providers/gemini.toml | 1 + .../src/catalog/providers/inception.toml | 2 +- .../src/catalog/providers/kimi.toml | 1 + .../src/catalog/providers/minimax.toml | 1 + .../src/catalog/providers/openai.toml | 2 +- .../src/catalog/providers/zai.toml | 1 + lib/crates/fabro-model/src/ids.rs | 38 +- lib/crates/fabro-model/src/lib.rs | 4 +- lib/crates/fabro-model/src/model_ref.rs | 12 +- lib/crates/fabro-model/src/provider.rs | 334 --------------- lib/crates/fabro-model/src/types.rs | 26 +- lib/crates/fabro-server/src/demo/mod.rs | 42 +- lib/crates/fabro-server/src/diagnostics.rs | 17 +- lib/crates/fabro-server/src/install.rs | 129 +++--- lib/crates/fabro-server/src/run_manifest.rs | 11 +- lib/crates/fabro-server/src/server/tests.rs | 36 +- .../fabro-server/tests/it/api/install.rs | 58 ++- .../tests/it/api/install_openai_compatible.rs | 6 +- lib/crates/fabro-static/src/env_vars.rs | 2 - lib/crates/fabro-store/src/run_state.rs | 6 +- .../tests/serializable_projection.rs | 4 +- lib/crates/fabro-types/src/graph.rs | 6 - lib/crates/fabro-validate/src/lib.rs | 1 - .../fabro-workflow/src/billing_rollup.rs | 4 +- .../fabro-workflow/src/event/convert.rs | 16 +- .../fabro-workflow/src/handler/agent.rs | 4 +- .../fabro-workflow/src/handler/llm/acp.rs | 75 ++-- .../fabro-workflow/src/handler/llm/api.rs | 181 +++------ .../fabro-workflow/src/handler/llm/cli.rs | 224 +++++----- .../src/handler/llm/launch_env.rs | 15 +- .../src/handler/llm/preamble.rs | 5 +- .../fabro-workflow/src/handler/llm/routing.rs | 49 +++ .../fabro-workflow/src/handler/prompt.rs | 21 +- .../fabro-workflow/src/lifecycle/git.rs | 9 +- .../fabro-workflow/src/operations/create.rs | 4 +- .../fabro-workflow/src/operations/start.rs | 26 +- lib/crates/fabro-workflow/src/outcome.rs | 55 +-- .../src/pipeline/execute/tests.rs | 9 +- .../fabro-workflow/src/pipeline/finalize.rs | 21 +- .../fabro-workflow/src/pipeline/initialize.rs | 60 +-- .../src/pipeline/pull_request.rs | 10 +- .../fabro-workflow/src/pipeline/transform.rs | 3 +- .../fabro-workflow/src/pipeline/types.rs | 2 - .../fabro-workflow/src/pipeline/validate.rs | 5 +- lib/crates/fabro-workflow/src/services.rs | 23 +- lib/crates/fabro-workflow/src/test_support.rs | 9 +- .../src/transforms/model_resolution.rs | 3 +- .../tests/it/daytona_integration.rs | 17 +- .../fabro-workflow/tests/it/integration.rs | 54 ++- .../fabro-workflow/tests/materialize_run.rs | 4 +- .../src/models/model-features.ts | 5 - 109 files changed, 1738 insertions(+), 2509 deletions(-) delete mode 100644 lib/crates/fabro-model/src/provider.rs diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 03ba33463..1626a2e73 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -4477,7 +4477,6 @@ components: - reasoning - reasoning_effort - prompt_cache - - effort properties: tools: type: boolean @@ -4493,10 +4492,6 @@ components: prompt_cache: type: boolean description: Whether the model endpoint supports prompt caching. - effort: - type: boolean - deprecated: true - description: Deprecated compatibility flag equivalent to reasoning_effort = levels. ModelCosts: description: Pricing per million tokens in USD. diff --git a/docs/public/reference/user-configuration.mdx b/docs/public/reference/user-configuration.mdx index 0d1f4cde6..8a216cecb 100644 --- a/docs/public/reference/user-configuration.mdx +++ b/docs/public/reference/user-configuration.mdx @@ -223,7 +223,6 @@ vision = false reasoning = true reasoning_effort = "levels" prompt_cache = true -effort = true [llm.models."team-code-large".controls] reasoning_effort = ["low", "medium", "high"] @@ -269,7 +268,6 @@ cache_input_cost_per_mtok = 0.60 | `reasoning` | boolean | `false` | Whether the model has reasoning behavior. | | `reasoning_effort` | `"levels"` \| `"none"` | `"none"` | How Fabro may expose reasoning effort for this model. | | `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. | -| `effort` | boolean | `false` | Whether the provider exposes a native effort parameter. | ## `[llm.models..controls]` diff --git a/lib/crates/fabro-agent/src/agent_profile.rs b/lib/crates/fabro-agent/src/agent_profile.rs index 048449dff..632ef4655 100644 --- a/lib/crates/fabro-agent/src/agent_profile.rs +++ b/lib/crates/fabro-agent/src/agent_profile.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use fabro_llm::types::ToolDefinition; -use fabro_model::{Catalog, Model, Provider, ProviderId}; +use fabro_model::{AgentProfileKind, Catalog, Model, ProviderId}; use tokio::sync::Mutex; use crate::profiles::EnvContext; @@ -14,10 +14,8 @@ use crate::subagent::{ use crate::tool_registry::ToolRegistry; pub trait AgentProfile: Send + Sync { - fn provider(&self) -> Provider; - fn provider_id(&self) -> ProviderId { - self.provider().id() - } + fn profile_kind(&self) -> AgentProfileKind; + fn provider_id(&self) -> ProviderId; fn model(&self) -> &str; fn catalog(&self) -> Option<&Catalog> { None @@ -79,7 +77,7 @@ pub trait AgentProfile: Send + Sync { #[cfg(test)] mod tests { - use fabro_model::Provider; + use fabro_model::{AgentProfileKind, ProviderId}; use super::*; use crate::test_support::{MockSandbox, TestProfile}; @@ -87,7 +85,8 @@ mod tests { #[test] fn profile_provider_and_model() { let profile = TestProfile::new(); - assert_eq!(profile.provider(), Provider::Anthropic); + assert_eq!(profile.profile_kind(), AgentProfileKind::Anthropic); + assert_eq!(profile.provider_id(), ProviderId::anthropic()); assert_eq!(profile.model(), "mock-model"); } diff --git a/lib/crates/fabro-agent/src/cli.rs b/lib/crates/fabro-agent/src/cli.rs index 1dae73d50..6c7760cd2 100644 --- a/lib/crates/fabro-agent/src/cli.rs +++ b/lib/crates/fabro-agent/src/cli.rs @@ -18,8 +18,9 @@ use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn}; use fabro_llm::provider::StreamEventStream; use fabro_llm::types::{Request, Response}; use fabro_mcp::config::McpServerSettings; +#[cfg(test)] use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{AgentProfileKind, Catalog, ModelHandle, Provider, ProviderId, adapter}; +use fabro_model::{AgentProfileKind, Catalog, ModelHandle, ProviderId}; use fabro_util::terminal::Styles; use fabro_vault::Vault; use tokio::io::{AsyncWriteExt, stdout}; @@ -206,115 +207,69 @@ fn build_tool_approval( } fn summarizer_model_id( - provider: &ResolvedProvider, - selected_model: &str, + provider_id: &ProviderId, + profile_kind: AgentProfileKind, catalog: &Catalog, + selected_model: &str, ) -> ModelHandle { - if Provider::from_id(&provider.provider_id).is_none() { - if let Some(model) = catalog.default_for_provider(&provider.provider_id) { - return ModelHandle::ByName { - provider: provider.provider_id.clone(), - model: model.id.clone(), - }; - } - return ModelHandle::ByName { - provider: provider.provider_id.clone(), - model: selected_model.to_string(), - }; - } - - if let Some(model) = catalog.probe_for_provider(provider.provider) { - return ModelHandle::ByName { - provider: provider.provider_id.clone(), - model: model.id.clone(), - }; - } - ModelHandle::ByName { - provider: provider.provider_id.clone(), - model: match provider.provider { - Provider::OpenAi | Provider::OpenAiCompatible => "gpt-4o-mini", - Provider::Gemini => "gemini-2.0-flash", - Provider::Anthropic => "claude-haiku-4-5", - Provider::Kimi => "kimi-k2.5", - Provider::Zai => "glm-4.7", - Provider::Minimax => "minimax-m2.5", - Provider::Inception => "mercury", - } - .to_string(), + provider: provider_id.clone(), + model: catalog + .default_for_provider(provider_id) + .map_or_else( + || match profile_kind { + AgentProfileKind::Anthropic => "claude-haiku-4-5", + AgentProfileKind::OpenAi => selected_model, + AgentProfileKind::Gemini => "gemini-2.0-flash", + }, + |model| model.id.as_str(), + ) + .to_string(), } } -fn build_summarizer(model_id: ModelHandle, llm_client: Client) -> WebFetchSummarizer { +fn build_summarizer( + provider_id: &ProviderId, + profile_kind: AgentProfileKind, + model: &str, + catalog: &Catalog, + llm_client: Client, +) -> WebFetchSummarizer { WebFetchSummarizer { - client: llm_client, - model_id, + client: llm_client, + model_id: summarizer_model_id(provider_id, profile_kind, catalog, model), } } fn build_profile( - provider: &ResolvedProvider, + profile_kind: AgentProfileKind, + provider_id: ProviderId, model: &str, summarizer: Option, catalog: Arc, ) -> Box { - match provider.profile_kind { + match profile_kind { AgentProfileKind::OpenAi => Box::new( OpenAiProfile::with_summarizer(model, summarizer) - .with_identity(provider.provider, provider.provider_id.clone()) + .with_provider_id(provider_id) .with_catalog(catalog), ), AgentProfileKind::Gemini => Box::new( GeminiProfile::with_summarizer(model, summarizer) - .with_identity(provider.provider, provider.provider_id.clone()) + .with_provider_id(provider_id) .with_catalog(catalog), ), AgentProfileKind::Anthropic => Box::new( AnthropicProfile::with_summarizer(model, summarizer) - .with_identity(provider.provider, provider.provider_id.clone()) + .with_provider_id(provider_id) .with_catalog(catalog), ), } } -#[derive(Debug, Clone)] -struct ResolvedProvider { - provider: Provider, - provider_id: ProviderId, - profile_kind: AgentProfileKind, -} - -fn resolve_provider(args: &AgentArgs, catalog: &Catalog) -> anyhow::Result { - let provider_id = ProviderId::new(args.provider.as_deref().unwrap_or("anthropic")); - if let Some(catalog_provider) = catalog.provider(&provider_id) { - let metadata = adapter::get(&catalog_provider.adapter).ok_or_else(|| { - anyhow::anyhow!( - "provider '{}' uses unknown adapter '{}'", - catalog_provider.id, - catalog_provider.adapter - ) - })?; - let profile_kind = metadata.default_profile; - let provider = adapter::profile_provider_for_provider_id( - &catalog_provider.id, - &catalog_provider.adapter, - ); - return Ok(ResolvedProvider { - provider, - provider_id: catalog_provider.id.clone(), - profile_kind, - }); - } - - let provider = Provider::from_id(&provider_id) - .ok_or_else(|| anyhow::anyhow!("unknown provider: {}", provider_id.as_str()))?; - let profile_kind = adapter::default_profile_for_provider_id(&provider_id); - - Ok(ResolvedProvider { - provider, - provider_id, - profile_kind, - }) +fn parse_provider(args: &AgentArgs) -> anyhow::Result { + let provider_str = args.provider.as_deref().unwrap_or("anthropic"); + Ok(provider_str.parse()?) } fn standalone_llm_source() -> Arc { @@ -327,15 +282,26 @@ fn standalone_llm_source() -> Arc { } } +fn profile_kind_for_provider( + catalog: &Catalog, + provider_id: &ProviderId, +) -> anyhow::Result { + let provider = catalog + .provider(provider_id) + .ok_or_else(|| anyhow::anyhow!("provider '{provider_id}' is not configured"))?; + Ok(provider.adapter.metadata().default_profile) +} + fn ensure_provider_registered(client: &Client, provider_id: &ProviderId) -> anyhow::Result<()> { - if client.has_provider(provider_id.as_str()) { + if client + .provider_names() + .iter() + .any(|name| *name == provider_id.as_str()) + { return Ok(()); } - anyhow::bail!( - "LLM credentials not configured for provider '{}'", - provider_id.as_str() - ); + anyhow::bail!("LLM credentials not configured for provider '{provider_id}'"); } fn format_tool_args(args: &serde_json::Value, cwd: &str) -> String { @@ -498,10 +464,8 @@ pub async fn run_with_args( mcp_servers: Vec, ) -> anyhow::Result<()> { let llm_source = standalone_llm_source(); - let catalog = Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .context("failed to build standalone agent LLM catalog")?, - ); + let catalog = + Arc::new(Catalog::from_builtin().context("failed to build standalone agent LLM catalog")?); run_with_args_and_source_and_catalog(args, llm_source, mcp_servers, catalog).await } @@ -515,10 +479,8 @@ pub async fn run_with_args_and_source( llm_source: Arc, mcp_servers: Vec, ) -> anyhow::Result<()> { - let catalog = Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .context("failed to build standalone agent LLM catalog")?, - ); + let catalog = + Arc::new(Catalog::from_builtin().context("failed to build standalone agent LLM catalog")?); run_with_args_and_source_and_catalog(args, llm_source, mcp_servers, catalog).await } @@ -549,10 +511,8 @@ pub async fn run_with_args_and_client( client: Client, mcp_servers: Vec, ) -> anyhow::Result<()> { - let catalog = Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .context("failed to build standalone agent LLM catalog")?, - ); + let catalog = + Arc::new(Catalog::from_builtin().context("failed to build standalone agent LLM catalog")?); run_with_args_and_client_and_catalog(args, client, mcp_servers, catalog).await } @@ -571,8 +531,8 @@ pub async fn run_with_args_and_client_and_catalog( // threads let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - let provider = resolve_provider(&args, catalog.as_ref())?; - ensure_provider_registered(&client, &provider.provider_id)?; + let provider_id = parse_provider(&args)?; + ensure_provider_registered(&client, &provider_id)?; if args.verbose { client.add_middleware(Arc::new(VerboseMiddleware { styles })); @@ -580,26 +540,31 @@ pub async fn run_with_args_and_client_and_catalog( client.add_middleware(Arc::new(DebugMiddleware { styles })); } - // Resolve model and build profile let model = if let Some(model) = args.model.clone() { model } else { catalog - .default_for_provider(&provider.provider_id) + .default_for_provider(&provider_id) .map(|model| model.id.clone()) .ok_or_else(|| { anyhow::anyhow!( - "provider '{}' has no default model in the catalog; pass --model explicitly", - provider.provider_id.as_str() + "provider '{provider_id}' has no default model in the catalog; pass --model explicitly" ) })? }; + let profile_kind = profile_kind_for_provider(&catalog, &provider_id)?; eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}"))); - let summarizer_model = summarizer_model_id(&provider, &model, catalog.as_ref()); let mut profile = build_profile( - &provider, + profile_kind, + provider_id.clone(), &model, - Some(build_summarizer(summarizer_model.clone(), client.clone())), + Some(build_summarizer( + &provider_id, + profile_kind, + &model, + &catalog, + client.clone(), + )), Arc::clone(&catalog), ); @@ -635,17 +600,21 @@ pub async fn run_with_args_and_client_and_catalog( let factory_client = client.clone(); let factory_model = model.clone(); let factory_catalog = Arc::clone(&catalog); + let factory_provider_id = provider_id.clone(); + let factory_profile_kind = profile_kind; let factory_env = Arc::clone(&env); let factory_hooks = config.tool_hooks.clone(); - let factory_provider = provider.clone(); - let factory_summarizer_model = summarizer_model.clone(); let factory: SessionFactory = Arc::new(move || { let child_summarizer = Some(build_summarizer( - factory_summarizer_model.clone(), + &factory_provider_id, + factory_profile_kind, + &factory_model, + &factory_catalog, factory_client.clone(), )); let child_profile: Arc = Arc::from(build_profile( - &factory_provider, + factory_profile_kind, + factory_provider_id.clone(), &factory_model, child_summarizer, Arc::clone(&factory_catalog), @@ -845,7 +814,6 @@ pub async fn run() -> anyhow::Result<()> { mod tests { use std::collections::HashMap; - use fabro_model::Provider; use fabro_model::catalog::ProviderCatalogSettings; use serde_json::json; @@ -948,36 +916,39 @@ mod tests { // build_profile tests fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()) - } - - fn resolved_builtin(provider: Provider) -> ResolvedProvider { - let provider_id = provider.id(); - ResolvedProvider { - provider, - provider_id: provider_id.clone(), - profile_kind: adapter::default_profile_for_provider_id(&provider_id), - } + Arc::new(Catalog::from_builtin().unwrap()) } #[test] fn build_profile_anthropic() { - let provider = resolved_builtin(Provider::Anthropic); - let profile = build_profile(&provider, "model", None, test_catalog()); - assert_eq!(profile.provider(), Provider::Anthropic); + let profile = build_profile( + AgentProfileKind::Anthropic, + ProviderId::anthropic(), + "model", + None, + test_catalog(), + ); + assert_eq!(profile.profile_kind(), AgentProfileKind::Anthropic); + assert_eq!(profile.provider_id(), ProviderId::anthropic()); } #[test] fn build_profile_openai() { - let provider = resolved_builtin(Provider::OpenAi); - let profile = build_profile(&provider, "model", None, test_catalog()); - assert_eq!(profile.provider(), Provider::OpenAi); + let profile = build_profile( + AgentProfileKind::OpenAi, + ProviderId::openai(), + "model", + None, + test_catalog(), + ); + assert_eq!(profile.profile_kind(), AgentProfileKind::OpenAi); + assert_eq!(profile.provider_id(), ProviderId::openai()); } #[test] fn ensure_provider_registered_reports_missing_credentials() { let client = Client::new(HashMap::new(), None, vec![]); - let error = ensure_provider_registered(&client, &Provider::Anthropic.id()).unwrap_err(); + let error = ensure_provider_registered(&client, &ProviderId::anthropic()).unwrap_err(); assert_eq!( error.to_string(), "LLM credentials not configured for provider 'anthropic'" @@ -986,13 +957,19 @@ mod tests { #[test] fn build_profile_gemini() { - let provider = resolved_builtin(Provider::Gemini); - let profile = build_profile(&provider, "model", None, test_catalog()); - assert_eq!(profile.provider(), Provider::Gemini); + let profile = build_profile( + AgentProfileKind::Gemini, + ProviderId::gemini(), + "model", + None, + test_catalog(), + ); + assert_eq!(profile.profile_kind(), AgentProfileKind::Gemini); + assert_eq!(profile.provider_id(), ProviderId::gemini()); } #[test] - fn resolve_provider_accepts_custom_catalog_provider() { + fn profile_kind_accepts_custom_catalog_provider() { let mut settings = LlmCatalogSettings::default(); settings .providers @@ -1015,14 +992,17 @@ mod tests { output_format: None, }; - let resolved = resolve_provider(&args, &catalog).unwrap(); - assert_eq!(resolved.provider_id, ProviderId::new("bedrock")); - assert_eq!(resolved.provider, Provider::OpenAiCompatible); - assert_eq!(resolved.profile_kind, AgentProfileKind::OpenAi); + let provider_id = parse_provider(&args).unwrap(); + assert_eq!(provider_id, ProviderId::new("bedrock")); + assert_eq!( + profile_kind_for_provider(&catalog, &provider_id).unwrap(), + AgentProfileKind::OpenAi + ); } #[test] - fn summarizer_model_id_uses_selected_model_for_custom_provider_without_catalog_default() { + fn summarizer_model_id_uses_selected_model_for_custom_openai_provider_without_catalog_default() + { let mut settings = LlmCatalogSettings::default(); settings .providers @@ -1033,26 +1013,16 @@ mod tests { ..ProviderCatalogSettings::default() }); let catalog = Catalog::from_builtin_with_overrides(&settings).unwrap(); - let args = AgentArgs { - prompt: "test".to_string(), - provider: Some("bedrock".to_string()), - model: Some("bedrock-claude-sonnet-4-6".to_string()), - permissions: None, - auto_approve: false, - debug: false, - verbose: false, - skills_dir: None, - output_format: None, - }; - let resolved = resolve_provider(&args, &catalog).unwrap(); + let provider_id = ProviderId::new("bedrock"); let model_id = summarizer_model_id( - &resolved, - args.model.as_deref().expect("explicit model"), + &provider_id, + AgentProfileKind::OpenAi, &catalog, + "bedrock-claude-sonnet-4-6", ); - assert_eq!(model_id.provider(), &ProviderId::new("bedrock")); + assert_eq!(model_id.provider(), &provider_id); assert_eq!(model_id.model_id(), "bedrock-claude-sonnet-4-6"); } @@ -1060,8 +1030,13 @@ mod tests { #[test] fn build_profile_can_register_subagent_tools() { - let provider = resolved_builtin(Provider::Anthropic); - let mut profile = build_profile(&provider, "model", None, test_catalog()); + let mut profile = build_profile( + AgentProfileKind::Anthropic, + ProviderId::anthropic(), + "model", + None, + test_catalog(), + ); let manager = Arc::new(AsyncMutex::new(SubAgentManager::new(1))); let factory: SessionFactory = Arc::new(|| { panic!("factory should not be called in this test"); diff --git a/lib/crates/fabro-agent/src/memory.rs b/lib/crates/fabro-agent/src/memory.rs index 071435c30..a86626846 100644 --- a/lib/crates/fabro-agent/src/memory.rs +++ b/lib/crates/fabro-agent/src/memory.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use fabro_model::Provider; +use fabro_model::AgentProfileKind; use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; @@ -13,22 +13,15 @@ pub async fn discover_memory( env: &dyn Sandbox, git_root: &str, working_dir: &str, - provider: Provider, + profile_kind: AgentProfileKind, cancel_token: &CancellationToken, ) -> Result, Error> { let directories = build_directory_walk(git_root, working_dir); - let candidate_filenames: Vec<&str> = match provider { - Provider::Anthropic => vec!["AGENTS.md", "CLAUDE.md"], - Provider::OpenAi - | Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => { - vec!["AGENTS.md", ".codex/instructions.md"] - } - Provider::Gemini => vec!["AGENTS.md", "GEMINI.md"], + let candidate_filenames: Vec<&str> = match profile_kind { + AgentProfileKind::Anthropic => vec!["AGENTS.md", "CLAUDE.md"], + AgentProfileKind::OpenAi => vec!["AGENTS.md", ".codex/instructions.md"], + AgentProfileKind::Gemini => vec!["AGENTS.md", "GEMINI.md"], }; let mut results = Vec::new(); @@ -145,7 +138,7 @@ mod tests { env.as_ref(), "/repo", "/repo", - Provider::Anthropic, + AgentProfileKind::Anthropic, &CancellationToken::new(), ) .await @@ -170,7 +163,7 @@ mod tests { env.as_ref(), "/repo", "/repo", - Provider::Anthropic, + AgentProfileKind::Anthropic, &CancellationToken::new(), ) .await @@ -187,7 +180,7 @@ mod tests { env.as_ref(), "/repo", "/repo", - Provider::OpenAi, + AgentProfileKind::OpenAi, &CancellationToken::new(), ) .await @@ -204,7 +197,7 @@ mod tests { env.as_ref(), "/repo", "/repo", - Provider::Gemini, + AgentProfileKind::Gemini, &CancellationToken::new(), ) .await @@ -231,7 +224,7 @@ mod tests { env.as_ref(), "/repo", "/repo", - Provider::Anthropic, + AgentProfileKind::Anthropic, &CancellationToken::new(), ) .await @@ -256,7 +249,7 @@ mod tests { env.as_ref(), "/repo", "/repo", - Provider::Anthropic, + AgentProfileKind::Anthropic, &CancellationToken::new(), ) .await @@ -278,7 +271,7 @@ mod tests { env.as_ref(), "/repo", "/repo/src", - Provider::Anthropic, + AgentProfileKind::Anthropic, &CancellationToken::new(), ) .await @@ -302,7 +295,7 @@ mod tests { env.as_ref(), "/repo", "/repo/src/app", - Provider::Anthropic, + AgentProfileKind::Anthropic, &CancellationToken::new(), ) .await diff --git a/lib/crates/fabro-agent/src/profiles/anthropic.rs b/lib/crates/fabro-agent/src/profiles/anthropic.rs index ed1ea4149..6059cfec5 100644 --- a/lib/crates/fabro-agent/src/profiles/anthropic.rs +++ b/lib/crates/fabro-agent/src/profiles/anthropic.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use fabro_model::{Catalog, Provider, ProviderId}; +use fabro_model::{AgentProfileKind, Catalog, ProviderId}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -37,8 +37,8 @@ impl AnthropicProfile { Self { base: BaseProfile { - provider: Provider::Anthropic, - provider_id: Provider::Anthropic.id(), + profile_kind: AgentProfileKind::Anthropic, + provider_id: ProviderId::anthropic(), model: model.into(), catalog: None, registry, @@ -46,22 +46,6 @@ impl AnthropicProfile { } } - /// Override the provider identity (e.g. for Kimi, which uses the Anthropic - /// Messages protocol but routes to a different adapter). - #[must_use] - pub fn with_provider(mut self, provider: Provider) -> Self { - self.base.provider = provider; - self - } - - /// Override the provider identity and provider ID together. - #[must_use] - pub fn with_identity(mut self, provider: Provider, provider_id: ProviderId) -> Self { - self.base.provider = provider; - self.base.provider_id = provider_id; - self - } - /// Override the provider ID while retaining the adapter/profile behavior. #[must_use] pub fn with_provider_id(mut self, provider_id: ProviderId) -> Self { @@ -77,8 +61,8 @@ impl AnthropicProfile { } impl AgentProfile for AnthropicProfile { - fn provider(&self) -> Provider { - self.base.provider + fn profile_kind(&self) -> AgentProfileKind { + self.base.profile_kind } fn provider_id(&self) -> ProviderId { @@ -200,7 +184,6 @@ in the project. Keep changes minimal and focused on the task."; mod tests { use std::sync::Arc; - use fabro_model::catalog::LlmCatalogSettings; use tokio::sync::Mutex as AsyncMutex; use super::*; @@ -208,27 +191,17 @@ mod tests { use crate::test_support::MockSandbox; fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()) + Arc::new(Catalog::from_builtin().unwrap()) } #[test] fn anthropic_profile_identity() { let profile = AnthropicProfile::new("claude-sonnet-4-20250514"); - assert_eq!(profile.provider(), Provider::Anthropic); + assert_eq!(profile.profile_kind(), AgentProfileKind::Anthropic); + assert_eq!(profile.provider_id(), ProviderId::anthropic()); assert_eq!(profile.model(), "claude-sonnet-4-20250514"); } - #[test] - fn with_provider_preserves_explicit_provider_id() { - let custom_id = ProviderId::new("acme-anthropic"); - let profile = AnthropicProfile::new("model") - .with_provider_id(custom_id.clone()) - .with_provider(Provider::Anthropic); - - assert_eq!(profile.provider(), Provider::Anthropic); - assert_eq!(profile.provider_id(), custom_id); - } - #[test] fn anthropic_context_window_from_catalog() { let profile = AnthropicProfile::new("claude-opus-4-6").with_catalog(test_catalog()); diff --git a/lib/crates/fabro-agent/src/profiles/gemini.rs b/lib/crates/fabro-agent/src/profiles/gemini.rs index eb9c2fe9c..b726b4d2a 100644 --- a/lib/crates/fabro-agent/src/profiles/gemini.rs +++ b/lib/crates/fabro-agent/src/profiles/gemini.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use fabro_model::{Catalog, Provider, ProviderId}; +use fabro_model::{AgentProfileKind, Catalog, ProviderId}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -39,8 +39,8 @@ impl GeminiProfile { Self { base: BaseProfile { - provider: Provider::Gemini, - provider_id: Provider::Gemini.id(), + profile_kind: AgentProfileKind::Gemini, + provider_id: ProviderId::gemini(), model: model.into(), catalog: None, registry, @@ -48,21 +48,6 @@ impl GeminiProfile { } } - /// Override the provider identity. - #[must_use] - pub fn with_provider(mut self, provider: Provider) -> Self { - self.base.provider = provider; - self - } - - /// Override the provider identity and provider ID together. - #[must_use] - pub fn with_identity(mut self, provider: Provider, provider_id: ProviderId) -> Self { - self.base.provider = provider; - self.base.provider_id = provider_id; - self - } - /// Override the provider ID while retaining the adapter/profile behavior. #[must_use] pub fn with_provider_id(mut self, provider_id: ProviderId) -> Self { @@ -78,8 +63,8 @@ impl GeminiProfile { } impl AgentProfile for GeminiProfile { - fn provider(&self) -> Provider { - self.base.provider + fn profile_kind(&self) -> AgentProfileKind { + self.base.profile_kind } fn provider_id(&self) -> ProviderId { @@ -242,7 +227,6 @@ in the project."; mod tests { use std::sync::Arc; - use fabro_model::catalog::LlmCatalogSettings; use tokio::sync::Mutex as AsyncMutex; use super::*; @@ -250,27 +234,17 @@ mod tests { use crate::test_support::MockSandbox; fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()) + Arc::new(Catalog::from_builtin().unwrap()) } #[test] fn gemini_profile_identity() { let profile = GeminiProfile::new("gemini-2.0-flash"); - assert_eq!(profile.provider(), Provider::Gemini); + assert_eq!(profile.profile_kind(), AgentProfileKind::Gemini); + assert_eq!(profile.provider_id(), ProviderId::gemini()); assert_eq!(profile.model(), "gemini-2.0-flash"); } - #[test] - fn with_provider_preserves_explicit_provider_id() { - let custom_id = ProviderId::new("acme-gemini"); - let profile = GeminiProfile::new("model") - .with_provider_id(custom_id.clone()) - .with_provider(Provider::Gemini); - - assert_eq!(profile.provider(), Provider::Gemini); - assert_eq!(profile.provider_id(), custom_id); - } - #[test] fn gemini_context_window_from_catalog() { let profile = GeminiProfile::new("gemini-3.1-pro-preview").with_catalog(test_catalog()); diff --git a/lib/crates/fabro-agent/src/profiles/mod.rs b/lib/crates/fabro-agent/src/profiles/mod.rs index 72c9cceb6..fbc8de5e9 100644 --- a/lib/crates/fabro-agent/src/profiles/mod.rs +++ b/lib/crates/fabro-agent/src/profiles/mod.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use fabro_model::{Catalog, Provider, ProviderId}; +use fabro_model::{AgentProfileKind, Catalog, ProviderId}; pub mod anthropic; pub mod gemini; @@ -16,14 +16,14 @@ use crate::tool_registry::ToolRegistry; /// Common fields shared by all provider profiles. /// -/// Each concrete profile embeds this struct and delegates `provider()`, +/// Each concrete profile embeds this struct and delegates `profile_kind()`, /// `model()`, `tool_registry()`, and `tool_registry_mut()` to it. pub struct BaseProfile { - pub provider: Provider, - pub provider_id: ProviderId, - pub model: String, - pub catalog: Option>, - pub registry: ToolRegistry, + pub profile_kind: AgentProfileKind, + pub provider_id: ProviderId, + pub model: String, + pub catalog: Option>, + pub registry: ToolRegistry, } /// Additional context for building environment blocks diff --git a/lib/crates/fabro-agent/src/profiles/openai.rs b/lib/crates/fabro-agent/src/profiles/openai.rs index c09246547..c03685f86 100644 --- a/lib/crates/fabro-agent/src/profiles/openai.rs +++ b/lib/crates/fabro-agent/src/profiles/openai.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use fabro_model::{Catalog, Provider, ProviderId}; +use fabro_model::{AgentProfileKind, Catalog, ProviderId}; use super::EnvContext; use crate::agent_profile::AgentProfile; @@ -35,8 +35,8 @@ impl OpenAiProfile { Self { base: BaseProfile { - provider: Provider::OpenAi, - provider_id: Provider::OpenAi.id(), + profile_kind: AgentProfileKind::OpenAi, + provider_id: ProviderId::openai(), model: model.into(), catalog: None, registry, @@ -44,22 +44,6 @@ impl OpenAiProfile { } } - /// Override the provider identity (e.g. for Z.AI or Minimax, which use the - /// OpenAI Chat Completions protocol but route to different adapters). - #[must_use] - pub fn with_provider(mut self, provider: Provider) -> Self { - self.base.provider = provider; - self - } - - /// Override the provider identity and provider ID together. - #[must_use] - pub fn with_identity(mut self, provider: Provider, provider_id: ProviderId) -> Self { - self.base.provider = provider; - self.base.provider_id = provider_id; - self - } - /// Override the provider ID while retaining the adapter/profile behavior. #[must_use] pub fn with_provider_id(mut self, provider_id: ProviderId) -> Self { @@ -79,15 +63,15 @@ impl OpenAiProfile { .as_ref() .and_then(|catalog| catalog.provider(&self.base.provider_id)) .map_or_else( - || self.base.provider.display_name().to_string(), + || self.base.provider_id.display_name(), |provider| provider.display_name.clone(), ) } } impl AgentProfile for OpenAiProfile { - fn provider(&self) -> Provider { - self.base.provider + fn profile_kind(&self) -> AgentProfileKind { + self.base.profile_kind } fn provider_id(&self) -> ProviderId { @@ -233,7 +217,6 @@ in the project."); mod tests { use std::sync::Arc; - use fabro_model::catalog::LlmCatalogSettings; use tokio::sync::Mutex as AsyncMutex; use super::*; @@ -241,33 +224,23 @@ mod tests { use crate::test_support::MockSandbox; fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()) + Arc::new(Catalog::from_builtin().unwrap()) } #[test] fn openai_profile_identity() { let profile = OpenAiProfile::new("o3-mini"); - assert_eq!(profile.provider(), Provider::OpenAi); + assert_eq!(profile.profile_kind(), AgentProfileKind::OpenAi); + assert_eq!(profile.provider_id(), ProviderId::openai()); assert_eq!(profile.model(), "o3-mini"); } - #[test] - fn with_provider_preserves_explicit_provider_id() { - let custom_id = ProviderId::new("bedrock"); - let profile = OpenAiProfile::new("model") - .with_provider_id(custom_id.clone()) - .with_provider(Provider::OpenAiCompatible); - - assert_eq!(profile.provider(), Provider::OpenAiCompatible); - assert_eq!(profile.provider_id(), custom_id); - } - #[test] fn openai_system_prompt_contains_env_context() { let profile = OpenAiProfile::new("o3-mini"); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); - assert!(prompt.contains("You are a coding agent powered by OpenAI")); + assert!(prompt.contains("You are a coding agent powered by openai")); assert!(prompt.contains("")); assert!(prompt.contains("linux")); assert!(prompt.contains("v4a patch format")); @@ -351,7 +324,7 @@ mod tests { #[test] fn kimi_provider_prompt_uses_catalog_display_name() { let profile = OpenAiProfile::new("kimi-k2.5") - .with_identity(Provider::Kimi, Provider::Kimi.id()) + .with_provider_id(ProviderId::new("kimi")) .with_catalog(test_catalog()); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); @@ -362,7 +335,7 @@ mod tests { #[test] fn zai_provider_prompt_uses_catalog_display_name() { let profile = OpenAiProfile::new("glm-4.7") - .with_identity(Provider::Zai, Provider::Zai.id()) + .with_provider_id(ProviderId::new("zai")) .with_catalog(test_catalog()); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); @@ -372,7 +345,7 @@ mod tests { #[test] fn minimax_provider_prompt_uses_catalog_display_name() { let profile = OpenAiProfile::new("minimax-m2.5") - .with_identity(Provider::Minimax, Provider::Minimax.id()) + .with_provider_id(ProviderId::new("minimax")) .with_catalog(test_catalog()); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); @@ -382,7 +355,7 @@ mod tests { #[test] fn inception_provider_prompt_uses_catalog_display_name() { let profile = OpenAiProfile::new("mercury-2") - .with_identity(Provider::Inception, Provider::Inception.id()) + .with_provider_id(ProviderId::new("inception")) .with_catalog(test_catalog()); let env = MockSandbox::linux(); let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]); diff --git a/lib/crates/fabro-agent/src/session.rs b/lib/crates/fabro-agent/src/session.rs index b67426159..212dc70f4 100644 --- a/lib/crates/fabro-agent/src/session.rs +++ b/lib/crates/fabro-agent/src/session.rs @@ -13,7 +13,7 @@ use fabro_llm::types::{ use fabro_llm::{Error as LlmError, retry}; use fabro_mcp::config::{McpServerSettings, McpTransport}; use fabro_mcp::connection_manager::McpConnectionManager; -use fabro_model::{Catalog, ModelRef, Provider, Speed}; +use fabro_model::{AgentProfileKind, Catalog, ModelRef, Speed}; use fabro_types::Principal; use futures::StreamExt; use tokio::sync::{Mutex as AsyncMutex, Notify, broadcast}; @@ -344,8 +344,8 @@ impl Session { } #[must_use] - pub fn provider(&self) -> Provider { - self.provider_profile.provider() + pub fn profile_kind(&self) -> AgentProfileKind { + self.provider_profile.profile_kind() } #[must_use] @@ -387,7 +387,7 @@ impl Session { self.sandbox.as_ref(), &doc_root, self.sandbox.working_directory(), - self.provider_profile.provider(), + self.provider_profile.profile_kind(), &cancel_token, ) .await?; diff --git a/lib/crates/fabro-agent/src/test_support.rs b/lib/crates/fabro-agent/src/test_support.rs index d7284bc11..06c75a163 100644 --- a/lib/crates/fabro-agent/src/test_support.rs +++ b/lib/crates/fabro-agent/src/test_support.rs @@ -9,7 +9,7 @@ use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; use fabro_llm::types::{ ContentPart, FinishReason, Message, Request, Response, StreamEvent, TokenCounts, }; -use fabro_model::Provider; +use fabro_model::{AgentProfileKind, ProviderId}; pub use fabro_sandbox::test_support::{MockSandbox, MutableMockSandbox}; use futures::stream; @@ -52,8 +52,12 @@ impl TestProfile { } impl AgentProfile for TestProfile { - fn provider(&self) -> Provider { - Provider::Anthropic + fn profile_kind(&self) -> AgentProfileKind { + AgentProfileKind::Anthropic + } + + fn provider_id(&self) -> ProviderId { + ProviderId::anthropic() } fn model(&self) -> &'static str { @@ -189,8 +193,8 @@ pub fn text_response(text: &str) -> Response { pub async fn make_client(provider: Arc) -> Client { let mut providers = HashMap::new(); providers.insert(provider.name().to_string(), provider.clone()); - // Also register under "anthropic" so TestProfile (Provider::Anthropic) routes - // correctly + // Also register under "anthropic" so TestProfile (ProviderId::anthropic()) + // routes correctly providers.insert("anthropic".to_string(), provider); Client::new(providers, Some("mock".into()), vec![]) } diff --git a/lib/crates/fabro-agent/src/tools.rs b/lib/crates/fabro-agent/src/tools.rs index 719799fd6..a01850738 100644 --- a/lib/crates/fabro-agent/src/tools.rs +++ b/lib/crates/fabro-agent/src/tools.rs @@ -686,6 +686,7 @@ mod tests { use std::collections::HashMap; use fabro_llm::provider::ProviderAdapter; + use fabro_model::ProviderId; use fabro_types::CommandTermination; use tokio_util::sync::CancellationToken; @@ -1410,7 +1411,7 @@ mod tests { let summarizer = WebFetchSummarizer { client, model_id: ModelHandle::ByName { - provider: fabro_model::Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model: "mock-model".to_string(), }, }; @@ -1511,7 +1512,7 @@ mod tests { let summarizer = WebFetchSummarizer { client, model_id: ModelHandle::ByName { - provider: fabro_model::Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model: "target-model".to_string(), }, }; diff --git a/lib/crates/fabro-agent/src/types.rs b/lib/crates/fabro-agent/src/types.rs index 925b3c12a..452d75653 100644 --- a/lib/crates/fabro-agent/src/types.rs +++ b/lib/crates/fabro-agent/src/types.rs @@ -429,7 +429,7 @@ pub struct SessionEvent { #[cfg(test)] mod tests { - use fabro_model::Provider; + use fabro_model::ProviderId; use super::*; @@ -671,7 +671,7 @@ mod tests { let event = AgentEvent::AssistantMessage { text: "Hello".into(), model: ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "test-model".into(), speed: None, }, diff --git a/lib/crates/fabro-agent/tests/it/guardrails.rs b/lib/crates/fabro-agent/tests/it/guardrails.rs index 4a990856c..76e98f933 100644 --- a/lib/crates/fabro-agent/tests/it/guardrails.rs +++ b/lib/crates/fabro-agent/tests/it/guardrails.rs @@ -1,39 +1,33 @@ use std::sync::Arc; use fabro_agent::{AgentProfile, AnthropicProfile, GeminiProfile, OpenAiProfile}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, Provider}; +use fabro_model::{Catalog, ProviderId}; #[test] fn profile_context_window_matches_catalog_for_default_models() { - let catalog = - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); - for &provider in Provider::ALL { + let catalog = Arc::new(Catalog::from_builtin().unwrap()); + for provider in catalog.providers() { let catalog_info = catalog - .default_for_provider(&provider.id()) + .default_for_provider(&provider.id) .cloned() - .unwrap_or_else(|| panic!("no default model for {provider:?} in catalog")); + .unwrap_or_else(|| panic!("no default model for {:?} in catalog", provider.id)); let model = &catalog_info.id; let context_window = usize::try_from(catalog_info.context_window()) .expect("catalog context window should be non-negative and fit in usize"); - let profile: Box = match provider { - Provider::OpenAi => { + let profile: Box = match provider.adapter.metadata().default_profile { + fabro_model::AgentProfileKind::OpenAi if provider.id == ProviderId::openai() => { Box::new(OpenAiProfile::new(model).with_catalog(Arc::clone(&catalog))) } - Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => Box::new( + fabro_model::AgentProfileKind::OpenAi => Box::new( OpenAiProfile::new(model) - .with_identity(provider, provider.id()) + .with_provider_id(provider.id.clone()) .with_catalog(Arc::clone(&catalog)), ), - Provider::Gemini => { + fabro_model::AgentProfileKind::Gemini => { Box::new(GeminiProfile::new(model).with_catalog(Arc::clone(&catalog))) } - Provider::Anthropic => { + fabro_model::AgentProfileKind::Anthropic => { Box::new(AnthropicProfile::new(model).with_catalog(Arc::clone(&catalog))) } }; @@ -42,7 +36,7 @@ fn profile_context_window_matches_catalog_for_default_models() { profile.context_window_size(), context_window, "context_window_size mismatch for {:?} model '{}': profile={} catalog={}", - provider, + provider.id, model, profile.context_window_size(), context_window diff --git a/lib/crates/fabro-agent/tests/it/parity_matrix.rs b/lib/crates/fabro-agent/tests/it/parity_matrix.rs index 31f27880d..ff86a26ce 100644 --- a/lib/crates/fabro-agent/tests/it/parity_matrix.rs +++ b/lib/crates/fabro-agent/tests/it/parity_matrix.rs @@ -15,62 +15,55 @@ use fabro_agent::{ }; use fabro_auth::EnvCredentialSource; use fabro_llm::client::Client; -use fabro_llm::provider::{Provider, ProviderAdapter}; +use fabro_llm::provider::ProviderAdapter; use fabro_llm::providers::OpenAiAdapter; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, ModelHandle}; +use fabro_model::{Catalog, ModelHandle, ProviderId}; use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, twin_openai}; use tokio::sync::Mutex as AsyncMutex; +type Provider = ProviderId; + #[derive(Clone)] struct OpenAiTwinOptions { base_url: String, api_key: String, } -fn summarizer_model_id(provider: Provider) -> ModelHandle { - match provider { - Provider::OpenAi - | Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => ModelHandle::ByName { - provider: Provider::OpenAi.id(), +fn summarizer_model_id(provider: &Provider) -> ModelHandle { + match provider.as_str() { + ProviderId::OPENAI | "kimi" | "zai" | "minimax" | "inception" => ModelHandle::ByName { + provider: ProviderId::openai(), model: "gpt-5.4-mini".to_string(), }, - Provider::Gemini => ModelHandle::ByName { - provider: Provider::Gemini.id(), + ProviderId::GEMINI => ModelHandle::ByName { + provider: ProviderId::gemini(), model: "gemini-3-flash-preview".to_string(), }, - Provider::Anthropic => ModelHandle::ByName { - provider: Provider::Anthropic.id(), + ProviderId::ANTHROPIC => ModelHandle::ByName { + provider: ProviderId::anthropic(), model: "claude-haiku-4-5".to_string(), }, + other => panic!("unexpected provider {other}"), } } -fn build_summarizer(provider: Provider, client: &Client) -> WebFetchSummarizer { +fn build_summarizer(provider: &Provider, client: &Client) -> WebFetchSummarizer { WebFetchSummarizer { client: client.clone(), model_id: summarizer_model_id(provider), } } -fn build_profile(provider: Provider, model: &str, client: &Client) -> Box { +fn build_profile(provider: &Provider, model: &str, client: &Client) -> Box { let summarizer = Some(build_summarizer(provider, client)); - match provider { - Provider::Anthropic => Box::new(AnthropicProfile::with_summarizer(model, summarizer)), - Provider::OpenAi => Box::new(OpenAiProfile::with_summarizer(model, summarizer)), - Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => Box::new( - OpenAiProfile::with_summarizer(model, summarizer) - .with_identity(provider, provider.id()), + match provider.as_str() { + ProviderId::ANTHROPIC => Box::new(AnthropicProfile::with_summarizer(model, summarizer)), + ProviderId::OPENAI => Box::new(OpenAiProfile::with_summarizer(model, summarizer)), + "kimi" | "zai" | "minimax" | "inception" => Box::new( + OpenAiProfile::with_summarizer(model, summarizer).with_provider_id(provider.clone()), ), - Provider::Gemini => Box::new(GeminiProfile::with_summarizer(model, summarizer)), + ProviderId::GEMINI => Box::new(GeminiProfile::with_summarizer(model, summarizer)), + other => panic!("unexpected provider {other}"), } } @@ -80,8 +73,8 @@ async fn make_session( cwd: &Path, twin: Option, ) -> Session { - let client = make_client(provider, twin.as_ref()).await; - let mut profile = build_profile(provider, model, &client); + let client = make_client(&provider, twin.as_ref()).await; + let mut profile = build_profile(&provider, model, &client); let env = Arc::new(LocalSandbox::new(cwd.to_path_buf())); // Register subagent tools so spawn_agent / wait / send_input / close_agent are @@ -90,28 +83,26 @@ async fn make_session( let factory_client = client.clone(); let factory_model: String = model.to_string(); let factory_cwd = cwd.to_path_buf(); + let factory_provider = provider.clone(); let factory: SessionFactory = Arc::new(move || { let sub_profile: Arc = { - let summarizer = Some(build_summarizer(provider, &factory_client)); - match provider { - Provider::Anthropic => Arc::new(AnthropicProfile::with_summarizer( + let summarizer = Some(build_summarizer(&factory_provider, &factory_client)); + match factory_provider.as_str() { + ProviderId::ANTHROPIC => Arc::new(AnthropicProfile::with_summarizer( &factory_model, summarizer, )), - Provider::OpenAi => { + ProviderId::OPENAI => { Arc::new(OpenAiProfile::with_summarizer(&factory_model, summarizer)) } - Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => Arc::new( + "kimi" | "zai" | "minimax" | "inception" => Arc::new( OpenAiProfile::with_summarizer(&factory_model, summarizer) - .with_identity(provider, provider.id()), + .with_provider_id(factory_provider.clone()), ), - Provider::Gemini => { + ProviderId::GEMINI => { Arc::new(GeminiProfile::with_summarizer(&factory_model, summarizer)) } + other => panic!("unexpected provider {other}"), } }; let sub_env = Arc::new(LocalSandbox::new(factory_cwd.clone())); @@ -140,22 +131,19 @@ async fn make_session_with_config( config: SessionOptions, twin: Option, ) -> Session { - let client = make_client(provider, twin.as_ref()).await; - let profile: Arc = Arc::from(build_profile(provider, model, &client)); + let client = make_client(&provider, twin.as_ref()).await; + let profile: Arc = Arc::from(build_profile(&provider, model, &client)); let env = Arc::new(LocalSandbox::new(cwd.to_path_buf())); Session::new(client, profile, env, config, None) } -async fn make_client(provider: Provider, twin: Option<&OpenAiTwinOptions>) -> Client { - if provider == Provider::OpenAi && fabro_test::TestMode::from_env().is_twin() { +async fn make_client(provider: &Provider, twin: Option<&OpenAiTwinOptions>) -> Client { + if provider == &ProviderId::openai() && fabro_test::TestMode::from_env().is_twin() { return make_twin_client(twin.expect("openai twin config should be provided")); } let source = EnvCredentialSource::new(); - let catalog = Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ); + let catalog = Arc::new(Catalog::from_builtin().expect("default catalog should build")); Client::from_source(&source, catalog) .await .expect("Client::from_source failed") @@ -196,7 +184,7 @@ macro_rules! openai_twin_provider_test { .await; } let mut session = make_session( - Provider::OpenAi, + ProviderId::openai(), "gpt-5.4-mini", tmp.path(), Some(twin), @@ -212,21 +200,21 @@ macro_rules! provider_tests { ($scenario:ident) => { provider_test!( $scenario, - Provider::Anthropic, + ProviderId::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( $scenario, - Provider::Gemini, + ProviderId::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] ); provider_test!( $scenario, - Provider::Kimi, + ProviderId::new("kimi"), "kimi-k2.5", kimi, keys = ["KIMI_API_KEY"] @@ -234,14 +222,14 @@ macro_rules! provider_tests { #[cfg(feature = "quarantine")] provider_test!( $scenario, - Provider::Zai, + ProviderId::new("zai"), "glm-4.7", zai, keys = ["ZAI_API_KEY"] ); provider_test!( $scenario, - Provider::Minimax, + ProviderId::new("minimax"), "minimax-m2.5", minimax, keys = ["MINIMAX_API_KEY"] @@ -249,7 +237,7 @@ macro_rules! provider_tests { #[cfg(feature = "quarantine")] provider_test!( $scenario, - Provider::Inception, + ProviderId::new("inception"), "mercury-2", inception, keys = ["INCEPTION_API_KEY"] @@ -282,28 +270,28 @@ provider_tests!(subagent_spawn); provider_test!( web_fetch, - Provider::Anthropic, + ProviderId::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( web_fetch, - Provider::OpenAi, + ProviderId::openai(), "gpt-5.4-mini", openai, keys = ["OPENAI_API_KEY"] ); provider_test!( web_fetch, - Provider::Gemini, + ProviderId::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] ); provider_test!( web_fetch, - Provider::Kimi, + ProviderId::new("kimi"), "kimi-k2.5", kimi, keys = ["KIMI_API_KEY", "OPENAI_API_KEY"] @@ -311,14 +299,14 @@ provider_test!( #[cfg(feature = "quarantine")] provider_test!( web_fetch, - Provider::Zai, + ProviderId::new("zai"), "glm-4.7", zai, keys = ["ZAI_API_KEY", "OPENAI_API_KEY"] ); provider_test!( web_fetch, - Provider::Minimax, + ProviderId::new("minimax"), "minimax-m2.5", minimax, keys = ["MINIMAX_API_KEY", "OPENAI_API_KEY"] @@ -326,7 +314,7 @@ provider_test!( #[cfg(feature = "quarantine")] provider_test!( web_fetch, - Provider::Inception, + ProviderId::new("inception"), "mercury-2", inception, keys = ["INCEPTION_API_KEY", "OPENAI_API_KEY"] @@ -334,28 +322,28 @@ provider_test!( provider_test!( web_search, - Provider::Anthropic, + ProviderId::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY", "BRAVE_SEARCH_API_KEY"] ); provider_test!( web_search, - Provider::OpenAi, + ProviderId::openai(), "gpt-5.4-mini", openai, keys = ["OPENAI_API_KEY", "BRAVE_SEARCH_API_KEY"] ); provider_test!( web_search, - Provider::Gemini, + ProviderId::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY", "BRAVE_SEARCH_API_KEY"] ); provider_test!( web_search, - Provider::Kimi, + ProviderId::new("kimi"), "kimi-k2.5", kimi, keys = ["KIMI_API_KEY", "BRAVE_SEARCH_API_KEY"] @@ -363,14 +351,14 @@ provider_test!( #[cfg(feature = "quarantine")] provider_test!( web_search, - Provider::Zai, + ProviderId::new("zai"), "glm-4.7", zai, keys = ["ZAI_API_KEY", "BRAVE_SEARCH_API_KEY"] ); provider_test!( web_search, - Provider::Minimax, + ProviderId::new("minimax"), "minimax-m2.5", minimax, keys = ["MINIMAX_API_KEY", "BRAVE_SEARCH_API_KEY"] @@ -378,7 +366,7 @@ provider_test!( #[cfg(feature = "quarantine")] provider_test!( web_search, - Provider::Inception, + ProviderId::new("inception"), "mercury-2", inception, keys = ["INCEPTION_API_KEY", "BRAVE_SEARCH_API_KEY"] @@ -401,21 +389,21 @@ macro_rules! non_openai_provider_tests { ($scenario:ident) => { provider_test!( $scenario, - Provider::Anthropic, + ProviderId::anthropic(), "claude-haiku-4-5", anthropic, keys = ["ANTHROPIC_API_KEY"] ); provider_test!( $scenario, - Provider::Gemini, + ProviderId::gemini(), "gemini-3-flash-preview", gemini, keys = ["GEMINI_API_KEY"] ); provider_test!( $scenario, - Provider::Kimi, + ProviderId::new("kimi"), "kimi-k2.5", kimi, keys = ["KIMI_API_KEY"] @@ -423,14 +411,14 @@ macro_rules! non_openai_provider_tests { #[cfg(feature = "quarantine")] provider_test!( $scenario, - Provider::Zai, + ProviderId::new("zai"), "glm-4.7", zai, keys = ["ZAI_API_KEY"] ); provider_test!( $scenario, - Provider::Minimax, + ProviderId::new("minimax"), "minimax-m2.5", minimax, keys = ["MINIMAX_API_KEY"] @@ -438,7 +426,7 @@ macro_rules! non_openai_provider_tests { #[cfg(feature = "quarantine")] provider_test!( $scenario, - Provider::Inception, + ProviderId::new("inception"), "mercury-2", inception, keys = ["INCEPTION_API_KEY"] @@ -684,7 +672,7 @@ macro_rules! reasoning_effort_tests { } reasoning_effort_tests!( - Provider::Anthropic, + ProviderId::anthropic(), "claude-haiku-4-5", anthropic_reasoning_effort, keys = ["ANTHROPIC_API_KEY"] @@ -692,33 +680,33 @@ reasoning_effort_tests!( // gpt-5-mini does not support the reasoning.effort parameter, so no OpenAI // test. reasoning_effort_tests!( - Provider::Gemini, + ProviderId::gemini(), "gemini-3-flash-preview", gemini_reasoning_effort, keys = ["GEMINI_API_KEY"] ); reasoning_effort_tests!( - Provider::Kimi, + ProviderId::new("kimi"), "kimi-k2.5", kimi_reasoning_effort, keys = ["KIMI_API_KEY"] ); #[cfg(feature = "quarantine")] reasoning_effort_tests!( - Provider::Zai, + ProviderId::new("zai"), "glm-4.7", zai_reasoning_effort, keys = ["ZAI_API_KEY"] ); reasoning_effort_tests!( - Provider::Minimax, + ProviderId::new("minimax"), "minimax-m2.5", minimax_reasoning_effort, keys = ["MINIMAX_API_KEY"] ); #[cfg(feature = "quarantine")] reasoning_effort_tests!( - Provider::Inception, + ProviderId::new("inception"), "mercury-2", inception_reasoning_effort, keys = ["INCEPTION_API_KEY"] @@ -763,45 +751,45 @@ macro_rules! loop_detection_tests { } loop_detection_tests!( - Provider::Anthropic, + ProviderId::anthropic(), "claude-haiku-4-5", anthropic_loop_detection, keys = ["ANTHROPIC_API_KEY"] ); loop_detection_tests!( - Provider::OpenAi, + ProviderId::openai(), "gpt-5.4-mini", openai_loop_detection, keys = ["OPENAI_API_KEY"] ); loop_detection_tests!( - Provider::Gemini, + ProviderId::gemini(), "gemini-3-flash-preview", gemini_loop_detection, keys = ["GEMINI_API_KEY"] ); loop_detection_tests!( - Provider::Kimi, + ProviderId::new("kimi"), "kimi-k2.5", kimi_loop_detection, keys = ["KIMI_API_KEY"] ); #[cfg(feature = "quarantine")] loop_detection_tests!( - Provider::Zai, + ProviderId::new("zai"), "glm-4.7", zai_loop_detection, keys = ["ZAI_API_KEY"] ); loop_detection_tests!( - Provider::Minimax, + ProviderId::new("minimax"), "minimax-m2.5", minimax_loop_detection, keys = ["MINIMAX_API_KEY"] ); #[cfg(feature = "quarantine")] loop_detection_tests!( - Provider::Inception, + ProviderId::new("inception"), "mercury-2", inception_loop_detection, keys = ["INCEPTION_API_KEY"] diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index d76ddd4dc..6f23ce5ca 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -16,7 +16,7 @@ mod generated { pub mod types { pub use fabro_model::{ Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode, - Provider, ReasoningEffortFeature, Speed as BillingSpeed, + ReasoningEffortFeature, Speed as BillingSpeed, }; pub use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, diff --git a/lib/crates/fabro-api/tests/model_features_round_trip.rs b/lib/crates/fabro-api/tests/model_features_round_trip.rs index 1cffcfe0a..033da8269 100644 --- a/lib/crates/fabro-api/tests/model_features_round_trip.rs +++ b/lib/crates/fabro-api/tests/model_features_round_trip.rs @@ -16,7 +16,6 @@ fn model_features_json_matches_openapi_shape() { reasoning: true, reasoning_effort: ReasoningEffortFeature::Levels, prompt_cache: false, - effort: true, }; let json = serde_json::to_value(&features).unwrap(); @@ -25,7 +24,6 @@ fn model_features_json_matches_openapi_shape() { assert_eq!(json["reasoning"], true); assert_eq!(json["reasoning_effort"], "levels"); assert_eq!(json["prompt_cache"], false); - assert_eq!(json["effort"], true); let round_trip: ApiModelFeatures = serde_json::from_value(json).unwrap(); assert_eq!(round_trip, features); diff --git a/lib/crates/fabro-api/tests/model_round_trip.rs b/lib/crates/fabro-api/tests/model_round_trip.rs index 8baeb6447..6e89a980a 100644 --- a/lib/crates/fabro-api/tests/model_round_trip.rs +++ b/lib/crates/fabro-api/tests/model_round_trip.rs @@ -2,7 +2,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::Model as ApiModel; use fabro_model::{ - Model, ModelCosts, ModelFeatures, ModelLimits, Provider, ReasoningEffortFeature, + Model, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature, }; #[test] @@ -14,7 +14,7 @@ fn model_reuses_canonical_type() { fn model_json_matches_openapi_shape() { let model = Model { id: "claude-opus-4-7".to_string(), - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), family: "claude-4".to_string(), display_name: "Claude Opus 4.7".to_string(), limits: ModelLimits { @@ -29,7 +29,6 @@ fn model_json_matches_openapi_shape() { reasoning: true, reasoning_effort: ReasoningEffortFeature::Levels, prompt_cache: true, - effort: true, }, costs: ModelCosts { input_cost_per_mtok: Some(5.0), @@ -48,7 +47,6 @@ fn model_json_matches_openapi_shape() { assert_eq!(json["knowledge_cutoff"], "May 2025"); assert_eq!(json["features"]["reasoning_effort"], "levels"); assert_eq!(json["features"]["prompt_cache"], true); - assert_eq!(json["features"]["effort"], true); assert_eq!(json["estimated_output_tps"], 25.0); assert_eq!(json["configured"], true); diff --git a/lib/crates/fabro-api/tests/provider_round_trip.rs b/lib/crates/fabro-api/tests/provider_round_trip.rs index bcfbd5cc0..b20e15370 100644 --- a/lib/crates/fabro-api/tests/provider_round_trip.rs +++ b/lib/crates/fabro-api/tests/provider_round_trip.rs @@ -2,7 +2,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::Model as ApiModel; use fabro_model::{ - Model, ModelCosts, ModelFeatures, ModelLimits, Provider, ProviderId, ReasoningEffortFeature, + Model, ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature, }; use serde_json::json; @@ -14,17 +14,13 @@ fn provider_id_reuses_canonical_model_field_type() { #[test] fn provider_id_json_matches_openapi_shape_through_model() { assert_eq!( - serde_json::to_value(Provider::Anthropic.id()).unwrap(), + serde_json::to_value(ProviderId::anthropic()).unwrap(), json!("anthropic") ); assert_eq!( - serde_json::to_value(Provider::OpenAi.id()).unwrap(), + serde_json::to_value(ProviderId::openai()).unwrap(), json!("openai") ); - assert_eq!( - serde_json::to_value(Provider::OpenAiCompatible.id()).unwrap(), - json!("openai_compatible") - ); let model = Model { id: "venice-custom".to_string(), @@ -43,7 +39,6 @@ fn provider_id_json_matches_openapi_shape_through_model() { reasoning: false, reasoning_effort: ReasoningEffortFeature::None, prompt_cache: false, - effort: false, }, costs: ModelCosts { input_cost_per_mtok: None, diff --git a/lib/crates/fabro-auth/src/context.rs b/lib/crates/fabro-auth/src/context.rs index 93bf14c40..70c3b94eb 100644 --- a/lib/crates/fabro-auth/src/context.rs +++ b/lib/crates/fabro-auth/src/context.rs @@ -1,10 +1,12 @@ -use fabro_model::Provider; +use fabro_model::ProviderId; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthContextRequest { ApiKey { - provider: Provider, + provider_id: ProviderId, + display_name: String, env_var_names: Vec, + api_key_url: Option, }, DeviceCode { user_code: String, diff --git a/lib/crates/fabro-auth/src/credential.rs b/lib/crates/fabro-auth/src/credential.rs index d64b25ac8..7b512a7b9 100644 --- a/lib/crates/fabro-auth/src/credential.rs +++ b/lib/crates/fabro-auth/src/credential.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, Duration, Utc}; -use fabro_model::{Provider, ProviderId}; +use fabro_model::ProviderId; use fabro_redact::redact_string; use serde::{Deserialize, Serialize}; @@ -92,7 +92,7 @@ impl std::fmt::Debug for ApiKeyHeader { pub fn credential_id_for(credential: &AuthCredential) -> Result { match &credential.details { AuthDetails::ApiKey { .. } => Ok(credential.provider.to_string()), - AuthDetails::CodexOAuth { .. } if credential.provider == Provider::OpenAi.id() => { + AuthDetails::CodexOAuth { .. } if credential.provider == ProviderId::openai() => { Ok("openai_codex".to_string()) } AuthDetails::CodexOAuth { .. } => Err(format!( @@ -120,7 +120,7 @@ mod tests { fn oauth_credential(expires_at: DateTime) -> AuthCredential { AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: "access".to_string(), @@ -163,7 +163,7 @@ mod tests { #[test] fn credential_id_for_openai_api_key() { let credential = AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::ApiKey { key: "sk-test".to_string(), }, @@ -174,7 +174,7 @@ mod tests { #[test] fn credential_id_for_non_openai_codex_oauth_errors() { let mut credential = oauth_credential(Utc::now() + Duration::hours(1)); - credential.provider = Provider::Anthropic.id(); + credential.provider = ProviderId::anthropic(); assert!(credential_id_for(&credential).is_err()); } diff --git a/lib/crates/fabro-auth/src/env_source.rs b/lib/crates/fabro-auth/src/env_source.rs index dc755767f..0107531a5 100644 --- a/lib/crates/fabro-auth/src/env_source.rs +++ b/lib/crates/fabro-auth/src/env_source.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use async_trait::async_trait; use fabro_model::catalog::CatalogProvider; -use fabro_model::{Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter}; +use fabro_model::{Catalog, CredentialRef, HeaderValueRef, ProviderId}; use fabro_static::EnvVars; use crate::credential_source::{CredentialSource, ResolvedCredentials}; @@ -54,10 +54,7 @@ impl EnvCredentialSource { } let auth_header = key.map(|key| { - let policy = adapter::get(&provider.adapter) - .map_or(fabro_model::ApiKeyHeaderPolicy::Bearer, |adapter| { - adapter.api_key_header - }); + let policy = provider.adapter.metadata().api_key_header; build_api_key_header(policy, key) }); @@ -73,7 +70,7 @@ impl EnvCredentialSource { cred.base_url = self .env_base_url(&provider.id) .or_else(|| provider.base_url.clone()); - if provider.id == Provider::OpenAi.id() && cred.auth_header.is_some() { + if provider.id == ProviderId::openai() && cred.auth_header.is_some() { cred.org_id = self.lookup(EnvVars::OPENAI_ORG_ID); cred.project_id = self.lookup(EnvVars::OPENAI_PROJECT_ID); if let Some(account_id) = self.lookup(EnvVars::CHATGPT_ACCOUNT_ID) { @@ -89,13 +86,11 @@ impl EnvCredentialSource { } fn env_base_url(&self, provider: &ProviderId) -> Option { - match Provider::from_id(provider) { - Some(Provider::Anthropic) => self.lookup(EnvVars::ANTHROPIC_BASE_URL), - Some(Provider::OpenAi) => self.lookup(EnvVars::OPENAI_BASE_URL), - Some(Provider::Gemini) => self.lookup(EnvVars::GEMINI_BASE_URL), - Some(Provider::OpenAiCompatible) => self.lookup(EnvVars::OPENAI_COMPATIBLE_BASE_URL), - Some(Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception) - | None => None, + match provider.as_str() { + ProviderId::ANTHROPIC => self.lookup(EnvVars::ANTHROPIC_BASE_URL), + ProviderId::OPENAI => self.lookup(EnvVars::OPENAI_BASE_URL), + ProviderId::GEMINI => self.lookup(EnvVars::GEMINI_BASE_URL), + _ => None, } } @@ -179,7 +174,7 @@ mod tests { use std::sync::Arc; use fabro_model::catalog::LlmCatalogSettings; - use fabro_model::{Catalog, Provider, ProviderId}; + use fabro_model::{Catalog, ProviderId}; use super::EnvCredentialSource; use crate::CredentialSource; @@ -207,7 +202,7 @@ mod tests { let catalog = default_catalog(); assert_eq!(source.configured_providers(&catalog).await, vec![ - Provider::Anthropic.id() + ProviderId::anthropic() ]); } @@ -234,7 +229,7 @@ mod tests { let resolved = source.resolve(&catalog).await.unwrap(); let credential = resolved.credentials.first().unwrap(); - assert_eq!(credential.provider, Provider::OpenAi.id()); + assert_eq!(credential.provider, ProviderId::openai()); assert!(credential.codex_mode); assert_eq!( credential.base_url.as_deref(), @@ -255,7 +250,7 @@ mod tests { let resolved = source.resolve(&catalog).await.unwrap(); let credential = resolved.credentials.first().unwrap(); - assert_eq!(credential.provider, Provider::Kimi.id()); + assert_eq!(credential.provider, ProviderId::new("kimi")); assert_eq!( credential.base_url.as_deref(), Some("https://api.moonshot.ai/v1") @@ -285,7 +280,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ); let source = test_source(&[("ACME_API_KEY", "acme-key")]); @@ -333,7 +327,7 @@ context_window = 200000 tools = true vision = true reasoning = true -effort = true +reasoning_effort = "levels" "#, ); let source = test_source(&[("PORTKEY_API_KEY", "pk-live")]); @@ -381,7 +375,7 @@ context_window = 200000 tools = true vision = true reasoning = true -effort = true +reasoning_effort = "levels" "#, ); let source = test_source(&[]); diff --git a/lib/crates/fabro-auth/src/resolve.rs b/lib/crates/fabro-auth/src/resolve.rs index 28f5232c1..30afaad04 100644 --- a/lib/crates/fabro-auth/src/resolve.rs +++ b/lib/crates/fabro-auth/src/resolve.rs @@ -2,9 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use fabro_model::catalog::CatalogProvider; -use fabro_model::{ - ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, Provider, ProviderId, adapter, -}; +use fabro_model::{ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, ProviderId}; use fabro_static::EnvVars; use fabro_vault::Vault; use shlex::try_quote; @@ -46,25 +44,25 @@ pub struct ApiCredential { impl ApiCredential { /// Build an `ApiCredential` from an API key using the supplied catalog for /// auth header policy and provider base URL. - #[must_use] - pub fn from_api_key(provider: impl Into, key: String, catalog: &Catalog) -> Self { + pub fn from_api_key( + provider: impl Into, + key: String, + catalog: &Catalog, + ) -> Result { let provider_id = provider.into(); - let (auth_header, base_url) = match catalog.provider(&provider_id) { - Some(provider) => ( - auth_header_for_catalog_provider(provider, key), - provider.base_url.clone(), - ), - None => (default_auth_header_for_provider(&provider_id, key), None), - }; - Self { - provider: provider_id, - auth_header: Some(auth_header), + let provider = catalog + .provider(&provider_id) + .ok_or_else(|| ResolveError::NotConfigured(provider_id.clone()))?; + let auth_header = auth_header_for_catalog_provider(provider, key); + Ok(Self { + provider: provider_id, + auth_header: Some(auth_header), extra_headers: HashMap::new(), - base_url, - codex_mode: false, - org_id: None, - project_id: None, - } + base_url: provider.base_url.clone(), + codex_mode: false, + org_id: None, + project_id: None, + }) } } @@ -79,17 +77,8 @@ pub fn build_api_key_header(policy: ApiKeyHeaderPolicy, key: String) -> ApiKeyHe } } -fn default_auth_header_for_provider(provider: &ProviderId, key: String) -> ApiKeyHeader { - let policy = match Provider::from_id(provider) { - Some(Provider::Anthropic) => ApiKeyHeaderPolicy::Custom { name: "x-api-key" }, - _ => ApiKeyHeaderPolicy::Bearer, - }; - build_api_key_header(policy, key) -} - fn auth_header_for_catalog_provider(provider: &CatalogProvider, key: String) -> ApiKeyHeader { - let policy = adapter::get(&provider.adapter) - .map_or(ApiKeyHeaderPolicy::Bearer, |adapter| adapter.api_key_header); + let policy = provider.adapter.metadata().api_key_header; build_api_key_header(policy, key) } @@ -121,7 +110,7 @@ pub enum ResolveError { #[must_use] pub fn auth_issue_message(provider: &ProviderId, err: &ResolveError) -> String { - let provider_name = Provider::display_name_for_id(provider); + let provider_name = provider.display_name(); match err { ResolveError::NotConfigured(_) => { format!("{provider_name} is not configured") @@ -239,7 +228,7 @@ impl CredentialResolver { provider: &CatalogProvider, usage: CredentialUsage, ) -> Result { - if provider.id == Provider::OpenAi.id() + if provider.id == ProviderId::openai() && usage == CredentialUsage::CliAgent(CliAgentKind::Codex) { for credential_id in ["openai_codex", "openai"] { @@ -312,17 +301,11 @@ impl CredentialResolver { provider: &ProviderId, catalog: &Catalog, ) -> Option { - let env_base_url = match Provider::from_id(provider) { - Some(Provider::Anthropic) => { - self.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_BASE_URL) - } - Some(Provider::OpenAi) => self.lookup_env_or_vault(vault, EnvVars::OPENAI_BASE_URL), - Some(Provider::Gemini) => self.lookup_env_or_vault(vault, EnvVars::GEMINI_BASE_URL), - Some(Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception) - | None => None, - Some(Provider::OpenAiCompatible) => { - self.lookup_env_or_vault(vault, EnvVars::OPENAI_COMPATIBLE_BASE_URL) - } + let env_base_url = match provider.as_str() { + ProviderId::ANTHROPIC => self.lookup_env_or_vault(vault, EnvVars::ANTHROPIC_BASE_URL), + ProviderId::OPENAI => self.lookup_env_or_vault(vault, EnvVars::OPENAI_BASE_URL), + ProviderId::GEMINI => self.lookup_env_or_vault(vault, EnvVars::GEMINI_BASE_URL), + _ => None, }; env_base_url.or_else(|| { catalog @@ -364,10 +347,10 @@ impl CredentialResolver { let base_url = self.provider_base_url_for_catalog(vault, &credential.provider, catalog); match &credential.details { AuthDetails::ApiKey { key } => { - let auth_header = catalog.provider(&credential.provider).map_or_else( - || default_auth_header_for_provider(&credential.provider, key.clone()), - |provider| auth_header_for_catalog_provider(provider, key.clone()), - ); + let provider = catalog + .provider(&credential.provider) + .ok_or_else(|| ResolveError::NotConfigured(credential.provider.clone()))?; + let auth_header = auth_header_for_catalog_provider(provider, key.clone()); let mut cred = ApiCredential { provider: credential.provider.clone(), auth_header: Some(auth_header), @@ -380,7 +363,7 @@ impl CredentialResolver { cred.base_url = base_url; cred.extra_headers = self.resolved_extra_headers_for_catalog(vault, &credential.provider, catalog)?; - if credential.provider == Provider::OpenAi.id() { + if credential.provider == ProviderId::openai() { cred.org_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_ORG_ID); cred.project_id = self.lookup_env_or_vault(vault, EnvVars::OPENAI_PROJECT_ID); } @@ -435,14 +418,14 @@ impl CredentialResolver { catalog: &Catalog, ) -> CliCredential { let mut env_vars = HashMap::new(); - let provider = Provider::from_id(&credential.provider); - let login_command = match (provider, &credential.details, kind) { - (Some(Provider::OpenAi), AuthDetails::ApiKey { key }, CliAgentKind::Codex) => { + let is_openai = credential.provider == ProviderId::openai(); + let login_command = match (is_openai, &credential.details, kind) { + (true, AuthDetails::ApiKey { key }, CliAgentKind::Codex) => { env_vars.insert(EnvVars::OPENAI_API_KEY.to_string(), key.clone()); Some(codex_login_command(key)) } ( - Some(Provider::OpenAi), + true, AuthDetails::CodexOAuth { tokens, account_id, .. }, @@ -530,10 +513,10 @@ mod tests { use crate::credential::{OAuthConfig, OAuthTokens}; use crate::vault_ext::vault_get_credential; - fn api_key_credential(provider: Provider, key: &str) -> AuthCredential { + fn api_key_credential(provider: ProviderId, key: &str) -> AuthCredential { AuthCredential { - provider: provider.id(), - details: AuthDetails::ApiKey { + provider, + details: AuthDetails::ApiKey { key: key.to_string(), }, } @@ -541,7 +524,7 @@ mod tests { fn oauth_credential(token_url: String, expires_at: chrono::DateTime) -> AuthCredential { AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: "expired-access".to_string(), @@ -581,14 +564,14 @@ mod tests { vault_set_credential( &mut vault, "openai", - &api_key_credential(Provider::OpenAi, "vault-key"), + &api_key_credential(ProviderId::openai(), "vault-key"), ) .unwrap(); let resolver = test_resolver(vault, Arc::new(|_| Some("env-key".to_string()))); let catalog = default_catalog(); let resolved = resolver - .resolve(Provider::OpenAi, CredentialUsage::ApiRequest, &catalog) + .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await .unwrap(); @@ -618,7 +601,7 @@ mod tests { let catalog = default_catalog(); let resolved = resolver - .resolve(Provider::OpenAi, CredentialUsage::ApiRequest, &catalog) + .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await .unwrap(); @@ -644,13 +627,17 @@ mod tests { let catalog = default_catalog(); let err = resolver - .resolve(Provider::Anthropic, CredentialUsage::ApiRequest, &catalog) + .resolve( + ProviderId::anthropic(), + CredentialUsage::ApiRequest, + &catalog, + ) .await .unwrap_err(); assert!(matches!( err, - ResolveError::NotConfigured(provider) if provider == Provider::Anthropic.id() + ResolveError::NotConfigured(provider) if provider == ProviderId::anthropic() )); } @@ -661,14 +648,18 @@ mod tests { vault_set_credential( &mut vault, "anthropic", - &api_key_credential(Provider::Anthropic, "anthropic-key"), + &api_key_credential(ProviderId::anthropic(), "anthropic-key"), ) .unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let catalog = default_catalog(); let ResolvedCredential::Api(api) = resolver - .resolve(Provider::Anthropic, CredentialUsage::ApiRequest, &catalog) + .resolve( + ProviderId::anthropic(), + CredentialUsage::ApiRequest, + &catalog, + ) .await .unwrap() else { @@ -685,17 +676,17 @@ mod tests { } #[tokio::test] - async fn openai_compatible_resolves_with_openai_base_url_from_vault() { + async fn custom_openai_compatible_resolves_with_catalog_base_url_from_vault() { let catalog = catalog_with( r#" -[providers.openai_compatible] -display_name = "OpenAI Compatible" +[providers.acme] +display_name = "Acme" adapter = "openai_compatible" base_url = "https://default.example.com/v1" -credentials = ["credential:openai_compatible"] +credentials = ["credential:acme"] [models."compat-model"] -provider = "openai_compatible" +provider = "acme" display_name = "Compat Model" family = "openai" default = true @@ -707,29 +698,20 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ); let dir = tempfile::tempdir().unwrap(); let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_credential( &mut vault, - "openai_compatible", - &api_key_credential(Provider::OpenAiCompatible, "compat-key"), + "acme", + &api_key_credential(ProviderId::new("acme"), "compat-key"), ) .unwrap(); - vault - .set( - "OPENAI_COMPATIBLE_BASE_URL", - "https://compat.example.com/v1", - fabro_vault::SecretType::Environment, - None, - ) - .unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); let resolved = resolver .resolve( - Provider::OpenAiCompatible, + ProviderId::new("acme"), CredentialUsage::ApiRequest, &catalog, ) @@ -745,7 +727,7 @@ effort = false ); assert_eq!( api.base_url.as_deref(), - Some("https://compat.example.com/v1") + Some("https://default.example.com/v1") ); } @@ -767,7 +749,7 @@ effort = false let ResolvedCredential::Cli(cli) = resolver .resolve( - Provider::OpenAi, + ProviderId::openai(), CredentialUsage::CliAgent(CliAgentKind::Codex), &catalog, ) @@ -799,7 +781,7 @@ effort = false vault_set_credential( &mut vault, "openai", - &api_key_credential(Provider::OpenAi, "openai-key"), + &api_key_credential(ProviderId::openai(), "openai-key"), ) .unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); @@ -807,7 +789,7 @@ effort = false let ResolvedCredential::Cli(cli) = resolver .resolve( - Provider::OpenAi, + ProviderId::openai(), CredentialUsage::CliAgent(CliAgentKind::Codex), &catalog, ) @@ -851,7 +833,7 @@ effort = false vault_set_credential( &mut vault, "openai", - &api_key_credential(Provider::OpenAi, "openai-key"), + &api_key_credential(ProviderId::openai(), "openai-key"), ) .unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); @@ -859,7 +841,7 @@ effort = false let ResolvedCredential::Cli(cli) = resolver .resolve( - Provider::OpenAi, + ProviderId::openai(), CredentialUsage::CliAgent(CliAgentKind::Codex), &catalog, ) @@ -901,7 +883,7 @@ effort = false vault_set_credential( &mut vault, "openai", - &api_key_credential(Provider::OpenAi, "vault-key"), + &api_key_credential(ProviderId::openai(), "vault-key"), ) .unwrap(); vault @@ -919,7 +901,7 @@ effort = false let catalog = default_catalog(); let ResolvedCredential::Api(api) = resolver - .resolve(Provider::OpenAi, CredentialUsage::ApiRequest, &catalog) + .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await .unwrap() else { @@ -936,7 +918,7 @@ effort = false vault_set_credential( &mut vault, "openai", - &api_key_credential(Provider::OpenAi, "vault-key"), + &api_key_credential(ProviderId::openai(), "vault-key"), ) .unwrap(); let resolver = test_resolver(vault, Arc::new(|_| None)); @@ -944,7 +926,7 @@ effort = false let catalog = default_catalog(); assert_eq!(resolver.configured_providers(&vault, &catalog), vec![ - Provider::OpenAi.id() + ProviderId::openai() ]); } @@ -971,7 +953,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ); let dir = tempfile::tempdir().unwrap(); @@ -1017,7 +998,7 @@ effort = false let catalog = default_catalog(); assert_eq!(resolver.configured_providers(&vault, &catalog), vec![ - Provider::OpenAi.id() + ProviderId::openai() ]); } @@ -1062,7 +1043,7 @@ effort = false let ResolvedCredential::Cli(cli) = resolver .resolve( - Provider::OpenAi, + ProviderId::openai(), CredentialUsage::CliAgent(CliAgentKind::Codex), &catalog, ) @@ -1111,7 +1092,7 @@ effort = false let err = resolver .resolve( - Provider::OpenAi, + ProviderId::openai(), CredentialUsage::CliAgent(CliAgentKind::Codex), &catalog, ) @@ -1120,27 +1101,27 @@ effort = false assert!(matches!( err, - ResolveError::RefreshTokenMissing(provider) if provider == Provider::OpenAi.id() + ResolveError::RefreshTokenMissing(provider) if provider == ProviderId::openai() )); } #[test] fn auth_issue_message_formats_refresh_token_missing() { let message = auth_issue_message( - &Provider::OpenAi.id(), - &ResolveError::RefreshTokenMissing(Provider::OpenAi.id()), + &ProviderId::openai(), + &ResolveError::RefreshTokenMissing(ProviderId::openai()), ); assert_eq!( message, - "OpenAI requires re-authentication: refresh token missing" + "openai requires re-authentication: refresh token missing" ); } #[test] fn api_credential_debug_redacts_secret_material() { let credential = ApiCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), auth_header: Some(ApiKeyHeader::Bearer("sk-test".to_string())), extra_headers: HashMap::new(), base_url: None, diff --git a/lib/crates/fabro-auth/src/strategies/api_key.rs b/lib/crates/fabro-auth/src/strategies/api_key.rs index 0c5cb37c8..a8250d2c8 100644 --- a/lib/crates/fabro-auth/src/strategies/api_key.rs +++ b/lib/crates/fabro-auth/src/strategies/api_key.rs @@ -1,18 +1,35 @@ use async_trait::async_trait; -use fabro_model::Provider; +use fabro_model::catalog::CatalogProvider; +use fabro_model::{CredentialRef, ProviderId}; use crate::context::{AuthContextRequest, AuthContextResponse}; use crate::credential::{AuthCredential, AuthDetails}; use crate::strategy::AuthStrategy; pub struct ApiKeyStrategy { - provider: Provider, + provider_id: ProviderId, + display_name: String, + env_var_names: Vec, + api_key_url: Option, } impl ApiKeyStrategy { #[must_use] - pub fn new(provider: Provider) -> Self { - Self { provider } + pub fn new(provider: &CatalogProvider) -> Self { + let env_var_names = provider + .credentials + .iter() + .filter_map(|credential_ref| match credential_ref { + CredentialRef::Env(name) => Some(name.clone()), + CredentialRef::Credential(_) => None, + }) + .collect(); + Self { + provider_id: provider.id.clone(), + display_name: provider.display_name.clone(), + env_var_names, + api_key_url: provider.api_key_url.clone(), + } } } @@ -20,20 +37,17 @@ impl ApiKeyStrategy { impl AuthStrategy for ApiKeyStrategy { async fn init(&mut self) -> anyhow::Result { Ok(AuthContextRequest::ApiKey { - provider: self.provider, - env_var_names: self - .provider - .api_key_env_vars() - .iter() - .map(|name| (*name).to_string()) - .collect(), + provider_id: self.provider_id.clone(), + display_name: self.display_name.clone(), + env_var_names: self.env_var_names.clone(), + api_key_url: self.api_key_url.clone(), }) } async fn complete(&mut self, response: AuthContextResponse) -> anyhow::Result { match response { AuthContextResponse::ApiKey { key } => Ok(AuthCredential { - provider: self.provider.id(), + provider: self.provider_id.clone(), details: AuthDetails::ApiKey { key }, }), AuthContextResponse::DeviceCodeConfirmed => { diff --git a/lib/crates/fabro-auth/src/strategies/codex_device.rs b/lib/crates/fabro-auth/src/strategies/codex_device.rs index 4d01aa1e9..9b6e9117b 100644 --- a/lib/crates/fabro-auth/src/strategies/codex_device.rs +++ b/lib/crates/fabro-auth/src/strategies/codex_device.rs @@ -300,7 +300,7 @@ impl AuthStrategy for CodexDeviceStrategy { .map_err(anyhow::Error::msg)?; Ok(AuthCredential { - provider: fabro_model::Provider::OpenAi.id(), + provider: fabro_model::ProviderId::openai(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: token_response.access_token, diff --git a/lib/crates/fabro-auth/src/strategy.rs b/lib/crates/fabro-auth/src/strategy.rs index 1d28afccc..b29d7aa35 100644 --- a/lib/crates/fabro-auth/src/strategy.rs +++ b/lib/crates/fabro-auth/src/strategy.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use fabro_model::Provider; +use fabro_model::{Catalog, ProviderId}; use crate::context::{AuthContextRequest, AuthContextResponse}; use crate::credential::{AuthCredential, OAuthConfig}; @@ -40,13 +40,22 @@ pub fn codex_oauth_config() -> OAuthConfig { } #[must_use] -pub fn strategy_for(provider: Provider, method: AuthMethod) -> Box { +pub fn strategy_for( + provider_id: &ProviderId, + method: AuthMethod, + catalog: &Catalog, +) -> Box { match method { - AuthMethod::ApiKey => Box::new(ApiKeyStrategy::new(provider)), + AuthMethod::ApiKey => { + let provider = catalog + .provider(provider_id) + .expect("API key auth requires a catalog provider"); + Box::new(ApiKeyStrategy::new(provider)) + } AuthMethod::CodexDevice(config) => { assert_eq!( - provider, - Provider::OpenAi, + provider_id.as_str(), + ProviderId::OPENAI, "Codex device auth is only supported for OpenAI" ); Box::new(CodexDeviceStrategy::new(config)) @@ -71,11 +80,15 @@ mod tests { #[tokio::test] async fn api_key_strategy_uses_provider_env_names() { - let mut strategy = ApiKeyStrategy::new(Provider::Anthropic); + let catalog = Catalog::builtin(); + let provider = catalog.provider(&ProviderId::anthropic()).unwrap(); + let mut strategy = ApiKeyStrategy::new(provider); let request = strategy.init().await.unwrap(); assert_eq!(request, AuthContextRequest::ApiKey { - provider: Provider::Anthropic, + provider_id: ProviderId::anthropic(), + display_name: "Anthropic".to_string(), env_var_names: vec!["ANTHROPIC_API_KEY".to_string()], + api_key_url: Some("https://console.anthropic.com/settings/keys".to_string()), }); } } diff --git a/lib/crates/fabro-auth/src/vault_ext.rs b/lib/crates/fabro-auth/src/vault_ext.rs index b2f460040..53dba827c 100644 --- a/lib/crates/fabro-auth/src/vault_ext.rs +++ b/lib/crates/fabro-auth/src/vault_ext.rs @@ -43,14 +43,14 @@ pub fn vault_credentials_for_provider( #[cfg(test)] mod tests { use chrono::{Duration, Utc}; - use fabro_model::Provider; + use fabro_model::ProviderId; use super::*; use crate::credential::{AuthDetails, OAuthConfig, OAuthTokens}; fn oauth_credential() -> AuthCredential { AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: "access".to_string(), @@ -90,14 +90,14 @@ mod tests { let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); vault_set_credential(&mut vault, "openai_codex", &oauth_credential()).unwrap(); vault_set_credential(&mut vault, "anthropic", &AuthCredential { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), details: AuthDetails::ApiKey { key: "anthropic-key".to_string(), }, }) .unwrap(); - let credentials = vault_credentials_for_provider(&vault, Provider::OpenAi); + let credentials = vault_credentials_for_provider(&vault, ProviderId::openai()); assert_eq!(credentials.len(), 1); assert_eq!(credentials[0].0, "openai_codex"); diff --git a/lib/crates/fabro-auth/src/vault_source.rs b/lib/crates/fabro-auth/src/vault_source.rs index 201f2bc0d..01bd188b2 100644 --- a/lib/crates/fabro-auth/src/vault_source.rs +++ b/lib/crates/fabro-auth/src/vault_source.rs @@ -85,8 +85,7 @@ mod tests { use std::sync::Arc; use chrono::{Duration, Utc}; - use fabro_model::catalog::LlmCatalogSettings; - use fabro_model::{Catalog, Provider}; + use fabro_model::{Catalog, ProviderId}; use fabro_vault::{SecretType, Vault}; use tokio::sync::RwLock as AsyncRwLock; @@ -94,10 +93,10 @@ mod tests { use crate::credential::{AuthCredential, AuthDetails, OAuthConfig, OAuthTokens}; use crate::{CredentialSource, ResolveError}; - fn api_key_credential(provider: Provider, key: &str) -> AuthCredential { + fn api_key_credential(provider: ProviderId, key: &str) -> AuthCredential { AuthCredential { - provider: provider.id(), - details: AuthDetails::ApiKey { + provider, + details: AuthDetails::ApiKey { key: key.to_string(), }, } @@ -105,7 +104,7 @@ mod tests { fn expired_openai_credential() -> AuthCredential { AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::CodexOAuth { tokens: OAuthTokens { access_token: "expired-access".to_string(), @@ -126,7 +125,7 @@ mod tests { } fn default_catalog() -> Catalog { - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap() + Catalog::from_builtin().unwrap() } #[tokio::test] @@ -144,8 +143,11 @@ mod tests { vault .set( "anthropic", - &serde_json::to_string(&api_key_credential(Provider::Anthropic, "anthropic-key")) - .unwrap(), + &serde_json::to_string(&api_key_credential( + ProviderId::anthropic(), + "anthropic-key", + )) + .unwrap(), SecretType::Credential, None, ) @@ -158,14 +160,14 @@ mod tests { let resolved = source.resolve(&catalog).await.unwrap(); assert_eq!(resolved.credentials.len(), 1); - assert_eq!(resolved.credentials[0].provider, Provider::Anthropic.id()); + assert_eq!(resolved.credentials[0].provider, ProviderId::anthropic()); assert_eq!(resolved.auth_issues.len(), 1); assert!(matches!( &resolved.auth_issues[0].1, ResolveError::RefreshFailed { provider, .. - } if provider == &Provider::OpenAi.id() + } if provider == &ProviderId::openai() )); } @@ -176,7 +178,7 @@ mod tests { vault .set( "openai", - &serde_json::to_string(&api_key_credential(Provider::OpenAi, "openai-key")) + &serde_json::to_string(&api_key_credential(ProviderId::openai(), "openai-key")) .unwrap(), SecretType::Credential, None, @@ -185,8 +187,11 @@ mod tests { vault .set( "anthropic", - &serde_json::to_string(&api_key_credential(Provider::Anthropic, "anthropic-key")) - .unwrap(), + &serde_json::to_string(&api_key_credential( + ProviderId::anthropic(), + "anthropic-key", + )) + .unwrap(), SecretType::Credential, None, ) @@ -196,8 +201,8 @@ mod tests { let catalog = default_catalog(); assert_eq!(source.configured_providers(&catalog).await, vec![ - Provider::Anthropic.id(), - Provider::OpenAi.id() + ProviderId::anthropic(), + ProviderId::openai() ]); } } diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 26fdeb1f1..ccdeb1773 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -771,7 +771,7 @@ pub(crate) struct ProviderLoginArgs { /// LLM provider to authenticate with #[arg(long)] - pub(crate) provider: fabro_model::Provider, + pub(crate) provider: fabro_model::ProviderId, /// Read an API key from stdin instead of prompting #[arg(long)] @@ -1579,7 +1579,7 @@ pub(crate) struct InstallGithubArgs { #[derive(Args, Debug, Clone, Default)] pub(crate) struct InstallNonInteractiveArgs { #[arg(long, hide = true)] - pub(crate) llm_provider: Option, + pub(crate) llm_provider: Option, #[arg(long, hide = true)] pub(crate) llm_api_key_stdin: bool, diff --git a/lib/crates/fabro-cli/src/commands/install.rs b/lib/crates/fabro-cli/src/commands/install.rs index 39ac95d59..b8e7dcda1 100644 --- a/lib/crates/fabro-cli/src/commands/install.rs +++ b/lib/crates/fabro-cli/src/commands/install.rs @@ -7,6 +7,7 @@ use std::future::Future; use std::net::SocketAddr; use std::path::Path; use std::process::Stdio; +use std::sync::LazyLock; use std::time::Duration; use anyhow::{Context, Result, bail}; @@ -30,7 +31,8 @@ use fabro_install::{ InstallListenConfig, PendingSettingsWrite, merge_server_settings as merge_server_settings_impl, persist_install_outputs_direct, write_github_app_settings, write_token_settings, }; -use fabro_model::Provider; +use fabro_model::catalog::CatalogProvider; +use fabro_model::{Catalog, CredentialRef, ProviderId}; use fabro_server::serve; use fabro_store::ArtifactStore; use fabro_types::ServerSettings; @@ -70,6 +72,46 @@ const GITHUB_APP_PRIVATE_KEY_KEY: &str = "GITHUB_APP_PRIVATE_KEY"; const GITHUB_APP_CLIENT_SECRET_KEY: &str = "GITHUB_APP_CLIENT_SECRET"; const GITHUB_APP_WEBHOOK_SECRET_KEY: &str = "GITHUB_APP_WEBHOOK_SECRET"; +static INSTALL_CATALOG: LazyLock = LazyLock::new(|| { + Catalog::from_builtin().expect("embedded install model catalog should be valid") +}); + +fn supports_install_api_key(provider: &CatalogProvider) -> bool { + provider.credentials.iter().any(|credential| { + matches!( + credential, + CredentialRef::Credential(_) | CredentialRef::Env(_) + ) + }) +} + +fn install_llm_provider_ids(catalog: &Catalog) -> Vec { + catalog + .providers() + .iter() + .filter(|provider| supports_install_api_key(provider)) + .map(|provider| provider.id.clone()) + .collect() +} + +fn provider_env_var_label(provider: &ProviderId, catalog: &Catalog) -> String { + catalog + .provider(provider) + .map(|provider| { + provider + .credentials + .iter() + .filter_map(|credential| match credential { + CredentialRef::Env(name) => Some(name.as_str()), + CredentialRef::Credential(_) => None, + }) + .collect::>() + .join(" / ") + }) + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| "API_KEY".to_string()) +} + // --------------------------------------------------------------------------- // Auth status display // --------------------------------------------------------------------------- @@ -379,7 +421,8 @@ impl InstallInputSource for InteractiveInstallInputSource { } let mut credentials = Vec::new(); - let mut configured_providers: Vec = Vec::new(); + let catalog = &*INSTALL_CATALOG; + let mut configured_providers: Vec = Vec::new(); let mut openai_configured = false; if facts.codex_detected { @@ -394,23 +437,23 @@ impl InstallInputSource for InteractiveInstallInputSource { if use_device_auth { let credential = authenticate_provider_with_method( - Provider::OpenAi, + ProviderId::openai(), AuthMethod::CodexDevice(codex_oauth_config()), s, printer, ) .await?; credentials.push(credential); - configured_providers.push(Provider::OpenAi); + configured_providers.push(ProviderId::openai()); openai_configured = true; } } if !openai_configured { - let primary_providers = [Provider::Anthropic, Provider::OpenAi, Provider::Gemini]; + let primary_providers = install_llm_provider_ids(catalog); let primary_labels: Vec = primary_providers .iter() - .map(|p| provider_display_name(*p).to_string()) + .map(|p| provider_display_name(p, catalog)) .collect(); let primary_idx: usize = spawn_blocking({ let labels = primary_labels.clone(); @@ -418,8 +461,8 @@ impl InstallInputSource for InteractiveInstallInputSource { }) .await??; - let first_provider = primary_providers[primary_idx]; - credentials.push(authenticate_provider(first_provider, s, printer).await?); + let first_provider = primary_providers[primary_idx].clone(); + credentials.push(authenticate_provider(first_provider.clone(), s, printer).await?); configured_providers.push(first_provider); } @@ -427,18 +470,19 @@ impl InstallInputSource for InteractiveInstallInputSource { spawn_blocking(|| prompt_confirm("Set up additional LLM providers?", false)).await??; if add_more { - let remaining_labels: Vec = Provider::ALL + let install_providers = install_llm_provider_ids(catalog); + let remaining_labels: Vec = install_providers .iter() .filter(|p| !configured_providers.contains(p)) .map(|p| { - let env_vars = p.api_key_env_vars().join(" / "); - format!("{} ({})", provider_display_name(*p), env_vars) + let env_vars = provider_env_var_label(p, catalog); + format!("{} ({})", provider_display_name(p, catalog), env_vars) }) .collect(); - let remaining_providers: Vec = Provider::ALL + let remaining_providers: Vec = install_providers .iter() .filter(|p| !configured_providers.contains(p)) - .copied() + .cloned() .collect(); let selected_indices: Vec = spawn_blocking({ @@ -448,7 +492,7 @@ impl InstallInputSource for InteractiveInstallInputSource { .await??; for idx in selected_indices { - let provider = remaining_providers[idx]; + let provider = remaining_providers[idx].clone(); credentials.push(authenticate_provider(provider, s, printer).await?); } } @@ -642,6 +686,7 @@ impl InstallInputSource for NonInteractiveInstallInputSource { let provider = self .args .llm_provider + .clone() .context("non-interactive install requires --llm-provider")?; let credential = authenticate_provider_with_api_key_source(provider, self.api_key_source()?, s, printer) @@ -2506,7 +2551,7 @@ client_id = "client-id" description: None, }, credential_secret_request(&AuthCredential { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), details: fabro_auth::AuthDetails::ApiKey { key: "anthropic-key".to_string(), }, @@ -3026,6 +3071,22 @@ root = "{}" assert!(value["created_at"].as_str().is_some()); } + #[test] + fn install_llm_providers_come_from_catalog_api_key_providers() { + let ids = install_llm_provider_ids(Catalog::builtin()); + + assert!(ids.contains(&ProviderId::anthropic())); + assert!(ids.contains(&ProviderId::openai())); + assert!(ids.contains(&ProviderId::gemini())); + assert!(ids.contains(&ProviderId::new("kimi"))); + assert!(ids.contains(&ProviderId::new("zai"))); + assert!(ids.contains(&ProviderId::new("minimax"))); + assert!(ids.contains(&ProviderId::new("inception"))); + assert!(ids.contains(&ProviderId::new("venice"))); + assert!(!ids.contains(&ProviderId::new("ollama"))); + assert!(!ids.contains(&ProviderId::new("litellm"))); + } + #[test] fn non_interactive_source_rejects_missing_scripted_inputs() { let args = install_args(true, InstallNonInteractiveArgs::default()); @@ -3039,7 +3100,7 @@ root = "{}" #[test] fn non_interactive_source_rejects_hidden_args_without_switch() { let args = install_args(false, InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), ..InstallNonInteractiveArgs::default() }); let err = NonInteractiveInstallInputSource::new(&args).unwrap_err(); @@ -3052,7 +3113,7 @@ root = "{}" #[test] fn non_interactive_source_rejects_conflicting_api_key_inputs() { let args = install_args(true, InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), llm_api_key_stdin: true, llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), @@ -3148,7 +3209,7 @@ root = "{}" fn non_interactive_source_rejects_missing_github_strategy() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_username: Some("brynary".to_string()), ..InstallNonInteractiveArgs::default() @@ -3166,7 +3227,7 @@ root = "{}" fn non_interactive_source_rejects_missing_github_username_for_new_config() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), ..InstallNonInteractiveArgs::default() @@ -3183,7 +3244,7 @@ root = "{}" fn non_interactive_source_allows_keep_existing_settings_without_username() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), keep_existing_settings: true, @@ -3198,7 +3259,7 @@ root = "{}" fn non_interactive_source_rejects_missing_github_owner_for_app() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::App), ..InstallNonInteractiveArgs::default() @@ -3217,7 +3278,7 @@ root = "{}" fn non_interactive_source_rejects_github_owner_for_token() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), github_owner: Some("personal".to_string()), @@ -3237,7 +3298,7 @@ root = "{}" fn non_interactive_source_rejects_github_username_for_app() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::App), github_owner: Some("personal".to_string()), @@ -3257,7 +3318,7 @@ root = "{}" fn non_interactive_source_allows_github_app_setup() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::App), github_owner: Some("personal".to_string()), @@ -3272,7 +3333,7 @@ root = "{}" async fn non_interactive_source_requires_config_choice_when_settings_exist() { let source = NonInteractiveInstallInputSource { args: InstallNonInteractiveArgs { - llm_provider: Some(Provider::Anthropic), + llm_provider: Some(ProviderId::anthropic()), llm_api_key_env: Some("ANTHROPIC_API_KEY".to_string()), github_strategy: Some(InstallGitHubStrategyArg::Token), github_username: Some("brynary".to_string()), diff --git a/lib/crates/fabro-cli/src/commands/model.rs b/lib/crates/fabro-cli/src/commands/model.rs index e7f284205..659e4af3d 100644 --- a/lib/crates/fabro-cli/src/commands/model.rs +++ b/lib/crates/fabro-cli/src/commands/model.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result, bail}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_api::types as api_types; -use fabro_model::{Model, ModelTestMode, Provider, ProviderId}; +use fabro_model::{Model, ModelTestMode, ProviderId}; use fabro_util::terminal::Styles; use futures::{StreamExt, stream}; use serde::Serialize; @@ -311,7 +311,7 @@ async fn test_models_via_server( for info in &unconfigured { skipped += 1; - let provider_name = Provider::display_name_for_id(&info.provider); + let provider_name = info.provider.display_name(); if !skipped_providers.contains(&provider_name) { skipped_providers.push(provider_name); } @@ -476,35 +476,34 @@ mod tests { server_client::Client::new_no_proxy(api_url).unwrap() } - fn test_model_json(id: &str, provider: Provider) -> serde_json::Value { + fn test_model_json(id: &str, provider: ProviderId) -> serde_json::Value { serde_json::to_value(Model { - id: id.to_string(), - provider: provider.id(), - family: "test".to_string(), - display_name: format!("{id} display"), - limits: ModelLimits { + id: id.to_string(), + provider, + family: "test".to_string(), + display_name: format!("{id} display"), + limits: ModelLimits { context_window: 128_000, max_output: Some(4096), }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { + training: None, + knowledge_cutoff: None, + features: ModelFeatures { tools: true, vision: false, reasoning: false, reasoning_effort: ReasoningEffortFeature::None, prompt_cache: false, - effort: false, }, - costs: ModelCosts { + costs: ModelCosts { input_cost_per_mtok: Some(1.0), output_cost_per_mtok: Some(2.0), cache_input_cost_per_mtok: None, }, estimated_output_tps: Some(100.0), - aliases: vec!["tm".to_string()], - default: false, - configured: false, + aliases: vec!["tm".to_string()], + default: false, + configured: false, }) .unwrap() } @@ -527,7 +526,6 @@ mod tests { reasoning: false, reasoning_effort: ReasoningEffortFeature::None, prompt_cache: false, - effort: false, }, costs: ModelCosts { input_cost_per_mtok: Some(1.0), @@ -765,7 +763,7 @@ mod tests { .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [test_model_json("test-model", Provider::Anthropic)], + "data": [test_model_json("test-model", ProviderId::anthropic())], "meta": { "has_more": false } }) .to_string(), @@ -779,7 +777,7 @@ mod tests { mock.assert_async().await; assert_eq!(models.len(), 1); assert_eq!(models[0].id, "test-model"); - assert_eq!(models[0].provider, Provider::Anthropic.id()); + assert_eq!(models[0].provider, ProviderId::anthropic()); } #[tokio::test] @@ -796,7 +794,7 @@ mod tests { .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [test_model_json("model-a", Provider::Anthropic)], + "data": [test_model_json("model-a", ProviderId::anthropic())], "meta": { "has_more": false } }) .to_string(), @@ -825,7 +823,7 @@ mod tests { .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [test_model_json("claude-sonnet-4-5", Provider::Anthropic)], + "data": [test_model_json("claude-sonnet-4-5", ProviderId::anthropic())], "meta": { "has_more": false } }) .to_string(), @@ -854,7 +852,7 @@ mod tests { .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [test_model_json("model-a", Provider::Anthropic)], + "data": [test_model_json("model-a", ProviderId::anthropic())], "meta": { "has_more": true } }) .to_string(), @@ -871,7 +869,7 @@ mod tests { .header("Content-Type", "application/json") .body( serde_json::json!({ - "data": [test_model_json("model-b", Provider::OpenAi)], + "data": [test_model_json("model-b", ProviderId::openai())], "meta": { "has_more": false } }) .to_string(), diff --git a/lib/crates/fabro-cli/src/commands/provider/login.rs b/lib/crates/fabro-cli/src/commands/provider/login.rs index 352ddda05..06dd8ec2b 100644 --- a/lib/crates/fabro-cli/src/commands/provider/login.rs +++ b/lib/crates/fabro-cli/src/commands/provider/login.rs @@ -26,7 +26,13 @@ pub(super) async fn login_command( ) .await? } else { - provider_auth::authenticate_provider(args.provider, &s, printer).await? + provider_auth::authenticate_provider_with_catalog( + args.provider, + &s, + printer, + ctx.catalog()?, + ) + .await? }; let credential_id = credential_id_for(&credential).map_err(anyhow::Error::msg)?; let value = serde_json::to_string(&credential)?; diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 5f51d6e8c..4fea33577 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -471,7 +471,7 @@ mod tests { use chrono::{DateTime, Utc}; use fabro_agent::{AgentEvent, SandboxEvent}; use fabro_llm::types::TokenCounts; - use fabro_model::{Catalog, ModelRef, Provider}; + use fabro_model::{Catalog, ModelRef, ProviderId}; use fabro_types::run_event::CliEnsureCompletedProps; use fabro_types::{ MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProvider, @@ -571,7 +571,7 @@ mod tests { agent_event(stage, AgentEvent::AssistantMessage { text: "done".into(), model: ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: model.into(), speed: None, }, @@ -589,19 +589,22 @@ mod tests { status: "succeeded".into(), preferred_label: None, suggested_next_ids: Vec::new(), - billing: Some(billed_model_usage_from_llm( - Catalog::builtin(), - &ModelRef { - provider: Provider::OpenAi.id(), - model_id: "gpt-5-mini".into(), - speed: None, - }, - &TokenCounts { - input_tokens: 1200, - output_tokens: 300, - ..TokenCounts::default() - }, - )), + billing: Some( + billed_model_usage_from_llm( + Catalog::builtin(), + &ModelRef { + provider: ProviderId::openai(), + model_id: "gpt-5-mini".into(), + speed: None, + }, + &TokenCounts { + input_tokens: 1200, + output_tokens: 300, + ..TokenCounts::default() + }, + ) + .unwrap(), + ), failure: None, notes: None, files_touched: Vec::new(), diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index 3f4af9cee..c39cabedf 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -653,7 +653,7 @@ mod tests { use fabro_auth::{AuthCredential, AuthDetails}; use fabro_config::Storage; use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question}; - use fabro_model::Provider; + use fabro_model::ProviderId; use fabro_types::run_event::{ InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps, RunFailedProps, RunStatusTransitionProps, @@ -994,7 +994,7 @@ mod tests { .set( "anthropic", &serde_json::to_string(&AuthCredential { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), details: AuthDetails::ApiKey { key: "vault-key".to_string(), }, diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index 43f39ad80..bb3686da6 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -619,7 +619,7 @@ destination = "{destination}" Commands::Provider(ProviderNamespace { command: ProviderCommand::Login(args), }) => { - assert_eq!(args.provider, fabro_model::Provider::OpenAi); + assert_eq!(args.provider, fabro_model::ProviderId::openai()); } _ => panic!("unexpected command variant"), } @@ -633,7 +633,7 @@ destination = "{destination}" Commands::Provider(ProviderNamespace { command: ProviderCommand::Login(args), }) => { - assert_eq!(args.provider, fabro_model::Provider::Anthropic); + assert_eq!(args.provider, fabro_model::ProviderId::anthropic()); } _ => panic!("unexpected command variant"), } @@ -654,7 +654,7 @@ destination = "{destination}" Commands::Provider(ProviderNamespace { command: ProviderCommand::Login(args), }) => { - assert_eq!(args.provider, fabro_model::Provider::Anthropic); + assert_eq!(args.provider, fabro_model::ProviderId::anthropic()); assert!(args.api_key_stdin); } _ => panic!("unexpected command variant"), @@ -1156,9 +1156,17 @@ destination = "{destination}" } #[test] - fn parse_provider_login_bogus_provider() { - let result = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "bogus"]); - assert!(result.is_err(), "should fail with unknown provider"); + fn parse_provider_login_accepts_open_ended_provider_id() { + let cli = Cli::try_parse_from(["fabro", "provider", "login", "--provider", "bogus"]) + .expect("provider IDs are resolved against the catalog at runtime"); + match *cli.command.unwrap() { + Commands::Provider(ProviderNamespace { + command: ProviderCommand::Login(args), + }) => { + assert_eq!(args.provider, fabro_model::ProviderId::new("bogus")); + } + _ => panic!("expected provider login command"), + } } #[test] diff --git a/lib/crates/fabro-cli/src/shared/provider_auth.rs b/lib/crates/fabro-cli/src/shared/provider_auth.rs index 444f1d402..a98261ab8 100644 --- a/lib/crates/fabro-cli/src/shared/provider_auth.rs +++ b/lib/crates/fabro-cli/src/shared/provider_auth.rs @@ -20,36 +20,13 @@ use fabro_auth::{ }; use fabro_llm::client::Client as LlmClient; use fabro_llm::generate::{GenerateParams, generate}; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, Provider}; +use fabro_model::catalog::CatalogProvider; +use fabro_model::{Catalog, CredentialRef, ProviderId}; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use tokio::task::spawn_blocking; use tokio::time::timeout; -// --------------------------------------------------------------------------- -// Provider key URLs -// --------------------------------------------------------------------------- - -pub(crate) fn provider_key_url(provider: Provider) -> &'static str { - match provider { - Provider::Anthropic => "https://console.anthropic.com/settings/keys", - Provider::OpenAi => "https://platform.openai.com/api-keys", - Provider::Gemini => "https://aistudio.google.com/apikey", - Provider::Kimi => "https://platform.moonshot.cn/console/api-keys", - Provider::Zai => "https://open.bigmodel.cn/usercenter/apikeys", - Provider::Minimax => { - "https://platform.minimaxi.com/user-center/basic-information/interface-key" - } - Provider::Inception => "https://console.inceptionlabs.ai/api-keys", - Provider::OpenAiCompatible => "", - } -} - -pub(crate) fn provider_display_name(provider: Provider) -> &'static str { - provider.display_name() -} - // --------------------------------------------------------------------------- // Interactive prompts // --------------------------------------------------------------------------- @@ -80,22 +57,50 @@ pub(crate) enum ApiKeySource { fn default_catalog_for_provider_auth() -> Result> { Ok(Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .context("failed to build provider auth catalog")?, + Catalog::from_builtin().context("failed to build provider auth catalog")?, )) } +pub(crate) fn provider_display_name(provider: &ProviderId, catalog: &Catalog) -> String { + catalog.provider(provider).map_or_else( + || provider.display_name(), + |provider| provider.display_name.clone(), + ) +} + +fn api_key_catalog_provider<'a>( + provider: &ProviderId, + catalog: &'a Catalog, +) -> Result<&'a CatalogProvider> { + let catalog_provider = catalog + .provider(provider) + .with_context(|| format!("provider '{provider}' is not configured in the model catalog"))?; + let supports_api_key = catalog_provider.credentials.iter().any(|credential| { + matches!( + credential, + CredentialRef::Credential(_) | CredentialRef::Env(_) + ) + }); + anyhow::ensure!( + supports_api_key, + "provider '{}' does not define an API-key credential path", + catalog_provider.id + ); + Ok(catalog_provider) +} + pub(crate) async fn validate_api_key( - provider: Provider, + provider: &ProviderId, api_key: &str, catalog: Arc, ) -> Result<()> { + api_key_catalog_provider(provider, catalog.as_ref())?; let client = LlmClient::from_credentials( vec![ApiCredential::from_api_key( - provider, + provider.clone(), api_key.to_string(), catalog.as_ref(), - )], + )?], Arc::clone(&catalog), ) .await @@ -106,7 +111,7 @@ pub(crate) async fn validate_api_key( .map_or_else(|| format!("unknown-{provider}"), |model| model.id.clone()); let params = GenerateParams::new(probe_model, Arc::new(client)) - .provider(<&'static str>::from(provider)) + .provider(provider.to_string()) .prompt("Say OK") .max_tokens(16); @@ -156,7 +161,7 @@ async fn read_api_key_from_source(source: &ApiKeySource, prompt: &str) -> Result } async fn read_and_validate_api_key( - provider: Provider, + provider: &ProviderId, source: &ApiKeySource, env_var: &str, s: &Styles, @@ -189,8 +194,8 @@ async fn read_and_validate_api_key( } } -pub(crate) async fn pick_auth_method(provider: Provider) -> Result { - if provider != Provider::OpenAi { +pub(crate) async fn pick_auth_method(provider: &ProviderId) -> Result { + if provider != &ProviderId::openai() { return Ok(AuthMethod::ApiKey); } @@ -205,16 +210,27 @@ pub(crate) async fn pick_auth_method(provider: Provider) -> Result { } pub(crate) async fn authenticate_provider( - provider: Provider, + provider: ProviderId, s: &Styles, printer: Printer, ) -> Result { - let method = pick_auth_method(provider).await?; - authenticate_provider_with_method(provider, method, s, printer).await + authenticate_provider_with_catalog(provider, s, printer, default_catalog_for_provider_auth()?) + .await +} + +pub(crate) async fn authenticate_provider_with_catalog( + provider: ProviderId, + s: &Styles, + printer: Printer, + catalog: Arc, +) -> Result { + api_key_catalog_provider(&provider, catalog.as_ref())?; + let method = pick_auth_method(&provider).await?; + authenticate_provider_with_method_and_catalog(provider, method, s, printer, catalog).await } pub(crate) async fn authenticate_provider_with_api_key_source( - provider: Provider, + provider: ProviderId, source: ApiKeySource, s: &Styles, printer: Printer, @@ -230,13 +246,14 @@ pub(crate) async fn authenticate_provider_with_api_key_source( } pub(crate) async fn authenticate_provider_with_api_key_source_and_catalog( - provider: Provider, + provider: ProviderId, source: ApiKeySource, s: &Styles, printer: Printer, catalog: Arc, ) -> Result { - let mut strategy = strategy_for(provider, AuthMethod::ApiKey); + api_key_catalog_provider(&provider, catalog.as_ref())?; + let mut strategy = strategy_for(&provider, AuthMethod::ApiKey, catalog.as_ref()); let request = strategy.init().await?; present_to_user(&request, s, printer); let response = await_user_response_from_source(&request, &source, s, printer, catalog).await?; @@ -244,38 +261,55 @@ pub(crate) async fn authenticate_provider_with_api_key_source_and_catalog( } pub(crate) async fn authenticate_provider_with_method( - provider: Provider, + provider: ProviderId, method: AuthMethod, s: &Styles, printer: Printer, ) -> Result { - let mut strategy = strategy_for(provider, method); - let request = strategy.init().await?; - present_to_user(&request, s, printer); - let response = await_user_response_from_source( - &request, - &ApiKeySource::Prompt, + authenticate_provider_with_method_and_catalog( + provider, + method, s, printer, default_catalog_for_provider_auth()?, ) - .await?; + .await +} + +pub(crate) async fn authenticate_provider_with_method_and_catalog( + provider: ProviderId, + method: AuthMethod, + s: &Styles, + printer: Printer, + catalog: Arc, +) -> Result { + api_key_catalog_provider(&provider, catalog.as_ref())?; + let mut strategy = strategy_for(&provider, method, catalog.as_ref()); + let request = strategy.init().await?; + present_to_user(&request, s, printer); + let response = + await_user_response_from_source(&request, &ApiKeySource::Prompt, s, printer, catalog) + .await?; strategy.complete(response).await } pub(crate) fn present_to_user(request: &AuthContextRequest, s: &Styles, printer: Printer) { match request { AuthContextRequest::ApiKey { - provider, + display_name, env_var_names, + api_key_url, + .. } => { let env_var = env_var_names.first().map_or("API_KEY", String::as_str); - let url = provider_key_url(*provider); - fabro_util::printerr!( - printer, - " {}", - s.dim.apply_to(format!("Get your API key at: {url}")) - ); + if let Some(url) = api_key_url.as_deref() { + fabro_util::printerr!( + printer, + " {}", + s.dim + .apply_to(format!("Get your {display_name} API key at: {url}")) + ); + } fabro_util::printerr!( printer, " {}", @@ -313,12 +347,13 @@ async fn await_user_response_from_source( ) -> Result { match request { AuthContextRequest::ApiKey { - provider, + provider_id, env_var_names, + .. } => { let env_var = env_var_names.first().map_or("API_KEY", String::as_str); - let key = - read_and_validate_api_key(*provider, source, env_var, s, printer, catalog).await?; + let key = read_and_validate_api_key(provider_id, source, env_var, s, printer, catalog) + .await?; Ok(AuthContextResponse::ApiKey { key }) } AuthContextRequest::DeviceCode { .. } => { @@ -346,15 +381,37 @@ async fn await_user_response_from_source( mod tests { use super::*; - // -- Provider key URLs -- + #[test] + fn builtin_api_key_providers_have_key_urls() { + let catalog = Catalog::builtin(); + for provider in [ + ProviderId::anthropic(), + ProviderId::openai(), + ProviderId::gemini(), + ProviderId::new("kimi"), + ProviderId::new("zai"), + ProviderId::new("minimax"), + ProviderId::new("inception"), + ] { + let provider = api_key_catalog_provider(&provider, catalog).unwrap(); + let url = provider.api_key_url.as_deref().unwrap_or_default(); + assert!(!url.is_empty(), "{} has empty URL", provider.id); + assert!(url.starts_with("https://"), "{} URL: {url}", provider.id); + } + } #[test] - fn every_provider_has_key_url() { - for provider in Provider::ALL { - let url = provider_key_url(*provider); - assert!(!url.is_empty(), "{provider:?} has empty URL"); - assert!(url.starts_with("https://"), "{provider:?} URL: {url}"); - } + fn api_key_catalog_provider_rejects_unconfigured_provider() { + let catalog = Catalog::builtin(); + let provider = ProviderId::new("bogus"); + + let err = api_key_catalog_provider(&provider, catalog).unwrap_err(); + + assert!( + err.to_string() + .contains("provider 'bogus' is not configured in the model catalog"), + "unexpected error: {err}" + ); } // -- API key validation -- @@ -362,7 +419,7 @@ mod tests { #[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))] async fn validate_api_key_rejects_invalid_key() { let result = validate_api_key( - Provider::Anthropic, + &ProviderId::anthropic(), "sk-invalid-key-12345", default_catalog_for_provider_auth().unwrap(), ) diff --git a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs index 800e5663d..8a9b19d61 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/doctor.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/doctor.rs @@ -7,7 +7,7 @@ use std::process::Output; use fabro_auth::{AuthCredential, AuthDetails}; use fabro_config::Storage; -use fabro_model::Provider; +use fabro_model::ProviderId; use fabro_test::{fabro_snapshot, test_context, twin_openai}; use fabro_vault::{SecretType, Vault}; @@ -31,7 +31,7 @@ fn seed_openai_vault(storage_dir: &std::path::Path, base_url: &str, api_key: &st .set( "openai", &serde_json::to_string(&AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::ApiKey { key: api_key.to_string(), }, diff --git a/lib/crates/fabro-cli/tests/it/cmd/model.rs b/lib/crates/fabro-cli/tests/it/cmd/model.rs index 94d418d41..a94e8b243 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model.rs @@ -182,8 +182,7 @@ fn list_uses_configured_server_target_without_server_flag() { "features": { "tools": true, "vision": false, - "reasoning": false, - "effort": false + "reasoning": false }, "costs": { "input_cost_per_mtok": 1.0, @@ -237,8 +236,7 @@ fn list_uses_fabro_config_for_machine_settings() { "features": { "tools": true, "vision": false, - "reasoning": false, - "effort": false + "reasoning": false }, "costs": { "input_cost_per_mtok": 1.0, diff --git a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs b/lib/crates/fabro-cli/tests/it/cmd/model_test.rs index fc9dcbf6b..f7d731d2d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model_test.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model_test.rs @@ -40,8 +40,7 @@ fn model_json(id: &str, provider: &str, configured: bool) -> serde_json::Value { "features": { "tools": true, "vision": false, - "reasoning": false, - "effort": false + "reasoning": false }, "costs": { "input_cost_per_mtok": 1.0, @@ -267,7 +266,7 @@ fn model_test_skipped_footer_sources_from_listing() { String::from_utf8_lossy(&output.stderr) ); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Skipped 1 model(s) (no credentials: OpenAI)")); + assert!(stderr.contains("Skipped 1 model(s) (no credentials: openai)")); } #[test] diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index a3a64562f..8fead8139 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -5,7 +5,7 @@ use fabro_auth::{AuthCredential, AuthDetails}; use fabro_config::Storage; -use fabro_model::Provider; +use fabro_model::ProviderId; use fabro_test::{fabro_json_snapshot, fabro_snapshot, test_context}; use fabro_vault::{SecretType, Vault}; use httpmock::MockServer; @@ -91,7 +91,7 @@ fn seed_anthropic_vault(storage_dir: &std::path::Path, base_url: &str) { .set( "anthropic", &serde_json::to_string(&AuthCredential { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), details: AuthDetails::ApiKey { key: "vault-anthropic-key".to_string(), }, diff --git a/lib/crates/fabro-cli/tests/it/workflow/acp.rs b/lib/crates/fabro-cli/tests/it/workflow/acp.rs index 3c3dbef97..3a20fc63a 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/acp.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/acp.rs @@ -6,7 +6,7 @@ use fabro_acp::test_support::fake_acp_agent_script; use fabro_auth::{AuthCredential, AuthDetails}; use fabro_config::Storage; -use fabro_model::Provider; +use fabro_model::ProviderId; use fabro_test::test_context; use fabro_types::EventBody; use fabro_vault::{SecretType, Vault}; @@ -164,7 +164,7 @@ fn seed_openai_vault(storage_dir: &std::path::Path) { .set( "openai", &serde_json::to_string(&AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::ApiKey { key: "test-openai-key".to_string(), }, diff --git a/lib/crates/fabro-cli/tests/it/workflow/hooks.rs b/lib/crates/fabro-cli/tests/it/workflow/hooks.rs index 491a707c2..cc68abaf4 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/hooks.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/hooks.rs @@ -13,7 +13,7 @@ use std::process::Output; use fabro_auth::{AuthCredential, AuthDetails}; use fabro_config::Storage; -use fabro_model::Provider; +use fabro_model::ProviderId; use fabro_test::{ TestMode, TwinOpenAi, TwinScenario, TwinScenarios, TwinToolCall, test_context, twin_openai, }; @@ -100,7 +100,7 @@ fn seed_openai_vault(storage_dir: &std::path::Path, base_url: &str, api_key: &st .set( "openai", &serde_json::to_string(&AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::ApiKey { key: api_key.to_string(), }, diff --git a/lib/crates/fabro-cli/tests/it/workflow/real_cli.rs b/lib/crates/fabro-cli/tests/it/workflow/real_cli.rs index ee074c704..ad0b51fcb 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/real_cli.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/real_cli.rs @@ -1,19 +1,19 @@ use std::sync::Arc; use fabro_graphviz::graph::{AttrValue, Node}; -use fabro_llm::provider::Provider; +use fabro_model::ProviderId; use fabro_workflow::context::Context; use fabro_workflow::event::Emitter; use fabro_workflow::handler::agent::{CodergenBackend, CodergenResult, CodergenRunRequest}; use fabro_workflow::handler::llm::cli::AgentCliBackend; /// Run a real CLI tool via LocalSandbox and verify the full flow. -async fn run_real_cli_test(provider: Provider, model: &str) { +async fn run_real_cli_test(provider: ProviderId, model: &str) { let workspace = tempfile::tempdir().expect("real CLI test workspace should create"); let env: Arc = Arc::new(fabro_agent::LocalSandbox::new( workspace.path().to_path_buf(), )); - let backend = AgentCliBackend::new_from_env(model.to_string(), provider); + let backend = AgentCliBackend::new_from_env(model.to_string(), provider.clone()); let mut node = Node::new("real_cli_test"); node.attrs.insert( @@ -57,15 +57,15 @@ async fn run_real_cli_test(provider: Provider, model: &str) { #[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))] async fn real_cli_claude() { - run_real_cli_test(Provider::Anthropic, "haiku").await; + run_real_cli_test(ProviderId::anthropic(), "haiku").await; } #[fabro_macros::e2e_test(live("OPENAI_API_KEY"))] async fn real_cli_codex() { - run_real_cli_test(Provider::OpenAi, "").await; + run_real_cli_test(ProviderId::openai(), "").await; } #[fabro_macros::e2e_test(live("GEMINI_API_KEY"))] async fn real_cli_gemini() { - run_real_cli_test(Provider::Gemini, "gemini-2.5-flash").await; + run_real_cli_test(ProviderId::gemini(), "gemini-2.5-flash").await; } diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index e1d779c20..3b740dabb 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -307,6 +307,7 @@ fn provider_settings_to_catalog( model_catalog::ProviderCatalogSettings { display_name: settings.display_name, adapter: settings.adapter, + api_key_url: settings.api_key_url, base_url: settings.base_url, credentials: settings.credentials, extra_headers: settings.extra_headers, @@ -365,7 +366,6 @@ fn model_features_to_catalog(features: &LlmModelFeatures) -> model_catalog::Sett reasoning: features.reasoning, reasoning_effort: features.reasoning_effort, prompt_cache: features.prompt_cache, - effort: features.effort, } } @@ -705,7 +705,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, None, None, diff --git a/lib/crates/fabro-config/src/layers/llm.rs b/lib/crates/fabro-config/src/layers/llm.rs index 50535276b..984fa829d 100644 --- a/lib/crates/fabro-config/src/layers/llm.rs +++ b/lib/crates/fabro-config/src/layers/llm.rs @@ -58,6 +58,8 @@ pub struct ProviderSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub adapter: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_key_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub base_url: Option, /// Ordered list of credential references — first successful wins. Each /// entry must be a typed `CredentialRef` (`credential:` or @@ -145,8 +147,6 @@ pub struct ModelFeatures { pub reasoning_effort: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt_cache: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effort: Option, } /// User-facing allow-list for native control values Fabro accepts on this @@ -519,7 +519,6 @@ max_output = 32768 tools = true vision = false reasoning = true -effort = false [models."kimi-k2.5".costs] input_cost_per_mtok = 0.60 @@ -547,7 +546,6 @@ cache_input_cost_per_mtok = 0.15 assert_eq!(features.tools, Some(true)); assert_eq!(features.vision, Some(false)); assert_eq!(features.reasoning, Some(true)); - assert_eq!(features.effort, Some(false)); let costs = m.costs.as_ref().unwrap(); assert_eq!(costs.base.input_cost_per_mtok, Some(0.60)); diff --git a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs index 847ae266b..3a6d8a3c6 100644 --- a/lib/crates/fabro-dev/src/commands/docs_options_reference.rs +++ b/lib/crates/fabro-dev/src/commands/docs_options_reference.rs @@ -271,7 +271,6 @@ vision = false reasoning = true reasoning_effort = "levels" prompt_cache = true -effort = true [llm.models."team-code-large".controls] reasoning_effort = ["low", "medium", "high"] @@ -317,7 +316,6 @@ cache_input_cost_per_mtok = 0.60 | `reasoning` | boolean | `false` | Whether the model has reasoning behavior. | | `reasoning_effort` | `"levels"` \| `"none"` | `"none"` | How Fabro may expose reasoning effort for this model. | | `prompt_cache` | boolean | `false` | Whether prompt cache pricing/usage applies. | -| `effort` | boolean | `false` | Whether the provider exposes a native effort parameter. | ## `[llm.models..controls]` diff --git a/lib/crates/fabro-hooks/src/executor.rs b/lib/crates/fabro-hooks/src/executor.rs index 5e2a39899..52a3a1383 100644 --- a/lib/crates/fabro-hooks/src/executor.rs +++ b/lib/crates/fabro-hooks/src/executor.rs @@ -726,7 +726,6 @@ impl HookExecutor for HookExecutorImpl { #[cfg(test)] mod tests { use fabro_auth::{CredentialSource, EnvCredentialSource}; - use fabro_model::catalog::LlmCatalogSettings; use fabro_types::fixtures; use fabro_util::env::TestEnv; @@ -749,7 +748,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()) + Arc::new(Catalog::from_builtin().unwrap()) } fn test_http_client() -> fabro_http::HttpClient { diff --git a/lib/crates/fabro-hooks/src/runner.rs b/lib/crates/fabro-hooks/src/runner.rs index 5c001fda9..f6e1fe368 100644 --- a/lib/crates/fabro-hooks/src/runner.rs +++ b/lib/crates/fabro-hooks/src/runner.rs @@ -7,8 +7,6 @@ use fabro_auth::CredentialSource; #[cfg(test)] use fabro_auth::EnvCredentialSource; use fabro_model::Catalog; -#[cfg(test)] -use fabro_model::catalog::LlmCatalogSettings; use crate::config::{HookDefinition, HookSettings}; use crate::executor::{HookExecutor, HookExecutorImpl}; @@ -50,10 +48,7 @@ impl HookRunner { config, executor, llm_source: Arc::new(EnvCredentialSource::new()), - catalog: Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ), + catalog: Arc::new(Catalog::from_builtin().expect("default catalog should build")), compiled_matchers, } } @@ -289,10 +284,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ) + Arc::new(Catalog::from_builtin().expect("default catalog should build")) } fn make_hook(event: HookEvent, name: &str) -> HookDefinition { diff --git a/lib/crates/fabro-llm/src/adapter_registry.rs b/lib/crates/fabro-llm/src/adapter_registry.rs index 37eb5f6f0..3184e3d4e 100644 --- a/lib/crates/fabro-llm/src/adapter_registry.rs +++ b/lib/crates/fabro-llm/src/adapter_registry.rs @@ -1,9 +1,8 @@ -//! Adapter factory registry keyed by stable adapter strings. +//! Adapter factory registry keyed by [`fabro_model::AdapterKind`]. //! -//! Mirrors the static [`fabro_model::adapter`] metadata: every metadata key +//! Mirrors the static [`fabro_model::adapter`] metadata: every adapter kind //! ships with a matching factory in this module. Tests in this file enforce -//! that the registry covers every metadata key and never adds keys that have -//! no metadata. +//! that the registry covers every adapter kind. //! //! Factories take a pre-built [`AdapterConfig`] derived from resolved //! credentials + provider settings, and produce a boxed @@ -16,8 +15,7 @@ use std::collections::HashMap; use std::sync::Arc; use fabro_auth::ApiKeyHeader; -use fabro_model::Catalog; -use fabro_model::adapter::{self as model_adapter, AdapterMetadata}; +use fabro_model::{AdapterKind, Catalog}; use crate::client::auth_value; use crate::provider::ProviderAdapter; @@ -175,82 +173,28 @@ fn build_openai_compatible(config: AdapterConfig) -> Arc { Arc::new(build_openai_compatible_adapter(config)) } -/// Single source of truth pairing every adapter key with its factory. Both -/// `factory_for` and `registered_keys` derive from this table. -const FACTORIES: &[(&str, AdapterFactory)] = &[ - (model_adapter::ANTHROPIC.key, build_anthropic), - (model_adapter::OPENAI.key, build_openai), - (model_adapter::GEMINI.key, build_gemini), - ( - model_adapter::OPENAI_COMPATIBLE.key, - build_openai_compatible, - ), -]; - -/// Look up a factory by adapter key. Returns `None` if the key has no factory -/// registered. +/// Return the factory for a known adapter kind. #[must_use] -pub fn factory_for(adapter_key: &str) -> Option { - FACTORIES - .iter() - .find_map(|(key, factory)| (*key == adapter_key).then_some(*factory)) -} - -/// Iterate every adapter key with a factory registered. -pub fn registered_keys() -> impl Iterator { - FACTORIES.iter().map(|(key, _)| *key) -} - -/// Look up adapter metadata by key, ensuring the metadata + factory pair -/// remains in sync. -#[must_use] -pub fn metadata_for(adapter_key: &str) -> Option<&'static AdapterMetadata> { - model_adapter::get(adapter_key) +pub fn factory_for(adapter_kind: AdapterKind) -> AdapterFactory { + match adapter_kind { + AdapterKind::Anthropic => build_anthropic, + AdapterKind::OpenAi => build_openai, + AdapterKind::Gemini => build_gemini, + AdapterKind::OpenAiCompatible => build_openai_compatible, + } } #[cfg(test)] mod tests { use super::*; - #[test] - fn every_metadata_key_has_a_factory() { - for key in model_adapter::keys() { - assert!( - factory_for(key).is_some(), - "adapter metadata key `{key}` has no matching factory in fabro-llm", - ); - } - } - - #[test] - fn every_factory_has_metadata() { - for key in registered_keys() { - assert!( - metadata_for(key).is_some(), - "fabro-llm factory `{key}` has no matching metadata in fabro-model", - ); - } - } - - #[test] - fn registered_factory_set_matches_metadata_set() { - let metadata: std::collections::BTreeSet<&str> = model_adapter::keys().collect(); - let factories: std::collections::BTreeSet<&str> = registered_keys().collect(); - assert_eq!(metadata, factories); - } - - #[test] - fn unknown_key_returns_none_factory() { - assert!(factory_for("does_not_exist").is_none()); - } - #[test] fn anthropic_factory_builds_anthropic_adapter() { let config = AdapterConfig::new("anthropic", ApiKeyHeader::Custom { name: "x-api-key".to_string(), value: "test-key".to_string(), }); - let adapter = factory_for("anthropic").unwrap()(config); + let adapter = factory_for(AdapterKind::Anthropic)(config); assert_eq!(adapter.name(), "anthropic"); } @@ -266,7 +210,7 @@ mod tests { project_id: None, catalog: None, }; - let adapter = factory_for("openai_compatible").unwrap()(config); + let adapter = factory_for(AdapterKind::OpenAiCompatible)(config); assert_eq!(adapter.name(), "kimi"); } @@ -337,6 +281,6 @@ mod tests { #[should_panic(expected = "openai_compatible adapter requires a base_url")] fn openai_compatible_factory_panics_without_base_url() { let config = AdapterConfig::new("kimi", ApiKeyHeader::Bearer("k".to_string())); - let _ = factory_for("openai_compatible").unwrap()(config); + let _ = factory_for(AdapterKind::OpenAiCompatible)(config); } } diff --git a/lib/crates/fabro-llm/src/client.rs b/lib/crates/fabro-llm/src/client.rs index f5235b746..2c7f9657c 100644 --- a/lib/crates/fabro-llm/src/client.rs +++ b/lib/crates/fabro-llm/src/client.rs @@ -82,15 +82,7 @@ impl Client { source: None, }); }; - let Some(factory) = factory_for(&provider.adapter) else { - return Err(Error::Configuration { - message: format!( - "Provider \"{provider_id}\" uses unsupported adapter \"{}\"", - provider.adapter - ), - source: None, - }); - }; + let factory = factory_for(provider.adapter); let adapter = factory(AdapterConfig { provider_id: provider.id.to_string(), @@ -352,6 +344,7 @@ pub(crate) fn auth_value(auth_header: &ApiKeyHeader) -> String { mod tests { use async_trait::async_trait; use fabro_auth::{CredentialSource, ResolvedCredentials}; + use fabro_model::ProviderId; use fabro_model::catalog::LlmCatalogSettings; use futures::stream; @@ -525,8 +518,7 @@ mod tests { #[tokio::test] async fn complete_rejects_unsupported_reasoning_effort_before_dispatch() { - let catalog = - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let catalog = Arc::new(Catalog::from_builtin().unwrap()); let mut client = Client::new(HashMap::new(), None, vec![]); client.catalog = Some(Arc::clone(&catalog)); client @@ -552,8 +544,7 @@ mod tests { #[tokio::test] async fn complete_rejects_unsupported_speed_before_dispatch() { - let catalog = - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let catalog = Arc::new(Catalog::from_builtin().unwrap()); let mut client = Client::new(HashMap::new(), None, vec![]); client.catalog = Some(Arc::clone(&catalog)); client @@ -579,8 +570,7 @@ mod tests { #[tokio::test] async fn complete_accepts_standard_speed_without_catalog_declaration() { - let catalog = - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let catalog = Arc::new(Catalog::from_builtin().unwrap()); let mut client = Client::new(HashMap::new(), None, vec![]); client.catalog = Some(Arc::clone(&catalog)); client @@ -600,8 +590,7 @@ mod tests { #[tokio::test] async fn complete_skips_control_validation_for_unknown_model_passthrough() { - let catalog = - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let catalog = Arc::new(Catalog::from_builtin().unwrap()); let mut client = Client::new(HashMap::new(), None, vec![]); client.catalog = Some(Arc::clone(&catalog)); client @@ -622,8 +611,7 @@ mod tests { #[tokio::test] async fn stream_rejects_unsupported_speed_before_dispatch() { - let catalog = - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()); + let catalog = Arc::new(Catalog::from_builtin().unwrap()); let mut client = Client::new(HashMap::new(), None, vec![]); client.catalog = Some(Arc::clone(&catalog)); client @@ -655,7 +643,7 @@ mod tests { let client = Client::from_credentials( vec![ ApiCredential { - provider: fabro_model::Provider::Anthropic.id(), + provider: ProviderId::anthropic(), auth_header: Some(ApiKeyHeader::Custom { name: "x-api-key".to_string(), value: "anthropic-key".to_string(), @@ -667,7 +655,7 @@ mod tests { project_id: None, }, ApiCredential { - provider: fabro_model::Provider::OpenAi.id(), + provider: ProviderId::openai(), auth_header: Some(ApiKeyHeader::Bearer("openai-key".to_string())), extra_headers: HashMap::new(), base_url: None, @@ -688,11 +676,11 @@ mod tests { } #[tokio::test] - async fn from_credentials_supports_openai_compatible_provider_constants() { + async fn from_credentials_supports_builtin_openai_compatible_providers() { let catalog = catalog_with(""); let client = Client::from_credentials( vec![ApiCredential { - provider: fabro_model::Provider::Kimi.id(), + provider: ProviderId::new("kimi"), auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())), extra_headers: HashMap::new(), base_url: None, @@ -742,7 +730,7 @@ mod tests { async fn from_source_registers_provider_from_resolved_credentials() { let source = StubSource { credentials: vec![ApiCredential { - provider: fabro_model::Provider::Anthropic.id(), + provider: ProviderId::anthropic(), auth_header: Some(ApiKeyHeader::Custom { name: "x-api-key".to_string(), value: "anthropic-key".to_string(), @@ -785,7 +773,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ); @@ -833,7 +820,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ); @@ -884,7 +870,7 @@ context_window = 200000 tools = true vision = true reasoning = true -effort = true +reasoning_effort = "levels" "#, ); diff --git a/lib/crates/fabro-llm/src/lib.rs b/lib/crates/fabro-llm/src/lib.rs index 45b847011..5e756b989 100644 --- a/lib/crates/fabro-llm/src/lib.rs +++ b/lib/crates/fabro-llm/src/lib.rs @@ -11,4 +11,4 @@ pub mod tools; pub mod types; pub use error::{Error, ProviderErrorDetail, ProviderErrorKind, Result}; -pub use fabro_model::{ModelHandle, Provider}; +pub use fabro_model::{ModelHandle, ProviderId}; diff --git a/lib/crates/fabro-llm/src/model_test.rs b/lib/crates/fabro-llm/src/model_test.rs index 87255e0d7..4fee55a24 100644 --- a/lib/crates/fabro-llm/src/model_test.rs +++ b/lib/crates/fabro-llm/src/model_test.rs @@ -169,7 +169,7 @@ fn validate_deep_result(result: &GenerateResult) -> Result<(), String> { mod tests { use std::collections::HashMap; - use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, Provider, ReasoningEffortFeature}; + use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, ProviderId, ReasoningEffortFeature}; use super::*; use crate::types::{FinishReason, Message, Response, StepResult, TokenCounts, ToolResult}; @@ -177,7 +177,7 @@ mod tests { fn test_model_with(features: ModelFeatures) -> Model { Model { id: "test-model".to_string(), - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), family: "test".to_string(), display_name: "Test Model".to_string(), limits: ModelLimits { @@ -225,7 +225,6 @@ mod tests { reasoning: true, reasoning_effort: ReasoningEffortFeature::Levels, prompt_cache: false, - effort: true, }); let outcome = run_model_test(&info, ModelTestMode::Deep, empty_test_client()).await; diff --git a/lib/crates/fabro-llm/src/provider.rs b/lib/crates/fabro-llm/src/provider.rs index 57bd38df8..0a4b93b75 100644 --- a/lib/crates/fabro-llm/src/provider.rs +++ b/lib/crates/fabro-llm/src/provider.rs @@ -1,6 +1,6 @@ use std::pin::Pin; -pub use fabro_model::{ModelHandle, Provider}; +pub use fabro_model::{ModelHandle, ProviderId}; use futures::Stream; use crate::error::Error; diff --git a/lib/crates/fabro-llm/src/providers/openai_compatible.rs b/lib/crates/fabro-llm/src/providers/openai_compatible.rs index de9780def..17525319b 100644 --- a/lib/crates/fabro-llm/src/providers/openai_compatible.rs +++ b/lib/crates/fabro-llm/src/providers/openai_compatible.rs @@ -1378,7 +1378,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ) .unwrap(); diff --git a/lib/crates/fabro-model/src/adapter.rs b/lib/crates/fabro-model/src/adapter.rs index f6b94cbaa..1de17203f 100644 --- a/lib/crates/fabro-model/src/adapter.rs +++ b/lib/crates/fabro-model/src/adapter.rs @@ -1,20 +1,69 @@ //! Adapter metadata vocabulary shared by the model catalog and LLM factories. //! -//! Adapters are Rust-owned: each registered adapter key maps to a static +//! Adapters are Rust-owned: each [`AdapterKind`] maps to static //! [`AdapterMetadata`] describing how the adapter dispatches agent profiles, //! formats API key headers, and which native control values it supports. //! -//! Provider/model catalog rows reference adapters by key. Both the catalog -//! (in `fabro-model`) and the LLM factory registry (in `fabro-llm`) must agree -//! on the same set of adapter keys; the parity is enforced by tests. +//! Provider/model catalog rows parse adapter strings into [`AdapterKind`]. +//! Runtime code should carry the typed kind instead of re-matching on strings. -use strum::VariantArray; +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString, IntoStaticStr, VariantArray}; use crate::Speed; -use crate::ids::ProviderId; -use crate::provider::Provider; use crate::reasoning::ReasoningEffort; +/// Stable adapter identity for protocol/client behavior. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Display, + EnumString, + IntoStaticStr, + VariantArray, +)] +#[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum AdapterKind { + Anthropic, + #[serde(rename = "openai")] + #[strum(to_string = "openai")] + OpenAi, + Gemini, + #[serde(rename = "openai_compatible")] + #[strum(to_string = "openai_compatible")] + OpenAiCompatible, +} + +impl AdapterKind { + #[must_use] + pub fn as_str(self) -> &'static str { + self.into() + } + + #[must_use] + pub fn metadata(self) -> &'static AdapterMetadata { + match self { + Self::Anthropic => &ANTHROPIC, + Self::OpenAi => &OPENAI, + Self::Gemini => &GEMINI, + Self::OpenAiCompatible => &OPENAI_COMPATIBLE, + } + } +} + +impl AsRef for AdapterKind { + fn as_ref(&self) -> &str { + (*self).as_str() + } +} + /// Internal dispatch key that `fabro-agent` maps to a concrete agent profile. /// /// This is **not** a settings field. The agent profile is inferred from the @@ -57,18 +106,14 @@ pub struct AdapterControlCapabilities { /// Static metadata for a single adapter implementation. #[derive(Debug, Clone, Copy)] pub struct AdapterMetadata { - /// Stable adapter key referenced from `[llm.providers.] adapter = - /// "..."`. - pub key: &'static str, + /// Typed stable adapter identity. + pub kind: AdapterKind, /// Default agent profile dispatched for providers that use this adapter. - pub default_profile: AgentProfileKind, - /// Provider behavior to use when the catalog provider ID is not a built-in - /// provider enum variant. - pub fallback_provider: Provider, + pub default_profile: AgentProfileKind, /// How API keys for this adapter are converted into auth headers. - pub api_key_header: ApiKeyHeaderPolicy, + pub api_key_header: ApiKeyHeaderPolicy, /// Native control values the adapter can transmit. - pub controls: AdapterControlCapabilities, + pub controls: AdapterControlCapabilities, } /// Every reasoning-effort variant. Re-exposed as a const slice so static @@ -79,11 +124,10 @@ const FAST_SPEEDS: &[Speed] = &[Speed::Fast]; /// Anthropic — `anthropic` adapter. pub const ANTHROPIC: AdapterMetadata = AdapterMetadata { - key: "anthropic", - default_profile: AgentProfileKind::Anthropic, - fallback_provider: Provider::Anthropic, - api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-api-key" }, - controls: AdapterControlCapabilities { + kind: AdapterKind::Anthropic, + default_profile: AgentProfileKind::Anthropic, + api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-api-key" }, + controls: AdapterControlCapabilities { native_reasoning_effort: FULL_REASONING_EFFORTS, additional_speeds: FAST_SPEEDS, }, @@ -91,11 +135,10 @@ pub const ANTHROPIC: AdapterMetadata = AdapterMetadata { /// OpenAI — `openai` adapter. pub const OPENAI: AdapterMetadata = AdapterMetadata { - key: "openai", - default_profile: AgentProfileKind::OpenAi, - fallback_provider: Provider::OpenAi, - api_key_header: ApiKeyHeaderPolicy::Bearer, - controls: AdapterControlCapabilities { + kind: AdapterKind::OpenAi, + default_profile: AgentProfileKind::OpenAi, + api_key_header: ApiKeyHeaderPolicy::Bearer, + controls: AdapterControlCapabilities { native_reasoning_effort: FULL_REASONING_EFFORTS, additional_speeds: &[], }, @@ -103,13 +146,12 @@ pub const OPENAI: AdapterMetadata = AdapterMetadata { /// Google Gemini — `gemini` adapter. pub const GEMINI: AdapterMetadata = AdapterMetadata { - key: "gemini", - default_profile: AgentProfileKind::Gemini, - fallback_provider: Provider::Gemini, - api_key_header: ApiKeyHeaderPolicy::Custom { + kind: AdapterKind::Gemini, + default_profile: AgentProfileKind::Gemini, + api_key_header: ApiKeyHeaderPolicy::Custom { name: "x-goog-api-key", }, - controls: AdapterControlCapabilities { + controls: AdapterControlCapabilities { native_reasoning_effort: FULL_REASONING_EFFORTS, additional_speeds: &[], }, @@ -119,11 +161,10 @@ pub const GEMINI: AdapterMetadata = AdapterMetadata { /// Routes through the OpenAI agent profile but accepts arbitrary `base_url` /// per provider settings. pub const OPENAI_COMPATIBLE: AdapterMetadata = AdapterMetadata { - key: "openai_compatible", - default_profile: AgentProfileKind::OpenAi, - fallback_provider: Provider::OpenAiCompatible, - api_key_header: ApiKeyHeaderPolicy::Bearer, - controls: AdapterControlCapabilities { + kind: AdapterKind::OpenAiCompatible, + default_profile: AgentProfileKind::OpenAi, + api_key_header: ApiKeyHeaderPolicy::Bearer, + controls: AdapterControlCapabilities { // `openai_compatible` providers vary widely; the catalog requires // models declaring `features.reasoning_effort = "levels"` to // enumerate exactly which effort values their endpoint accepts. @@ -135,79 +176,28 @@ pub const OPENAI_COMPATIBLE: AdapterMetadata = AdapterMetadata { /// All built-in adapter metadata, in stable iteration order. pub const ALL_ADAPTERS: &[AdapterMetadata] = &[ANTHROPIC, OPENAI, GEMINI, OPENAI_COMPATIBLE]; -/// Look up adapter metadata by stable key. -#[must_use] -pub fn get(key: &str) -> Option<&'static AdapterMetadata> { - ALL_ADAPTERS.iter().find(|a| a.key == key) -} - -/// Iterate every registered adapter key. -pub fn keys() -> impl Iterator { - ALL_ADAPTERS.iter().map(|a| a.key) -} - -/// Default adapter key for a provider ID when no explicit catalog provider row -/// is available. -#[must_use] -pub fn default_for_provider_id(provider: &ProviderId) -> &'static str { - match Provider::from_id(provider) { - Some(Provider::Anthropic) => ANTHROPIC.key, - Some(Provider::OpenAi) => OPENAI.key, - Some(Provider::Gemini) => GEMINI.key, - Some( - Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible, - ) - | None => OPENAI_COMPATIBLE.key, - } -} - -/// Default agent profile for a provider ID. -#[must_use] -pub fn default_profile_for_provider_id(provider: &ProviderId) -> AgentProfileKind { - get(default_for_provider_id(provider)) - .expect("default adapter key must be registered") - .default_profile -} - -/// Agent-facing provider enum to use for a catalog provider. -#[must_use] -pub fn profile_provider_for_provider_id(provider_id: &ProviderId, adapter_key: &str) -> Provider { - Provider::from_id(provider_id).unwrap_or_else(|| { - get(adapter_key) - .expect("catalog provider adapter key must be registered") - .fallback_provider - }) -} - #[cfg(test)] mod tests { use super::*; #[test] - fn lookup_by_known_key() { - assert_eq!(get("anthropic").unwrap().key, "anthropic"); - assert_eq!(get("openai").unwrap().key, "openai"); - assert_eq!(get("gemini").unwrap().key, "gemini"); - assert_eq!(get("openai_compatible").unwrap().key, "openai_compatible"); + fn adapter_kind_round_trips_as_snake_case() { + for kind in AdapterKind::VARIANTS { + let json = serde_json::to_string(kind).unwrap(); + assert_eq!(json, format!("\"{}\"", kind.as_str())); + let parsed: AdapterKind = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, *kind); + assert_eq!(kind.as_str().parse::().unwrap(), *kind); + } } #[test] - fn lookup_unknown_key_returns_none() { - assert!(get("does_not_exist").is_none()); - } - - #[test] - fn keys_are_unique_and_match_all_adapters() { - let keys: Vec<&'static str> = keys().collect(); - let mut sorted = keys.clone(); - sorted.sort_unstable(); - sorted.dedup(); - assert_eq!(sorted.len(), keys.len(), "duplicate adapter key"); - assert_eq!(sorted.len(), ALL_ADAPTERS.len()); + fn metadata_kind_matches_adapter_kind_variants() { + let kinds: Vec = ALL_ADAPTERS.iter().map(|adapter| adapter.kind).collect(); + assert_eq!(kinds, AdapterKind::VARIANTS); + for kind in AdapterKind::VARIANTS { + assert_eq!(kind.metadata().kind, *kind); + } } #[test] @@ -233,53 +223,6 @@ mod tests { assert_eq!(OPENAI_COMPATIBLE.default_profile, AgentProfileKind::OpenAi); } - #[test] - fn adapters_declare_fallback_provider_for_custom_provider_ids() { - assert_eq!(ANTHROPIC.fallback_provider, Provider::Anthropic); - assert_eq!(OPENAI.fallback_provider, Provider::OpenAi); - assert_eq!(GEMINI.fallback_provider, Provider::Gemini); - assert_eq!( - OPENAI_COMPATIBLE.fallback_provider, - Provider::OpenAiCompatible - ); - } - - #[test] - fn default_profile_for_builtin_provider_ids_uses_adapter_metadata() { - assert_eq!( - default_profile_for_provider_id(&ProviderId::anthropic()), - AgentProfileKind::Anthropic - ); - assert_eq!( - default_profile_for_provider_id(&ProviderId::openai()), - AgentProfileKind::OpenAi - ); - assert_eq!( - default_profile_for_provider_id(&ProviderId::gemini()), - AgentProfileKind::Gemini - ); - } - - #[test] - fn profile_provider_for_provider_id_preserves_builtins() { - assert_eq!( - profile_provider_for_provider_id(&ProviderId::new("kimi"), OPENAI_COMPATIBLE.key,), - Provider::Kimi - ); - } - - #[test] - fn profile_provider_for_custom_provider_maps_openai_compatible_adapter() { - assert_eq!( - profile_provider_for_provider_id(&ProviderId::new("bedrock"), OPENAI_COMPATIBLE.key), - Provider::OpenAiCompatible - ); - assert_eq!( - profile_provider_for_provider_id(&ProviderId::new("openrouter"), OPENAI.key), - Provider::OpenAi - ); - } - #[test] fn every_adapter_supports_full_native_reasoning_effort() { for adapter in ALL_ADAPTERS { @@ -287,7 +230,7 @@ mod tests { adapter.controls.native_reasoning_effort.len(), FULL_REASONING_EFFORTS.len(), "adapter {} should expose all reasoning-effort values", - adapter.key, + adapter.kind, ); } } diff --git a/lib/crates/fabro-model/src/billing.rs b/lib/crates/fabro-model/src/billing.rs index a2ea21cef..8387f728c 100644 --- a/lib/crates/fabro-model/src/billing.rs +++ b/lib/crates/fabro-model/src/billing.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, IntoStaticStr}; use crate::catalog::{Catalog, CatalogModelSettings}; -use crate::{Model, ModelCosts, Provider, ProviderId, adapter}; +use crate::{AdapterKind, Model, ModelCosts, ProviderId}; const TOKENS_PER_MTOK: i128 = 1_000_000; const ANTHROPIC_CACHE_WRITE_5M_NUMERATOR: i64 = 5; @@ -231,16 +231,12 @@ pub struct GeminiModelPricing { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "provider", rename_all = "snake_case")] +#[serde(tag = "algorithm", rename_all = "snake_case")] pub enum ModelPricingPolicy { + #[serde(rename = "openai")] OpenAi(OpenAiModelPricing), - OpenAiCompatible(OpenAiModelPricing), Anthropic(AnthropicModelPricing), Gemini(GeminiModelPricing), - Kimi(OpenAiModelPricing), - Zai(OpenAiModelPricing), - Minimax(OpenAiModelPricing), - Inception(OpenAiModelPricing), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -271,65 +267,23 @@ pub struct GeminiBillingFacts { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "provider", rename_all = "snake_case")] +#[serde(tag = "algorithm", rename_all = "snake_case")] pub enum ModelBillingFacts { + #[serde(rename = "openai")] OpenAi(OpenAiBillingFacts), - OpenAiCompatible(OpenAiBillingFacts), Anthropic(AnthropicBillingFacts), Gemini(GeminiBillingFacts), - Kimi(OpenAiBillingFacts), - Zai(OpenAiBillingFacts), - Minimax(OpenAiBillingFacts), - Inception(OpenAiBillingFacts), } impl ModelBillingFacts { #[must_use] - pub fn for_provider(provider: Provider) -> Self { - Self::for_builtin_provider(provider, &TokenCounts::default()) - } - - #[must_use] - pub fn for_provider_id(provider: &ProviderId, tokens: &TokenCounts) -> Self { - Provider::from_id(provider).map_or_else( - || Self::OpenAiCompatible(OpenAiBillingFacts::default()), - |provider| Self::for_builtin_provider(provider, tokens), - ) - } - - #[must_use] - pub fn for_provider_adapter( - provider: &ProviderId, - adapter_key: &str, - tokens: &TokenCounts, - ) -> Self { - if let Some(provider) = Provider::from_id(provider) { - return Self::for_builtin_provider(provider, tokens); - } - - match adapter_key { - key if key == adapter::ANTHROPIC.key => { - Self::Anthropic(anthropic_billing_facts(tokens)) + pub fn for_adapter(adapter: AdapterKind, tokens: &TokenCounts) -> Self { + match adapter { + AdapterKind::OpenAi | AdapterKind::OpenAiCompatible => { + Self::OpenAi(OpenAiBillingFacts::default()) } - key if key == adapter::GEMINI.key => Self::Gemini(GeminiBillingFacts::default()), - key if key == adapter::OPENAI.key => Self::OpenAi(OpenAiBillingFacts::default()), - key if key == adapter::OPENAI_COMPATIBLE.key => { - Self::OpenAiCompatible(OpenAiBillingFacts::default()) - } - _ => Self::OpenAiCompatible(OpenAiBillingFacts::default()), - } - } - - fn for_builtin_provider(provider: Provider, tokens: &TokenCounts) -> Self { - match provider { - Provider::OpenAi => Self::OpenAi(OpenAiBillingFacts::default()), - Provider::OpenAiCompatible => Self::OpenAiCompatible(OpenAiBillingFacts::default()), - Provider::Anthropic => Self::Anthropic(anthropic_billing_facts(tokens)), - Provider::Gemini => Self::Gemini(GeminiBillingFacts::default()), - Provider::Kimi => Self::Kimi(OpenAiBillingFacts::default()), - Provider::Zai => Self::Zai(OpenAiBillingFacts::default()), - Provider::Minimax => Self::Minimax(OpenAiBillingFacts::default()), - Provider::Inception => Self::Inception(OpenAiBillingFacts::default()), + AdapterKind::Anthropic => Self::Anthropic(anthropic_billing_facts(tokens)), + AdapterKind::Gemini => Self::Gemini(GeminiBillingFacts::default()), } } } @@ -467,7 +421,7 @@ impl Catalog { pricing_for_model_costs( model, provider.id.clone(), - provider.adapter.as_str(), + provider.adapter, model_ref.speed, &costs, ) @@ -478,13 +432,9 @@ impl Catalog { &self, model_ref: &ModelRef, tokens: &TokenCounts, - ) -> ModelBillingFacts { - self.provider(&model_ref.provider).map_or_else( - || ModelBillingFacts::for_provider_id(&model_ref.provider, tokens), - |provider| { - ModelBillingFacts::for_provider_adapter(&provider.id, &provider.adapter, tokens) - }, - ) + ) -> Option { + self.provider(&model_ref.provider) + .map(|provider| ModelBillingFacts::for_adapter(provider.adapter, tokens)) } } @@ -530,28 +480,12 @@ impl Model { speed, } } - - #[must_use] - pub fn pricing_for(&self, speed: Option) -> Option { - if matches!(speed, Some(Speed::Fast)) { - return None; - } - let provider = self.builtin_provider()?; - let provider_id = provider.id(); - pricing_for_model_costs( - self, - provider_id.clone(), - adapter::default_for_provider_id(&provider_id), - speed, - &self.costs, - ) - } } fn pricing_for_model_costs( model: &Model, provider_id: ProviderId, - adapter_key: &str, + adapter: AdapterKind, speed: Option, costs: &ModelCosts, ) -> Option { @@ -559,8 +493,7 @@ fn pricing_for_model_costs( let output = costs.output_cost_per_mtok.map(PricePerMTok::from_usd)?; let cached_input = costs.cache_input_cost_per_mtok.map(PricePerMTok::from_usd); - let policy = - pricing_policy_for_provider_adapter(&provider_id, adapter_key, input, output, cached_input); + let policy = pricing_policy_for_adapter(adapter, input, output, cached_input); Some(ModelPricing { model: ModelRef { provider: provider_id, @@ -571,91 +504,27 @@ fn pricing_for_model_costs( }) } -fn pricing_policy_for_provider_adapter( - provider_id: &ProviderId, - adapter_key: &str, +fn pricing_policy_for_adapter( + adapter: AdapterKind, input: PricePerMTok, output: PricePerMTok, cached_input: Option, ) -> ModelPricingPolicy { - if let Some(provider) = Provider::from_id(provider_id) { - return pricing_policy_for_builtin_provider(provider, input, output, cached_input); - } - - match adapter_key { - key if key == adapter::ANTHROPIC.key => { - anthropic_pricing_policy(input, output, cached_input) - } - key if key == adapter::GEMINI.key => ModelPricingPolicy::Gemini(GeminiModelPricing { + match adapter { + AdapterKind::Anthropic => anthropic_pricing_policy(input, output, cached_input), + AdapterKind::Gemini => ModelPricingPolicy::Gemini(GeminiModelPricing { input, output, cached_input, storage: None, }), - key if key == adapter::OPENAI.key => ModelPricingPolicy::OpenAi(OpenAiModelPricing { - input, - cached_input, - output, - }), - key if key == adapter::OPENAI_COMPATIBLE.key => { - ModelPricingPolicy::OpenAiCompatible(OpenAiModelPricing { + AdapterKind::OpenAi | AdapterKind::OpenAiCompatible => { + ModelPricingPolicy::OpenAi(OpenAiModelPricing { input, cached_input, output, }) } - _ => ModelPricingPolicy::OpenAiCompatible(OpenAiModelPricing { - input, - cached_input, - output, - }), - } -} - -fn pricing_policy_for_builtin_provider( - provider: Provider, - input: PricePerMTok, - output: PricePerMTok, - cached_input: Option, -) -> ModelPricingPolicy { - match provider { - Provider::OpenAi => ModelPricingPolicy::OpenAi(OpenAiModelPricing { - input, - cached_input, - output, - }), - Provider::OpenAiCompatible => ModelPricingPolicy::OpenAiCompatible(OpenAiModelPricing { - input, - cached_input, - output, - }), - Provider::Anthropic => anthropic_pricing_policy(input, output, cached_input), - Provider::Gemini => ModelPricingPolicy::Gemini(GeminiModelPricing { - input, - output, - cached_input, - storage: None, - }), - Provider::Kimi => ModelPricingPolicy::Kimi(OpenAiModelPricing { - input, - cached_input, - output, - }), - Provider::Zai => ModelPricingPolicy::Zai(OpenAiModelPricing { - input, - cached_input, - output, - }), - Provider::Minimax => ModelPricingPolicy::Minimax(OpenAiModelPricing { - input, - cached_input, - output, - }), - Provider::Inception => ModelPricingPolicy::Inception(OpenAiModelPricing { - input, - cached_input, - output, - }), } } @@ -687,15 +556,7 @@ impl ModelPricing { } let bill = match (&self.policy, &input.facts) { - (ModelPricingPolicy::OpenAi(pricing), ModelBillingFacts::OpenAi(_)) - | ( - ModelPricingPolicy::OpenAiCompatible(pricing), - ModelBillingFacts::OpenAiCompatible(_), - ) - | (ModelPricingPolicy::Kimi(pricing), ModelBillingFacts::Kimi(_)) - | (ModelPricingPolicy::Zai(pricing), ModelBillingFacts::Zai(_)) - | (ModelPricingPolicy::Minimax(pricing), ModelBillingFacts::Minimax(_)) - | (ModelPricingPolicy::Inception(pricing), ModelBillingFacts::Inception(_)) => { + (ModelPricingPolicy::OpenAi(pricing), ModelBillingFacts::OpenAi(_)) => { Some(bill_openai_like(pricing, &input.usage.tokens)) } (ModelPricingPolicy::Anthropic(pricing), ModelBillingFacts::Anthropic(facts)) => { @@ -787,8 +648,8 @@ fn bill_gemini( #[cfg(test)] mod tests { use super::*; - use crate::Catalog; use crate::catalog::LlmCatalogSettings; + use crate::{Catalog, ProviderId}; fn catalog_from_toml(source: &str) -> Catalog { let settings: LlmCatalogSettings = @@ -805,7 +666,7 @@ mod tests { input: ModelBillingInput { usage: ModelUsage { model: ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "gpt-5.4".to_string(), speed: None, }, @@ -940,7 +801,7 @@ mod tests { fn openai_pricing_bills_cached_input_and_reasoning_output() { let pricing = ModelPricing { model: ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "gpt-5.4".to_string(), speed: None, }, @@ -977,7 +838,7 @@ mod tests { fn catalog_pricing_uses_speed_cost_overrides() { let pricing = Catalog::builtin() .pricing_for(&ModelRef { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model_id: "claude-opus-4-6".to_string(), speed: Some(Speed::Fast), }) @@ -987,7 +848,7 @@ mod tests { panic!("expected anthropic pricing"); }; - assert_eq!(pricing.model.provider, Provider::Anthropic.id()); + assert_eq!(pricing.model.provider, ProviderId::anthropic()); assert_eq!(pricing.model.model_id, "claude-opus-4-6"); assert_eq!(pricing.model.speed, Some(Speed::Fast)); assert_eq!(anthropic.input.usd_micros, 30_000_000); @@ -1001,7 +862,7 @@ mod tests { fn catalog_pricing_standard_speed_uses_base_costs() { let pricing = Catalog::builtin() .pricing_for(&ModelRef { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model_id: "claude-opus-4-6".to_string(), speed: Some(Speed::Standard), }) @@ -1104,8 +965,8 @@ cache_input_cost_per_mtok = 0.1 }) .unwrap(); - let ModelPricingPolicy::OpenAiCompatible(openai_like) = pricing.policy else { - panic!("expected openai-compatible adapter pricing"); + let ModelPricingPolicy::OpenAi(openai_like) = pricing.policy else { + panic!("expected OpenAI billing algorithm for OpenAI-compatible adapter"); }; assert_eq!(pricing.model.provider, ProviderId::new("proxy")); assert_eq!(openai_like.input.usd_micros, 1_000_000); @@ -1177,7 +1038,7 @@ output_cost_per_mtok = 2.0 assert!( Catalog::builtin() .pricing_for(&ModelRef { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model_id: "unknown".to_string(), speed: None, }) @@ -1186,7 +1047,7 @@ output_cost_per_mtok = 2.0 assert!( Catalog::builtin() .pricing_for(&ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "gpt-5.4".to_string(), speed: Some(Speed::Fast), }) @@ -1198,7 +1059,7 @@ output_cost_per_mtok = 2.0 fn anthropic_billing_supports_distinct_cache_write_buckets() { let pricing = ModelPricing { model: ModelRef { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model_id: "claude-opus-4-6".to_string(), speed: Some(Speed::Fast), }, @@ -1244,7 +1105,7 @@ output_cost_per_mtok = 2.0 fn gemini_billing_requires_storage_pricing_when_storage_facts_exist() { let pricing = ModelPricing { model: ModelRef { - provider: Provider::Gemini.id(), + provider: ProviderId::gemini(), model_id: "gemini-3.1-pro-preview".to_string(), speed: None, }, @@ -1304,4 +1165,63 @@ output_cost_per_mtok = 2.0 serde_json::json!({}) ); } + + #[test] + fn pricing_policy_serializes_with_algorithm_tag() { + let policy = ModelPricingPolicy::OpenAi(OpenAiModelPricing { + input: PricePerMTok { usd_micros: 1 }, + cached_input: None, + output: PricePerMTok { usd_micros: 2 }, + }); + + assert_eq!( + serde_json::to_value(policy).unwrap(), + serde_json::json!({ + "algorithm": "openai", + "input": { "usd_micros": 1 }, + "cached_input": null, + "output": { "usd_micros": 2 } + }) + ); + } + + #[test] + fn old_provider_tagged_billing_facts_are_rejected() { + let error = serde_json::from_value::(serde_json::json!({ + "provider": "openai" + })) + .unwrap_err(); + assert!(error.to_string().contains("algorithm")); + } + + #[test] + fn old_provider_tagged_pricing_policy_is_rejected() { + let error = + serde_json::from_value::(openai_pricing_json("provider", "kimi")) + .unwrap_err(); + assert!(error.to_string().contains("algorithm")); + } + + #[test] + fn openai_compatible_adapter_uses_openai_billing_algorithm() { + let facts = + ModelBillingFacts::for_adapter(AdapterKind::OpenAiCompatible, &TokenCounts::default()); + assert_eq!( + facts, + ModelBillingFacts::OpenAi(OpenAiBillingFacts::default()) + ); + } + + fn openai_pricing_json(tag: &str, tag_value: &str) -> serde_json::Value { + let mut value = serde_json::json!({ + "input": { "usd_micros": 1 }, + "cached_input": null, + "output": { "usd_micros": 2 } + }); + value + .as_object_mut() + .unwrap() + .insert(tag.to_string(), tag_value.into()); + value + } } diff --git a/lib/crates/fabro-model/src/catalog.rs b/lib/crates/fabro-model/src/catalog.rs index a8542ad26..ddf41704a 100644 --- a/lib/crates/fabro-model/src/catalog.rs +++ b/lib/crates/fabro-model/src/catalog.rs @@ -5,13 +5,11 @@ use std::sync::LazyLock; use rust_embed::RustEmbed; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use strum::VariantArray; use toml::de::Error as TomlDeError; use crate::Speed; -use crate::adapter::{self, AdapterMetadata}; +use crate::adapter::{AdapterKind, AdapterMetadata}; use crate::ids::ProviderId; -use crate::provider::Provider; use crate::reasoning::ReasoningEffort; use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; @@ -42,6 +40,8 @@ pub struct ProviderCatalogSettings { #[serde(default)] pub adapter: Option, #[serde(default)] + pub api_key_url: Option, + #[serde(default)] pub base_url: Option, #[serde(default)] pub credentials: Option>, @@ -110,8 +110,6 @@ pub struct SettingsModelFeatures { pub reasoning_effort: Option, #[serde(default)] pub prompt_cache: Option, - #[serde(default)] - pub effort: Option, } #[derive(Debug, Clone, Default, PartialEq, Deserialize)] @@ -362,7 +360,8 @@ pub struct FallbackTarget { pub struct CatalogProvider { pub id: ProviderId, pub display_name: String, - pub adapter: String, + pub adapter: AdapterKind, + pub api_key_url: Option, pub base_url: Option, pub credentials: Vec, pub extra_headers: HashMap, @@ -500,8 +499,7 @@ pub enum CatalogBuildError { /// Typed model catalog backed by a `Vec`. /// -/// Use [`Catalog::builtin()`] for the embedded settings-backed catalog, or -/// [`Catalog::from_models()`] for testing with custom model sets. +/// Use [`Catalog::builtin()`] for the embedded settings-backed catalog. #[derive(Debug)] pub struct Catalog { models: Vec, @@ -626,52 +624,10 @@ impl Catalog { Self::from_settings(&settings) } - /// Create a catalog from a custom set of models (useful for testing). - #[must_use] - pub fn from_models(models: Vec) -> Self { - let mut providers = Vec::::new(); - let mut seen = HashSet::::new(); - for model in &models { - if !seen.insert(model.provider.clone()) { - continue; - } - providers.push(CatalogProvider { - id: model.provider.clone(), - display_name: Provider::display_name_for_id(&model.provider), - adapter: adapter::default_for_provider_id(&model.provider).to_string(), - base_url: None, - credentials: Vec::new(), - extra_headers: HashMap::new(), - priority: 0, - aliases: Vec::new(), - }); - } - - let provider_index = providers - .iter() - .enumerate() - .map(|(idx, provider)| (provider.id.clone(), idx)) - .collect::>(); - let model_settings = models - .iter() - .map(|model| { - (model.id.clone(), CatalogModelSettings { - api_id: model.id.clone(), - controls: default_controls_for_model(model), - speed_costs: HashMap::new(), - }) - }) - .collect(); - let model_index = build_model_index(&models); - - Self { - models, - providers, - model_settings, - model_index, - provider_aliases: HashMap::new(), - provider_index, - } + /// Builds a fresh catalog from embedded provider TOML without user + /// overrides. + pub fn from_builtin() -> Result { + Self::from_builtin_toml() } fn builtin_settings() -> Result { @@ -798,18 +754,6 @@ impl Catalog { self.default_for_configured_ids(&configured) } - /// Default model for the best configured provider, falling back to the - /// global catalog default. - #[must_use] - pub fn default_for_configured(&self, configured: &[Provider]) -> &Model { - let configured = configured - .iter() - .copied() - .map(Provider::id) - .collect::>(); - self.default_for_configured_ids(&configured) - } - /// Default model for the best-available built-in provider IDs, falling /// back to the global catalog default. #[must_use] @@ -832,10 +776,11 @@ impl Catalog { /// connectivity checks. Falls back to the provider's default when no /// explicit override is configured. #[must_use] - pub fn probe_for_provider(&self, p: Provider) -> Option<&Model> { - let override_id: Option<&str> = match p { - Provider::Anthropic => Some("claude-haiku-4-5"), - Provider::OpenAi => Some("gpt-5.4-mini"), + pub fn probe_for_provider(&self, p: &ProviderId) -> Option<&Model> { + let provider_id = self.provider(p).map_or(p, |provider| &provider.id); + let override_id: Option<&str> = match provider_id.as_str() { + ProviderId::ANTHROPIC => Some("claude-haiku-4-5"), + ProviderId::OPENAI => Some("gpt-5.4-mini"), _ => None, }; if let Some(id) = override_id { @@ -843,7 +788,7 @@ impl Catalog { return Some(info); } } - self.default_for_provider(&p.id()) + self.default_for_provider(provider_id) } /// Find the closest model on a target provider matching the reference's @@ -949,6 +894,7 @@ fn merge_provider_settings( ProviderCatalogSettings { display_name: higher.display_name.or(fallback.display_name), adapter: higher.adapter.or(fallback.adapter), + api_key_url: higher.api_key_url.or(fallback.api_key_url), base_url: higher.base_url.or(fallback.base_url), credentials: higher.credentials.or(fallback.credentials), extra_headers: higher.extra_headers.or(fallback.extra_headers), @@ -1022,7 +968,6 @@ fn merge_model_features_settings( reasoning: higher.reasoning.or(fallback.reasoning), reasoning_effort: higher.reasoning_effort.or(fallback.reasoning_effort), prompt_cache: higher.prompt_cache.or(fallback.prompt_cache), - effort: higher.effort.or(fallback.effort), } } @@ -1077,14 +1022,15 @@ fn build_providers( continue; } - let adapter = required_provider_string(&provider_id, settings.adapter.as_ref(), "adapter")?; - if adapter::get(adapter.as_str()).is_none() { - return Err(CatalogBuildError::UnknownAdapter { - provider: provider_id, - adapter, - }); - } - if adapter == adapter::OPENAI_COMPATIBLE.key && settings.base_url.is_none() { + let adapter_name = + required_provider_string(&provider_id, settings.adapter.as_ref(), "adapter")?; + let adapter = AdapterKind::from_str(&adapter_name).map_err(|_| { + CatalogBuildError::UnknownAdapter { + provider: provider_id.clone(), + adapter: adapter_name, + } + })?; + if adapter == AdapterKind::OpenAiCompatible && settings.base_url.is_none() { return Err(CatalogBuildError::MissingOpenAiCompatibleBaseUrl { provider: provider_id, }); @@ -1094,6 +1040,7 @@ fn build_providers( id: provider_id, display_name: settings.display_name.clone().unwrap_or_else(|| id.clone()), adapter, + api_key_url: settings.api_key_url.clone(), base_url: settings.base_url.clone(), credentials: settings.credentials.clone().unwrap_or_default(), extra_headers: settings.extra_headers.clone().unwrap_or_default(), @@ -1152,7 +1099,7 @@ fn build_model( field: "features", })?; let model_features = build_model_features(model_id, features)?; - let adapter = adapter::get(&provider.adapter).expect("provider adapter was validated earlier"); + let adapter = provider.adapter.metadata(); let controls = build_model_controls(model_id, &model_features, settings, adapter)?; let costs = build_model_costs(settings.costs.as_ref()); let speed_costs = build_speed_costs(model_id, settings.costs.as_ref(), &controls)?; @@ -1196,13 +1143,7 @@ fn build_model_features( model: model_id.to_string(), field: "features.reasoning", })?; - let reasoning_effort = features.reasoning_effort.unwrap_or_else(|| { - if features.effort.unwrap_or_default() { - ReasoningEffortFeature::Levels - } else { - ReasoningEffortFeature::None - } - }); + let reasoning_effort = features.reasoning_effort.unwrap_or_default(); if !reasoning && reasoning_effort == ReasoningEffortFeature::Levels { return Err(CatalogBuildError::ReasoningEffortWithoutReasoning { model: model_id.to_string(), @@ -1225,7 +1166,6 @@ fn build_model_features( reasoning, reasoning_effort, prompt_cache: features.prompt_cache.unwrap_or_default(), - effort: reasoning_effort == ReasoningEffortFeature::Levels, }) } @@ -1301,7 +1241,7 @@ fn build_model_controls( if !adapter.controls.native_reasoning_effort.contains(value) { return Err(CatalogBuildError::UnsupportedReasoningEffort { model: model_id.to_string(), - adapter: adapter.key.to_string(), + adapter: adapter.kind.to_string(), value: *value, }); } @@ -1359,7 +1299,7 @@ fn parse_speed_control( if !adapter.controls.additional_speeds.contains(&speed) { return Err(CatalogBuildError::UnsupportedSpeed { model: model_id.to_string(), - adapter: adapter.key.to_string(), + adapter: adapter.kind.to_string(), speed, }); } @@ -1476,17 +1416,6 @@ fn validate_builtin_fragment( Ok(()) } -fn default_controls_for_model(model: &Model) -> CatalogModelControls { - CatalogModelControls { - reasoning_effort: if model.features.reasoning_effort == ReasoningEffortFeature::Levels { - ReasoningEffort::VARIANTS.to_vec() - } else { - Vec::new() - }, - speed: Vec::new(), - } -} - fn provider_order(left: &CatalogProvider, right: &CatalogProvider) -> std::cmp::Ordering { right .priority @@ -1502,14 +1431,12 @@ fn model_order(left: &Model, right: &Model) -> std::cmp::Ordering { #[cfg(test)] mod tests { - use std::str::FromStr; - use strum::VariantArray; use super::*; - use crate::Speed; - use crate::provider::Provider; + use crate::adapter::AdapterKind; use crate::reasoning::ReasoningEffort; + use crate::{ProviderId, Speed}; fn minimal_settings(source: &str) -> LlmCatalogSettings { toml::from_str(source).expect("fixture should parse as an LLM settings layer") @@ -1518,9 +1445,8 @@ mod tests { // ---- Catalog struct tests ---- #[test] - fn builtin_with_empty_overrides_matches_builtin_catalog() { - let catalog = Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("empty overrides should build"); + fn from_builtin_matches_builtin_catalog() { + let catalog = Catalog::from_builtin().expect("built-in catalog should build"); assert_eq!( catalog.get("sonnet").map(|model| model.id.as_str()), @@ -1580,7 +1506,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, )) .expect("custom provider overlay should build"); @@ -1589,7 +1514,7 @@ effort = false .provider(&ProviderId::new("acme-ai")) .expect("provider alias should resolve"); assert_eq!(provider.id, ProviderId::new("acme")); - assert_eq!(provider.adapter, "openai_compatible"); + assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); let model = catalog.get("al").expect("model alias should resolve"); assert_eq!(model.id, "acme-large"); @@ -1615,7 +1540,7 @@ enabled = true let provider = catalog .provider(&ollama) .expect("enabled Ollama provider should be present"); - assert_eq!(provider.adapter, "openai_compatible"); + assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); assert_eq!( provider.base_url.as_deref(), Some("http://localhost:11434/v1") @@ -1650,19 +1575,18 @@ enabled = true #[test] fn builtin_list_by_provider() { - let anthropic = Catalog::builtin().list(Some(&Provider::Anthropic.id())); + let anthropic = Catalog::builtin().list(Some(&ProviderId::anthropic())); assert!(!anthropic.is_empty()); assert!( anthropic .iter() - .all(|m| m.provider == Provider::Anthropic.id()) + .all(|m| m.provider == ProviderId::anthropic()) ); } #[test] fn builtin_list_unknown_provider_empty() { - // OpenAiCompatible has no catalog models - let models = Catalog::builtin().list(Some(&Provider::OpenAiCompatible.id())); + let models = Catalog::builtin().list(Some(&ProviderId::new("missing-provider"))); assert!(models.is_empty()); } @@ -1675,18 +1599,18 @@ enabled = true #[test] fn builtin_default_for_provider() { let m = Catalog::builtin() - .default_for_provider(&Provider::Anthropic.id()) + .default_for_provider(&ProviderId::anthropic()) .unwrap(); assert_eq!(m.id, "claude-sonnet-4-6"); assert!(m.default); let m = Catalog::builtin() - .default_for_provider(&Provider::OpenAi.id()) + .default_for_provider(&ProviderId::openai()) .unwrap(); assert_eq!(m.id, "gpt-5.4"); let m = Catalog::builtin() - .default_for_provider(&Provider::Gemini.id()) + .default_for_provider(&ProviderId::gemini()) .unwrap(); assert_eq!(m.id, "gemini-3.1-pro-preview"); } @@ -1694,7 +1618,7 @@ enabled = true #[test] fn builtin_probe_openai_returns_override() { let m = Catalog::builtin() - .probe_for_provider(Provider::OpenAi) + .probe_for_provider(&ProviderId::openai()) .unwrap(); assert_eq!(m.id, "gpt-5.4-mini"); } @@ -1702,7 +1626,7 @@ enabled = true #[test] fn builtin_probe_anthropic_returns_override() { let m = Catalog::builtin() - .probe_for_provider(Provider::Anthropic) + .probe_for_provider(&ProviderId::anthropic()) .unwrap(); assert_eq!(m.id, "claude-haiku-4-5"); } @@ -1710,7 +1634,7 @@ enabled = true #[test] fn builtin_probe_gemini_returns_default() { let m = Catalog::builtin() - .probe_for_provider(Provider::Gemini) + .probe_for_provider(&ProviderId::gemini()) .unwrap(); assert_eq!(m.id, "gemini-3.1-pro-preview"); } @@ -1719,7 +1643,7 @@ enabled = true fn builtin_closest_opus_to_gemini() { let opus = Catalog::builtin().get("claude-opus-4-6").unwrap(); let result = Catalog::builtin() - .closest(&Provider::Gemini.id(), opus) + .closest(&ProviderId::gemini(), opus) .unwrap(); assert_eq!(result.id, "gemini-3.1-pro-preview"); } @@ -1729,7 +1653,7 @@ enabled = true let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); assert!( Catalog::builtin() - .closest(&Provider::OpenAi.id(), haiku) + .closest(&ProviderId::openai(), haiku) .is_none() ); } @@ -1741,7 +1665,7 @@ enabled = true "openai".to_string(), ])]); let chain = Catalog::builtin().build_fallback_chain( - &Provider::Anthropic.id(), + &ProviderId::anthropic(), "claude-opus-4-6", &fallbacks, ); @@ -1756,7 +1680,7 @@ enabled = true fn builtin_build_fallback_chain_unknown_model() { let fallbacks = HashMap::from([("anthropic".to_string(), vec!["gemini".to_string()])]); let chain = Catalog::builtin().build_fallback_chain( - &Provider::Anthropic.id(), + &ProviderId::anthropic(), "unknown-xyz", &fallbacks, ); @@ -1767,7 +1691,7 @@ enabled = true fn builtin_build_fallback_chain_provider_not_in_map() { let fallbacks = HashMap::from([("openai".to_string(), vec!["anthropic".to_string()])]); let chain = Catalog::builtin().build_fallback_chain( - &Provider::Anthropic.id(), + &ProviderId::anthropic(), "claude-opus-4-6", &fallbacks, ); @@ -1781,7 +1705,7 @@ enabled = true "kimi".to_string(), ])]); let chain = Catalog::builtin().build_fallback_chain( - &Provider::Anthropic.id(), + &ProviderId::anthropic(), "claude-haiku-4-5", &fallbacks, ); @@ -1794,66 +1718,32 @@ enabled = true fn builtin_build_fallback_chain_empty_map() { let fallbacks = HashMap::new(); let chain = Catalog::builtin().build_fallback_chain( - &Provider::Anthropic.id(), + &ProviderId::anthropic(), "claude-opus-4-6", &fallbacks, ); assert!(chain.is_empty()); } - #[test] - fn from_models_custom_catalog() { - use crate::types::{Model, ModelCosts, ModelFeatures, ModelLimits}; - - let models = vec![Model { - id: "test-model".to_string(), - provider: Provider::Anthropic.id(), - family: "test".to_string(), - display_name: "Test Model".to_string(), - limits: ModelLimits { - context_window: 100_000, - max_output: Some(4096), - }, - training: None, - knowledge_cutoff: None, - features: ModelFeatures { - tools: true, - vision: false, - reasoning: false, - reasoning_effort: ReasoningEffortFeature::None, - prompt_cache: false, - effort: false, - }, - costs: ModelCosts { - input_cost_per_mtok: Some(1.0), - output_cost_per_mtok: Some(5.0), - cache_input_cost_per_mtok: None, - }, - estimated_output_tps: None, - aliases: vec!["test".to_string()], - default: true, - configured: false, - }]; - - let catalog = Catalog::from_models(models); - assert_eq!(catalog.get("test-model").unwrap().id, "test-model"); - assert_eq!(catalog.get("test").unwrap().id, "test-model"); - assert!(catalog.get("nonexistent").is_none()); - assert_eq!(catalog.default_model().id, "test-model"); - assert_eq!(catalog.list(None).len(), 1); - } - #[test] fn builtin_catalog_is_loaded_from_provider_toml_settings() { let catalog = Catalog::builtin(); assert_eq!( catalog.provider(&ProviderId::openai()).unwrap().adapter, - "openai" + AdapterKind::OpenAi ); assert_eq!( catalog - .provider(&ProviderId::kimi()) + .provider(&ProviderId::openai()) + .unwrap() + .api_key_url + .as_deref(), + Some("https://platform.openai.com/api-keys") + ); + assert_eq!( + catalog + .provider(&ProviderId::new("kimi")) .unwrap() .base_url .as_deref(), @@ -2207,7 +2097,7 @@ context_window = 1000 tools = false vision = false reasoning = true -effort = true +reasoning_effort = "levels" [models.model.controls] reasoning_effort = ["turbo"] @@ -2286,7 +2176,6 @@ reasoning_effort = ["low", "medium"] crate::ReasoningEffortFeature::Levels ); assert!(model.features.prompt_cache); - assert!(model.features.effort); assert_eq!( catalog .model_settings("model") @@ -2297,104 +2186,6 @@ reasoning_effort = ["low", "medium"] ); } - #[test] - fn catalog_from_settings_maps_legacy_effort_to_reasoning_effort_feature() { - let settings = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" - -[models.with_effort] -provider = "test" -display_name = "With Effort" -family = "test" -default = true - -[models.with_effort.limits] -context_window = 1000 - -[models.with_effort.features] -tools = true -vision = false -reasoning = true -effort = true - -[models.no_effort] -provider = "test" -display_name = "No Effort" -family = "test" - -[models.no_effort.limits] -context_window = 1000 - -[models.no_effort.features] -tools = true -vision = false -reasoning = true -effort = false -"#, - ); - - let catalog = Catalog::from_settings(&settings).unwrap(); - - let with_effort = catalog.get("with_effort").unwrap(); - assert_eq!( - with_effort.features.reasoning_effort, - crate::ReasoningEffortFeature::Levels - ); - assert!(with_effort.features.effort); - - let no_effort = catalog.get("no_effort").unwrap(); - assert_eq!( - no_effort.features.reasoning_effort, - crate::ReasoningEffortFeature::None - ); - assert!(!no_effort.features.effort); - } - - #[test] - fn catalog_merge_prefers_explicit_reasoning_effort_over_legacy_effort() { - let fallback = minimal_settings( - r#" -[providers.test] -display_name = "Test" -adapter = "openai" - -[models.model] -provider = "test" -display_name = "Model" -family = "test" -default = true - -[models.model.limits] -context_window = 1000 - -[models.model.features] -tools = true -vision = false -reasoning = true -reasoning_effort = "levels" -"#, - ); - let higher = minimal_settings( - r" -[models.model.features] -effort = false -", - ); - - let merged = merge_catalog_settings(higher, fallback); - let catalog = Catalog::from_settings(&merged).unwrap(); - let model = catalog.get("model").unwrap(); - - assert_eq!( - model.features.reasoning_effort, - crate::ReasoningEffortFeature::Levels - ); - assert!(model.features.effort); - } - #[test] fn catalog_from_settings_rejects_reasoning_effort_controls_when_feature_is_none() { let settings = minimal_settings( @@ -2464,20 +2255,23 @@ reasoning_effort = "levels" #[test] fn every_provider_has_catalog_models() { - for &provider in Provider::ALL { - let models = Catalog::builtin().list(Some(&provider.id())); + let catalog = Catalog::builtin(); + for provider in catalog.providers() { + let models = catalog.list(Some(&provider.id)); assert!( !models.is_empty(), - "Provider {provider:?} has no models in catalog" + "Provider {:?} has no models in catalog", + provider.id, ); } } #[test] fn every_provider_has_exactly_one_default_model() { - for &provider in Provider::ALL { - let defaults: Vec<_> = Catalog::builtin() - .list(Some(&provider.id())) + let catalog = Catalog::builtin(); + for provider in catalog.providers() { + let defaults: Vec<_> = catalog + .list(Some(&provider.id)) .into_iter() .filter(|m| m.default) .collect(); @@ -2485,7 +2279,7 @@ reasoning_effort = "levels" defaults.len(), 1, "Provider {:?} should have exactly one default model, found {}: {:?}", - provider, + provider.id, defaults.len(), defaults.iter().map(|m| &m.id).collect::>() ); @@ -2505,18 +2299,6 @@ reasoning_effort = "levels" } } - #[test] - fn provider_static_str_roundtrips_through_from_str() { - for &provider in Provider::ALL { - let roundtripped = Provider::from_str(<&'static str>::from(provider)); - assert_eq!( - roundtripped, - Ok(provider), - "Provider::{provider:?} IntoStaticStr does not round-trip through from_str" - ); - } - } - // ---- Model info snapshot tests ---- #[test] @@ -2546,7 +2328,6 @@ reasoning_effort = "levels" reasoning: true, reasoning_effort: Levels, prompt_cache: true, - effort: true, }, costs: ModelCosts { input_cost_per_mtok: Some( @@ -2616,7 +2397,6 @@ reasoning_effort = "levels" reasoning: true, reasoning_effort: Levels, prompt_cache: false, - effort: true, }, costs: ModelCosts { input_cost_per_mtok: Some( @@ -2676,7 +2456,6 @@ reasoning_effort = "levels" reasoning: false, reasoning_effort: None, prompt_cache: false, - effort: false, }, costs: ModelCosts { input_cost_per_mtok: Some( @@ -2707,13 +2486,13 @@ reasoning_effort = "levels" #[test] fn glm_4_7_in_catalog() { let m = Catalog::builtin().get("glm-4.7").unwrap(); - assert_eq!(m.provider, Provider::Zai.id()); + assert_eq!(m.provider, ProviderId::new("zai")); } #[test] fn minimax_m2_5_in_catalog() { let m = Catalog::builtin().get("minimax-m2.5").unwrap(); - assert_eq!(m.provider, Provider::Minimax.id()); + assert_eq!(m.provider, ProviderId::new("minimax")); } #[test] @@ -2739,7 +2518,6 @@ reasoning_effort = "levels" reasoning: true, reasoning_effort: Levels, prompt_cache: false, - effort: true, }, costs: ModelCosts { input_cost_per_mtok: Some( @@ -2794,7 +2572,6 @@ reasoning_effort = "levels" reasoning: true, reasoning_effort: Levels, prompt_cache: false, - effort: true, }, costs: ModelCosts { input_cost_per_mtok: Some( @@ -2847,7 +2624,6 @@ reasoning_effort = "levels" reasoning: true, reasoning_effort: Levels, prompt_cache: false, - effort: true, }, costs: ModelCosts { input_cost_per_mtok: Some( @@ -2926,7 +2702,6 @@ reasoning_effort = "levels" reasoning: true, reasoning_effort: Levels, prompt_cache: false, - effort: true, }, costs: ModelCosts { input_cost_per_mtok: None, @@ -2959,7 +2734,7 @@ reasoning_effort = "levels" fn closest_model_sonnet_to_gemini() { let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap(); let result = Catalog::builtin() - .closest(&Provider::Gemini.id(), sonnet) + .closest(&ProviderId::gemini(), sonnet) .unwrap(); assert_eq!(result.id, "gemini-3.1-pro-preview"); } @@ -2968,7 +2743,7 @@ reasoning_effort = "levels" fn closest_model_haiku_to_kimi() { let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap(); let result = Catalog::builtin() - .closest(&Provider::Kimi.id(), haiku) + .closest(&ProviderId::new("kimi"), haiku) .unwrap(); assert_eq!(result.id, "kimi-k2.5"); } @@ -2978,7 +2753,7 @@ reasoning_effort = "levels" let glm = Catalog::builtin().get("glm-4.7").unwrap(); assert!( Catalog::builtin() - .closest(&Provider::Gemini.id(), glm) + .closest(&ProviderId::gemini(), glm) .is_none() ); } diff --git a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml index 1c51fa643..1bdf66e67 100644 --- a/lib/crates/fabro-model/src/catalog/providers/anthropic.toml +++ b/lib/crates/fabro-model/src/catalog/providers/anthropic.toml @@ -1,6 +1,7 @@ [providers.anthropic] display_name = "Anthropic" adapter = "anthropic" +api_key_url = "https://console.anthropic.com/settings/keys" credentials = ["credential:anthropic", "env:ANTHROPIC_API_KEY"] priority = 100 diff --git a/lib/crates/fabro-model/src/catalog/providers/gemini.toml b/lib/crates/fabro-model/src/catalog/providers/gemini.toml index d7e4c8b0d..3516b8410 100644 --- a/lib/crates/fabro-model/src/catalog/providers/gemini.toml +++ b/lib/crates/fabro-model/src/catalog/providers/gemini.toml @@ -1,6 +1,7 @@ [providers.gemini] display_name = "Gemini" adapter = "gemini" +api_key_url = "https://aistudio.google.com/apikey" credentials = ["credential:gemini", "env:GEMINI_API_KEY", "env:GOOGLE_API_KEY"] priority = 80 diff --git a/lib/crates/fabro-model/src/catalog/providers/inception.toml b/lib/crates/fabro-model/src/catalog/providers/inception.toml index 34d01cf9d..b4261b9cf 100644 --- a/lib/crates/fabro-model/src/catalog/providers/inception.toml +++ b/lib/crates/fabro-model/src/catalog/providers/inception.toml @@ -1,10 +1,10 @@ [providers.inception] display_name = "Inception" adapter = "openai_compatible" +api_key_url = "https://console.inceptionlabs.ai/api-keys" base_url = "https://api.inceptionlabs.ai/v1" credentials = ["credential:inception", "env:INCEPTION_API_KEY"] priority = 40 -aliases = ["inception_labs"] [models."mercury-2"] provider = "inception" diff --git a/lib/crates/fabro-model/src/catalog/providers/kimi.toml b/lib/crates/fabro-model/src/catalog/providers/kimi.toml index b9be19ad5..c56a27d6f 100644 --- a/lib/crates/fabro-model/src/catalog/providers/kimi.toml +++ b/lib/crates/fabro-model/src/catalog/providers/kimi.toml @@ -1,6 +1,7 @@ [providers.kimi] display_name = "Kimi" adapter = "openai_compatible" +api_key_url = "https://platform.moonshot.cn/console/api-keys" base_url = "https://api.moonshot.ai/v1" credentials = ["credential:kimi", "env:KIMI_API_KEY"] priority = 70 diff --git a/lib/crates/fabro-model/src/catalog/providers/minimax.toml b/lib/crates/fabro-model/src/catalog/providers/minimax.toml index 1a11c1395..d07e89784 100644 --- a/lib/crates/fabro-model/src/catalog/providers/minimax.toml +++ b/lib/crates/fabro-model/src/catalog/providers/minimax.toml @@ -1,6 +1,7 @@ [providers.minimax] display_name = "MiniMax" adapter = "openai_compatible" +api_key_url = "https://platform.minimaxi.com/user-center/basic-information/interface-key" base_url = "https://api.minimax.io/v1" credentials = ["credential:minimax", "env:MINIMAX_API_KEY"] priority = 50 diff --git a/lib/crates/fabro-model/src/catalog/providers/openai.toml b/lib/crates/fabro-model/src/catalog/providers/openai.toml index f93ead090..6207764ab 100644 --- a/lib/crates/fabro-model/src/catalog/providers/openai.toml +++ b/lib/crates/fabro-model/src/catalog/providers/openai.toml @@ -1,9 +1,9 @@ [providers.openai] display_name = "OpenAI" adapter = "openai" +api_key_url = "https://platform.openai.com/api-keys" credentials = ["credential:openai", "credential:openai_codex", "env:OPENAI_API_KEY"] priority = 90 -aliases = ["open_ai"] [models."gpt-5.2"] provider = "openai" diff --git a/lib/crates/fabro-model/src/catalog/providers/zai.toml b/lib/crates/fabro-model/src/catalog/providers/zai.toml index b7fac68c9..c862be816 100644 --- a/lib/crates/fabro-model/src/catalog/providers/zai.toml +++ b/lib/crates/fabro-model/src/catalog/providers/zai.toml @@ -1,6 +1,7 @@ [providers.zai] display_name = "Z.ai" adapter = "openai_compatible" +api_key_url = "https://open.bigmodel.cn/usercenter/apikeys" base_url = "https://api.z.ai/api/coding/paas/v4" credentials = ["credential:zai", "env:ZAI_API_KEY"] priority = 60 diff --git a/lib/crates/fabro-model/src/ids.rs b/lib/crates/fabro-model/src/ids.rs index 9f9fcabaa..18d1bdd86 100644 --- a/lib/crates/fabro-model/src/ids.rs +++ b/lib/crates/fabro-model/src/ids.rs @@ -5,6 +5,7 @@ //! keeping wire format compatible with plain strings. use std::fmt; +use std::str::FromStr; use serde::{Deserialize, Serialize}; @@ -21,11 +22,6 @@ impl ProviderId { pub const ANTHROPIC: &'static str = "anthropic"; pub const OPENAI: &'static str = "openai"; pub const GEMINI: &'static str = "gemini"; - pub const KIMI: &'static str = "kimi"; - pub const ZAI: &'static str = "zai"; - pub const MINIMAX: &'static str = "minimax"; - pub const INCEPTION: &'static str = "inception"; - pub const OPENAI_COMPATIBLE: &'static str = "openai_compatible"; /// Construct a provider ID from any string-like value without validation. /// Catalog construction is responsible for canonicalisation; consumers @@ -62,28 +58,8 @@ impl ProviderId { } #[must_use] - pub fn kimi() -> Self { - Self::new(Self::KIMI) - } - - #[must_use] - pub fn zai() -> Self { - Self::new(Self::ZAI) - } - - #[must_use] - pub fn minimax() -> Self { - Self::new(Self::MINIMAX) - } - - #[must_use] - pub fn inception() -> Self { - Self::new(Self::INCEPTION) - } - - #[must_use] - pub fn openai_compatible() -> Self { - Self::new(Self::OPENAI_COMPATIBLE) + pub fn display_name(&self) -> String { + self.0.clone() } } @@ -111,6 +87,14 @@ impl From for ProviderId { } } +impl FromStr for ProviderId { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(Self::from(s)) + } +} + impl AsRef for ProviderId { fn as_ref(&self) -> &str { &self.0 diff --git a/lib/crates/fabro-model/src/lib.rs b/lib/crates/fabro-model/src/lib.rs index a9adada4a..2d96d1cb2 100644 --- a/lib/crates/fabro-model/src/lib.rs +++ b/lib/crates/fabro-model/src/lib.rs @@ -5,12 +5,11 @@ pub mod catalog; pub mod ids; pub mod model_ref; pub mod model_test; -pub mod provider; pub mod reasoning; pub mod types; pub use adapter::{ - AdapterControlCapabilities, AdapterMetadata, AgentProfileKind, ApiKeyHeaderPolicy, + AdapterControlCapabilities, AdapterKind, AdapterMetadata, AgentProfileKind, ApiKeyHeaderPolicy, }; pub use billing::{ AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts, @@ -24,6 +23,5 @@ pub use catalog::{ pub use ids::{ModelId, ProviderId}; pub use model_ref::ModelHandle; pub use model_test::ModelTestMode; -pub use provider::Provider; pub use reasoning::ReasoningEffort; pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits, ReasoningEffortFeature}; diff --git a/lib/crates/fabro-model/src/model_ref.rs b/lib/crates/fabro-model/src/model_ref.rs index 90e643c35..c9560a049 100644 --- a/lib/crates/fabro-model/src/model_ref.rs +++ b/lib/crates/fabro-model/src/model_ref.rs @@ -59,13 +59,13 @@ impl fmt::Debug for ModelHandle { #[cfg(test)] mod tests { use super::*; + use crate::ProviderId; use crate::catalog::Catalog; - use crate::provider::Provider; #[test] fn by_name_display() { let r = ModelHandle::ByName { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model: "claude-opus-4-6".to_string(), }; assert_eq!(r.to_string(), "anthropic:claude-opus-4-6"); @@ -74,11 +74,11 @@ mod tests { #[test] fn by_name_accessors() { let r = ModelHandle::ByName { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model: "gpt-5.4".to_string(), }; assert_eq!(r.model_id(), "gpt-5.4"); - assert_eq!(r.provider(), &Provider::OpenAi.id()); + assert_eq!(r.provider(), &ProviderId::openai()); } #[test] @@ -93,13 +93,13 @@ mod tests { let info = Catalog::builtin().get("gpt-5.4").unwrap().clone(); let r = ModelHandle::Resolved(Arc::new(info)); assert_eq!(r.model_id(), "gpt-5.4"); - assert_eq!(r.provider(), &Provider::OpenAi.id()); + assert_eq!(r.provider(), &ProviderId::openai()); } #[test] fn debug_format() { let r = ModelHandle::ByName { - provider: Provider::Gemini.id(), + provider: ProviderId::gemini(), model: "gemini-3.1-pro-preview".to_string(), }; let debug = format!("{r:?}"); diff --git a/lib/crates/fabro-model/src/provider.rs b/lib/crates/fabro-model/src/provider.rs deleted file mode 100644 index 24fb360fe..000000000 --- a/lib/crates/fabro-model/src/provider.rs +++ /dev/null @@ -1,334 +0,0 @@ -use fabro_static::EnvVars; -use serde::{Deserialize, Serialize}; -use strum::{Display, EnumString, IntoStaticStr}; - -use crate::ids::ProviderId; - -// --------------------------------------------------------------------------- -// Provider enum - built-in provider compatibility -// --------------------------------------------------------------------------- - -/// Known built-in LLM providers. -/// -/// Open-ended product identity is [`ProviderId`], because settings can define -/// additional provider IDs. This enum remains for built-in compatibility -/// paths: install/auth flows, legacy env var mappings, adapter defaults, and -/// tests that intentionally iterate the shipped providers. -#[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Display, - EnumString, - IntoStaticStr, -)] -#[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] -pub enum Provider { - Anthropic, - #[serde(rename = "openai", alias = "open_ai")] - #[strum(to_string = "openai", serialize = "open_ai")] - OpenAi, - Gemini, - Kimi, - Zai, - Minimax, - #[strum(to_string = "inception", serialize = "inception_labs")] - Inception, - #[serde(rename = "openai_compatible", alias = "open_ai_compatible")] - #[strum(to_string = "openai_compatible", serialize = "open_ai_compatible")] - OpenAiCompatible, -} - -impl Provider { - #[must_use] - pub fn id(self) -> ProviderId { - ProviderId::from(<&'static str>::from(self)) - } - - #[must_use] - pub fn from_id(id: &ProviderId) -> Option { - id.as_str().parse().ok() - } - - /// All known provider variants, for use in guardrail tests and iteration. - pub const ALL: &[Self] = &[ - Self::Anthropic, - Self::OpenAi, - Self::Gemini, - Self::Kimi, - Self::Zai, - Self::Minimax, - Self::Inception, - ]; - - /// Environment variable names that can provide the API key for this - /// provider. Gemini accepts either `GEMINI_API_KEY` or - /// `GOOGLE_API_KEY`. - #[must_use] - pub fn api_key_env_vars(self) -> &'static [&'static str] { - match self { - Self::Anthropic => &[EnvVars::ANTHROPIC_API_KEY], - Self::OpenAi => &[EnvVars::OPENAI_API_KEY], - Self::Gemini => &[EnvVars::GEMINI_API_KEY, EnvVars::GOOGLE_API_KEY], - Self::Kimi => &[EnvVars::KIMI_API_KEY], - Self::Zai => &[EnvVars::ZAI_API_KEY], - Self::Minimax => &[EnvVars::MINIMAX_API_KEY], - Self::Inception => &[EnvVars::INCEPTION_API_KEY], - Self::OpenAiCompatible => &[], - } - } - - /// Returns `true` if at least one of the provider's API key env vars is - /// set. - #[must_use] - #[expect( - clippy::disallowed_methods, - reason = "Provider discovery intentionally checks the process env for known API-key names." - )] - pub fn has_api_key(self) -> bool { - self.api_key_env_vars() - .iter() - .any(|var| std::env::var(var).is_ok()) - } - - /// Pick the best default provider based on which API keys are available. - /// - /// Checks Anthropic → OpenAI → Gemini; falls back to Anthropic if none - /// have a key configured. - #[must_use] - pub fn default_from_env() -> Self { - Self::default_with(Self::has_api_key) - } - - /// Pick the best default provider based on an explicit configured list. - #[must_use] - pub fn default_for_configured(configured: &[Self]) -> Self { - Self::default_with(|p| configured.contains(&p)) - } - - /// Testable core of [`default_from_env`]: walks the precedence list and - /// returns the first provider for which `is_configured` returns `true`. - fn default_with(is_configured: impl Fn(Self) -> bool) -> Self { - const PRECEDENCE: [Provider; 3] = [Provider::Anthropic, Provider::OpenAi, Provider::Gemini]; - PRECEDENCE - .iter() - .copied() - .find(|&p| is_configured(p)) - .unwrap_or(Self::Anthropic) - } - - /// Human-readable display name for the provider. - #[must_use] - pub fn display_name(self) -> &'static str { - match self { - Self::Anthropic => "Anthropic", - Self::OpenAi => "OpenAI", - Self::Gemini => "Gemini", - Self::Kimi => "Kimi", - Self::Zai => "Zai", - Self::Minimax => "Minimax", - Self::Inception => "Inception", - Self::OpenAiCompatible => "OpenAI Compatible", - } - } - - #[must_use] - pub fn display_name_for_id(id: &ProviderId) -> String { - Self::from_id(id).map_or_else( - || id.to_string(), - |provider| provider.display_name().to_string(), - ) - } -} - -impl From for ProviderId { - fn from(provider: Provider) -> Self { - provider.id() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_kimi() { - assert_eq!("kimi".parse::().unwrap(), Provider::Kimi); - } - - #[test] - fn provider_id_preserves_canonical_builtin_strings() { - assert_eq!(Provider::Anthropic.id().as_str(), ProviderId::ANTHROPIC); - assert_eq!(Provider::OpenAi.id().as_str(), ProviderId::OPENAI); - assert_eq!(Provider::Gemini.id().as_str(), ProviderId::GEMINI); - assert_eq!(Provider::Kimi.id().as_str(), ProviderId::KIMI); - assert_eq!(Provider::Zai.id().as_str(), ProviderId::ZAI); - assert_eq!(Provider::Minimax.id().as_str(), ProviderId::MINIMAX); - assert_eq!(Provider::Inception.id().as_str(), ProviderId::INCEPTION); - assert_eq!( - Provider::OpenAiCompatible.id().as_str(), - ProviderId::OPENAI_COMPATIBLE, - ); - } - - #[test] - fn provider_from_id_accepts_builtins_and_rejects_custom_ids() { - assert_eq!( - Provider::from_id(&ProviderId::openai()), - Some(Provider::OpenAi) - ); - assert_eq!(Provider::from_id(&ProviderId::new("venice")), None); - } - - #[test] - fn parse_zai() { - assert_eq!("zai".parse::().unwrap(), Provider::Zai); - } - - #[test] - fn parse_minimax() { - assert_eq!("minimax".parse::().unwrap(), Provider::Minimax); - } - - #[test] - fn kimi_as_str() { - assert_eq!(Provider::Kimi.to_string(), "kimi"); - assert_eq!(<&'static str>::from(Provider::Kimi), "kimi"); - } - - #[test] - fn zai_as_str() { - assert_eq!(Provider::Zai.to_string(), "zai"); - assert_eq!(<&'static str>::from(Provider::Zai), "zai"); - } - - #[test] - fn minimax_as_str() { - assert_eq!(Provider::Minimax.to_string(), "minimax"); - assert_eq!(<&'static str>::from(Provider::Minimax), "minimax"); - } - - #[test] - fn parse_inception() { - assert_eq!( - "inception".parse::().unwrap(), - Provider::Inception - ); - assert_eq!( - "inception_labs".parse::().unwrap(), - Provider::Inception - ); - } - - #[test] - fn inception_as_str() { - assert_eq!(Provider::Inception.to_string(), "inception"); - assert_eq!(<&'static str>::from(Provider::Inception), "inception"); - } - - #[test] - fn default_with_all_configured_prefers_anthropic() { - assert_eq!(Provider::default_with(|_| true), Provider::Anthropic); - } - - #[test] - fn default_with_only_openai() { - assert_eq!( - Provider::default_with(|p| p == Provider::OpenAi), - Provider::OpenAi - ); - } - - #[test] - fn default_for_configured_only_openai() { - assert_eq!( - Provider::default_for_configured(&[Provider::OpenAi]), - Provider::OpenAi - ); - } - - #[test] - fn default_with_only_gemini() { - assert_eq!( - Provider::default_with(|p| p == Provider::Gemini), - Provider::Gemini - ); - } - - #[test] - fn default_with_openai_and_gemini_prefers_openai() { - assert_eq!( - Provider::default_with(|p| p == Provider::OpenAi || p == Provider::Gemini), - Provider::OpenAi, - ); - } - - #[test] - fn default_with_none_configured_falls_back_to_anthropic() { - assert_eq!(Provider::default_with(|_| false), Provider::Anthropic); - } - - #[test] - fn default_with_only_kimi_falls_back_to_anthropic() { - assert_eq!( - Provider::default_with(|p| p == Provider::Kimi), - Provider::Anthropic - ); - } - - #[test] - fn api_key_env_vars_anthropic() { - assert_eq!(Provider::Anthropic.api_key_env_vars(), &[ - "ANTHROPIC_API_KEY" - ]); - } - - #[test] - fn api_key_env_vars_openai() { - assert_eq!(Provider::OpenAi.api_key_env_vars(), &["OPENAI_API_KEY"]); - } - - #[test] - fn api_key_env_vars_gemini_has_two() { - let vars = Provider::Gemini.api_key_env_vars(); - assert_eq!(vars.len(), 2); - assert_eq!(vars, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]); - } - - #[test] - fn api_key_env_vars_kimi() { - assert_eq!(Provider::Kimi.api_key_env_vars(), &["KIMI_API_KEY"]); - } - - #[test] - fn api_key_env_vars_zai() { - assert_eq!(Provider::Zai.api_key_env_vars(), &["ZAI_API_KEY"]); - } - - #[test] - fn api_key_env_vars_minimax() { - assert_eq!(Provider::Minimax.api_key_env_vars(), &["MINIMAX_API_KEY"]); - } - - #[test] - fn api_key_env_vars_inception() { - assert_eq!(Provider::Inception.api_key_env_vars(), &[ - "INCEPTION_API_KEY" - ]); - } - - #[test] - fn every_provider_has_at_least_one_env_var() { - assert!( - Provider::ALL - .iter() - .all(|p| !p.api_key_env_vars().is_empty()) - ); - } -} diff --git a/lib/crates/fabro-model/src/types.rs b/lib/crates/fabro-model/src/types.rs index 8f754160a..2f56c7e13 100644 --- a/lib/crates/fabro-model/src/types.rs +++ b/lib/crates/fabro-model/src/types.rs @@ -1,7 +1,6 @@ use serde::{Deserialize, Serialize}; use crate::ids::ProviderId; -use crate::provider::Provider; // --- 2.9 Model --- @@ -44,10 +43,6 @@ pub struct ModelFeatures { /// Whether this model endpoint supports prompt caching annotations. #[serde(default)] pub prompt_cache: bool, - /// Deprecated compatibility bool equivalent to - /// `reasoning_effort == "levels"`. - #[serde(default)] - pub effort: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -88,10 +83,6 @@ impl Model { &self.provider } - pub fn builtin_provider(&self) -> Option { - Provider::from_id(&self.provider) - } - pub fn family(&self) -> &str { &self.family } @@ -124,10 +115,6 @@ impl Model { self.features.reasoning_effort == ReasoningEffortFeature::Levels } - pub fn supports_effort(&self) -> bool { - self.supports_reasoning_effort() - } - pub fn supports_prompt_cache(&self) -> bool { self.features.prompt_cache } @@ -168,13 +155,13 @@ impl Model { #[cfg(test)] mod tests { use crate::catalog::Catalog; - use crate::provider::Provider; + use crate::ids::ProviderId; #[test] fn inherent_methods_return_correct_values() { let info = Catalog::builtin().get("claude-opus-4-7").unwrap(); assert_eq!(info.id(), "claude-opus-4-7"); - assert_eq!(info.provider(), &Provider::Anthropic.id()); + assert_eq!(info.provider(), &ProviderId::anthropic()); assert_eq!(info.family(), "claude-4"); assert_eq!(info.display_name(), "Claude Opus 4.7"); assert_eq!(info.context_window(), 1_000_000); @@ -182,7 +169,7 @@ mod tests { assert!(info.supports_tools()); assert!(info.supports_vision()); assert!(info.supports_reasoning()); - assert!(info.supports_effort()); + assert!(info.supports_reasoning_effort()); assert_eq!(info.training(), Some("2025-08-01")); assert_eq!(info.knowledge_cutoff(), Some("May 2025")); assert_eq!(info.input_cost_per_mtok(), Some(5.0)); @@ -192,11 +179,4 @@ mod tests { assert!(!info.aliases().is_empty()); assert!(!info.is_default()); } - - #[test] - fn builtin_provider_matches_known_static_provider_ids() { - for info in Catalog::builtin().list(None) { - assert_eq!(info.builtin_provider(), Provider::from_id(info.provider())); - } - } } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index eb8cb4fb7..dc91f0855 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1008,11 +1008,11 @@ mod runs { .collect() } - fn billing_model(provider: fabro_model::Provider, model_id: &str) -> BillingModelRef { + fn billing_model(provider: fabro_model::ProviderId, model_id: &str) -> BillingModelRef { BillingModelRef { - provider: provider.id(), + provider, model_id: model_id.into(), - speed: None, + speed: None, } } @@ -1381,7 +1381,7 @@ mod runs { EventBody::AgentMessage(AgentMessageProps { text: "I'll start by loading the environment configurations for both production and staging to compare them.".into(), model: fabro_model::ModelRef { - provider: fabro_model::Provider::Anthropic.id(), + provider: fabro_model::ProviderId::anthropic(), model_id: "claude-opus-4-6".into(), speed: None, }, @@ -1438,7 +1438,7 @@ mod runs { EventBody::AgentMessage(AgentMessageProps { text: "I've detected drift in 3 resources between production and staging:\n\n1. **redis.max_connections** — production has 200, staging has 100\n2. **redis.tls** — enabled in production, disabled in staging\n3. **iam.session_duration** — production uses 3600s, staging uses 1800s".into(), model: fabro_model::ModelRef { - provider: fabro_model::Provider::Anthropic.id(), + provider: fabro_model::ProviderId::anthropic(), model_id: "claude-opus-4-6".into(), speed: None, }, @@ -1459,7 +1459,7 @@ mod runs { name: "Detect Drift".into(), }, model: Some(billing_model( - fabro_model::Provider::Anthropic, + fabro_model::ProviderId::anthropic(), "claude-opus-4-6", )), billing: BilledTokenCounts { @@ -1481,7 +1481,7 @@ mod runs { name: "Propose Changes".into(), }, model: Some(billing_model( - fabro_model::Provider::Gemini, + fabro_model::ProviderId::gemini(), "gemini-3.1-pro-preview", )), billing: BilledTokenCounts { @@ -1503,7 +1503,7 @@ mod runs { name: "Review Changes".into(), }, model: Some(billing_model( - fabro_model::Provider::OpenAi, + fabro_model::ProviderId::openai(), "gpt-5.3-codex", )), billing: BilledTokenCounts { @@ -1525,7 +1525,7 @@ mod runs { name: "Apply Changes".into(), }, model: Some(billing_model( - fabro_model::Provider::Anthropic, + fabro_model::ProviderId::anthropic(), "claude-opus-4-6", )), billing: BilledTokenCounts { @@ -1563,7 +1563,7 @@ mod runs { total_tokens: 43470, total_usd_micros: Some(1_350_000), }, - model: billing_model(fabro_model::Provider::Anthropic, "claude-opus-4-6"), + model: billing_model(fabro_model::ProviderId::anthropic(), "claude-opus-4-6"), stages: 2, }, BillingByModel { @@ -1576,7 +1576,10 @@ mod runs { total_tokens: 37390, total_usd_micros: Some(720_000), }, - model: billing_model(fabro_model::Provider::Gemini, "gemini-3.1-pro-preview"), + model: billing_model( + fabro_model::ProviderId::gemini(), + "gemini-3.1-pro-preview", + ), stages: 1, }, BillingByModel { @@ -1589,7 +1592,7 @@ mod runs { total_tokens: 11760, total_usd_micros: Some(190_000), }, - model: billing_model(fabro_model::Provider::OpenAi, "gpt-5.3-codex"), + model: billing_model(fabro_model::ProviderId::openai(), "gpt-5.3-codex"), stages: 1, }, ], @@ -1909,11 +1912,11 @@ mod workflows { mod billing { use fabro_api::types::*; - fn billing_model(provider: fabro_model::Provider, model_id: &str) -> BillingModelRef { + fn billing_model(provider: fabro_model::ProviderId, model_id: &str) -> BillingModelRef { BillingModelRef { - provider: provider.id(), + provider, model_id: model_id.into(), - speed: None, + speed: None, } } @@ -1941,7 +1944,7 @@ mod billing { total_tokens: 391_230, total_usd_micros: Some(12_150_000), }, - model: billing_model(fabro_model::Provider::Anthropic, "claude-opus-4-6"), + model: billing_model(fabro_model::ProviderId::anthropic(), "claude-opus-4-6"), stages: 18, }, BillingByModel { @@ -1954,7 +1957,10 @@ mod billing { total_tokens: 336_510, total_usd_micros: Some(6_480_000), }, - model: billing_model(fabro_model::Provider::Gemini, "gemini-3.1-pro-preview"), + model: billing_model( + fabro_model::ProviderId::gemini(), + "gemini-3.1-pro-preview", + ), stages: 9, }, BillingByModel { @@ -1967,7 +1973,7 @@ mod billing { total_tokens: 105_840, total_usd_micros: Some(1_710_000), }, - model: billing_model(fabro_model::Provider::OpenAi, "gpt-5.3-codex"), + model: billing_model(fabro_model::ProviderId::openai(), "gpt-5.3-codex"), stages: 9, }, ], diff --git a/lib/crates/fabro-server/src/diagnostics.rs b/lib/crates/fabro-server/src/diagnostics.rs index ea1aead56..2dd4f9ccc 100644 --- a/lib/crates/fabro-server/src/diagnostics.rs +++ b/lib/crates/fabro-server/src/diagnostics.rs @@ -5,7 +5,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use fabro_auth::auth_issue_message; use fabro_llm::client::Client as LlmClient; use fabro_llm::types::{Message, Request}; -use fabro_model::{Catalog, Provider}; +use fabro_model::{Catalog, ProviderId}; use fabro_sandbox::daytona; use fabro_static::EnvVars; use fabro_types::settings::server::GithubIntegrationStrategy; @@ -112,20 +112,21 @@ async fn check_llm_providers(state: &AppState) -> CheckResult { details.push(CheckDetail::new(message)); } - let providers: Vec = result + let providers: Vec = result .client .provider_names() .iter() - .filter_map(|name| name.parse::().ok()) + .map(|name| ProviderId::new(*name)) .collect(); let client = &result.client; let catalog = state.catalog(); - let probe_outcomes = join_all(providers.iter().map(|&provider| { + let probe_outcomes = join_all(providers.iter().map(|provider| { let catalog = catalog.clone(); + let provider = provider.clone(); async move { let outcome = timeout( Duration::from_secs(30), - probe_llm_provider(client, provider, catalog.as_ref()), + probe_llm_provider(client, &provider, catalog.as_ref()), ) .await; (provider, outcome) @@ -204,7 +205,7 @@ fn short_error_line(rendered: &str) -> String { } } -fn probe_model(provider: Provider, catalog: &Catalog) -> String { +fn probe_model(provider: &ProviderId, catalog: &Catalog) -> String { catalog .probe_for_provider(provider) .map_or_else(|| format!("unknown-{provider}"), |m| m.id.clone()) @@ -212,7 +213,7 @@ fn probe_model(provider: Provider, catalog: &Catalog) -> String { async fn probe_llm_provider( client: &LlmClient, - provider: Provider, + provider: &ProviderId, catalog: &Catalog, ) -> fabro_llm::Result<()> { let request = Request { @@ -725,7 +726,7 @@ mod tests { }, ); let credential = AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::ApiKey { key: "vault-openai-key".to_string(), }, diff --git a/lib/crates/fabro-server/src/install.rs b/lib/crates/fabro-server/src/install.rs index 32e7a5e9d..2e5a9502a 100644 --- a/lib/crates/fabro-server/src/install.rs +++ b/lib/crates/fabro-server/src/install.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::convert::Infallible; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; use std::time::{Duration, Instant}; use anyhow::{Context as _, anyhow, bail}; @@ -13,7 +13,9 @@ use axum::routing::{get, post, put}; use axum::{Json, Router, middleware}; use base64::Engine as _; use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD}; -use fabro_auth::{AuthCredential, AuthDetails, credential_id_for}; +use fabro_auth::{ + ApiKeyHeader, AuthCredential, AuthDetails, build_api_key_header, credential_id_for, +}; use fabro_config::Storage; use fabro_config::bind::{Bind, BindRequest}; use fabro_config::envfile::EnvFileUpdate; @@ -23,7 +25,8 @@ use fabro_install::{ merge_server_settings, persist_install_outputs_direct, write_github_app_settings, write_object_store_settings, write_sandbox_settings, write_token_settings, }; -use fabro_model::Provider; +use fabro_model::catalog::CatalogProvider; +use fabro_model::{AdapterKind, Catalog, CredentialRef, ProviderId}; use fabro_sandbox::daytona; use fabro_static::EnvVars; use fabro_store::ArtifactStore; @@ -75,7 +78,7 @@ pub type InstallFinishHook = Arc anyhow::Result<() #[derive(Clone, Debug, Default)] struct InstallUpstreamConfig { - provider_base_urls: HashMap, + provider_base_urls: HashMap, github_api_base_url: Option, daytona_api_base_url: Option, daytona_organization_id: Option, @@ -96,6 +99,10 @@ const REDACTED_SECRET_VALUE: &str = "[REDACTED]"; const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20); const VALIDATION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +static INSTALL_CATALOG: LazyLock = LazyLock::new(|| { + Catalog::from_builtin().expect("embedded install model catalog should be valid") +}); + impl InstallAppState { #[must_use] pub fn new(token: String, storage_dir: &Path, config_path: &Path) -> Self { @@ -190,12 +197,12 @@ impl InstallAppState { #[must_use] pub fn with_provider_base_url( mut self, - provider: Provider, + provider: impl Into, base_url: impl Into, ) -> Self { self.upstreams .provider_base_urls - .insert(provider, base_url.into()); + .insert(provider.into(), base_url.into()); self } @@ -248,7 +255,7 @@ struct LlmProvidersInput { #[derive(Clone, Debug, Deserialize, Serialize)] struct LlmProviderInput { - provider: Provider, + provider: ProviderId, api_key: String, } @@ -487,7 +494,7 @@ struct GithubAppInstall { #[derive(Clone, Debug, Deserialize)] struct InstallLlmTestInput { - provider: Provider, + provider: ProviderId, api_key: String, } @@ -773,7 +780,7 @@ async fn post_install_llm_test( } observe_operator(&state, &headers); - if let Some(error) = unsupported_install_provider_error(input.provider) { + if let Err(error) = install_catalog_provider(&input.provider) { return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, error); } @@ -804,7 +811,7 @@ async fn put_install_llm( // completed with zero credentials. `/install/finish` still requires the // step to be present, just not populated. for provider in &input.providers { - if let Some(error) = unsupported_install_provider_error(provider.provider) { + if let Err(error) = install_catalog_provider(&provider.provider) { return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, error); } if provider.api_key.trim().is_empty() { @@ -820,10 +827,23 @@ async fn put_install_llm( StatusCode::NO_CONTENT.into_response() } -fn unsupported_install_provider_error(provider: Provider) -> Option<&'static str> { - match provider { - Provider::OpenAiCompatible => Some("openai_compatible is not supported by install in v1"), - _ => None, +fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogProvider, String> { + let catalog_provider = INSTALL_CATALOG + .provider(provider) + .ok_or_else(|| format!("provider '{provider}' is not configured in the model catalog"))?; + let supports_api_key = catalog_provider.credentials.iter().any(|credential| { + matches!( + credential, + CredentialRef::Credential(_) | CredentialRef::Env(_) + ) + }); + if supports_api_key { + Ok(catalog_provider) + } else { + Err(format!( + "provider '{}' does not define an API-key credential path", + catalog_provider.id + )) } } @@ -1525,7 +1545,7 @@ async fn post_install_finish( } for provider in llm.providers { let credential = AuthCredential { - provider: provider.provider.id(), + provider: provider.provider, details: AuthDetails::ApiKey { key: provider.api_key, }, @@ -1888,7 +1908,7 @@ fn redacted_llm(pending_install: &PendingInstall) -> serde_json::Value { |llm| { serde_json::json!({ "providers": llm.providers.iter().map(|provider| serde_json::json!({ - "provider": <&'static str>::from(provider.provider), + "provider": provider.provider.to_string(), "configured": true, })).collect::>() }) @@ -2041,27 +2061,17 @@ async fn validate_llm_provider( state: &InstallAppState, input: &InstallLlmTestInput, ) -> anyhow::Result<()> { - let (auth_header, auth_value) = match input.provider { - Provider::Anthropic => ("x-api-key", input.api_key.clone()), - Provider::OpenAi => ("Authorization", format!("Bearer {}", input.api_key)), - Provider::Gemini => ("x-goog-api-key", input.api_key.clone()), - Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => { - bail!("{} is not supported by install validation", input.provider); - } - }; - - let base_url = provider_base_url(state, input.provider); + let provider = install_catalog_provider(&input.provider).map_err(anyhow::Error::msg)?; + let auth_header = build_api_key_header( + provider.adapter.metadata().api_key_header, + input.api_key.clone(), + ); + let base_url = provider_base_url(state, provider)?; let endpoint = install_upstream_endpoint(&base_url, &["models"])?; let client = install_http_client_for_url(&base_url)?; - let mut request = client - .get(endpoint) - .header(auth_header, auth_value) - .header("User-Agent", "fabro-server"); - if matches!(input.provider, Provider::Anthropic) { + let mut request = client.get(endpoint).header("User-Agent", "fabro-server"); + request = add_api_key_header(request, auth_header); + if provider.adapter == AdapterKind::Anthropic { request = request.header("anthropic-version", "2023-06-01"); } @@ -2085,29 +2095,44 @@ async fn validate_llm_provider( clippy::disallowed_methods, reason = "Install flow checks documented provider base-url overrides while building defaults." )] -fn provider_base_url(state: &InstallAppState, provider: Provider) -> String { +fn provider_base_url( + state: &InstallAppState, + provider: &CatalogProvider, +) -> anyhow::Result { state .upstreams .provider_base_urls - .get(&provider) + .get(&provider.id) .cloned() - .or_else(|| match provider { - Provider::Anthropic => std::env::var(EnvVars::ANTHROPIC_BASE_URL).ok(), - Provider::OpenAi => std::env::var(EnvVars::OPENAI_BASE_URL).ok(), - Provider::Gemini => std::env::var(EnvVars::GEMINI_BASE_URL).ok(), - Provider::Kimi | Provider::Zai | Provider::Minimax | Provider::Inception => None, - Provider::OpenAiCompatible => std::env::var(EnvVars::OPENAI_COMPATIBLE_BASE_URL).ok(), + .or_else(|| match provider.id.as_str() { + ProviderId::ANTHROPIC => std::env::var(EnvVars::ANTHROPIC_BASE_URL).ok(), + ProviderId::OPENAI => std::env::var(EnvVars::OPENAI_BASE_URL).ok(), + ProviderId::GEMINI => std::env::var(EnvVars::GEMINI_BASE_URL).ok(), + _ => None, }) - .unwrap_or_else(|| match provider { - Provider::Anthropic => DEFAULT_ANTHROPIC_BASE_URL.to_string(), - Provider::OpenAi => DEFAULT_OPENAI_BASE_URL.to_string(), - Provider::Gemini => DEFAULT_GEMINI_BASE_URL.to_string(), - Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => String::new(), + .or_else(|| provider.base_url.clone()) + .or_else(|| match provider.adapter { + AdapterKind::Anthropic => Some(DEFAULT_ANTHROPIC_BASE_URL.to_string()), + AdapterKind::OpenAi => Some(DEFAULT_OPENAI_BASE_URL.to_string()), + AdapterKind::Gemini => Some(DEFAULT_GEMINI_BASE_URL.to_string()), + AdapterKind::OpenAiCompatible => None, }) + .with_context(|| { + format!( + "provider '{}' does not define an install validation base URL", + provider.id + ) + }) +} + +fn add_api_key_header( + request: fabro_http::RequestBuilder, + auth_header: ApiKeyHeader, +) -> fabro_http::RequestBuilder { + match auth_header { + ApiKeyHeader::Bearer(value) => request.header("Authorization", format!("Bearer {value}")), + ApiKeyHeader::Custom { name, value } => request.header(name, value), + } } async fn validate_github_token(state: &InstallAppState, token: &str) -> anyhow::Result { diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 342517c3c..590a54cee 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -1313,7 +1313,7 @@ fn report_to_api(report: &CheckReport) -> types::PreflightCheckReport { #[cfg(test)] mod tests { - use fabro_model::Provider; + use fabro_model::ProviderId; use fabro_model::catalog::LlmCatalogSettings; use super::*; @@ -1369,7 +1369,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()) + Arc::new(Catalog::from_builtin().unwrap()) } fn manifest_workflow() -> types::ManifestWorkflow { @@ -1436,7 +1436,7 @@ enabled = {clone_enabled} prepared.settings.clone(), validated.graph(), Catalog::builtin(), - &[Provider::Anthropic.id()], + &[ProviderId::anthropic()], ) .run; @@ -2100,7 +2100,7 @@ provider = "daytona" .set( "openai", &serde_json::to_string(&fabro_auth::AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: fabro_auth::AuthDetails::ApiKey { key: "test-openai-key".to_string(), }, @@ -2196,7 +2196,7 @@ digraph Demo { #[tokio::test] async fn preflight_resolves_model_aliases_from_app_state_catalog() { - let llm_catalog_settings: fabro_model::catalog::LlmCatalogSettings = toml::from_str( + let llm_catalog_settings: LlmCatalogSettings = toml::from_str( r#" [providers.acme] display_name = "Acme" @@ -2218,7 +2218,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ) .expect("catalog fixture should parse"); diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 27faff8bf..142b88b50 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -17,7 +17,7 @@ use fabro_interview::{ }; use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest}; use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, ModelRef, Provider, Speed}; +use fabro_model::{Catalog, ModelRef, ProviderId, Speed}; use fabro_types::settings::ServerAuthMethod; use fabro_types::{ AttrValue, AuthMethod, CommandTermination, FailureCategory, FailureDetail, Graph, @@ -94,10 +94,7 @@ fn spa_fixture_root() -> PathBuf { } fn state_test_catalog() -> Arc { - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ) + Arc::new(Catalog::from_builtin().expect("default catalog should build")) } fn test_app_with_scheduler(state: Arc) -> Router { @@ -194,7 +191,7 @@ async fn mock_daytona_current_key<'a>( fn openai_api_key_credential(key: &str) -> AuthCredential { AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::ApiKey { key: key.to_string(), }, @@ -1023,7 +1020,7 @@ async fn create_secret_stores_valid_credential_entries() { let state = test_app_state(); let app = crate::test_support::build_test_router(Arc::clone(&state)); let credential = fabro_auth::AuthCredential { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), details: fabro_auth::AuthDetails::CodexOAuth { tokens: fabro_auth::OAuthTokens { access_token: "access".to_string(), @@ -1286,7 +1283,7 @@ async fn llm_source_configured_providers_reads_openai_codex_from_vault() { .llm_source .configured_providers(catalog.as_ref()) .await, - vec![Provider::OpenAi.id()] + vec![ProviderId::openai()] ); } @@ -2282,7 +2279,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ) .expect("catalog fixture should parse"); @@ -2769,9 +2765,7 @@ fn test_billed_usage( "output_tokens": output_tokens } }, - "facts": { - "provider": "open_ai" - } + "facts": { "algorithm": "openai" } }, "total_usd_micros": input_tokens + output_tokens })) @@ -4043,7 +4037,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ) .expect("catalog fixture should parse"); @@ -8499,7 +8492,7 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() { agg.total_runs = 1; agg.by_model.insert( ModelRef { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model_id: "claude-opus-4-6".to_string(), speed: None, }, @@ -8518,7 +8511,7 @@ async fn get_aggregate_billing_returns_provider_model_speed_identity() { ); agg.by_model.insert( ModelRef { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model_id: "claude-opus-4-6".to_string(), speed: Some(Speed::Fast), }, @@ -8585,7 +8578,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { by_model: vec![ fabro_workflow::ProjectionBillingByModel { model: ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "gpt-5.4".to_string(), speed: None, }, @@ -8602,7 +8595,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { }, fabro_workflow::ProjectionBillingByModel { model: ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "gpt-5.4".to_string(), speed: Some(Speed::Fast), }, @@ -8629,7 +8622,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { assert_eq!(accumulator.by_model.len(), 2); assert_eq!( accumulator.by_model[&ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "gpt-5.4".to_string(), speed: None, }] @@ -8638,7 +8631,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { ); assert_eq!( accumulator.by_model[&ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "gpt-5.4".to_string(), speed: None, }] @@ -8648,7 +8641,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { ); assert_eq!( accumulator.by_model[&ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "gpt-5.4".to_string(), speed: Some(Speed::Fast), }] @@ -8657,7 +8650,7 @@ fn aggregate_billing_counts_projection_rollup_usage_visits() { ); assert_eq!( accumulator.by_model[&ModelRef { - provider: Provider::OpenAi.id(), + provider: ProviderId::openai(), model_id: "gpt-5.4".to_string(), speed: Some(Speed::Fast), }] @@ -9607,7 +9600,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ) .expect("catalog fixture should parse"); diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index 153342491..dbde7e70e 100644 --- a/lib/crates/fabro-server/tests/it/api/install.rs +++ b/lib/crates/fabro-server/tests/it/api/install.rs @@ -13,7 +13,7 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_config::{ServerSettingsBuilder, Storage}; use fabro_install::OBJECT_STORE_MANAGED_COMMENT; -use fabro_model::Provider; +use fabro_model::ProviderId; use fabro_server::install::{ InstallAppState, InstallFinishHook, InstallFinishInfo, build_install_router, }; @@ -1246,6 +1246,60 @@ async fn token_install_finish_invokes_shutdown_callback_after_accepting() { assert!(callback_invoked.load(Ordering::Acquire)); } +#[tokio::test] +async fn install_llm_accepts_catalog_openai_compatible_provider() { + let llm_mock = MockServer::start_async().await; + llm_mock + .mock_async(|when, then| { + when.method("GET") + .path("/v1/models") + .header("authorization", "Bearer kimi-test-key"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"data":[{"id":"kimi-k2-5"}]}"#); + }) + .await; + + let app = build_install_router( + InstallAppState::for_test("test-install-token") + .with_provider_base_url(ProviderId::new("kimi"), format!("{}/v1", llm_mock.url(""))), + ); + + let test_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/install/llm/test") + .header("authorization", "Bearer test-install-token") + .header("content-type", "application/json") + .body(Body::from( + r#"{"provider":"kimi","api_key":"kimi-test-key"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(test_response, StatusCode::OK, "POST /install/llm/test").await; + assert_eq!(body["ok"], true); + + let put_response = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/install/llm") + .header("authorization", "Bearer test-install-token") + .header("content-type", "application/json") + .body(Body::from( + r#"{"providers":[{"provider":"kimi","api_key":"kimi-test-key"}]}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + response_status(put_response, StatusCode::NO_CONTENT, "PUT /install/llm").await; +} + #[tokio::test] async fn install_validation_endpoints_validate_credentials_and_github_token() { let llm_mock = MockServer::start_async().await; @@ -1279,7 +1333,7 @@ async fn install_validation_endpoints_validate_credentials_and_github_token() { let app = build_install_router( InstallAppState::for_test("test-install-token") - .with_provider_base_url(Provider::Anthropic, format!("{}/v1", llm_mock.url(""))) + .with_provider_base_url(ProviderId::anthropic(), format!("{}/v1", llm_mock.url(""))) .with_github_api_base_url(github_mock.url("")), ); diff --git a/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs b/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs index c25574c48..b6ca0cb65 100644 --- a/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs +++ b/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs @@ -6,7 +6,7 @@ use tower::ServiceExt; use crate::helpers::response_json; #[tokio::test] -async fn install_llm_endpoints_reject_openai_compatible_in_v1() { +async fn install_llm_endpoints_reject_non_catalog_openai_compatible_provider() { let app = build_install_router(InstallAppState::for_test("test-install-token")); let test_response = app @@ -32,7 +32,7 @@ async fn install_llm_endpoints_reject_openai_compatible_in_v1() { .await; assert_eq!( test_body["errors"][0]["detail"], - "openai_compatible is not supported by install in v1" + "provider 'openai_compatible' is not configured in the model catalog" ); let put_response = app @@ -57,6 +57,6 @@ async fn install_llm_endpoints_reject_openai_compatible_in_v1() { .await; assert_eq!( put_body["errors"][0]["detail"], - "openai_compatible is not supported by install in v1" + "provider 'openai_compatible' is not configured in the model catalog" ); } diff --git a/lib/crates/fabro-static/src/env_vars.rs b/lib/crates/fabro-static/src/env_vars.rs index f1c5937d7..eec275f72 100644 --- a/lib/crates/fabro-static/src/env_vars.rs +++ b/lib/crates/fabro-static/src/env_vars.rs @@ -52,7 +52,6 @@ impl EnvVars { pub const MINIMAX_API_KEY: &'static str = "MINIMAX_API_KEY"; pub const OPENAI_API_KEY: &'static str = "OPENAI_API_KEY"; pub const OPENAI_BASE_URL: &'static str = "OPENAI_BASE_URL"; - pub const OPENAI_COMPATIBLE_BASE_URL: &'static str = "OPENAI_COMPATIBLE_BASE_URL"; pub const OPENAI_ORGANIZATION: &'static str = "OPENAI_ORGANIZATION"; pub const OPENAI_PROJECT: &'static str = "OPENAI_PROJECT"; pub const OPENAI_ORG_ID: &'static str = "OPENAI_ORG_ID"; @@ -190,7 +189,6 @@ mod tests { EnvVars::MINIMAX_API_KEY, EnvVars::OPENAI_API_KEY, EnvVars::OPENAI_BASE_URL, - EnvVars::OPENAI_COMPATIBLE_BASE_URL, EnvVars::OPENAI_ORGANIZATION, EnvVars::OPENAI_PROJECT, EnvVars::OPENAI_ORG_ID, diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 62a8966dd..cec38ed97 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -1000,9 +1000,7 @@ mod tests { "output_tokens": output_tokens } }, - "facts": { - "provider": "open_ai" - } + "facts": { "algorithm": "openai" } }, "total_usd_micros": input_tokens + output_tokens })) @@ -2816,7 +2814,7 @@ mod tests { "cache_write_tokens": 4 } }, - "facts": { "provider": "open_ai" } + "facts": { "algorithm": "openai" } }, "total_usd_micros": 123 })) diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs index eadda42f3..a580a74f7 100644 --- a/lib/crates/fabro-store/tests/serializable_projection.rs +++ b/lib/crates/fabro-store/tests/serializable_projection.rs @@ -66,9 +66,7 @@ fn sample_usage() -> BilledModelUsage { "output_tokens": 45 } }, - "facts": { - "provider": "open_ai" - } + "facts": { "algorithm": "openai" } }, "total_usd_micros": 168 })) diff --git a/lib/crates/fabro-types/src/graph.rs b/lib/crates/fabro-types/src/graph.rs index 786fe269e..e4d766c62 100644 --- a/lib/crates/fabro-types/src/graph.rs +++ b/lib/crates/fabro-types/src/graph.rs @@ -233,11 +233,6 @@ impl Node { self.int_attr("max_tokens").filter(|&v| v > 0) } - #[must_use] - pub fn reasoning_effort(&self) -> &str { - self.str_attr("reasoning_effort").unwrap_or("high") - } - #[must_use] pub fn speed(&self) -> Option<&str> { self.str_attr("speed") @@ -589,7 +584,6 @@ mod tests { assert_eq!(node.timeout(), None); assert_eq!(node.model(), None); assert_eq!(node.provider(), None); - assert_eq!(node.reasoning_effort(), "high"); assert_eq!(node.speed(), None); assert!(!node.auto_status()); assert!(!node.allow_partial()); diff --git a/lib/crates/fabro-validate/src/lib.rs b/lib/crates/fabro-validate/src/lib.rs index 5b342d518..55294bc1d 100644 --- a/lib/crates/fabro-validate/src/lib.rs +++ b/lib/crates/fabro-validate/src/lib.rs @@ -195,7 +195,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ) .unwrap(); diff --git a/lib/crates/fabro-workflow/src/billing_rollup.rs b/lib/crates/fabro-workflow/src/billing_rollup.rs index 802c1f4a0..a91b06061 100644 --- a/lib/crates/fabro-workflow/src/billing_rollup.rs +++ b/lib/crates/fabro-workflow/src/billing_rollup.rs @@ -147,9 +147,7 @@ mod tests { "output_tokens": output_tokens } }, - "facts": { - "provider": "open_ai" - } + "facts": { "algorithm": "openai" } }, "total_usd_micros": input_tokens + output_tokens })) diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 3fff23db7..3ef33c89f 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -1,6 +1,5 @@ use ::fabro_types::{ - BilledTokenCounts, EventBody, RunControlAction, RunEvent, RunId, StageOutcome, - run_event as fabro_types, + EventBody, RunControlAction, RunEvent, RunId, StageOutcome, run_event as fabro_types, }; use chrono::Utc; use fabro_agent::{AgentEvent, SandboxEvent}; @@ -8,7 +7,7 @@ use uuid::Uuid; use super::Event; use super::stored_fields::stored_event_fields; -use crate::outcome::unpriced_model_usage_from_llm; +use crate::outcome::billed_token_counts_from_llm; use crate::stage_scope::StageScope; fn stage_status_from_string(status: &str) -> StageOutcome { @@ -550,8 +549,7 @@ fn event_body_from_event(event: &Event) -> EventBody { usage, tool_call_count, } => { - let billed = unpriced_model_usage_from_llm(model.clone(), usage); - let billing = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&billed)); + let billing = billed_token_counts_from_llm(usage); EventBody::AgentMessage(fabro_types::AgentMessageProps { text: text.clone(), model: model.clone(), @@ -1299,7 +1297,7 @@ mod tests { use chrono::Utc; use fabro_agent::{AgentEvent, SandboxEvent}; use fabro_llm::types::TokenCounts as LlmTokenCounts; - use fabro_model::{ModelRef, Provider, ProviderId}; + use fabro_model::{ModelRef, ProviderId}; use super::*; use crate::error::Error; @@ -1340,9 +1338,7 @@ mod tests { "output_tokens": output_tokens } }, - "facts": { - "provider": "open_ai" - } + "facts": { "algorithm": "openai" } }, "total_usd_micros": input_tokens + output_tokens })) @@ -2012,7 +2008,7 @@ mod tests { event: AgentEvent::AssistantMessage { text: "ok".to_string(), model: ModelRef { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model_id: "claude-sonnet".to_string(), speed: None, }, diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 2f8b5abf1..8e16f2379 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -266,7 +266,7 @@ impl Handler for AgentHandler { let prompt_provider = node .provider() .map(String::from) - .or_else(|| Some(services.run.provider.to_string())); + .or_else(|| Some(services.run.provider_id.to_string())); let prompt_model = node.model().map(String::from); let stage_scope = StageScope::for_handler(context, &node.id); services.run.emitter.emit_scoped( @@ -345,7 +345,7 @@ impl Handler for AgentHandler { let response_provider = node .provider() .map(String::from) - .or_else(|| Some(services.run.provider.to_string())) + .or_else(|| Some(services.run.provider_id.to_string())) .unwrap_or_default(); services.run.emitter.emit_scoped( &Event::PromptCompleted { diff --git a/lib/crates/fabro-workflow/src/handler/llm/acp.rs b/lib/crates/fabro-workflow/src/handler/llm/acp.rs index 472178179..1c0d2e00a 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/acp.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/acp.rs @@ -10,21 +10,21 @@ use fabro_acp::{ use fabro_agent::{Sandbox, StaticEnvProvider, ToolEnvProvider}; use fabro_auth::CredentialResolver; use fabro_graphviz::graph::Node; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, Provider}; +use fabro_model::{AgentProfileKind, Catalog, ProviderId}; use fabro_util::time::elapsed_ms; use tokio_util::sync::CancellationToken; use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest}; -use super::changed_files; use super::cli::AgentCli; use super::launch_env::{AgentLaunchEnvRequest, resolve_agent_launch_env}; +use super::{changed_files, routing}; use crate::error::Error; use crate::event::{Emitter, Event, StageScope}; pub struct AgentAcpBackend { model: String, - provider: Provider, + provider_id: ProviderId, + profile_kind: AgentProfileKind, tool_env: Option>, github_token_refresh_managed: bool, resolver: Option, @@ -33,29 +33,47 @@ pub struct AgentAcpBackend { impl AgentAcpBackend { #[must_use] - pub fn new(model: String, provider: Provider, resolver: CredentialResolver) -> Self { + pub fn new( + model: String, + provider_id: impl Into, + resolver: CredentialResolver, + ) -> Self { + let provider_id = provider_id.into(); + let catalog = default_catalog(); + let profile_kind = routing::default_profile_kind(catalog.as_ref(), &provider_id); Self { model, - provider, + provider_id, + profile_kind, tool_env: None, github_token_refresh_managed: false, resolver: Some(resolver), - catalog: default_catalog(), + catalog, } } #[must_use] - pub fn new_from_env(model: String, provider: Provider) -> Self { + pub fn new_from_env(model: String, provider_id: impl Into) -> Self { + let provider_id = provider_id.into(); + let catalog = default_catalog(); + let profile_kind = routing::default_profile_kind(catalog.as_ref(), &provider_id); Self { model, - provider, + provider_id, + profile_kind, tool_env: None, github_token_refresh_managed: false, resolver: None, - catalog: default_catalog(), + catalog, } } + #[must_use] + pub fn with_profile_kind(mut self, profile_kind: AgentProfileKind) -> Self { + self.profile_kind = profile_kind; + self + } + #[must_use] pub fn with_env(mut self, env: HashMap) -> Self { self.tool_env = Some(Arc::new(StaticEnvProvider(env))); @@ -90,16 +108,20 @@ impl AgentAcpBackend { ) -> Result { let files_before = changed_files::detect_changed_files(sandbox).await; let model = node.model().unwrap_or(&self.model); - let provider = node - .provider() - .and_then(|value| value.parse::().ok()) - .unwrap_or(self.provider); + let provider = routing::resolve_provider_context( + self.catalog.as_ref(), + &self.provider_id, + model, + node.provider(), + )?; + let provider_id = provider.provider_id; + let profile_kind = provider.profile_kind; let command = resolve_acp_command(node.acp_command()).map_err(acp_command_error_to_workflow)?; let launch_env = resolve_agent_launch_env(AgentLaunchEnvRequest { - provider, - cli: AgentCli::for_provider(provider), + provider_id: provider_id.clone(), + cli: AgentCli::for_profile_kind(profile_kind), catalog: self.catalog.as_ref(), resolver: self.resolver.as_ref(), tool_env: self.tool_env.as_ref(), @@ -121,7 +143,7 @@ impl AgentAcpBackend { node_id: node.id.clone(), visit: stage_scope.visit, mode: "acp".to_string(), - provider: provider.to_string(), + provider: provider_id.to_string(), model: model.to_string(), command: command_display, }, @@ -210,10 +232,7 @@ impl AgentAcpBackend { } fn default_catalog() -> Arc { - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ) + Arc::new(Catalog::from_builtin().expect("default catalog should build")) } #[async_trait] @@ -289,7 +308,7 @@ mod tests { use fabro_acp::test_support::fake_acp_agent_script; use fabro_agent::{LocalSandbox, Sandbox, shell_quote}; use fabro_graphviz::graph::{AttrValue, Node}; - use fabro_model::Provider; + use fabro_model::ProviderId; use fabro_sandbox::test_support::MockSandbox; use fabro_types::EventBody; use tokio_util::sync::CancellationToken; @@ -330,7 +349,7 @@ mod tests { ); let backend = - AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi).with_env( + AgentAcpBackend::new_from_env("fake-acp".to_string(), ProviderId::openai()).with_env( HashMap::from([("ACP_MODE".to_string(), "write_file".to_string())]), ); let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); @@ -386,7 +405,7 @@ mod tests { )), ); - let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi) + let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), ProviderId::openai()) .with_env(HashMap::from([ ( "ACP_PROMPT_RECORD".to_string(), @@ -444,7 +463,7 @@ mod tests { ); let backend = - AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi).with_env( + AgentAcpBackend::new_from_env("fake-acp".to_string(), ProviderId::openai()).with_env( HashMap::from([("ACP_STOP_REASON".to_string(), "cancelled".to_string())]), ); let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); @@ -497,7 +516,7 @@ mod tests { node.attrs .insert("acp_command".to_string(), AttrValue::String(raw_command)); - let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi); + let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), ProviderId::openai()); let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); let emitter = Arc::new(Emitter::default()); let events = Arc::new(Mutex::new(Vec::new())); @@ -549,7 +568,7 @@ mod tests { node.attrs .insert("backend".to_string(), AttrValue::String("acp".to_string())); - let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi); + let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), ProviderId::openai()); let emitter = Arc::new(Emitter::default()); let context = Context::new(); let result = backend @@ -603,7 +622,7 @@ mod tests { ); let backend = - AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi).with_env( + AgentAcpBackend::new_from_env("fake-acp".to_string(), ProviderId::openai()).with_env( HashMap::from([("OPENAI_API_KEY".to_string(), "test-key".to_string())]), ); let emitter = Arc::new(Emitter::default()); diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 9a618a08a..d860b2f60 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -13,10 +13,9 @@ use fabro_graphviz::graph::{AttrValue, Node}; use fabro_llm::client::Client; use fabro_llm::types::{Message, ReasoningEffort, Request, Speed, TokenCounts}; use fabro_mcp::config::McpServerSettings; +#[cfg(test)] use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{ - AgentProfileKind, Catalog, FallbackTarget, ModelRef, Provider, ProviderId, adapter, -}; +use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ModelRef, ProviderId}; use fabro_types::settings::run::RunModelControls; use fabro_types::{SessionCapability, StageId}; use tokio::sync::Mutex as TokioMutex; @@ -25,6 +24,8 @@ use tokio_util::sync::CancellationToken; use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest}; use super::activation_lease::{ActivationLease, ActivationLeaseOptions}; +use super::routing; +use super::routing::ProviderContext; use crate::context::WorkflowContext; use crate::context::keys::Fidelity; use crate::error::Error; @@ -102,13 +103,6 @@ enum AgentApiErrorDisposition { Terminal(Error), } -#[derive(Clone)] -struct ProviderContext { - provider: Provider, - provider_id: ProviderId, - profile_kind: AgentProfileKind, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct EffectiveRequestControls { pub(super) reasoning_effort: Option, @@ -169,7 +163,6 @@ fn discard_session( fn build_profile( model: &str, - provider: Provider, provider_id: ProviderId, profile_kind: AgentProfileKind, catalog: Arc, @@ -177,33 +170,31 @@ fn build_profile( match profile_kind { AgentProfileKind::OpenAi => Box::new( OpenAiProfile::new(model) - .with_identity(provider, provider_id) + .with_provider_id(provider_id) .with_catalog(catalog), ), AgentProfileKind::Gemini => Box::new( GeminiProfile::new(model) - .with_identity(provider, provider_id) + .with_provider_id(provider_id) .with_catalog(catalog), ), AgentProfileKind::Anthropic => Box::new( AnthropicProfile::new(model) - .with_identity(provider, provider_id) + .with_provider_id(provider_id) .with_catalog(catalog), ), } } pub(super) fn effective_request_controls( - catalog: &Catalog, run_model_controls: &RunModelControls, - model: &str, node: &Node, ) -> Result { let reasoning_effort = match control_attr(node, "reasoning_effort") .or(run_model_controls.reasoning_effort.as_deref()) { Some(value) => Some(parse_reasoning_effort(node, value)?), - None => legacy_reasoning_effort_default(catalog, model), + None => None, }; let speed = control_attr(node, "speed") .or(run_model_controls.speed.as_deref()) @@ -257,21 +248,6 @@ where .join(", ") } -fn legacy_reasoning_effort_default(catalog: &Catalog, model: &str) -> Option { - match catalog.model_settings(model) { - Some(settings) - if settings - .controls - .reasoning_effort - .contains(&ReasoningEffort::High) => - { - Some(ReasoningEffort::High) - } - Some(_) => None, - None => Some(ReasoningEffort::High), - } -} - /// Shared state for tracking file modifications from agent tool calls. struct FileTracking { /// Maps tool_call_id → file_path for in-flight write/edit calls. @@ -357,7 +333,6 @@ fn spawn_event_forwarder( /// and reused so the LLM sees the full conversation history. pub struct AgentApiBackend { model: String, - provider: Provider, provider_id: ProviderId, profile_kind: AgentProfileKind, fallback_chain: Vec, @@ -374,20 +349,18 @@ impl AgentApiBackend { #[must_use] pub fn new( model: String, - provider: Provider, + provider_id: impl Into, fallback_chain: Vec, source: Arc, steering_hub: Arc, ) -> Self { - let catalog = Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ); + let catalog = Arc::new(Catalog::from_builtin().expect("default catalog should build")); + let provider_id = provider_id.into(); + let profile_kind = routing::default_profile_kind(catalog.as_ref(), &provider_id); Self::new_with_catalog( model, - provider, - provider.id(), - adapter::default_profile_for_provider_id(&provider.id()), + provider_id, + profile_kind, fallback_chain, source, steering_hub, @@ -398,7 +371,6 @@ impl AgentApiBackend { #[must_use] pub fn new_with_catalog( model: String, - provider: Provider, provider_id: ProviderId, profile_kind: AgentProfileKind, fallback_chain: Vec, @@ -408,7 +380,6 @@ impl AgentApiBackend { ) -> Self { Self { model, - provider, provider_id, profile_kind, fallback_chain, @@ -425,19 +396,25 @@ impl AgentApiBackend { #[must_use] pub fn new_from_env( model: String, - provider: Provider, + provider_id: impl Into, fallback_chain: Vec, steering_hub: Arc, ) -> Self { Self::new( model, - provider, + provider_id, fallback_chain, Arc::new(EnvCredentialSource::new()), steering_hub, ) } + #[must_use] + pub fn with_profile_kind(mut self, profile_kind: AgentProfileKind) -> Self { + self.profile_kind = profile_kind; + self + } + #[must_use] pub fn with_env(mut self, env: HashMap) -> Self { self.tool_env = Some(Arc::new(StaticEnvProvider(env))); @@ -462,12 +439,8 @@ impl AgentApiBackend { self } - fn effective_request_controls( - &self, - model: &str, - node: &Node, - ) -> Result { - effective_request_controls(self.catalog.as_ref(), &self.run_model_controls, model, node) + fn effective_request_controls(&self, node: &Node) -> Result { + effective_request_controls(&self.run_model_controls, node) } fn resolve_provider_context( @@ -475,40 +448,12 @@ impl AgentApiBackend { model: &str, provider_attr: Option<&str>, ) -> Result { - let provider_id = if let Some(provider) = provider_attr { - let requested = ProviderId::from(provider); - self.catalog - .provider(&requested) - .ok_or_else(|| { - Error::Precondition(format!("Provider \"{provider}\" is not configured")) - })? - .id - .clone() - } else if let Some(model) = self.catalog.get(model) { - model.provider.clone() - } else { - self.provider_id.clone() - }; - let Some(provider) = self.catalog.provider(&provider_id) else { - return Ok(ProviderContext { - provider: self.provider, - provider_id: self.provider_id.clone(), - profile_kind: self.profile_kind, - }); - }; - let profile_kind = adapter::get(&provider.adapter) - .map(|metadata| metadata.default_profile) - .ok_or_else(|| { - Error::Precondition(format!( - "Provider \"{provider_id}\" uses unknown adapter \"{}\"", - provider.adapter, - )) - })?; - Ok(ProviderContext { - provider: adapter::profile_provider_for_provider_id(&provider.id, &provider.adapter), - provider_id: provider.id.clone(), - profile_kind, - }) + routing::resolve_provider_context( + self.catalog.as_ref(), + &self.provider_id, + model, + provider_attr, + ) } async fn create_session( @@ -546,15 +491,13 @@ impl AgentApiBackend { tool_hooks: Option>, mcp_servers: Vec, ) -> Result { - let controls = - effective_request_controls(catalog.as_ref(), run_model_controls, model, node)?; + let controls = effective_request_controls(run_model_controls, node)?; let client = Client::from_source(source, Arc::clone(&catalog)) .await .map_err(|e| Error::handler_with_source("Failed to create LLM client", e))?; let mut profile = build_profile( model, - provider.provider, provider.provider_id.clone(), provider.profile_kind, Arc::clone(&catalog), @@ -584,7 +527,6 @@ impl AgentApiBackend { let factory: SessionFactory = Arc::new(move || { let child_profile: Arc = Arc::from(build_profile( &factory_model, - factory_provider.provider, factory_provider.provider_id.clone(), factory_provider.profile_kind, Arc::clone(&factory_catalog), @@ -699,7 +641,7 @@ impl CodergenBackend for AgentApiBackend { let model = node.model().unwrap_or(&self.model); let provider = self.resolve_provider_context(model, node.provider())?; let provider_id = provider.provider_id.to_string(); - let controls = self.effective_request_controls(model, node)?; + let controls = self.effective_request_controls(node)?; let max_tokens = node .max_tokens() @@ -779,7 +721,7 @@ impl CodergenBackend for AgentApiBackend { .get(&target.model) .and_then(|m| m.limits.max_output) }); - let fallback_controls = self.effective_request_controls(&target.model, node)?; + let fallback_controls = self.effective_request_controls(node)?; let fallback_request = Request { model: target.model.clone(), @@ -823,7 +765,7 @@ impl CodergenBackend for AgentApiBackend { speed: actual_speed, }, &response.usage, - ); + )?; Ok(CodergenResult::Text { text: response.text(), @@ -1122,7 +1064,7 @@ impl CodergenBackend for AgentApiBackend { } } - let billing_controls = self.effective_request_controls(session.model(), node)?; + let billing_controls = self.effective_request_controls(node)?; let stage_usage = billed_model_usage_from_llm( self.catalog.as_ref(), &ModelRef { @@ -1131,7 +1073,7 @@ impl CodergenBackend for AgentApiBackend { speed: billing_controls.speed, }, &total_usage, - ); + )?; // Extract last assistant response from the session history. let response = session @@ -1235,8 +1177,12 @@ mod tests { } impl AgentProfile for ShutdownTestProfile { - fn provider(&self) -> Provider { - Provider::OpenAi + fn profile_kind(&self) -> AgentProfileKind { + AgentProfileKind::OpenAi + } + + fn provider_id(&self) -> ProviderId { + ProviderId::openai() } fn model(&self) -> &str { @@ -1287,19 +1233,20 @@ mod tests { fn agent_backend_stores_config() { let backend = AgentApiBackend::new_from_env( "claude-opus-4-6".to_string(), - Provider::OpenAi, + ProviderId::openai(), Vec::new(), SteeringHub::for_tests(), ); assert_eq!(backend.model, "claude-opus-4-6"); - assert_eq!(backend.provider, Provider::OpenAi); + assert_eq!(backend.provider_id, ProviderId::openai()); + assert_eq!(backend.profile_kind, AgentProfileKind::OpenAi); } #[test] fn agent_backend_initializes_empty_sessions() { let backend = AgentApiBackend::new_from_env( "claude-opus-4-6".to_string(), - Provider::Anthropic, + ProviderId::anthropic(), Vec::new(), SteeringHub::for_tests(), ); @@ -1416,10 +1363,9 @@ mod tests { fn build_profile_can_register_subagent_tools() { let mut profile = build_profile( "claude-opus-4-6", - Provider::Anthropic, - Provider::Anthropic.id(), + ProviderId::anthropic(), AgentProfileKind::Anthropic, - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()), + Arc::new(Catalog::from_builtin().unwrap()), ); let manager = Arc::new(TokioMutex::new(SubAgentManager::new(1))); let factory: SessionFactory = Arc::new(|| { @@ -1458,14 +1404,12 @@ max_output = 8192 tools = true vision = false reasoning = false -effort = false "#, ) .unwrap(); let catalog = Arc::new(Catalog::from_builtin_with_overrides(&settings).unwrap()); let backend = AgentApiBackend::new_with_catalog( "acme-llama".to_string(), - Provider::OpenAiCompatible, ProviderId::from("acme"), AgentProfileKind::OpenAi, Vec::new(), @@ -1480,14 +1424,13 @@ effort = false assert_eq!(provider.provider_id, ProviderId::from("acme")); assert_eq!(provider.profile_kind, AgentProfileKind::OpenAi); - assert_eq!(provider.provider, Provider::OpenAiCompatible); } #[test] fn run_model_controls_apply_when_node_omits_controls() { let backend = AgentApiBackend::new_from_env( "gpt-5.4".to_string(), - Provider::OpenAi, + ProviderId::openai(), Vec::new(), SteeringHub::for_tests(), ) @@ -1497,9 +1440,7 @@ effort = false }); let node = Node::new("work"); - let controls = backend - .effective_request_controls("gpt-5.4", &node) - .unwrap(); + let controls = backend.effective_request_controls(&node).unwrap(); assert_eq!(controls.reasoning_effort, Some(ReasoningEffort::Low)); assert_eq!(controls.speed, Some(Speed::Fast)); @@ -1509,7 +1450,7 @@ effort = false fn node_controls_override_run_model_controls() { let backend = AgentApiBackend::new_from_env( "gpt-5.4".to_string(), - Provider::OpenAi, + ProviderId::openai(), Vec::new(), SteeringHub::for_tests(), ) @@ -1527,27 +1468,23 @@ effort = false fabro_graphviz::graph::AttrValue::String("standard".to_string()), ); - let controls = backend - .effective_request_controls("gpt-5.4", &node) - .unwrap(); + let controls = backend.effective_request_controls(&node).unwrap(); assert_eq!(controls.reasoning_effort, Some(ReasoningEffort::High)); assert_eq!(controls.speed, Some(Speed::Standard)); } #[test] - fn known_model_without_effort_omits_legacy_high_default() { + fn omitted_reasoning_effort_stays_unset() { let backend = AgentApiBackend::new_from_env( - "kimi-k2.5".to_string(), - Provider::Kimi, + "gpt-5.4".to_string(), + ProviderId::openai(), Vec::new(), SteeringHub::for_tests(), ); let node = Node::new("work"); - let controls = backend - .effective_request_controls("kimi-k2.5", &node) - .unwrap(); + let controls = backend.effective_request_controls(&node).unwrap(); assert_eq!(controls.reasoning_effort, None); } @@ -1560,7 +1497,7 @@ effort = false .set( "anthropic", &serde_json::to_string(&AuthCredential { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), details: AuthDetails::ApiKey { key: "anthropic-key".to_string(), }, @@ -1572,7 +1509,7 @@ effort = false .unwrap(); let backend = AgentApiBackend::new( "claude-opus-4-6".to_string(), - Provider::Anthropic, + ProviderId::anthropic(), Vec::new(), Arc::new(VaultCredentialSource::with_env_lookup( Arc::new(AsyncRwLock::new(vault)), @@ -1592,7 +1529,7 @@ effort = false async fn api_backend_shutdown_closes_cached_sessions_once() { let backend = AgentApiBackend::new_from_env( "gpt-5.4".to_string(), - Provider::OpenAi, + ProviderId::openai(), Vec::new(), SteeringHub::for_tests(), ); diff --git a/lib/crates/fabro-workflow/src/handler/llm/cli.rs b/lib/crates/fabro-workflow/src/handler/llm/cli.rs index 72d0b6bfb..a5562294f 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/cli.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/cli.rs @@ -11,8 +11,7 @@ use fabro_agent::{Sandbox, StaticEnvProvider, ToolEnvProvider, shell_quote}; use fabro_auth::CredentialResolver; use fabro_graphviz::graph::Node; use fabro_llm::types::TokenCounts; -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, ModelRef, Provider}; +use fabro_model::{AgentProfileKind, Catalog, ModelRef, ProviderId}; use fabro_types::settings::run::RunModelControls; use fabro_types::{CommandOutputStream, CommandTermination, LlmBackend}; use fabro_util::time::elapsed_ms; @@ -58,16 +57,11 @@ pub enum AgentCli { } impl AgentCli { - pub fn for_provider(provider: Provider) -> Self { - match provider { - Provider::Anthropic => Self::Claude, - Provider::Gemini => Self::Gemini, - Provider::OpenAi - | Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => Self::Codex, + pub fn for_profile_kind(profile_kind: AgentProfileKind) -> Self { + match profile_kind { + AgentProfileKind::Anthropic => Self::Claude, + AgentProfileKind::OpenAi => Self::Codex, + AgentProfileKind::Gemini => Self::Gemini, } } @@ -121,49 +115,41 @@ pub fn is_cli_only_model(model: &str) -> bool { CLI_ONLY_MODELS.contains(&model) } -/// Build the CLI command string for a given provider. +/// Build the CLI command string for a given agent profile. /// /// The `prompt_file` is the path to a file containing the prompt text, which /// is piped into the command's stdin via `cat`. #[must_use] -pub fn cli_command_for_provider(provider: Provider, model: &str, prompt_file: &str) -> String { +pub fn cli_command_for_profile_kind( + profile_kind: AgentProfileKind, + model: &str, + prompt_file: &str, +) -> String { let prompt_file = shell_quote(prompt_file); + let cli = AgentCli::for_profile_kind(profile_kind); let model_flag = if model.is_empty() { String::new() } else { let model = shell_quote(model); - match provider { - Provider::OpenAi - | Provider::Gemini - | Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => { - format!(" -m {model}") - } - Provider::Anthropic => format!(" --model {model}"), + match cli { + AgentCli::Codex | AgentCli::Gemini => format!(" -m {model}"), + AgentCli::Claude => format!(" --model {model}"), } }; // Use `cat | command` instead of `command < file` because the background // launch wrapper (`setsid sh -c '...' { + AgentCli::Codex => { format!("cat {prompt_file} | codex exec --json --full-auto{model_flag}") } // --yolo: auto-approve all tool calls - Provider::Gemini => format!("cat {prompt_file} | gemini -o json --yolo{model_flag}"), + AgentCli::Gemini => format!("cat {prompt_file} | gemini -o json --yolo{model_flag}"), // --dangerously-skip-permissions: bypass all permission checks (required for // non-interactive use). CLAUDECODE= unset to allow running inside a Claude Code // session. - Provider::Anthropic => format!( + AgentCli::Claude => format!( "cat {prompt_file} | CLAUDECODE= claude -p --verbose --output-format stream-json --dangerously-skip-permissions{model_flag}" ), } @@ -316,17 +302,12 @@ fn parse_gemini_json(output: &str) -> Option { }) } -/// Parse CLI output, choosing the right parser based on provider. -pub fn parse_cli_response(provider: Provider, output: &str) -> Option { - match provider { - Provider::OpenAi - | Provider::Kimi - | Provider::Zai - | Provider::Minimax - | Provider::Inception - | Provider::OpenAiCompatible => parse_codex_ndjson(output), - Provider::Gemini => parse_gemini_json(output), - Provider::Anthropic => parse_claude_ndjson(output), +/// Parse CLI output, choosing the right parser based on profile behavior. +pub fn parse_cli_response(profile_kind: AgentProfileKind, output: &str) -> Option { + match AgentCli::for_profile_kind(profile_kind) { + AgentCli::Codex => parse_codex_ndjson(output), + AgentCli::Gemini => parse_gemini_json(output), + AgentCli::Claude => parse_claude_ndjson(output), } } @@ -334,7 +315,8 @@ pub fn parse_cli_response(provider: Provider, output: &str) -> Option>, github_token_refresh_managed: bool, resolver: Option, @@ -344,31 +326,49 @@ pub struct AgentCliBackend { impl AgentCliBackend { #[must_use] - pub fn new(model: String, provider: Provider, resolver: CredentialResolver) -> Self { + pub fn new( + model: String, + provider_id: impl Into, + resolver: CredentialResolver, + ) -> Self { + let provider_id = provider_id.into(); + let catalog = default_catalog(); + let profile_kind = routing::default_profile_kind(catalog.as_ref(), &provider_id); Self { model, - provider, + provider_id, + profile_kind, tool_env: None, github_token_refresh_managed: false, resolver: Some(resolver), run_model_controls: RunModelControls::default(), - catalog: default_catalog(), + catalog, } } #[must_use] - pub fn new_from_env(model: String, provider: Provider) -> Self { + pub fn new_from_env(model: String, provider_id: impl Into) -> Self { + let provider_id = provider_id.into(); + let catalog = default_catalog(); + let profile_kind = routing::default_profile_kind(catalog.as_ref(), &provider_id); Self { model, - provider, + provider_id, + profile_kind, tool_env: None, github_token_refresh_managed: false, resolver: None, run_model_controls: RunModelControls::default(), - catalog: default_catalog(), + catalog, } } + #[must_use] + pub fn with_profile_kind(mut self, profile_kind: AgentProfileKind) -> Self { + self.profile_kind = profile_kind; + self + } + #[must_use] pub fn with_env(mut self, env: HashMap) -> Self { self.tool_env = Some(Arc::new(StaticEnvProvider(env))); @@ -400,10 +400,7 @@ impl AgentCliBackend { } fn default_catalog() -> Arc { - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ) + Arc::new(Catalog::from_builtin().expect("default catalog should build")) } #[async_trait] @@ -432,28 +429,27 @@ impl CodergenBackend for AgentCliBackend { // 3. Build CLI command let model = node.model().unwrap_or(&self.model); - let provider = node - .provider() - .and_then(|s| s.parse::().ok()) - .unwrap_or(self.provider); - let controls = effective_request_controls( + let provider = routing::resolve_provider_context( self.catalog.as_ref(), - &self.run_model_controls, + &self.provider_id, model, - node, + node.provider(), )?; + let provider_id = provider.provider_id; + let profile_kind = provider.profile_kind; + let controls = effective_request_controls(&self.run_model_controls, node)?; - let cli = AgentCli::for_provider(provider); + let cli = AgentCli::for_profile_kind(profile_kind); verify_cli_available(cli, sandbox, &cancel_token).await?; - let command = cli_command_for_provider(provider, model, &prompt_path); + let command = cli_command_for_profile_kind(profile_kind, model, &prompt_path); let stage_scope = StageScope::for_handler(context, &node.id); emitter.emit_scoped( &Event::AgentCliStarted { node_id: node.id.clone(), visit: stage_scope.visit, mode: "cli".to_string(), - provider: provider.to_string(), + provider: provider_id.to_string(), model: model.to_string(), command: command.clone(), }, @@ -461,7 +457,7 @@ impl CodergenBackend for AgentCliBackend { ); let launch_env = resolve_agent_launch_env(AgentLaunchEnvRequest { - provider, + provider_id: provider_id.clone(), cli, catalog: self.catalog.as_ref(), resolver: self.resolver.as_ref(), @@ -647,7 +643,7 @@ impl CodergenBackend for AgentCliBackend { } // 4. Parse the CLI output - let parsed = parse_cli_response(provider, &stdout) + let parsed = parse_cli_response(profile_kind, &stdout) .ok_or_else(|| Error::handler("Failed to parse CLI output".to_string()))?; // 5. Detect changed files @@ -657,7 +653,7 @@ impl CodergenBackend for AgentCliBackend { let stage_usage = billed_model_usage_from_llm( self.catalog.as_ref(), &ModelRef { - provider: provider.id(), + provider: provider_id, model_id: model.to_string(), speed: controls.speed, }, @@ -666,7 +662,7 @@ impl CodergenBackend for AgentCliBackend { output_tokens: parsed.output_tokens, ..TokenCounts::default() }, - ); + )?; Ok(CodergenResult::Text { text: parsed.text, @@ -750,6 +746,7 @@ mod tests { use fabro_agent::LocalSandbox; use fabro_agent::sandbox::ExecResult; use fabro_graphviz::graph::AttrValue; + use fabro_model::ProviderId; use super::*; use crate::context::Context; @@ -759,15 +756,33 @@ mod tests { #[test] fn agent_cli_for_provider() { assert_eq!( - AgentCli::for_provider(Provider::Anthropic), + AgentCli::for_profile_kind(AgentProfileKind::Anthropic), AgentCli::Claude ); - assert_eq!(AgentCli::for_provider(Provider::OpenAi), AgentCli::Codex); - assert_eq!(AgentCli::for_provider(Provider::Gemini), AgentCli::Gemini); - assert_eq!(AgentCli::for_provider(Provider::Kimi), AgentCli::Codex); - assert_eq!(AgentCli::for_provider(Provider::Zai), AgentCli::Codex); - assert_eq!(AgentCli::for_provider(Provider::Minimax), AgentCli::Codex); - assert_eq!(AgentCli::for_provider(Provider::Inception), AgentCli::Codex); + assert_eq!( + AgentCli::for_profile_kind(AgentProfileKind::OpenAi), + AgentCli::Codex + ); + assert_eq!( + AgentCli::for_profile_kind(AgentProfileKind::Gemini), + AgentCli::Gemini + ); + assert_eq!( + AgentCli::for_profile_kind(AgentProfileKind::OpenAi), + AgentCli::Codex + ); + assert_eq!( + AgentCli::for_profile_kind(AgentProfileKind::OpenAi), + AgentCli::Codex + ); + assert_eq!( + AgentCli::for_profile_kind(AgentProfileKind::OpenAi), + AgentCli::Codex + ); + assert_eq!( + AgentCli::for_profile_kind(AgentProfileKind::OpenAi), + AgentCli::Codex + ); } #[test] @@ -958,19 +973,26 @@ mod tests { ); } - // -- Cycle 1: cli_command_for_provider -- + // -- Cycle 1: cli_command_for_profile_kind -- #[test] fn cli_command_for_codex() { - let cmd = cli_command_for_provider(Provider::OpenAi, "gpt-5.3-codex", "/tmp/prompt.txt"); + let cmd = cli_command_for_profile_kind( + AgentProfileKind::OpenAi, + "gpt-5.3-codex", + "/tmp/prompt.txt", + ); assert!(cmd.starts_with("cat /tmp/prompt.txt | codex exec --json --full-auto")); assert!(cmd.contains("-m gpt-5.3-codex")); } #[test] fn cli_command_for_claude() { - let cmd = - cli_command_for_provider(Provider::Anthropic, "claude-opus-4-6", "/tmp/prompt.txt"); + let cmd = cli_command_for_profile_kind( + AgentProfileKind::Anthropic, + "claude-opus-4-6", + "/tmp/prompt.txt", + ); assert!(cmd.starts_with("cat /tmp/prompt.txt |")); assert!(cmd.contains("claude -p")); assert!(cmd.contains("--dangerously-skip-permissions")); @@ -980,20 +1002,24 @@ mod tests { #[test] fn cli_command_for_gemini() { - let cmd = cli_command_for_provider(Provider::Gemini, "gemini-3.1-pro", "/tmp/prompt.txt"); + let cmd = cli_command_for_profile_kind( + AgentProfileKind::Gemini, + "gemini-3.1-pro", + "/tmp/prompt.txt", + ); assert!(cmd.starts_with("cat /tmp/prompt.txt | gemini -o json --yolo")); assert!(cmd.contains("-m gemini-3.1-pro")); } #[test] fn cli_command_omits_model_when_empty() { - let cmd = cli_command_for_provider(Provider::OpenAi, "", "/tmp/prompt.txt"); + let cmd = cli_command_for_profile_kind(AgentProfileKind::OpenAi, "", "/tmp/prompt.txt"); assert!(cmd.contains("codex exec --json --full-auto")); assert!(!cmd.contains("-m ")); - let cmd = cli_command_for_provider(Provider::Anthropic, "", "/tmp/prompt.txt"); + let cmd = cli_command_for_profile_kind(AgentProfileKind::Anthropic, "", "/tmp/prompt.txt"); assert!(cmd.contains("--dangerously-skip-permissions")); assert!(!cmd.contains("--model ")); - let cmd = cli_command_for_provider(Provider::Gemini, "", "/tmp/prompt.txt"); + let cmd = cli_command_for_profile_kind(AgentProfileKind::Gemini, "", "/tmp/prompt.txt"); assert!(cmd.contains("--yolo")); assert!(!cmd.contains("-m ")); } @@ -1014,7 +1040,7 @@ mod tests { let output = r#"{"type":"system","message":"Claude CLI v1.0"} {"type":"assistant","message":{"content":"thinking..."}} {"type":"result","result":"Here is the implementation.","usage":{"input_tokens":100,"output_tokens":50}}"#; - let response = parse_cli_response(Provider::Anthropic, output).unwrap(); + let response = parse_cli_response(AgentProfileKind::Anthropic, output).unwrap(); assert_eq!(response.text, "Here is the implementation."); assert_eq!(response.input_tokens, 100); assert_eq!(response.output_tokens, 50); @@ -1024,7 +1050,7 @@ mod tests { fn parse_claude_ndjson_uses_last_result() { let output = r#"{"type":"result","result":"first","usage":{"input_tokens":10,"output_tokens":5}} {"type":"result","result":"second","usage":{"input_tokens":20,"output_tokens":10}}"#; - let response = parse_cli_response(Provider::Anthropic, output).unwrap(); + let response = parse_cli_response(AgentProfileKind::Anthropic, output).unwrap(); assert_eq!(response.text, "second"); assert_eq!(response.input_tokens, 20); } @@ -1033,13 +1059,13 @@ mod tests { fn parse_claude_ndjson_returns_none_for_no_result() { let output = r#"{"type":"system","message":"hello"} {"type":"assistant","message":{"content":"no result line"}}"#; - assert!(parse_cli_response(Provider::Anthropic, output).is_none()); + assert!(parse_cli_response(AgentProfileKind::Anthropic, output).is_none()); } #[test] fn parse_gemini_json_extracts_text_and_usage() { let output = r#"{"session_id":"abc","response":"Gemini says hello","stats":{"models":{"gemini-2.5-flash":{"tokens":{"input":200,"candidates":80,"total":280}}}}}"#; - let response = parse_cli_response(Provider::Gemini, output).unwrap(); + let response = parse_cli_response(AgentProfileKind::Gemini, output).unwrap(); assert_eq!(response.text, "Gemini says hello"); assert_eq!(response.input_tokens, 200); assert_eq!(response.output_tokens, 80); @@ -1048,7 +1074,7 @@ mod tests { #[test] fn parse_gemini_json_handles_missing_stats() { let output = r#"{"response":"hello"}"#; - let response = parse_cli_response(Provider::Gemini, output).unwrap(); + let response = parse_cli_response(AgentProfileKind::Gemini, output).unwrap(); assert_eq!(response.text, "hello"); assert_eq!(response.input_tokens, 0); assert_eq!(response.output_tokens, 0); @@ -1056,7 +1082,7 @@ mod tests { #[test] fn parse_gemini_json_returns_none_for_invalid_json() { - assert!(parse_cli_response(Provider::Gemini, "not json").is_none()); + assert!(parse_cli_response(AgentProfileKind::Gemini, "not json").is_none()); } // -- Cycle 4: parse_cli_response — Codex NDJSON -- @@ -1068,7 +1094,7 @@ mod tests { {"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"thinking..."}} {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Fixed the bug."}} {"type":"turn.completed","usage":{"input_tokens":300,"output_tokens":150}}"#; - let response = parse_cli_response(Provider::OpenAi, output).unwrap(); + let response = parse_cli_response(AgentProfileKind::OpenAi, output).unwrap(); assert_eq!(response.text, "Fixed the bug."); assert_eq!(response.input_tokens, 300); assert_eq!(response.output_tokens, 150); @@ -1077,14 +1103,14 @@ mod tests { #[test] fn parse_codex_ndjson_handles_no_message() { let output = r#"{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}"#; - let response = parse_cli_response(Provider::OpenAi, output).unwrap(); + let response = parse_cli_response(AgentProfileKind::OpenAi, output).unwrap(); assert_eq!(response.text, ""); assert_eq!(response.input_tokens, 10); } #[test] fn parse_codex_ndjson_returns_none_for_no_events() { - assert!(parse_cli_response(Provider::OpenAi, "not json at all").is_none()); + assert!(parse_cli_response(AgentProfileKind::OpenAi, "not json at all").is_none()); } // -- Cycle 5: Node::backend() accessor (tested here since the accessor is @@ -1291,8 +1317,8 @@ mod tests { } fn test_router() -> BackendRouter { - let cli_backend = AgentCliBackend::new_from_env("model".into(), Provider::Anthropic); - let acp_backend = AgentAcpBackend::new_from_env("model".into(), Provider::Anthropic); + let cli_backend = AgentCliBackend::new_from_env("model".into(), ProviderId::anthropic()); + let acp_backend = AgentAcpBackend::new_from_env("model".into(), ProviderId::anthropic()); BackendRouter::new(Box::new(StubBackend), cli_backend, acp_backend) } @@ -1454,7 +1480,8 @@ mod tests { termination: CommandTermination::Cancelled, exit_code: None, }); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let backend = + AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let node = Node::new("step"); let context = Context::new(); let emitter = Arc::new(Emitter::default()); @@ -1507,7 +1534,8 @@ mod tests { termination: CommandTermination::TimedOut, exit_code: None, }); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let backend = + AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let node = Node::new("step"); let context = Context::new(); let emitter = Arc::new(Emitter::default()); diff --git a/lib/crates/fabro-workflow/src/handler/llm/launch_env.rs b/lib/crates/fabro-workflow/src/handler/llm/launch_env.rs index df1b124cf..5ed6000be 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/launch_env.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/launch_env.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use fabro_agent::{Sandbox, ToolEnvProvider}; use fabro_auth::{CliAgentKind, CredentialResolver, CredentialUsage, ResolvedCredential}; -use fabro_model::{Catalog, CredentialRef, Provider}; +use fabro_model::{Catalog, CredentialRef, ProviderId}; use tokio_util::sync::CancellationToken; use super::cli::{AgentCli, process_env_var}; @@ -11,7 +11,7 @@ use crate::error::Error; use crate::event::{Emitter, RunNoticeCode, RunNoticeLevel}; pub(crate) struct AgentLaunchEnvRequest<'a> { - pub provider: Provider, + pub provider_id: ProviderId, pub cli: AgentCli, pub catalog: &'a Catalog, pub resolver: Option<&'a CredentialResolver>, @@ -35,7 +35,7 @@ pub(crate) async fn resolve_agent_launch_env( let mut launch_env = if let Some(resolver) = request.resolver { let resolved = resolver .resolve( - request.provider, + request.provider_id.clone(), CredentialUsage::CliAgent(cli_agent), request.catalog, ) @@ -79,8 +79,7 @@ pub(crate) async fn resolve_agent_launch_env( cli_credential.env_vars } else { let mut env = HashMap::new(); - let provider_id = request.provider.id(); - if let Some(provider) = request.catalog.provider(&provider_id) { + if let Some(provider) = request.catalog.provider(&request.provider_id) { for credential_ref in &provider.credentials { let CredentialRef::Env(name) = credential_ref else { continue; @@ -89,12 +88,6 @@ pub(crate) async fn resolve_agent_launch_env( env.insert(name.clone(), value); } } - } else { - for name in request.provider.api_key_env_vars() { - if let Some(value) = process_env_var(name) { - env.insert((*name).to_string(), value); - } - } } env }; diff --git a/lib/crates/fabro-workflow/src/handler/llm/preamble.rs b/lib/crates/fabro-workflow/src/handler/llm/preamble.rs index c5da0fd29..6d1bd106d 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/preamble.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/preamble.rs @@ -587,7 +587,7 @@ fn build_summary_preamble( mod tests { use fabro_graphviz::graph::AttrValue; use fabro_llm::types::TokenCounts; - use fabro_model::{Catalog, ModelRef, Provider}; + use fabro_model::{Catalog, ModelRef, ProviderId}; use super::*; use crate::outcome::{BilledModelUsage, billed_model_usage_from_llm}; @@ -596,7 +596,7 @@ mod tests { billed_model_usage_from_llm( Catalog::builtin(), &ModelRef { - provider: Provider::Anthropic.id(), + provider: ProviderId::anthropic(), model_id: model.to_string(), speed: None, }, @@ -606,6 +606,7 @@ mod tests { ..TokenCounts::default() }, ) + .unwrap() } // --- truncate mode --- diff --git a/lib/crates/fabro-workflow/src/handler/llm/routing.rs b/lib/crates/fabro-workflow/src/handler/llm/routing.rs index 1c1dfba5d..a6838b46c 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/routing.rs @@ -1,4 +1,5 @@ use fabro_graphviz::graph::{self, Node}; +use fabro_model::{AgentProfileKind, Catalog, ProviderId}; use fabro_types::LlmBackend; use super::cli::is_cli_only_model; @@ -43,6 +44,54 @@ pub(crate) fn node_needs_api_backend(node: &Node) -> bool { } } +#[derive(Clone)] +pub(super) struct ProviderContext { + pub(super) provider_id: ProviderId, + pub(super) profile_kind: AgentProfileKind, +} + +pub(super) fn default_profile_kind( + catalog: &Catalog, + provider_id: &ProviderId, +) -> AgentProfileKind { + catalog + .provider(provider_id) + .unwrap_or_else(|| panic!("Provider \"{provider_id}\" is not configured")) + .adapter + .metadata() + .default_profile +} + +pub(super) fn resolve_provider_context( + catalog: &Catalog, + default_provider_id: &ProviderId, + model: &str, + provider_attr: Option<&str>, +) -> Result { + let provider_id = if let Some(provider) = provider_attr { + let requested = ProviderId::from(provider); + catalog + .provider(&requested) + .ok_or_else(|| { + Error::Precondition(format!("Provider \"{provider}\" is not configured")) + })? + .id + .clone() + } else if let Some(model) = catalog.get(model) { + model.provider.clone() + } else { + default_provider_id.clone() + }; + + let provider = catalog.provider(&provider_id).ok_or_else(|| { + Error::Precondition(format!("Provider \"{provider_id}\" is not configured")) + })?; + Ok(ProviderContext { + provider_id: provider.id.clone(), + profile_kind: provider.adapter.metadata().default_profile, + }) +} + fn unsupported_backend_error(raw: &str) -> Error { Error::Validation(format!( "unsupported LLM backend \"{raw}\"; expected one of: {}", diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index c65744741..6546d7947 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use fabro_graphviz::graph::{Graph, Node}; -use fabro_model::Provider; +use fabro_model::ProviderId; use super::agent::{ CodergenBackend, CodergenResult, OneShotRequest, expand_variables, extract_status_fields, @@ -70,15 +70,22 @@ impl Handler for PromptHandler { // 1b. Discover project docs for system prompt when project_memory is enabled let system_prompt = if node.project_memory() { let working_dir = services.run.sandbox.working_directory(); - let provider = node + let profile_kind = node .provider() - .and_then(|s| s.parse::().ok()) - .unwrap_or(services.run.provider); + .map(ProviderId::from) + .and_then(|provider_id| { + services + .run + .catalog + .provider(&provider_id) + .map(|provider| provider.adapter.metadata().default_profile) + }) + .unwrap_or(services.run.profile_kind); let docs = match fabro_agent::discover_memory( &*services.run.sandbox, working_dir, working_dir, - provider, + profile_kind, &services.run.cancel_token(), ) .await @@ -102,7 +109,7 @@ impl Handler for PromptHandler { let prompt_provider = node .provider() .map(String::from) - .or_else(|| Some(services.run.provider.to_string())); + .or_else(|| Some(services.run.provider_id.to_string())); let prompt_model = node.model().map(String::from); let stage_scope = StageScope::for_handler(context, &node.id); services.run.emitter.emit_scoped( @@ -163,7 +170,7 @@ impl Handler for PromptHandler { let response_provider = node .provider() .map(String::from) - .or_else(|| Some(services.run.provider.to_string())) + .or_else(|| Some(services.run.provider_id.to_string())) .unwrap_or_default(); services.run.emitter.emit_scoped( diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index b9182ced2..7e8e6dca9 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -586,7 +586,6 @@ mod tests { use fabro_core::state::ExecutionState; use fabro_graphviz::graph::types::{AttrValue, Edge, Graph, Node}; use fabro_model::Catalog; - use fabro_model::catalog::LlmCatalogSettings; use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection}; use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase}; use fabro_types::{EventBody, RunBlobId, RunEvent, WorkflowSettings, fixtures}; @@ -1202,12 +1201,10 @@ mod tests { )), None, tokio_util::sync::CancellationToken::new(), - fabro_model::Provider::Anthropic, + fabro_model::ProviderId::anthropic(), + fabro_model::AgentProfileKind::Anthropic, Arc::new(fabro_auth::EnvCredentialSource::new()), - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ), + Arc::new(Catalog::from_builtin().expect("default catalog should build")), Arc::new(SandboxGitRuntime::new()), Arc::clone(&lifecycle.metadata_runtime), lifecycle.metadata_writer.clone(), diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index d91f07fbf..23624c5bd 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -10,8 +10,6 @@ use std::sync::Arc; use fabro_config::Storage; use fabro_graphviz::graph::{AttrValue, Graph}; -#[cfg(test)] -use fabro_model::catalog::LlmCatalogSettings; use fabro_model::{Catalog, ProviderId}; use fabro_sandbox::SandboxProvider; use fabro_store::Database; @@ -519,7 +517,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()) + Arc::new(Catalog::from_builtin().unwrap()) } fn validate_dot(dot_source: &str, settings: WorkflowSettings) -> Validated { diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 22c24caee..970c1a875 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use fabro_auth::{CredentialSource, EnvCredentialSource, VaultCredentialSource}; use fabro_interview::{AutoApproveInterviewer, Interviewer}; use fabro_mcp::config::{McpServerSettings, McpTransport}; -use fabro_model::{Catalog, FallbackTarget, ProviderId, adapter}; +use fabro_model::{Catalog, FallbackTarget, ProviderId}; use fabro_sandbox::config::{ DaytonaNetwork, DaytonaSnapshotSettings, DaytonaVolumeMount, DockerfileSource as SandboxDockerfileSource, @@ -348,17 +348,7 @@ impl RunSession { let catalog_provider = catalog.provider(&provider_id).ok_or_else(|| { Error::Precondition(format!("Provider \"{provider_id}\" is not configured")) })?; - let profile_kind = adapter::get(&catalog_provider.adapter) - .map(|metadata| metadata.default_profile) - .ok_or_else(|| { - Error::Precondition(format!( - "Provider \"{provider_id}\" uses unknown adapter \"{}\"", - catalog_provider.adapter, - )) - })?; - let provider_enum = - adapter::profile_provider_for_provider_id(&provider_id, &catalog_provider.adapter); - + let profile_kind = catalog_provider.adapter.metadata().default_profile; let fallback_chain = resolve_fallback_chain(catalog.as_ref(), &provider_id, &model, &resolved.model); let mcp_servers = resolved @@ -436,7 +426,6 @@ impl RunSession { sandbox, llm: LlmSpec { model: model.clone(), - provider: provider_enum, provider_id: provider_id.clone(), profile_kind, fallback_chain, @@ -1121,8 +1110,6 @@ mod tests { DaytonaSandboxLayer, DaytonaVolumeLayer, RunCloneLayer, RunExecutionLayer, RunLayer, RunSandboxLayer, WorkflowSettingsBuilder, }; - use fabro_model::Provider; - use fabro_model::catalog::LlmCatalogSettings; use fabro_store::Database; use fabro_types::settings::ModelRef; use fabro_types::settings::run::RunMode; @@ -1175,10 +1162,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ) + Arc::new(Catalog::from_builtin().expect("default catalog should build")) } #[test] @@ -1191,7 +1175,7 @@ mod tests { let chain = resolve_fallback_chain( catalog.as_ref(), - &Provider::Anthropic.id(), + &ProviderId::anthropic(), "claude-opus-4-6", &settings, ); @@ -1212,7 +1196,7 @@ mod tests { let chain = resolve_fallback_chain( catalog.as_ref(), - &Provider::Anthropic.id(), + &ProviderId::anthropic(), "claude-opus-4-6", &settings, ); diff --git a/lib/crates/fabro-workflow/src/outcome.rs b/lib/crates/fabro-workflow/src/outcome.rs index 2f14537d8..3d106678f 100644 --- a/lib/crates/fabro-workflow/src/outcome.rs +++ b/lib/crates/fabro-workflow/src/outcome.rs @@ -3,22 +3,23 @@ pub use fabro_core::outcome::{ }; use fabro_llm::types::TokenCounts as LlmTokenCounts; use fabro_model::{ - Catalog, ModelBillingFacts, ModelBillingInput, ModelRef, ModelUsage, TokenCounts, + BilledTokenCounts, Catalog, ModelBillingInput, ModelRef, ModelUsage, TokenCounts, }; pub use fabro_types::BilledModelUsage; -use crate::error::classify_failure_reason; +use crate::error::{Error, classify_failure_reason}; pub type Outcome = fabro_core::Outcome>; -#[must_use] pub fn billed_model_usage_from_llm( catalog: &Catalog, model: &ModelRef, usage: &LlmTokenCounts, -) -> BilledModelUsage { +) -> Result { let tokens = token_counts_from_llm_usage(usage); - let facts = catalog.billing_facts_for(model, &tokens); + let facts = catalog.billing_facts_for(model, &tokens).ok_or_else(|| { + Error::Precondition(format!("Provider \"{}\" is not configured", model.provider)) + })?; let input = ModelBillingInput { usage: ModelUsage { model: model.clone(), @@ -32,22 +33,23 @@ pub fn billed_model_usage_from_llm( .and_then(|pricing| pricing.bill(&input)) .map(|amount| amount.0); - BilledModelUsage { + Ok(BilledModelUsage { input, total_usd_micros, - } + }) } #[must_use] -pub fn unpriced_model_usage_from_llm(model: ModelRef, usage: &LlmTokenCounts) -> BilledModelUsage { +pub fn billed_token_counts_from_llm(usage: &LlmTokenCounts) -> BilledTokenCounts { let tokens = token_counts_from_llm_usage(usage); - let facts = ModelBillingFacts::for_provider_id(&model.provider, &tokens); - BilledModelUsage { - input: ModelBillingInput { - usage: ModelUsage { model, tokens }, - facts, - }, - total_usd_micros: None, + BilledTokenCounts { + input_tokens: tokens.input_tokens, + output_tokens: tokens.output_tokens, + total_tokens: tokens.total_tokens(), + reasoning_tokens: tokens.reasoning_tokens, + cache_read_tokens: tokens.cache_read_tokens, + cache_write_tokens: tokens.cache_write_tokens, + total_usd_micros: None, } } @@ -147,7 +149,7 @@ fn token_counts_from_llm_usage(usage: &LlmTokenCounts) -> TokenCounts { mod tests { use fabro_llm::types::TokenCounts; use fabro_model::catalog::LlmCatalogSettings; - use fabro_model::{Catalog, ModelRef, Provider, ProviderId, Speed}; + use fabro_model::{Catalog, ModelRef, ProviderId, Speed}; use super::{OutcomeExt, billed_model_usage_from_llm}; @@ -170,9 +172,10 @@ mod tests { }; let billed = billed_model_usage_from_llm( Catalog::builtin(), - &model_ref(Provider::OpenAi.id(), "gpt-5.4", None), + &model_ref(ProviderId::openai(), "gpt-5.4", None), &usage, - ); + ) + .unwrap(); assert_eq!(billed.total_usd_micros, Some(3_562_500)); assert_eq!(billed.tokens().output_tokens, 125_000); @@ -201,12 +204,13 @@ mod tests { let billed = billed_model_usage_from_llm( Catalog::builtin(), &model_ref( - Provider::Anthropic.id(), + ProviderId::anthropic(), "claude-opus-4-6", Some(Speed::Fast), ), &usage, - ); + ) + .unwrap(); assert_eq!(billed.total_usd_micros, Some(6_435_000)); } @@ -252,7 +256,8 @@ output_cost_per_mtok = 2.0 &catalog, &model_ref(ProviderId::new("proxy"), "canonical-model", None), &usage, - ); + ) + .unwrap(); assert_eq!(&billed.model().provider, &ProviderId::new("proxy")); assert_eq!(billed.model_id(), "canonical-model"); @@ -299,7 +304,8 @@ output_cost_per_mtok = 2.0 output_tokens: 250_000, ..TokenCounts::default() }, - ); + ) + .unwrap(); assert_eq!(billed.model_id(), "wire-model"); assert_eq!(billed.total_usd_micros, None); @@ -316,9 +322,10 @@ output_cost_per_mtok = 2.0 }; let billed = billed_model_usage_from_llm( Catalog::builtin(), - &model_ref(Provider::Anthropic.id(), "claude-opus-4-6", None), + &model_ref(ProviderId::anthropic(), "claude-opus-4-6", None), &usage, - ); + ) + .unwrap(); assert_eq!(billed.tokens().clone(), usage); } diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index 61bc1850e..c65c9e36f 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -248,8 +248,7 @@ async fn execute_test_run_with_options( }, llm: LlmSpec { model: "test-model".to_string(), - provider: fabro_llm::Provider::Anthropic, - provider_id: fabro_llm::Provider::Anthropic.id(), + provider_id: fabro_model::ProviderId::anthropic(), profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), @@ -313,8 +312,7 @@ async fn execute_runs_start_to_exit_and_returns_final_context() { }, llm: LlmSpec { model: "test-model".to_string(), - provider: fabro_llm::Provider::Anthropic, - provider_id: fabro_llm::Provider::Anthropic.id(), + provider_id: fabro_model::ProviderId::anthropic(), profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), @@ -393,8 +391,7 @@ async fn run_with_lifecycle( }, llm: LlmSpec { model: "test-model".to_string(), - provider: fabro_llm::Provider::Anthropic, - provider_id: fabro_llm::Provider::Anthropic.id(), + provider_id: fabro_model::ProviderId::anthropic(), profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index 867295264..1d573207c 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -645,7 +645,6 @@ mod tests { use bytes::Bytes; use fabro_graphviz::graph::Graph; use fabro_model::Catalog; - use fabro_model::catalog::LlmCatalogSettings; use fabro_sandbox::test_support::MockSandbox; use fabro_store::{Database, EventEnvelope, RunDatabase, RunProjection}; use fabro_types::run_event::{MetadataSnapshotFailureKind, MetadataSnapshotPhase}; @@ -875,9 +874,7 @@ mod tests { "output_tokens": output_tokens } }, - "facts": { - "provider": "open_ai" - } + "facts": { "algorithm": "openai" } }, "total_usd_micros": input_tokens + output_tokens })) @@ -1027,12 +1024,10 @@ mod tests { sandbox, None, tokio_util::sync::CancellationToken::new(), - fabro_model::Provider::Anthropic, + fabro_model::ProviderId::anthropic(), + fabro_model::AgentProfileKind::Anthropic, Arc::new(fabro_auth::EnvCredentialSource::new()), - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ), + Arc::new(Catalog::from_builtin().expect("default catalog should build")), Arc::new(SandboxGitRuntime::new()), metadata_runtime, metadata_writer, @@ -1057,12 +1052,10 @@ mod tests { )), None, tokio_util::sync::CancellationToken::new(), - fabro_model::Provider::Anthropic, + fabro_model::ProviderId::anthropic(), + fabro_model::AgentProfileKind::Anthropic, Arc::new(fabro_auth::EnvCredentialSource::new()), - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ), + Arc::new(Catalog::from_builtin().expect("default catalog should build")), Arc::new(SandboxGitRuntime::new()), Arc::new(RunMetadataRuntime::new()), None, diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index f90607744..e10ee59f9 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -151,7 +151,6 @@ async fn build_registry( let build_llm_registry = || { let model = spec.model.clone(); - let provider = spec.provider; let provider_id = spec.provider_id.clone(); let profile_kind = spec.profile_kind; let fallback_chain = spec.fallback_chain.clone(); @@ -165,7 +164,6 @@ async fn build_registry( let tool_env_provider = Arc::clone(&tool_env_provider_for_backend); let api = AgentApiBackend::new_with_catalog( model.clone(), - provider, provider_id.clone(), profile_kind, fallback_chain.clone(), @@ -179,8 +177,14 @@ async fn build_registry( let cli = cli_resolver .clone() .map_or_else( - || AgentCliBackend::new_from_env(model.clone(), provider), - |resolver| AgentCliBackend::new(model.clone(), provider, resolver), + || { + AgentCliBackend::new_from_env(model.clone(), provider_id.clone()) + .with_profile_kind(profile_kind) + }, + |resolver| { + AgentCliBackend::new(model.clone(), provider_id.clone(), resolver) + .with_profile_kind(profile_kind) + }, ) .with_catalog(Arc::clone(&catalog_for_api)) .with_run_model_controls(model_controls.clone()) @@ -188,8 +192,14 @@ async fn build_registry( let acp = cli_resolver .clone() .map_or_else( - || AgentAcpBackend::new_from_env(model.clone(), provider), - |resolver| AgentAcpBackend::new(model.clone(), provider, resolver), + || { + AgentAcpBackend::new_from_env(model.clone(), provider_id.clone()) + .with_profile_kind(profile_kind) + }, + |resolver| { + AgentAcpBackend::new(model.clone(), provider_id.clone(), resolver) + .with_profile_kind(profile_kind) + }, ) .with_catalog(Arc::clone(&catalog_for_api)) .with_tool_env_provider(tool_env_provider.clone(), github_token_refresh_managed); @@ -676,7 +686,8 @@ pub async fn initialize( Arc::clone(&sandbox), hook_runner.clone(), options.run_options.cancel_token.clone(), - options.llm.provider, + options.llm.provider_id.clone(), + options.llm.profile_kind, Arc::clone(&llm_source), catalog, sandbox_git, @@ -723,7 +734,6 @@ mod tests { use fabro_auth::{AuthCredential, AuthDetails}; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_interview::AutoApproveInterviewer; - use fabro_model::catalog::LlmCatalogSettings; use fabro_sandbox::SandboxSpec; use fabro_store::Database; use fabro_types::settings::run::RunModelControls; @@ -745,10 +755,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ) + Arc::new(Catalog::from_builtin().expect("default catalog should build")) } fn memory_store() -> Arc { @@ -891,8 +898,7 @@ mod tests { }, llm: LlmSpec { model: "test-model".to_string(), - provider: fabro_llm::Provider::Anthropic, - provider_id: fabro_llm::Provider::Anthropic.id(), + provider_id: fabro_model::ProviderId::anthropic(), profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), @@ -954,8 +960,7 @@ mod tests { }, llm: LlmSpec { model: "test-model".to_string(), - provider: fabro_llm::Provider::Anthropic, - provider_id: fabro_llm::Provider::Anthropic.id(), + provider_id: fabro_model::ProviderId::anthropic(), profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), @@ -1006,8 +1011,8 @@ mod tests { assert!(initialized.engine.dry_run); assert_eq!(initialized.model, "test-model"); assert_eq!( - initialized.engine.run.provider, - fabro_llm::Provider::Anthropic + initialized.engine.run.provider_id, + fabro_model::ProviderId::anthropic() ); assert!( initialized @@ -1030,7 +1035,7 @@ mod tests { .set( "anthropic", &serde_json::to_string(&AuthCredential { - provider: fabro_llm::Provider::Anthropic.id(), + provider: fabro_model::ProviderId::anthropic(), details: AuthDetails::ApiKey { key: "anthropic-key".to_string(), }, @@ -1051,8 +1056,7 @@ mod tests { let (_registry, effective_dry_run) = build_registry( &LlmSpec { model: "claude-opus-4-6".to_string(), - provider: fabro_llm::Provider::Anthropic, - provider_id: fabro_llm::Provider::Anthropic.id(), + provider_id: fabro_model::ProviderId::anthropic(), profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), @@ -1140,7 +1144,7 @@ mod tests { .set( "openai", &serde_json::to_string(&AuthCredential { - provider: fabro_llm::Provider::OpenAi.id(), + provider: fabro_model::ProviderId::openai(), details: AuthDetails::ApiKey { key: "openai-key".to_string(), }, @@ -1170,8 +1174,7 @@ mod tests { }, llm: LlmSpec { model: "fake-acp".to_string(), - provider: fabro_llm::Provider::OpenAi, - provider_id: fabro_llm::Provider::OpenAi.id(), + provider_id: fabro_model::ProviderId::openai(), profile_kind: fabro_model::AgentProfileKind::OpenAi, fallback_chain: Vec::new(), mcp_servers: Vec::new(), @@ -1270,8 +1273,7 @@ mod tests { }, llm: LlmSpec { model: "test-model".to_string(), - provider: fabro_llm::Provider::Anthropic, - provider_id: fabro_llm::Provider::Anthropic.id(), + provider_id: fabro_model::ProviderId::anthropic(), profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), @@ -1388,8 +1390,7 @@ mod tests { }, llm: LlmSpec { model: "test-model".to_string(), - provider: fabro_llm::Provider::Anthropic, - provider_id: fabro_llm::Provider::Anthropic.id(), + provider_id: fabro_model::ProviderId::anthropic(), profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), @@ -1455,8 +1456,7 @@ mod tests { }, llm: LlmSpec { model: "test-model".to_string(), - provider: fabro_llm::Provider::Anthropic, - provider_id: fabro_llm::Provider::Anthropic.id(), + provider_id: fabro_model::ProviderId::anthropic(), profile_kind: fabro_model::AgentProfileKind::Anthropic, fallback_chain: Vec::new(), mcp_servers: Vec::new(), diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 537caec0d..2335d9250 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -6,8 +6,6 @@ use fabro_graphviz::parser; use fabro_llm::client::Client; use fabro_llm::generate::{GenerateParams, generate_object}; use fabro_model::Catalog; -#[cfg(test)] -use fabro_model::catalog::LlmCatalogSettings; use fabro_store::RunProjection; use fabro_types::PullRequestRecord; use fabro_types::settings::run::MergeStrategy; @@ -680,6 +678,7 @@ mod tests { use fabro_llm::client::Client; use fabro_llm::provider::{ProviderAdapter, StreamEventStream}; use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts}; + use fabro_model::ProviderId; use fabro_store::Database; use fabro_types::{ BilledTokenCounts, RunProjection, RunSpec, SuccessReason, WorkflowSettings, @@ -780,10 +779,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ) + Arc::new(Catalog::from_builtin().expect("default catalog should build")) } fn explicit_client(provider_name: &str, text: &str) -> Arc { @@ -826,7 +822,7 @@ mod tests { fn openai_api_key_credential(key: &str) -> AuthCredential { AuthCredential { - provider: fabro_model::Provider::OpenAi.id(), + provider: ProviderId::openai(), details: AuthDetails::ApiKey { key: key.to_string(), }, diff --git a/lib/crates/fabro-workflow/src/pipeline/transform.rs b/lib/crates/fabro-workflow/src/pipeline/transform.rs index 5bba82788..f3afe43e0 100644 --- a/lib/crates/fabro-workflow/src/pipeline/transform.rs +++ b/lib/crates/fabro-workflow/src/pipeline/transform.rs @@ -61,7 +61,6 @@ mod tests { use fabro_graphviz::graph::AttrValue; use fabro_model::Catalog; - use fabro_model::catalog::LlmCatalogSettings; use super::*; use crate::file_resolver::FilesystemFileResolver; @@ -75,7 +74,7 @@ mod tests { } fn test_catalog() -> Arc { - Arc::new(Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap()) + Arc::new(Catalog::from_builtin().unwrap()) } fn transform_options() -> TransformOptions { diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index 98825bd4d..9d02d0c5e 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use fabro_graphviz::graph::Graph; use fabro_interview::Interviewer; -use fabro_llm::Provider; use fabro_mcp::config::McpServerSettings; use fabro_model::{AgentProfileKind, Catalog, FallbackTarget, ProviderId}; use fabro_sandbox::SandboxSpec; @@ -219,7 +218,6 @@ impl Persisted { #[derive(Clone)] pub struct LlmSpec { pub model: String, - pub provider: Provider, pub provider_id: ProviderId, pub profile_kind: AgentProfileKind, pub fallback_chain: Vec, diff --git a/lib/crates/fabro-workflow/src/pipeline/validate.rs b/lib/crates/fabro-workflow/src/pipeline/validate.rs index abedb0709..916704623 100644 --- a/lib/crates/fabro-workflow/src/pipeline/validate.rs +++ b/lib/crates/fabro-workflow/src/pipeline/validate.rs @@ -20,7 +20,6 @@ pub fn validate( #[cfg(test)] mod tests { use fabro_model::Catalog; - use fabro_model::catalog::LlmCatalogSettings; use super::*; use crate::pipeline::parse::parse; @@ -28,9 +27,7 @@ mod tests { use crate::pipeline::types::TransformOptions; fn test_catalog() -> std::sync::Arc { - std::sync::Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap(), - ) + std::sync::Arc::new(Catalog::from_builtin().unwrap()) } fn run_pipeline(dot: &str) -> Validated { diff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs index b0d1deb89..336dd618b 100644 --- a/lib/crates/fabro-workflow/src/services.rs +++ b/lib/crates/fabro-workflow/src/services.rs @@ -10,11 +10,7 @@ use fabro_auth::CredentialSource; #[cfg(test)] use fabro_auth::ResolvedCredentials; use fabro_hooks::{HookContext, HookDecision, HookRunner}; -#[cfg(test)] -use fabro_model::ProviderId; -#[cfg(test)] -use fabro_model::catalog::LlmCatalogSettings; -use fabro_model::{Catalog, Provider}; +use fabro_model::{AgentProfileKind, Catalog, ProviderId}; use tokio_util::sync::CancellationToken; use crate::ManifestPath; @@ -42,7 +38,8 @@ pub struct RunServices { pub sandbox: Arc, pub hook_runner: Option>, pub(crate) cancel_token: CancellationToken, - pub provider: Provider, + pub provider_id: ProviderId, + pub profile_kind: AgentProfileKind, pub llm_source: Arc, pub catalog: Arc, pub(crate) sandbox_git: Arc, @@ -58,7 +55,8 @@ impl RunServices { sandbox: Arc, hook_runner: Option>, cancel_token: CancellationToken, - provider: Provider, + provider_id: ProviderId, + profile_kind: AgentProfileKind, llm_source: Arc, catalog: Arc, sandbox_git: Arc, @@ -71,7 +69,8 @@ impl RunServices { sandbox, hook_runner, cancel_token, - provider, + provider_id, + profile_kind, llm_source, catalog, sandbox_git, @@ -234,12 +233,10 @@ impl EngineServices { )), None, CancellationToken::new(), - Provider::Anthropic, + ProviderId::anthropic(), + AgentProfileKind::Anthropic, Arc::new(StubCredentialSource), - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ), + Arc::new(Catalog::from_builtin().expect("default catalog should build")), Arc::new(SandboxGitRuntime::new()), Arc::new(RunMetadataRuntime::new()), None, diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index 4c1647ae2..db4307139 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -8,7 +8,6 @@ use fabro_agent::Sandbox; use fabro_auth::{CredentialSource, EnvCredentialSource}; use fabro_graphviz::graph::Graph as GvGraph; use fabro_model::Catalog; -use fabro_model::catalog::LlmCatalogSettings; use fabro_store::{ArtifactStore, Database, RunProjection}; use object_store::local::LocalFileSystem; @@ -162,14 +161,12 @@ async fn initialized( sandbox, options.hook_runner, run_options.cancel_token.clone(), - fabro_llm::Provider::Anthropic, + fabro_model::ProviderId::anthropic(), + fabro_model::AgentProfileKind::Anthropic, options .llm_source .unwrap_or_else(|| Arc::new(EnvCredentialSource::new())), - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ), + Arc::new(Catalog::from_builtin().expect("default catalog should build")), Arc::new(SandboxGitRuntime::new()), Arc::new(RunMetadataRuntime::new()), None, diff --git a/lib/crates/fabro-workflow/src/transforms/model_resolution.rs b/lib/crates/fabro-workflow/src/transforms/model_resolution.rs index e389005bc..990c16f57 100644 --- a/lib/crates/fabro-workflow/src/transforms/model_resolution.rs +++ b/lib/crates/fabro-workflow/src/transforms/model_resolution.rs @@ -81,7 +81,6 @@ context_window = 128000 tools = true vision = false reasoning = false -effort = false "#, ) .unwrap(); @@ -89,7 +88,7 @@ effort = false } fn builtin_transform() -> ModelResolutionTransform { - let catalog = Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()).unwrap(); + let catalog = Catalog::from_builtin().unwrap(); ModelResolutionTransform::new(Arc::new(catalog)) } diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 9ea9aa4a8..57e4348f1 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -24,7 +24,7 @@ use std::sync::Arc; use fabro_agent::Sandbox; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; -use fabro_llm::provider::Provider; +use fabro_model::ProviderId; use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; use fabro_static::EnvVars; use fabro_store::{ArtifactKey, ArtifactStore, Database}; @@ -1002,7 +1002,7 @@ use fabro_workflow::handler::llm::AgentCliBackend; /// /// Installs the CLI tool in the sandbox, then runs the AgentCliBackend against /// it. -async fn run_daytona_cli_test(provider: Provider, model: &str, install_command: &str) { +async fn run_daytona_cli_test(provider: ProviderId, model: &str, install_command: &str) { let creds = load_github_app_credentials(); let config = DaytonaConfig { snapshot: Some(DaytonaSnapshotConfig { @@ -1066,7 +1066,7 @@ async fn run_daytona_cli_test(provider: Provider, model: &str, install_command: install_result.stdout ); - let backend = AgentCliBackend::new_from_env(model.to_string(), provider); + let backend = AgentCliBackend::new_from_env(model.to_string(), provider.clone()); let node = Node::new("daytona_cli_test"); let context = Context::new(); let emitter = Arc::new(Emitter::default()); @@ -1109,7 +1109,7 @@ async fn run_daytona_cli_test(provider: Provider, model: &str, install_command: #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] async fn daytona_cli_claude() { run_daytona_cli_test( - Provider::Anthropic, + ProviderId::anthropic(), "haiku", "curl -fsSL https://claude.ai/install.sh | bash", ) @@ -1118,13 +1118,18 @@ async fn daytona_cli_claude() { #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] async fn daytona_cli_codex() { - run_daytona_cli_test(Provider::OpenAi, "o4-mini", "npm install -g @openai/codex").await; + run_daytona_cli_test( + ProviderId::openai(), + "o4-mini", + "npm install -g @openai/codex", + ) + .await; } #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] async fn daytona_cli_gemini() { run_daytona_cli_test( - Provider::Gemini, + ProviderId::gemini(), "gemini-2.5-flash", "npm install -g @google/gemini-cli", ) diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 5d5ff43e4..2f9eec59e 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -31,9 +31,7 @@ use fabro_interview::{ Answer, AnswerValue, AutoApproveInterviewer, CallbackInterviewer, Interviewer, QueueInterviewer, RecordingInterviewer, }; -use fabro_llm::provider::Provider; -use fabro_model::Catalog; -use fabro_model::catalog::LlmCatalogSettings; +use fabro_model::{AgentProfileKind, Catalog, ProviderId}; use fabro_store::{ArtifactKey, ArtifactStore, Database}; use fabro_types::{CommandTermination, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref}; use fabro_validate::{Severity, validate, validate_or_raise}; @@ -64,10 +62,7 @@ use tokio_util::sync::CancellationToken; use ulid::Ulid; fn default_catalog() -> Arc { - Arc::new( - Catalog::from_builtin_with_overrides(&LlmCatalogSettings::default()) - .expect("default catalog should build"), - ) + Arc::new(Catalog::from_builtin().expect("default catalog should build")) } fn local_env() -> Arc { @@ -6865,7 +6860,7 @@ mod real_llm { fn openai_api_key_credential(key: &str) -> fabro_auth::AuthCredential { fabro_auth::AuthCredential { - provider: fabro_model::Provider::OpenAi.id(), + provider: ProviderId::openai(), details: fabro_auth::AuthDetails::ApiKey { key: key.to_string(), }, @@ -9816,7 +9811,7 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() { let claude_output = r#"{"type":"result","result":"I fixed the bug.","usage":{"input_tokens":500,"output_tokens":200}}"#; let test_env = Arc::new(CliTestEnv::new(claude_output)); let env: Arc = test_env.clone(); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let node = Node::new("fix_code"); let context = Context::new(); @@ -9886,7 +9881,7 @@ async fn cli_backend_run_detects_changed_files() { let claude_output = r#"{"type":"result","result":"Created new file.","usage":{"input_tokens":100,"output_tokens":50}}"#; let env: Arc = Arc::new(CliTestEnv::new(claude_output).with_git_diff_after("src/main.rs\nsrc/lib.rs\n")); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let node = Node::new("implement"); let context = Context::new(); @@ -9916,7 +9911,7 @@ async fn cli_backend_run_with_codex_provider() { let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"Implemented the feature.\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":300,\"output_tokens\":150}}"; let test_env = Arc::new(CliTestEnv::new(codex_output)); let env: Arc = test_env.clone(); - let backend = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi); + let backend = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), ProviderId::openai()); let node = Node::new("implement"); let context = Context::new(); @@ -10079,7 +10074,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() { } let failing_env: Arc = Arc::new(FailingCliEnv); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let node = Node::new("step"); let context = Context::new(); let emitter = Arc::new(Emitter::default()); @@ -10114,7 +10109,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() { #[tokio::test] async fn cli_backend_run_fails_on_unparseable_output() { let env: Arc = Arc::new(CliTestEnv::new("this is not json at all")); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let node = Node::new("step"); let context = Context::new(); @@ -10147,7 +10142,7 @@ async fn cli_backend_run_uses_node_model_override() { r#"{"type":"result","result":"ok","usage":{"input_tokens":10,"output_tokens":5}}"#; let test_env = Arc::new(CliTestEnv::new(claude_output)); let env: Arc = test_env.clone(); - let backend = AgentCliBackend::new_from_env("default-model".into(), Provider::Anthropic); + let backend = AgentCliBackend::new_from_env("default-model".into(), ProviderId::anthropic()); let mut node = Node::new("step"); node.attrs.insert( @@ -10185,7 +10180,7 @@ async fn cli_backend_run_uses_node_provider_override() { let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"ok\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}"; let test_env = Arc::new(CliTestEnv::new(codex_output)); let env: Arc = test_env.clone(); - let backend = AgentCliBackend::new_from_env("default-model".into(), Provider::Anthropic); + let backend = AgentCliBackend::new_from_env("default-model".into(), ProviderId::anthropic()); let mut node = Node::new("step"); node.attrs.insert( @@ -10220,7 +10215,7 @@ async fn cli_backend_run_returns_text_and_usage() { let claude_output = r#"{"type":"result","result":"done","usage":{"input_tokens":10,"output_tokens":5}}"#; let env: Arc = Arc::new(CliTestEnv::new(claude_output)); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let node = Node::new("step"); let context = Context::new(); @@ -10248,7 +10243,7 @@ async fn cli_backend_run_returns_text_and_usage() { // -- BackendRouter e2e: delegates to correct backend -- fn test_acp_backend() -> AgentAcpBackend { - AgentAcpBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic) + AgentAcpBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()) } #[tokio::test] @@ -10257,7 +10252,7 @@ async fn backend_router_delegates_to_cli_for_cli_node() { let env: Arc = Arc::new(CliTestEnv::new(claude_output)); let api_backend = Box::new(MockCodergenBackend); // would return "Response for ..." - let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let router = BackendRouter::new(api_backend, cli, test_acp_backend()); let mut node = Node::new("cli_step"); @@ -10298,7 +10293,7 @@ async fn backend_router_delegates_to_api_for_normal_node() { let env = local_env(); let api_backend = Box::new(MockCodergenBackend); - let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let router = BackendRouter::new(api_backend, cli, test_acp_backend()); let mut node = Node::new("api_step"); @@ -10338,7 +10333,7 @@ async fn backend_router_delegates_to_cli_for_backend_attr() { let env: Arc = Arc::new(CliTestEnv::new(codex_output)); let api_backend = Box::new(MockCodergenBackend); - let cli = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi); + let cli = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), ProviderId::openai()); let router = BackendRouter::new(api_backend, cli, test_acp_backend()); let mut node = Node::new("codex_step"); @@ -10381,11 +10376,11 @@ async fn backend_router_delegates_to_acp_for_acp_node() { Arc::new(fabro_agent::LocalSandbox::new(tempdir.path().to_path_buf())); let api_backend = Box::new(MockCodergenBackend); - let cli = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi); + let cli = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), ProviderId::openai()); let router = BackendRouter::new( api_backend, cli, - AgentAcpBackend::new_from_env("fake-acp".into(), Provider::OpenAi), + AgentAcpBackend::new_from_env("fake-acp".into(), ProviderId::openai()), ); let mut node = Node::new("acp_step"); @@ -10482,7 +10477,7 @@ async fn full_pipeline_with_cli_backend_node() { // Build engine with BackendRouter let api = MockCodergenBackend; - let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let router = BackendRouter::new(Box::new(api), cli, test_acp_backend()); let codergen_handler = AgentHandler::new(Some(Box::new(router))); @@ -10494,7 +10489,8 @@ async fn full_pipeline_with_cli_backend_node() { Box::new(AgentHandler::new(Some(Box::new({ // Second BackendRouter for the "agent" handler let api2 = MockCodergenBackend; - let cli2 = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let cli2 = + AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); BackendRouter::new(Box::new(api2), cli2, test_acp_backend()) })))), ); @@ -10602,14 +10598,14 @@ async fn stylesheet_backend_property_routes_to_cli() { // Run the pipeline let api = MockCodergenBackend; - let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let router = BackendRouter::new(Box::new(api), cli, test_acp_backend()); let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(Box::new(router))))); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); let api2 = MockCodergenBackend; - let cli2 = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic); + let cli2 = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); let router2 = BackendRouter::new(Box::new(api2), cli2, test_acp_backend()); registry.register( "agent", @@ -10657,7 +10653,7 @@ fn parse_real_claude_stream_json() { let output = r#"{"type":"system","subtype":"init","cwd":"/tmp","session_id":"abc"} {"type":"assistant","message":{"content":[{"type":"text","text":"4"}]}} {"type":"result","subtype":"success","is_error":false,"duration_ms":2000,"num_turns":1,"result":"4","usage":{"input_tokens":9,"output_tokens":5}}"#; - let response = parse_cli_response(Provider::Anthropic, output).unwrap(); + let response = parse_cli_response(AgentProfileKind::Anthropic, output).unwrap(); assert_eq!(response.text, "4"); assert_eq!(response.input_tokens, 9); assert_eq!(response.output_tokens, 5); @@ -10672,7 +10668,7 @@ fn parse_real_codex_ndjson() { {"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Confirming simple numeric reply**"}} {"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"4"}} {"type":"turn.completed","usage":{"input_tokens":7999,"cached_input_tokens":7040,"output_tokens":33}}"#; - let response = parse_cli_response(Provider::OpenAi, output).unwrap(); + let response = parse_cli_response(AgentProfileKind::OpenAi, output).unwrap(); assert_eq!(response.text, "4"); assert_eq!(response.input_tokens, 7999); assert_eq!(response.output_tokens, 33); @@ -10684,7 +10680,7 @@ fn parse_real_gemini_json() { // Real output captured from: gemini "What is 2+2?" -m gemini-2.5-flash // --sandbox -o json let output = r#"{"session_id":"abc","response":"4","stats":{"models":{"gemini-2.5-flash":{"api":{"totalRequests":1,"totalErrors":0,"totalLatencyMs":618},"tokens":{"input":123,"prompt":8911,"candidates":1,"total":8912,"cached":8788,"thoughts":0,"tool":0}}},"tools":{"totalCalls":0},"files":{"totalLinesAdded":0,"totalLinesRemoved":0}}}"#; - let response = parse_cli_response(Provider::Gemini, output).unwrap(); + let response = parse_cli_response(AgentProfileKind::Gemini, output).unwrap(); assert_eq!(response.text, "4"); assert_eq!(response.input_tokens, 123); assert_eq!(response.output_tokens, 1); diff --git a/lib/crates/fabro-workflow/tests/materialize_run.rs b/lib/crates/fabro-workflow/tests/materialize_run.rs index b546185bf..b2054474c 100644 --- a/lib/crates/fabro-workflow/tests/materialize_run.rs +++ b/lib/crates/fabro-workflow/tests/materialize_run.rs @@ -1,6 +1,6 @@ use fabro_graphviz::graph::Graph; use fabro_graphviz::parser; -use fabro_model::{Catalog, Provider}; +use fabro_model::{Catalog, ProviderId}; use fabro_types::WorkflowSettings; use fabro_types::settings::InterpString; use fabro_types::settings::run::{PullRequestSettings, RunGoal, RunModelSettings, RunNamespace}; @@ -75,7 +75,7 @@ fn materialize_run_uses_configured_provider_defaults() { WorkflowSettings::default(), &graph(source), Catalog::builtin(), - &[Provider::OpenAi.id()], + &[ProviderId::openai()], ); let resolved = &materialized.run; diff --git a/lib/packages/fabro-api-client/src/models/model-features.ts b/lib/packages/fabro-api-client/src/models/model-features.ts index 833f385d8..d45273462 100644 --- a/lib/packages/fabro-api-client/src/models/model-features.ts +++ b/lib/packages/fabro-api-client/src/models/model-features.ts @@ -38,11 +38,6 @@ export interface ModelFeatures { * Whether the model endpoint supports prompt caching. */ 'prompt_cache': boolean; - /** - * Deprecated compatibility flag equivalent to reasoning_effort = levels. - * @deprecated - */ - 'effort': boolean; } From 66519ee12a9de317ff1f3ac4d0896272a4e2b345 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 10:25:07 -0700 Subject: [PATCH 10/25] feat(errors): add structured failure diagnostics (#277) ## Summary - Make `FailureDetail` the canonical rich diagnostic shape for stage and terminal failures, with terminal `RunFailure` carrying `{ reason, detail }`. - Preserve cause chains and move process stdout/stderr diagnostics into sanitized `exec_output_tail` instead of embedding them in messages or causes. - Update ACP error plumbing, CLI/server/store rendering, OpenAPI, and the generated TypeScript API client for the nested failure contract. Closes #273 ## Test Plan - `cargo nextest run -p fabro-types -p fabro-core -p fabro-acp -p fabro-api -p fabro-store -p fabro-server -p fabro-workflow -p fabro-cli --no-fail-fast -E 'not test(/returns_svg/)' --status-level fail --final-status-level fail` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` - `bun run typecheck` in `lib/packages/fabro-api-client` - `bun run typecheck` in `apps/fabro-web` --- docs/public/api-reference/fabro-api.yaml | 19 +++- lib/crates/fabro-acp/src/error.rs | 41 ++++++- lib/crates/fabro-acp/src/lib.rs | 2 +- lib/crates/fabro-acp/src/session.rs | 5 +- lib/crates/fabro-acp/src/transport.rs | 46 +++++--- lib/crates/fabro-acp/tests/session.rs | 44 +++++-- lib/crates/fabro-api/build.rs | 1 + lib/crates/fabro-api/src/lib.rs | 22 ++-- .../fabro-api/tests/run_failure_round_trip.rs | 69 +++++------ .../fabro-cli/src/commands/run/events.rs | 11 +- .../fabro-cli/src/commands/run/output.rs | 2 +- .../src/commands/run/run_progress/event.rs | 3 +- .../fabro-cli/src/commands/run/runner.rs | 22 +--- lib/crates/fabro-cli/src/commands/run/wait.rs | 13 +-- lib/crates/fabro-core/src/error.rs | 62 +++++----- lib/crates/fabro-core/src/executor.rs | 80 ++++--------- lib/crates/fabro-core/src/test_fixtures.rs | 16 +-- lib/crates/fabro-server/src/server.rs | 16 ++- .../tests/it/scenario/lifecycle.rs | 2 +- lib/crates/fabro-store/src/run_state.rs | 68 +++++------ lib/crates/fabro-store/src/slate/mod.rs | 12 +- .../fabro-types/src/failure_signature.rs | 15 +++ lib/crates/fabro-types/src/outcome.rs | 64 ++++++++--- lib/crates/fabro-types/src/run_event/mod.rs | 6 +- lib/crates/fabro-types/src/run_failure.rs | 47 ++++++-- .../fabro-types/tests/run_failure_serde.rs | 101 +++++++++-------- lib/crates/fabro-util/src/error.rs | 31 ++++- lib/crates/fabro-workflow/src/error.rs | 107 ++++++++++++------ .../fabro-workflow/src/event/convert.rs | 17 +-- lib/crates/fabro-workflow/src/event/events.rs | 13 ++- .../fabro-workflow/src/handler/agent.rs | 4 +- .../fabro-workflow/src/handler/llm/acp.rs | 85 ++++++++++++-- .../fabro-workflow/src/handler/prompt.rs | 2 +- lib/crates/fabro-workflow/src/node_handler.rs | 40 +++---- lib/crates/fabro-workflow/src/outcome.rs | 4 +- .../src/.openapi-generator/FILES | 1 + .../src/models/failure-detail.ts | 42 +++++++ .../fabro-api-client/src/models/index.ts | 1 + .../src/models/run-failure.ts | 20 +--- 39 files changed, 721 insertions(+), 435 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/models/failure-detail.ts diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 1626a2e73..72c89cb6b 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -5005,12 +5005,11 @@ components: type: boolean default: false - RunFailure: - description: Rich terminal run failure diagnostics. + FailureDetail: + description: Rich diagnostic detail for a failed stage or terminal run. type: object required: - message - - reason - category properties: message: @@ -5019,8 +5018,6 @@ components: type: array items: type: string - reason: - $ref: "#/components/schemas/FailureReason" category: $ref: "#/components/schemas/FailureCategory" system_actor: @@ -5036,6 +5033,18 @@ components: - $ref: "#/components/schemas/ExecOutputTail" - type: "null" + RunFailure: + description: Terminal run failure reason and rich diagnostics. + type: object + required: + - reason + - detail + properties: + reason: + $ref: "#/components/schemas/FailureReason" + detail: + $ref: "#/components/schemas/FailureDetail" + RunManifest: description: Self-contained workflow run manifest. type: object diff --git a/lib/crates/fabro-acp/src/error.rs b/lib/crates/fabro-acp/src/error.rs index 37ae511e5..9e84978a2 100644 --- a/lib/crates/fabro-acp/src/error.rs +++ b/lib/crates/fabro-acp/src/error.rs @@ -1,5 +1,27 @@ +use fabro_types::{CommandTermination, ExecOutputTail}; + use crate::command::AcpCommandError; +#[derive(Debug)] +pub struct AcpProcessExit { + pub termination: CommandTermination, + pub exit_code: Option, + pub exec_output_tail: Option, +} + +impl std::fmt::Display for AcpProcessExit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let exit_code = self + .exit_code + .map_or_else(|| "unknown".to_string(), |code| code.to_string()); + write!( + f, + "ACP process exited before protocol completed: termination={}, exit_code={exit_code}", + self.termination + ) + } +} + #[derive(Debug, thiserror::Error)] pub enum AcpError { #[error(transparent)] @@ -15,7 +37,12 @@ pub enum AcpError { Cancelled, #[error("ACP turn timed out")] - TimedOut { stderr: String }, + TimedOut { + exec_output_tail: Option, + }, + + #[error("{0}")] + ProcessExited(AcpProcessExit), #[error("ACP prompt stopped with {stop_reason}: {text}")] StopReason { @@ -24,6 +51,18 @@ pub enum AcpError { }, } +impl AcpError { + #[must_use] + pub fn exec_output_tail(&self) -> Option { + match self { + Self::TimedOut { exec_output_tail } => exec_output_tail.clone(), + Self::ProcessExited(exit) => exit.exec_output_tail.clone(), + Self::Sandbox(source) => source.default_redacted_output_tail(), + _ => None, + } + } +} + impl From for AcpError { fn from(error: agent_client_protocol::Error) -> Self { Self::Protocol(error) diff --git a/lib/crates/fabro-acp/src/lib.rs b/lib/crates/fabro-acp/src/lib.rs index 41f4d2fbe..74ba22cca 100644 --- a/lib/crates/fabro-acp/src/lib.rs +++ b/lib/crates/fabro-acp/src/lib.rs @@ -8,5 +8,5 @@ pub mod test_support; mod transport; pub use command::{AcpCommand, AcpCommandError, resolve_acp_command}; -pub use error::AcpError; +pub use error::{AcpError, AcpProcessExit}; pub use session::{AcpRunRequest, AcpRunResult, render_stop_reason, run_acp_turn}; diff --git a/lib/crates/fabro-acp/src/session.rs b/lib/crates/fabro-acp/src/session.rs index bdff151d3..e0dd343b5 100644 --- a/lib/crates/fabro-acp/src/session.rs +++ b/lib/crates/fabro-acp/src/session.rs @@ -102,7 +102,7 @@ pub async fn run_acp_turn(request: AcpRunRequest) -> Result Result>>, stderr: Arc>>, startup_error: Arc>>, + process_exit: Arc>>, } impl TransportState { @@ -37,6 +39,7 @@ impl TransportState { handle: Arc::new(TokioMutex::new(None)), stderr: Arc::new(TokioMutex::new(None)), startup_error: Arc::new(TokioMutex::new(None)), + process_exit: Arc::new(TokioMutex::new(None)), } } @@ -49,10 +52,22 @@ impl TransportState { *self.startup_error.lock().await = Some(error); } + async fn set_process_exit(&self, termination: StdioProcessTermination, stderr: &str) { + *self.process_exit.lock().await = Some(AcpProcessExit { + termination: termination.termination, + exit_code: termination.exit_code, + exec_output_tail: redacted_stderr_tail(stderr), + }); + } + pub(crate) async fn take_startup_error(&self) -> Option { self.startup_error.lock().await.take() } + pub(crate) async fn take_process_exit(&self) -> Option { + self.process_exit.lock().await.take() + } + pub(crate) async fn terminate(&self) -> SandboxResult<()> { if let Some(handle) = self.handle.lock().await.as_ref().cloned() { handle.terminate().await?; @@ -66,6 +81,11 @@ impl TransportState { } String::new() } + + pub(crate) async fn exec_output_tail(&self) -> Option { + let stderr = self.stderr_tail().await; + redacted_stderr_tail(&stderr) + } } pub(crate) struct SandboxAcpTransport { @@ -156,21 +176,17 @@ impl ConnectTo for SandboxAcpTransport { return result; } } - Err(process_exited_before_protocol_completed(termination, &stderr)) + self.state.set_process_exit(termination, &stderr).await; + Err(process_exited_before_protocol_completed()) } } } } -fn process_exited_before_protocol_completed( - termination: StdioProcessTermination, - stderr: &str, -) -> ProtocolError { - let exit_code = termination - .exit_code - .map_or_else(|| "unknown".to_string(), |code| code.to_string()); - internal_error(format!( - "ACP process exited before protocol completed: termination={}, exit_code={exit_code}, stderr={stderr}", - termination.termination, - )) +fn redacted_stderr_tail(stderr: &str) -> Option { + fabro_sandbox::redacted_output_tail("", stderr, DEFAULT_EXEC_OUTPUT_TAIL_BYTES) +} + +fn process_exited_before_protocol_completed() -> ProtocolError { + internal_error("ACP process exited before protocol completed") } diff --git a/lib/crates/fabro-acp/tests/session.rs b/lib/crates/fabro-acp/tests/session.rs index 88fa6e297..e537e027b 100644 --- a/lib/crates/fabro-acp/tests/session.rs +++ b/lib/crates/fabro-acp/tests/session.rs @@ -370,7 +370,7 @@ async fn timeout_terminates_process_and_returns_timeout() { } #[tokio::test] -async fn malformed_json_returns_protocol_error() { +async fn malformed_json_returns_diagnostic_without_raw_stderr_in_message() { let tempdir = tempfile::tempdir().expect("create tempdir"); let err = run_fake_agent( @@ -382,11 +382,33 @@ async fn malformed_json_returns_protocol_error() { .await .expect_err("malformed JSON should error"); - assert!(matches!(err, AcpError::Protocol(_))); + match err { + AcpError::Protocol(error) => { + let message = error.to_string(); + assert!( + !message.contains("malformed json"), + "protocol error display should not include raw stderr: {message}" + ); + } + AcpError::ProcessExited(exit) => { + let message = exit.to_string(); + assert!( + !message.contains("malformed json"), + "process exit display should not include raw stderr: {message}" + ); + assert_eq!( + exit.exec_output_tail + .as_ref() + .and_then(|tail| tail.stderr.as_deref()), + Some("malformed json\n") + ); + } + other => panic!("expected protocol or process exit error, got {other:?}"), + } } #[tokio::test] -async fn early_exit_returns_protocol_error_with_stderr() { +async fn early_exit_returns_process_exit_with_redacted_stderr_tail() { let tempdir = tempfile::tempdir().expect("create tempdir"); let err = run_fake_agent( @@ -398,17 +420,23 @@ async fn early_exit_returns_protocol_error_with_stderr() { .await .expect_err("early exit should error"); - let AcpError::Protocol(error) = err else { - panic!("expected protocol error"); + let AcpError::ProcessExited(exit) = err else { + panic!("expected process exit error"); }; - let message = error.to_string(); + let message = exit.to_string(); assert!( message.contains("exit_code=2"), "early exit should include exit code in diagnostic: {message}" ); assert!( - message.contains("early boom"), - "early exit should include stderr tail in diagnostic: {message}" + !message.contains("early boom"), + "early exit display should not include raw stderr: {message}" + ); + assert_eq!( + exit.exec_output_tail + .as_ref() + .and_then(|tail| tail.stderr.as_deref()), + Some("early boom\n") ); } diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index dbe3773d8..7bbe1b462 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -191,6 +191,7 @@ fn main() { ("FailureReason", "fabro_types::status::FailureReason", &[]), ("FailureCategory", "fabro_types::FailureCategory", &[]), ("FailureSignature", "fabro_types::FailureSignature", &[]), + ("FailureDetail", "fabro_types::FailureDetail", &[]), ("RunFailure", "fabro_types::RunFailure", &[]), ("BlockedReason", "fabro_types::status::BlockedReason", &[]), ( diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 6f23ce5ca..a2df546bd 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -33,17 +33,17 @@ pub mod types { }; pub use fabro_types::{ AuthMethod, BilledTokenCounts, CommandTermination, Conclusion, DiffStats, DiffSummary, - DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureSignature, GitContext, - IdpIdentity, InterviewOption, InterviewQuestionRecord, PendingInterviewRecord, - PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus, - PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, - QuestionType, RepositoryRef, Run, RunClientProvenance, RunEvent, RunFailure, RunProjection, - RunProvenance, RunSandbox, RunSandboxRuntime, RunServerProvenance, SandboxDetails, - SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, - SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, - SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, StageCompletion, - StageHandler, StageOutcome, StageProjection, StageState, SystemActorKind, UserPrincipal, - WorkflowSettings, + DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail, + FailureSignature, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord, + PendingInterviewRecord, PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, + PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink, + PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, Run, + RunClientProvenance, RunEvent, RunFailure, RunProjection, RunProvenance, RunSandbox, + RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxNetwork, + SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources, + SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps, + SecretMetadata, SecretType, ServerSettings, StageCompletion, StageHandler, StageOutcome, + StageProjection, StageState, SystemActorKind, UserPrincipal, WorkflowSettings, }; pub use crate::generated::types::*; diff --git a/lib/crates/fabro-api/tests/run_failure_round_trip.rs b/lib/crates/fabro-api/tests/run_failure_round_trip.rs index 418688c09..caeabcfb7 100644 --- a/lib/crates/fabro-api/tests/run_failure_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_failure_round_trip.rs @@ -2,12 +2,12 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{ Conclusion as ApiConclusion, ExecOutputTail as ApiExecOutputTail, - FailureCategory as ApiFailureCategory, FailureSignature as ApiFailureSignature, - RunFailure as ApiRunFailure, + FailureCategory as ApiFailureCategory, FailureDetail as ApiFailureDetail, + FailureSignature as ApiFailureSignature, RunFailure as ApiRunFailure, }; use fabro_types::{ - Conclusion, ExecOutputTail, FailureCategory, FailureReason, FailureSignature, RunFailure, - StageOutcome, + Conclusion, ExecOutputTail, FailureCategory, FailureDetail, FailureReason, FailureSignature, + RunFailure, StageOutcome, }; use serde::Serialize; use serde_json::{Value, json}; @@ -16,6 +16,7 @@ use serde_json::{Value, json}; fn run_failure_family_reuses_domain_types() { assert_same_type::(); assert_same_type::(); + assert_same_type::(); assert_same_type::(); assert_same_type::(); assert_same_type::(); @@ -25,28 +26,35 @@ fn run_failure_family_reuses_domain_types() { fn run_failure_json_matches_openapi_shape() { assert_json( RunFailure { - message: "Failed to initialize sandbox".to_string(), - causes: vec!["connection refused".to_string()], - reason: FailureReason::SandboxInitFailed, - category: FailureCategory::TransientInfra, - system_actor: None, - signature: Some(FailureSignature("init|transient_infra|docker".to_string())), - exec_output_tail: Some(ExecOutputTail { - stdout: None, - stderr: Some("last stderr line".to_string()), - stdout_truncated: false, - stderr_truncated: true, - }), + reason: FailureReason::SandboxInitFailed, + detail: { + let mut detail = FailureDetail::new( + "Failed to initialize sandbox", + FailureCategory::TransientInfra, + ); + detail.causes = vec!["connection refused".to_string()]; + detail.signature = + Some(FailureSignature("init|transient_infra|docker".to_string())); + detail.exec_output_tail = Some(ExecOutputTail { + stdout: None, + stderr: Some("last stderr line".to_string()), + stdout_truncated: false, + stderr_truncated: true, + }); + detail + }, }, json!({ - "message": "Failed to initialize sandbox", - "causes": ["connection refused"], "reason": "sandbox_init_failed", - "category": "transient_infra", - "signature": "init|transient_infra|docker", - "exec_output_tail": { - "stderr": "last stderr line", - "stderr_truncated": true + "detail": { + "message": "Failed to initialize sandbox", + "causes": ["connection refused"], + "category": "transient_infra", + "signature": "init|transient_infra|docker", + "exec_output_tail": { + "stderr": "last stderr line", + "stderr_truncated": true + } } }), ); @@ -64,13 +72,8 @@ fn conclusion_json_uses_failure_object() { }, duration_ms: 42, failure: Some(RunFailure { - message: "boom".to_string(), - causes: Vec::new(), - reason: FailureReason::WorkflowError, - category: FailureCategory::Deterministic, - system_actor: None, - signature: None, - exec_output_tail: None, + reason: FailureReason::WorkflowError, + detail: FailureDetail::new("boom", FailureCategory::Deterministic), }), final_git_commit_sha: None, stages: Vec::new(), @@ -83,9 +86,11 @@ fn conclusion_json_uses_failure_object() { "status": "failed", "duration_ms": 42, "failure": { - "message": "boom", "reason": "workflow_error", - "category": "deterministic" + "detail": { + "message": "boom", + "category": "deterministic" + } }, "total_retries": 0, "diff": {} diff --git a/lib/crates/fabro-cli/src/commands/run/events.rs b/lib/crates/fabro-cli/src/commands/run/events.rs index 2f3d1857d..061ecdb08 100644 --- a/lib/crates/fabro-cli/src/commands/run/events.rs +++ b/lib/crates/fabro-cli/src/commands/run/events.rs @@ -455,7 +455,7 @@ fn format_event_pretty_value(envelope: &serde_json::Value, styles: &Styles) -> O } "run.failed" => { let error = prop_field(envelope, "failure") - .and_then(|failure| failure.get("message")) + .and_then(failure_message) .and_then(serde_json::Value::as_str) .unwrap_or("unknown error"); Some(format!( @@ -809,6 +809,13 @@ fn prop_str_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str prop_field(value, key)?.as_str() } +fn failure_message(failure: &serde_json::Value) -> Option<&serde_json::Value> { + failure + .get("detail") + .and_then(|detail| detail.get("message")) + .or_else(|| failure.get("message")) +} + fn format_pull_request_record_event( envelope: &serde_json::Value, styles: &Styles, @@ -1313,7 +1320,7 @@ mod tests { #[test] fn pretty_workflow_run_failed() { let styles = no_color_styles(); - let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.failed","properties":{"failure":{"message":"sandbox timeout","reason":"workflow_error","category":"deterministic"}}}"#; + let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.failed","properties":{"failure":{"reason":"workflow_error","detail":{"message":"sandbox timeout","category":"deterministic"}}}}"#; let result = format_event_pretty(line, &styles).unwrap(); assert!(result.contains("Failed"), "got: {result}"); assert!(result.contains("sandbox timeout"), "got: {result}"); diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index 049f81212..a4de2c462 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -244,7 +244,7 @@ pub(crate) fn print_run_conclusion( } if let Some(ref failure) = conclusion.failure { - let rendered = render_with_causes(&failure.message, &failure.causes); + let rendered = render_with_causes(&failure.detail.message, &failure.detail.causes); fabro_util::printerr!(printer, "Failure: {}", styles.red.apply_to(rendered)); } diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs index 77d4596e2..fcb072d49 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs @@ -2,6 +2,7 @@ use std::convert::TryFrom; use chrono::{DateTime, Utc}; use fabro_types::{BilledModelUsage, EventBody, RunEvent}; +use fabro_util::error; use fabro_workflow::event::RunNoticeLevel; use serde_json::Value; @@ -361,7 +362,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { name: node_label, error: props.failure.as_ref().map_or_else( || "unknown error".to_string(), - |failure| failure.message.clone(), + |failure| error::render_compact_with_causes(&failure.message, &failure.causes), ), }), EventBody::StageRetrying(props) => Some(ProgressEvent::StageRetrying { diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index c39cabedf..3b9e2593c 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -659,8 +659,8 @@ mod tests { RunFailedProps, RunStatusTransitionProps, }; use fabro_types::{ - AuthMethod, EventBody, FailureCategory, FailureReason, IdpIdentity, Principal, - QuestionType, RunFailure, SuccessReason, fixtures, + AuthMethod, EventBody, FailureCategory, FailureDetail, FailureReason, IdpIdentity, + Principal, QuestionType, RunFailure, SuccessReason, fixtures, }; use fabro_vault::{SecretType, Vault}; use fabro_workflow::event::RunEventSink; @@ -786,13 +786,8 @@ mod tests { assert_eq!( worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps { failure: RunFailure { - message: "cancelled".to_string(), - causes: Vec::new(), - reason: FailureReason::Cancelled, - category: FailureCategory::Canceled, - system_actor: None, - signature: None, - exec_output_tail: None, + reason: FailureReason::Cancelled, + detail: FailureDetail::new("cancelled", FailureCategory::Canceled), }, duration_ms: 10, final_git_commit_sha: None, @@ -805,13 +800,8 @@ mod tests { assert_eq!( worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps { failure: RunFailure { - message: "boom".to_string(), - causes: Vec::new(), - reason: FailureReason::Terminated, - category: FailureCategory::Deterministic, - system_actor: None, - signature: None, - exec_output_tail: None, + reason: FailureReason::Terminated, + detail: FailureDetail::new("boom", FailureCategory::Deterministic), }, duration_ms: 10, final_git_commit_sha: None, diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 6bde50d83..94c94b34e 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -135,8 +135,8 @@ fn print_human_output( #[cfg(test)] mod tests { use fabro_types::{ - BilledTokenCounts, FailureCategory, FailureReason, RunDiff, RunFailure, RunStatus, - StageOutcome, SuccessReason, fixtures, + BilledTokenCounts, FailureCategory, FailureDetail, FailureReason, RunDiff, RunFailure, + RunStatus, StageOutcome, SuccessReason, fixtures, }; use fabro_workflow::records::Conclusion; @@ -213,13 +213,8 @@ mod tests { }, duration_ms: 500, failure: Some(RunFailure { - message: "error".into(), - causes: Vec::new(), - reason: FailureReason::WorkflowError, - category: FailureCategory::Deterministic, - system_actor: None, - signature: None, - exec_output_tail: None, + reason: FailureReason::WorkflowError, + detail: FailureDetail::new("error", FailureCategory::Deterministic), }), final_git_commit_sha: None, stages: vec![], diff --git a/lib/crates/fabro-core/src/error.rs b/lib/crates/fabro-core/src/error.rs index ab213777a..ea99e87d9 100644 --- a/lib/crates/fabro-core/src/error.rs +++ b/lib/crates/fabro-core/src/error.rs @@ -1,8 +1,6 @@ use std::fmt; -use fabro_types::SystemActorKind; - -use crate::outcome::{FailureCategory, FailureDetail, Outcome, OutcomeMeta, StageOutcome}; +use crate::outcome::{FailureDetail, Outcome, OutcomeMeta, StageOutcome}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VisitLimitSource { @@ -24,16 +22,13 @@ impl fmt::Display for VisitLimitSource { /// to_fail_outcome(). #[derive(Debug, Clone)] pub struct HandlerErrorDetail { - pub message: String, - pub retryable: bool, - pub category: Option, - pub system_actor: Option, - pub signature: Option, + pub retryable: bool, + pub failure: FailureDetail, } impl fmt::Display for HandlerErrorDetail { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.message) + write!(f, "{}", self.failure.message) } } @@ -59,14 +54,16 @@ pub enum Error { #[error("stall timeout on node \"{node_id}\"")] StallTimeout { node_id: String }, #[error("{detail}")] - Handler { detail: HandlerErrorDetail }, + Handler { detail: Box }, #[error("{0}")] Other(String), } impl Error { pub fn handler(detail: HandlerErrorDetail) -> Self { - Self::Handler { detail } + Self::Handler { + detail: Box::new(detail), + } } pub fn blocked(message: impl Into) -> Self { @@ -85,12 +82,7 @@ impl Error { status: StageOutcome::Failed { retry_requested: false, }, - failure: Some(FailureDetail { - message: detail.message.clone(), - category: detail.category.unwrap_or(FailureCategory::Deterministic), - system_actor: detail.system_actor, - signature: detail.signature.clone(), - }), + failure: Some(detail.failure.clone()), ..Outcome::default() }, other => Outcome::fail(&other.to_string()), @@ -103,6 +95,7 @@ pub type Result = std::result::Result; #[cfg(test)] mod tests { use super::*; + use crate::outcome::FailureCategory; #[test] fn core_error_display() { @@ -148,33 +141,27 @@ mod tests { #[test] fn core_error_handler_is_retryable() { let retryable = Error::handler(HandlerErrorDetail { - message: "timeout".into(), - retryable: true, - category: None, - system_actor: None, - signature: None, + retryable: true, + failure: FailureDetail::new("timeout", FailureCategory::TransientInfra), }); assert!(retryable.is_retryable()); let not_retryable = Error::handler(HandlerErrorDetail { - message: "bad input".into(), - retryable: false, - category: None, - system_actor: None, - signature: None, + retryable: false, + failure: FailureDetail::new("bad input", FailureCategory::Deterministic), }); assert!(!not_retryable.is_retryable()); } #[test] fn core_error_handler_to_fail_outcome() { - use crate::outcome::FailureCategory; let err = Error::handler(HandlerErrorDetail { - message: "api down".into(), - retryable: true, - category: Some(FailureCategory::TransientInfra), - system_actor: None, - signature: Some("sig123".into()), + retryable: true, + failure: { + let mut failure = FailureDetail::new("api down", FailureCategory::TransientInfra); + failure.signature = Some(fabro_types::FailureSignature("sig123".into())); + failure + }, }); let outcome: Outcome = err.to_fail_outcome(); assert_eq!(outcome.status, StageOutcome::Failed { @@ -183,7 +170,14 @@ mod tests { let failure = outcome.failure.unwrap(); assert_eq!(failure.message, "api down"); assert_eq!(failure.category, FailureCategory::TransientInfra); - assert_eq!(failure.signature.as_deref(), Some("sig123")); + assert_eq!( + failure + .signature + .as_ref() + .map(ToString::to_string) + .as_deref(), + Some("sig123") + ); } #[test] diff --git a/lib/crates/fabro-core/src/executor.rs b/lib/crates/fabro-core/src/executor.rs index d9ca0d6cc..99fa5bb52 100644 --- a/lib/crates/fabro-core/src/executor.rs +++ b/lib/crates/fabro-core/src/executor.rs @@ -448,12 +448,24 @@ mod tests { use crate::context::Context; use crate::error::HandlerErrorDetail; use crate::lifecycle::RunLifecycle; - use crate::outcome::StageOutcome; + use crate::outcome::{FailureCategory, FailureDetail, StageOutcome}; use crate::retry::{BackoffPolicy, RetryPolicy}; use crate::test_fixtures::*; type NextNodeLog = Arc)>>>; + fn handler_error(message: &str, retryable: bool) -> HandlerErrorDetail { + let category = if retryable { + FailureCategory::TransientInfra + } else { + FailureCategory::Deterministic + }; + HandlerErrorDetail { + retryable, + failure: FailureDetail::new(message, category), + } + } + // Helper to build and run an executor with default settings async fn run_linear( node_ids: &[&str], @@ -1015,20 +1027,8 @@ mod tests { async fn executor_retry_on_retryable_error() { let handler = Arc::new( CountingHandler::new(vec![ - Err(Error::handler(HandlerErrorDetail { - message: "fail1".into(), - retryable: true, - category: None, - system_actor: None, - signature: None, - })), - Err(Error::handler(HandlerErrorDetail { - message: "fail2".into(), - retryable: true, - category: None, - system_actor: None, - signature: None, - })), + Err(Error::handler(handler_error("fail1", true))), + Err(Error::handler(handler_error("fail2", true))), Ok(Outcome::success()), ]) .with_retry_policy(RetryPolicy { @@ -1092,14 +1092,8 @@ mod tests { #[tokio::test] async fn executor_retry_non_retryable_error_no_retry() { let handler = Arc::new( - CountingHandler::new(vec![Err(Error::handler(HandlerErrorDetail { - message: "fatal".into(), - retryable: false, - category: None, - system_actor: None, - signature: None, - }))]) - .with_retry_policy(RetryPolicy::with_max_attempts(3)), + CountingHandler::new(vec![Err(Error::handler(handler_error("fatal", false)))]) + .with_retry_policy(RetryPolicy::with_max_attempts(3)), ); let result = run_linear( &["start", "end"], @@ -1116,13 +1110,7 @@ mod tests { async fn executor_retry_no_retry_by_default() { // Default policy is RetryPolicy::none() (max_attempts=1) let handler = Arc::new(CountingHandler::new(vec![Err(Error::handler( - HandlerErrorDetail { - message: "fail".into(), - retryable: true, - category: None, - system_actor: None, - signature: None, - }, + handler_error("fail", true), ))])); let result = run_linear( &["start", "end"], @@ -1239,13 +1227,7 @@ mod tests { } let handler = Arc::new( CountingHandler::new(vec![ - Err(Error::handler(HandlerErrorDetail { - message: "r".into(), - retryable: true, - category: None, - system_actor: None, - signature: None, - })), + Err(Error::handler(handler_error("r", true))), Ok(Outcome::success()), ]) .with_retry_policy(RetryPolicy { @@ -1284,13 +1266,7 @@ mod tests { } let handler = Arc::new( CountingHandler::new(vec![ - Err(Error::handler(HandlerErrorDetail { - message: "r".into(), - retryable: true, - category: None, - system_actor: None, - signature: None, - })), + Err(Error::handler(handler_error("r", true))), Ok(Outcome::success()), ]) .with_retry_policy(RetryPolicy { @@ -1335,13 +1311,7 @@ mod tests { } let handler = Arc::new( CountingHandler::new(vec![ - Err(Error::handler(HandlerErrorDetail { - message: "r".into(), - retryable: true, - category: None, - system_actor: None, - signature: None, - })), + Err(Error::handler(handler_error("r", true))), Ok(Outcome::success()), // should not be reached ]) .with_retry_policy(RetryPolicy { @@ -2139,13 +2109,7 @@ mod tests { if c == 0 { // First call: fail with retryable, then cancel stall during backoff self.stall.cancel(); - Err(Error::handler(HandlerErrorDetail { - message: "transient".into(), - retryable: true, - category: None, - system_actor: None, - signature: None, - })) + Err(Error::handler(handler_error("transient", true))) } else { Ok(Outcome::success()) } diff --git a/lib/crates/fabro-core/src/test_fixtures.rs b/lib/crates/fabro-core/src/test_fixtures.rs index 92c8107be..8ccefe4a7 100644 --- a/lib/crates/fabro-core/src/test_fixtures.rs +++ b/lib/crates/fabro-core/src/test_fixtures.rs @@ -8,7 +8,7 @@ use crate::context::Context; use crate::error::{Error, HandlerErrorDetail, Result}; use crate::graph::{EdgeSelection, EdgeSpec, Graph, NodeSpec}; use crate::handler::NodeHandler; -use crate::outcome::{Outcome, StageOutcome}; +use crate::outcome::{FailureCategory, FailureDetail, Outcome, StageOutcome}; use crate::retry::RetryPolicy; // ---- Test node ---- @@ -388,11 +388,8 @@ impl ErrorHandler { pub fn retryable(message: &str, policy: RetryPolicy) -> Self { Self { detail: HandlerErrorDetail { - message: message.to_string(), - retryable: true, - category: None, - system_actor: None, - signature: None, + retryable: true, + failure: FailureDetail::new(message, FailureCategory::TransientInfra), }, retry_policy: policy, } @@ -401,11 +398,8 @@ impl ErrorHandler { pub fn non_retryable(message: &str) -> Self { Self { detail: HandlerErrorDetail { - message: message.to_string(), - retryable: false, - category: None, - system_actor: None, - signature: None, + retryable: false, + failure: FailureDetail::new(message, FailureCategory::Deterministic), }, retry_policy: RetryPolicy::none(), } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index e878985ce..7c64d1a16 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -85,7 +85,9 @@ use fabro_types::{ EventBody, InterviewQuestionRecord, Principal, PullRequestRecord, QuestionType, RunBlobId, RunControlAction, RunEvent, RunId, ServerSettings, SessionCapability, }; -use fabro_util::error::{SharedError, collect_causes, render_with_causes}; +use fabro_util::error::{ + SharedError, collect_causes, render_compact_with_causes, render_with_causes, +}; use fabro_util::version::FABRO_VERSION; use fabro_vault::{Error as VaultError, SecretType, Vault}; use fabro_workflow::artifact_upload::ArtifactSink; @@ -2413,7 +2415,10 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent) managed_run.status = RunStatus::Failed { reason: props.failure.reason, }; - managed_run.error = Some(props.failure.message.clone()); + managed_run.error = Some(render_compact_with_causes( + &props.failure.detail.message, + &props.failure.detail.causes, + )); managed_run.active_api_stages.clear(); managed_run.active_non_steerable_agent_stages.clear(); } @@ -3362,10 +3367,9 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { .conclusion .as_ref() .and_then(|conclusion| { - conclusion - .failure - .as_ref() - .map(|failure| failure.message.clone()) + conclusion.failure.as_ref().map(|failure| { + render_compact_with_causes(&failure.detail.message, &failure.detail.causes) + }) }) .or_else(|| managed_run.error.clone()); managed_run.checkpoint = final_state.current_checkpoint().cloned(); diff --git a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs index e67a5e2fc..b9f8b05da 100644 --- a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs @@ -353,7 +353,7 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() { event["properties"]["failure"]["reason"] .as_str() .map(ToOwned::to_owned), - event["properties"]["failure"]["message"] + event["properties"]["failure"]["detail"]["message"] .as_str() .map(ToOwned::to_owned), ) diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index cec38ed97..da3def19e 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -16,6 +16,7 @@ use fabro_types::{ RunStatus, RunSummary, RunTimestamps, SandboxProvider, StageCompletion, StageHandler, StageId, StageOutcome, StageProjection, StageState, StartRecord, WorkflowRef, first_event_seq, }; +use fabro_util::error::render_compact_with_causes; use serde_json::Value; use crate::{Error, EventEnvelope, Result}; @@ -856,7 +857,7 @@ fn stage_completion_from_outcome( failure_reason: outcome .failure .as_ref() - .map(|failure| failure.message.clone()), + .map(|failure| render_compact_with_causes(&failure.message, &failure.causes)), timestamp, } } @@ -1600,7 +1601,7 @@ mod tests { "index": 0, "failure": { "message": "provider failed", - "failure_class": "transient_infra" + "category": "transient_infra" }, "will_retry": false, "duration_ms": 654, @@ -2185,13 +2186,8 @@ mod tests { 1, EventBody::RunFailed(RunFailedProps { failure: fabro_types::RunFailure { - message: "boom".to_string(), - causes: Vec::new(), - reason: FailureReason::WorkflowError, - category: FailureCategory::Deterministic, - system_actor: None, - signature: None, - exec_output_tail: None, + reason: FailureReason::WorkflowError, + detail: FailureDetail::new("boom", FailureCategory::Deterministic), }, duration_ms: 42, final_git_commit_sha: Some("abc123".to_string()), @@ -2305,9 +2301,11 @@ mod tests { "run.failed", &json!({ "failure": { - "message": "boom", "reason": "workflow_error", - "category": "deterministic" + "detail": { + "message": "boom", + "category": "deterministic" + } }, "duration_ms": 42, "diff_summary": { @@ -2337,16 +2335,18 @@ mod tests { 1, EventBody::RunFailed(RunFailedProps { failure: fabro_types::RunFailure { - message: "Failed to initialize sandbox".to_string(), - causes: vec![ - "Failed to pull Docker image buildpack-deps:noble".to_string(), - "connection refused".to_string(), - ], - reason: FailureReason::WorkflowError, - category: FailureCategory::TransientInfra, - system_actor: None, - signature: None, - exec_output_tail: None, + reason: FailureReason::WorkflowError, + detail: { + let mut detail = FailureDetail::new( + "Failed to initialize sandbox", + FailureCategory::TransientInfra, + ); + detail.causes = vec![ + "Failed to pull Docker image buildpack-deps:noble".to_string(), + "connection refused".to_string(), + ]; + detail + }, }, duration_ms: 42, final_git_commit_sha: None, @@ -2359,8 +2359,8 @@ mod tests { .unwrap(); let failure = state.conclusion.unwrap().failure.unwrap(); - assert_eq!(failure.message, "Failed to initialize sandbox"); - assert_eq!(failure.causes, vec![ + assert_eq!(failure.detail.message, "Failed to initialize sandbox"); + assert_eq!(failure.detail.causes, vec![ "Failed to pull Docker image buildpack-deps:noble".to_string(), "connection refused".to_string(), ]); @@ -2370,15 +2370,19 @@ mod tests { fn run_failed_projection_uses_nested_failure_reason_and_conclusion() { let mut state = running_projection(); let failure = fabro_types::RunFailure { - message: "Failed to initialize sandbox".to_string(), - causes: vec!["connection refused".to_string()], - reason: FailureReason::SandboxInitFailed, - category: FailureCategory::TransientInfra, - system_actor: Some(fabro_types::SystemActorKind::Engine), - signature: Some(fabro_types::FailureSignature( - "init|transient_infra|docker".to_string(), - )), - exec_output_tail: None, + reason: FailureReason::SandboxInitFailed, + detail: { + let mut detail = FailureDetail::new( + "Failed to initialize sandbox", + FailureCategory::TransientInfra, + ); + detail.causes = vec!["connection refused".to_string()]; + detail.system_actor = Some(fabro_types::SystemActorKind::Engine); + detail.signature = Some(fabro_types::FailureSignature( + "init|transient_infra|docker".to_string(), + )); + detail + }, }; state .apply_event(&test_event( diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index d94762152..e338384f5 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -725,9 +725,11 @@ mod tests { "run.failed", &serde_json::json!({ "failure": { - "message": "cancelled", "reason": "cancelled", - "category": "canceled" + "detail": { + "message": "cancelled", + "category": "canceled" + } }, "duration_ms": 1, }), @@ -1126,9 +1128,11 @@ mod tests { "run.failed", &serde_json::json!({ "failure": { - "message": "workflow failed", "reason": "workflow_error", - "category": "deterministic" + "detail": { + "message": "workflow failed", + "category": "deterministic" + } }, "duration_ms": 1, }), diff --git a/lib/crates/fabro-types/src/failure_signature.rs b/lib/crates/fabro-types/src/failure_signature.rs index 227a95c5b..5d64816bb 100644 --- a/lib/crates/fabro-types/src/failure_signature.rs +++ b/lib/crates/fabro-types/src/failure_signature.rs @@ -10,3 +10,18 @@ impl fmt::Display for FailureSignature { f.write_str(&self.0) } } + +impl FailureSignature { + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::ops::Deref for FailureSignature { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} diff --git a/lib/crates/fabro-types/src/outcome.rs b/lib/crates/fabro-types/src/outcome.rs index 8ef0c67dc..1c09ed6dd 100644 --- a/lib/crates/fabro-types/src/outcome.rs +++ b/lib/crates/fabro-types/src/outcome.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; use strum::{Display, EnumString, IntoStaticStr}; -use crate::SystemActorKind; +use crate::{ExecOutputTail, FailureSignature, SystemActorKind}; pub trait OutcomeMeta: Default + Clone + Send + Sync + fmt::Debug + Serialize + DeserializeOwned + 'static @@ -229,26 +229,27 @@ impl FailureCategory { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FailureDetail { - pub message: String, - #[serde(rename = "failure_class")] - pub category: FailureCategory, + pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub causes: Vec, + pub category: FailureCategory, #[serde(default, skip_serializing_if = "Option::is_none")] - pub system_actor: Option, - #[serde( - rename = "failure_signature", - default, - skip_serializing_if = "Option::is_none" - )] - pub signature: Option, + pub system_actor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec_output_tail: Option, } impl FailureDetail { pub fn new(message: impl Into, category: FailureCategory) -> Self { Self { message: message.into(), + causes: Vec::new(), category, system_actor: None, signature: None, + exec_output_tail: None, } } } @@ -305,10 +306,12 @@ impl Outcome { retry_requested: false, }, failure: Some(FailureDetail { - message: message.to_string(), - category: FailureCategory::Deterministic, - system_actor: None, - signature: None, + message: message.to_string(), + causes: Vec::new(), + category: FailureCategory::Deterministic, + system_actor: None, + signature: None, + exec_output_tail: None, }), ..Self::default() } @@ -327,7 +330,7 @@ impl Outcome { mod tests { use serde_json::json; - use super::{StageOutcome, StageState}; + use super::{FailureCategory, FailureDetail, StageOutcome, StageState}; #[test] fn stage_outcome_failed_serde_is_lossy_for_retry_intent() { @@ -366,6 +369,35 @@ mod tests { assert!(!StageState::Retrying.is_terminal()); assert!(!StageState::Running.is_terminal()); } + + #[test] + fn failure_detail_serializes_rich_diagnostics() { + use crate::{ExecOutputTail, FailureSignature}; + + let mut failure = FailureDetail::new("ACP turn failed", FailureCategory::Deterministic); + failure.causes = vec![ + "ACP protocol error".to_string(), + "agent exited before initialize completed".to_string(), + ]; + failure.signature = Some(FailureSignature("work|deterministic|acp".to_string())); + failure.exec_output_tail = Some(ExecOutputTail { + stdout: None, + stderr: Some("redacted stderr tail".to_string()), + stdout_truncated: false, + stderr_truncated: true, + }); + + let value = serde_json::to_value(&failure).expect("failure detail should serialize"); + + assert_eq!(value["message"], "ACP turn failed"); + assert_eq!(value["causes"][0], "ACP protocol error"); + assert_eq!(value["category"], "deterministic"); + assert_eq!(value["signature"], "work|deterministic|acp"); + assert_eq!(value["exec_output_tail"]["stderr"], "redacted stderr tail"); + assert_eq!(value["exec_output_tail"]["stderr_truncated"], true); + assert!(value.get("failure_class").is_none()); + assert!(value.get("failure_signature").is_none()); + } } #[derive(Debug, Clone)] diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index 5ceedebdf..a4509f005 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -1095,9 +1095,11 @@ mod tests { "run.failed", json!({ "failure": { - "message": "boom", "reason": "workflow_error", - "category": "deterministic" + "detail": { + "message": "boom", + "category": "deterministic" + } }, "duration_ms": 42, "diff_summary": { diff --git a/lib/crates/fabro-types/src/run_failure.rs b/lib/crates/fabro-types/src/run_failure.rs index f519063b5..969ad9697 100644 --- a/lib/crates/fabro-types/src/run_failure.rs +++ b/lib/crates/fabro-types/src/run_failure.rs @@ -1,18 +1,41 @@ use serde::{Deserialize, Serialize}; -use crate::{ExecOutputTail, FailureCategory, FailureReason, FailureSignature, SystemActorKind}; +use crate::{FailureDetail, FailureReason}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunFailure { - pub message: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub causes: Vec, - pub reason: FailureReason, - pub category: FailureCategory, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub system_actor: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signature: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec_output_tail: Option, + pub reason: FailureReason, + pub detail: FailureDetail, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::{FailureCategory, FailureDetail, FailureReason, RunFailure}; + + #[test] + fn run_failure_wraps_failure_detail_with_reason() { + let detail = FailureDetail::new("ACP turn failed", FailureCategory::Deterministic); + let failure = RunFailure { + reason: FailureReason::WorkflowError, + detail, + }; + + let value = serde_json::to_value(&failure).expect("run failure should serialize"); + + assert_eq!(value["reason"], "workflow_error"); + assert_eq!(value["detail"]["message"], "ACP turn failed"); + assert_eq!(value["detail"]["category"], "deterministic"); + assert_eq!( + value, + json!({ + "reason": "workflow_error", + "detail": { + "message": "ACP turn failed", + "category": "deterministic" + } + }) + ); + } } diff --git a/lib/crates/fabro-types/tests/run_failure_serde.rs b/lib/crates/fabro-types/tests/run_failure_serde.rs index a5bf63270..04b3fe00c 100644 --- a/lib/crates/fabro-types/tests/run_failure_serde.rs +++ b/lib/crates/fabro-types/tests/run_failure_serde.rs @@ -1,7 +1,7 @@ use fabro_types::run_event::run::RunFailedProps; use fabro_types::{ - Conclusion, EventBody, ExecOutputTail, FailureCategory, FailureReason, FailureSignature, - RunDiff, RunFailure, StageOutcome, SystemActorKind, + Conclusion, EventBody, ExecOutputTail, FailureCategory, FailureDetail, FailureReason, + FailureSignature, RunDiff, RunFailure, StageOutcome, SystemActorKind, }; use serde_json::json; @@ -9,23 +9,28 @@ use serde_json::json; fn run_failed_serializes_nested_failure_contract() { let body = EventBody::RunFailed(RunFailedProps { failure: RunFailure { - message: "Failed to initialize sandbox".to_string(), - causes: vec![ - "Failed to pull Docker image buildpack-deps:noble".to_string(), - "connection refused".to_string(), - ], - reason: FailureReason::SandboxInitFailed, - category: FailureCategory::TransientInfra, - system_actor: Some(SystemActorKind::Engine), - signature: Some(FailureSignature( - "init|transient_infra|docker-pull".to_string(), - )), - exec_output_tail: Some(ExecOutputTail { - stdout: Some("last stdout line".to_string()), - stderr: Some("last stderr line".to_string()), - stdout_truncated: false, - stderr_truncated: true, - }), + reason: FailureReason::SandboxInitFailed, + detail: { + let mut detail = FailureDetail::new( + "Failed to initialize sandbox", + FailureCategory::TransientInfra, + ); + detail.causes = vec![ + "Failed to pull Docker image buildpack-deps:noble".to_string(), + "connection refused".to_string(), + ]; + detail.system_actor = Some(SystemActorKind::Engine); + detail.signature = Some(FailureSignature( + "init|transient_infra|docker-pull".to_string(), + )); + detail.exec_output_tail = Some(ExecOutputTail { + stdout: Some("last stdout line".to_string()), + stderr: Some("last stderr line".to_string()), + stdout_truncated: false, + stderr_truncated: true, + }); + detail + }, }, duration_ms: 42, final_git_commit_sha: Some("abc123".to_string()), @@ -41,19 +46,21 @@ fn run_failed_serializes_nested_failure_contract() { value["properties"], json!({ "failure": { - "message": "Failed to initialize sandbox", - "causes": [ - "Failed to pull Docker image buildpack-deps:noble", - "connection refused" - ], "reason": "sandbox_init_failed", - "category": "transient_infra", - "system_actor": "engine", - "signature": "init|transient_infra|docker-pull", - "exec_output_tail": { - "stdout": "last stdout line", - "stderr": "last stderr line", - "stderr_truncated": true + "detail": { + "message": "Failed to initialize sandbox", + "causes": [ + "Failed to pull Docker image buildpack-deps:noble", + "connection refused" + ], + "category": "transient_infra", + "system_actor": "engine", + "signature": "init|transient_infra|docker-pull", + "exec_output_tail": { + "stdout": "last stdout line", + "stderr": "last stderr line", + "stderr_truncated": true + } } }, "duration_ms": 42, @@ -71,13 +78,8 @@ fn run_failed_serializes_nested_failure_contract() { fn run_failed_omits_empty_failure_optional_fields() { let body = EventBody::RunFailed(RunFailedProps { failure: RunFailure { - message: "boom".to_string(), - causes: Vec::new(), - reason: FailureReason::WorkflowError, - category: FailureCategory::Deterministic, - system_actor: None, - signature: None, - exec_output_tail: None, + reason: FailureReason::WorkflowError, + detail: FailureDetail::new("boom", FailureCategory::Deterministic), }, duration_ms: 1, final_git_commit_sha: None, @@ -92,9 +94,11 @@ fn run_failed_omits_empty_failure_optional_fields() { value["properties"], json!({ "failure": { - "message": "boom", "reason": "workflow_error", - "category": "deterministic" + "detail": { + "message": "boom", + "category": "deterministic" + } }, "duration_ms": 1 }) @@ -112,13 +116,12 @@ fn conclusion_serializes_rich_failure() { }, duration_ms: 42, failure: Some(RunFailure { - message: "run failed".to_string(), - causes: vec!["leaf cause".to_string()], - reason: FailureReason::WorkflowError, - category: FailureCategory::Deterministic, - system_actor: None, - signature: None, - exec_output_tail: None, + reason: FailureReason::WorkflowError, + detail: { + let mut detail = FailureDetail::new("run failed", FailureCategory::Deterministic); + detail.causes = vec!["leaf cause".to_string()]; + detail + }, }), final_git_commit_sha: None, stages: Vec::new(), @@ -129,7 +132,7 @@ fn conclusion_serializes_rich_failure() { let value = serde_json::to_value(&conclusion).expect("conclusion should serialize"); - assert_eq!(value["failure"]["message"], "run failed"); - assert_eq!(value["failure"]["causes"], json!(["leaf cause"])); + assert_eq!(value["failure"]["detail"]["message"], "run failed"); + assert_eq!(value["failure"]["detail"]["causes"], json!(["leaf cause"])); assert!(value.get("failure_reason").is_none()); } diff --git a/lib/crates/fabro-util/src/error.rs b/lib/crates/fabro-util/src/error.rs index b2cbddccc..e8f174a06 100644 --- a/lib/crates/fabro-util/src/error.rs +++ b/lib/crates/fabro-util/src/error.rs @@ -57,9 +57,20 @@ pub fn render_with_causes(message: &str, causes: &[String]) -> String { rendered } +pub fn render_compact_with_causes(message: &str, causes: &[String]) -> String { + let Some(cause) = causes.first() else { + return message.to_string(); + }; + if cause == message { + message.to_string() + } else { + format!("{message}: {cause}") + } +} + #[cfg(test)] mod tests { - use super::SharedError; + use super::{SharedError, render_compact_with_causes}; #[test] fn shared_error_preserves_chain_without_duplicating_top_level() { @@ -92,4 +103,22 @@ mod tests { ); } } + + #[test] + fn compact_cause_rendering_adds_first_cause_without_multiline_noise() { + assert_eq!( + render_compact_with_causes("Failed to initialize sandbox", &[ + "connection refused".to_string() + ]), + "Failed to initialize sandbox: connection refused" + ); + } + + #[test] + fn compact_cause_rendering_deduplicates_matching_cause() { + assert_eq!( + render_compact_with_causes("boom", &["boom".to_string()]), + "boom" + ); + } } diff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/crates/fabro-workflow/src/error.rs index cd830eb05..5d5b89349 100644 --- a/lib/crates/fabro-workflow/src/error.rs +++ b/lib/crates/fabro-workflow/src/error.rs @@ -2,7 +2,7 @@ use fabro_graphviz::Error as GraphvizError; use fabro_llm::{Error as LlmError, ProviderErrorKind}; pub use fabro_types::failure_signature::FailureSignature; pub use fabro_types::outcome::FailureCategory; -use fabro_types::{FailureReason, RunFailure}; +use fabro_types::{ExecOutputTail, FailureReason, RunFailure}; use fabro_util::error::{SharedError, collect_causes, collect_chain, render_with_causes}; use fabro_validate::Diagnostic; use thiserror::Error as ThisError; @@ -207,18 +207,20 @@ pub enum Error { #[error("Engine error: {message}")] Engine { - message: String, - failure_class: FailureCategory, + message: String, + failure_class: FailureCategory, + exec_output_tail: Option, #[source] - source: Option, + source: Option, }, #[error("Handler error: {message}")] Handler { - message: String, - failure_class: FailureCategory, + message: String, + failure_class: FailureCategory, + exec_output_tail: Option, #[source] - source: Option, + source: Option, }, #[error("LLM error: {0}")] @@ -255,6 +257,21 @@ impl Error { Self::Handler { message, failure_class, + exec_output_tail: None, + source: None, + } + } + + pub fn handler_with_exec_output_tail( + message: impl Into, + exec_output_tail: Option, + ) -> Self { + let message = message.into(); + let failure_class = classify_failure_reason(&message); + Self::Handler { + message, + failure_class, + exec_output_tail, source: None, } } @@ -262,6 +279,14 @@ impl Error { pub fn handler_with_source( message: impl Into, source: impl Into, + ) -> Self { + Self::handler_with_source_and_exec_output_tail(message, source, None) + } + + pub fn handler_with_source_and_exec_output_tail( + message: impl Into, + source: impl Into, + exec_output_tail: Option, ) -> Self { let message = message.into(); let source = SharedError::new(source.into()); @@ -271,6 +296,7 @@ impl Error { Self::Handler { message, failure_class, + exec_output_tail, source: Some(source), } } @@ -287,6 +313,7 @@ impl Error { Self::Engine { message, failure_class, + exec_output_tail: None, source: None, } } @@ -303,6 +330,7 @@ impl Error { Self::Engine { message, failure_class, + exec_output_tail: None, source: Some(source), } } @@ -374,21 +402,42 @@ impl Error { /// Return a stable failure signature hint when structured error info is /// available. #[must_use] - pub fn failure_signature_hint(&self) -> Option { + pub fn failure_signature_hint(&self) -> Option { match self { - Self::Llm(sdk_err) => Some(sdk_err.failure_signature_hint()), + Self::Llm(sdk_err) => Some(FailureSignature(sdk_err.failure_signature_hint())), _ => None, } } + #[must_use] + pub fn to_failure_detail(&self) -> FailureDetail { + let message = match self { + Self::Engine { message, .. } | Self::Handler { message, .. } => message.clone(), + _ => self.to_string(), + }; + let explicit_exec_output_tail = match self { + Self::Engine { + exec_output_tail, .. + } + | Self::Handler { + exec_output_tail, .. + } => exec_output_tail.clone(), + _ => None, + }; + FailureDetail { + message, + causes: self.causes(), + category: self.failure_category(), + system_actor: None, + signature: self.failure_signature_hint(), + exec_output_tail: explicit_exec_output_tail + .or_else(|| fabro_sandbox::default_redacted_output_tail(self)), + } + } + /// Build a fail `Outcome` with structured `FailureDetail`. pub fn to_fail_outcome(&self) -> Outcome { - let failure = FailureDetail { - message: self.display_with_causes(), - category: self.failure_category(), - system_actor: None, - signature: self.failure_signature_hint(), - }; + let failure = self.to_failure_detail(); Outcome { status: StageOutcome::Failed { retry_requested: false, @@ -401,18 +450,9 @@ impl Error { #[must_use] pub fn run_failure_from_error(error: &Error, reason: FailureReason) -> RunFailure { - let message = match error { - Error::Engine { message, .. } | Error::Handler { message, .. } => message.clone(), - _ => error.to_string(), - }; RunFailure { - message, - causes: error.causes(), reason, - category: error.failure_category(), - system_actor: None, - signature: error.failure_signature_hint().map(FailureSignature), - exec_output_tail: fabro_sandbox::default_redacted_output_tail(error), + detail: error.to_failure_detail(), } } @@ -422,13 +462,8 @@ pub fn run_failure_from_outcome_failure( reason: FailureReason, ) -> RunFailure { RunFailure { - message: failure.message.clone(), - causes: Vec::new(), reason, - category: failure.category, - system_actor: failure.system_actor, - signature: failure.signature.clone().map(FailureSignature), - exec_output_tail: None, + detail: failure.clone(), } } @@ -1663,7 +1698,9 @@ mod tests { }); assert_eq!( err.failure_signature_hint(), - Some("api_deterministic|openai|authentication".to_string()) + Some(FailureSignature( + "api_deterministic|openai|authentication".to_string() + )) ); } @@ -1912,10 +1949,10 @@ mod tests { let err = Error::handler("connection refused"); let failure = run_failure_from_error(&err, FailureReason::WorkflowError); - assert_eq!(failure.message, "connection refused"); - assert_eq!(failure.causes, Vec::::new()); + assert_eq!(failure.detail.message, "connection refused"); + assert_eq!(failure.detail.causes, Vec::::new()); assert_eq!(failure.reason, FailureReason::WorkflowError); - assert_eq!(failure.category, FailureCategory::TransientInfra); + assert_eq!(failure.detail.category, FailureCategory::TransientInfra); } #[test] diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 3ef33c89f..5538c01f7 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -1442,7 +1442,7 @@ mod tests { assert_eq!(stored.event_name(), "stage.failed"); let properties = stored.properties().unwrap(); assert_eq!(properties["failure"]["message"], "lint failed"); - assert_eq!(properties["failure"]["failure_class"], "deterministic"); + assert_eq!(properties["failure"]["category"], "deterministic"); assert_eq!(properties["will_retry"], true); assert_eq!(properties["billing"], serde_json::to_value(&usage).unwrap()); } @@ -1550,7 +1550,7 @@ mod tests { assert_eq!(stored.event_name(), "run.failed"); let properties = stored.properties().unwrap(); - assert_eq!(properties["failure"]["message"], "boom"); + assert_eq!(properties["failure"]["detail"]["message"], "boom"); assert_eq!(properties["duration_ms"], 900); } @@ -1570,11 +1570,11 @@ mod tests { let properties = stored.properties().unwrap(); assert_eq!( - properties["failure"]["message"], + properties["failure"]["detail"]["message"], "Failed to initialize sandbox" ); assert_eq!( - properties["failure"]["causes"], + properties["failure"]["detail"]["causes"], serde_json::json!(["connection refused"]) ); } @@ -1596,15 +1596,18 @@ mod tests { assert_eq!(stored.event_name(), "run.failed"); let properties = stored.properties().unwrap(); assert_eq!( - properties["failure"]["message"], + properties["failure"]["detail"]["message"], "Failed to initialize sandbox" ); assert_eq!( - properties["failure"]["causes"], + properties["failure"]["detail"]["causes"], serde_json::json!(["connection refused"]) ); assert_eq!(properties["failure"]["reason"], "sandbox_init_failed"); - assert_eq!(properties["failure"]["category"], "transient_infra"); + assert_eq!( + properties["failure"]["detail"]["category"], + "transient_infra" + ); assert_eq!(properties["duration_ms"], 900); assert_eq!(properties["final_git_commit_sha"], "abc123"); assert!(properties.get("error").is_none()); diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index a57d43020..88731cf9e 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -826,15 +826,16 @@ impl Event { duration_ms, .. } => { + let detail = &failure.detail; let tail = - fabro_types::ExecOutputTail::trace_summary(failure.exec_output_tail.as_ref()); + fabro_types::ExecOutputTail::trace_summary(detail.exec_output_tail.as_ref()); error!( - message = %failure.message, + message = %detail.message, reason = %failure.reason, - category = %failure.category, - system_actor = ?failure.system_actor, - signature = ?failure.signature, - cause_count = failure.causes.len(), + category = %detail.category, + system_actor = ?detail.system_actor, + signature = ?detail.signature, + cause_count = detail.causes.len(), exec_output_tail_present = tail.present, exec_stdout_tail_bytes = tail.stdout_bytes, exec_stderr_tail_bytes = tail.stderr_bytes, diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 8e16f2379..35d24d421 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -25,7 +25,7 @@ pub enum CodergenResult { files_touched: Vec, last_file_touched: Option, }, - Full(Outcome), + Full(Box), } pub struct CodergenRunRequest<'a> { @@ -313,7 +313,7 @@ impl Handler for AgentHandler { }) .await; match result { - Ok(CodergenResult::Full(outcome)) => return Ok(outcome), + Ok(CodergenResult::Full(outcome)) => return Ok(*outcome), Ok(CodergenResult::Text { text, usage, diff --git a/lib/crates/fabro-workflow/src/handler/llm/acp.rs b/lib/crates/fabro-workflow/src/handler/llm/acp.rs index 1c0d2e00a..3d03b7d44 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/acp.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/acp.rs @@ -188,7 +188,11 @@ impl AgentAcpBackend { ); return Err(Error::Cancelled); } - Err(AcpError::TimedOut { stderr }) => { + Err(AcpError::TimedOut { exec_output_tail }) => { + let stderr = exec_output_tail + .as_ref() + .and_then(|tail| tail.stderr.clone()) + .unwrap_or_default(); emitter.emit_scoped( &Event::AgentAcpTimedOut { node_id: node.id.clone(), @@ -198,7 +202,9 @@ impl AgentAcpBackend { }, stage_scope, ); - return Err(acp_error_to_workflow(AcpError::TimedOut { stderr })); + return Err(acp_error_to_workflow(AcpError::TimedOut { + exec_output_tail, + })); } Err(AcpError::StopReason { stop_reason, text }) => { emitter.emit_scoped( @@ -285,18 +291,21 @@ fn acp_command_error_to_workflow(error: AcpCommandError) -> Error { fn acp_error_to_workflow(error: AcpError) -> Error { match error { AcpError::Cancelled => Error::Cancelled, - AcpError::TimedOut { stderr } => { - if stderr.is_empty() { - Error::handler("ACP turn timed out") - } else { - Error::handler(format!("ACP turn timed out: {stderr}")) - } + AcpError::TimedOut { exec_output_tail } => { + Error::handler_with_exec_output_tail("ACP turn timed out", exec_output_tail) } AcpError::StopReason { stop_reason, text } => { Error::handler(format!("ACP prompt stopped with {stop_reason}: {text}")) } AcpError::Sandbox(source) => Error::handler_with_source("ACP turn failed", source), - other => Error::handler_with_source("ACP turn failed", other), + other => { + let exec_output_tail = other.exec_output_tail(); + Error::handler_with_source_and_exec_output_tail( + "ACP turn failed", + other, + exec_output_tail, + ) + } } } @@ -306,14 +315,15 @@ mod tests { use std::sync::{Arc, Mutex}; use fabro_acp::test_support::fake_acp_agent_script; + use fabro_acp::{AcpError, AcpProcessExit}; use fabro_agent::{LocalSandbox, Sandbox, shell_quote}; use fabro_graphviz::graph::{AttrValue, Node}; use fabro_model::ProviderId; use fabro_sandbox::test_support::MockSandbox; - use fabro_types::EventBody; + use fabro_types::{CommandTermination, EventBody, ExecOutputTail}; use tokio_util::sync::CancellationToken; - use super::AgentAcpBackend; + use super::{AgentAcpBackend, acp_error_to_workflow}; use crate::context::Context; use crate::event::{Emitter, StageScope}; use crate::handler::agent::{ @@ -660,6 +670,59 @@ mod tests { ); } + #[test] + fn acp_timeout_maps_stderr_to_exec_tail_not_message() { + let tail = ExecOutputTail { + stdout: None, + stderr: Some("redacted stderr tail".to_string()), + stdout_truncated: false, + stderr_truncated: true, + }; + let err = acp_error_to_workflow(AcpError::TimedOut { + exec_output_tail: Some(tail.clone()), + }); + + let detail = err.to_failure_detail(); + assert_eq!(detail.message, "ACP turn timed out"); + assert!(detail.causes.is_empty()); + assert_eq!(detail.exec_output_tail, Some(tail)); + } + + #[test] + fn acp_process_exit_maps_stderr_to_exec_tail_not_cause_text() { + let tail = ExecOutputTail { + stdout: None, + stderr: Some("early boom".to_string()), + stdout_truncated: false, + stderr_truncated: false, + }; + let err = acp_error_to_workflow(AcpError::ProcessExited(AcpProcessExit { + termination: CommandTermination::Exited, + exit_code: Some(2), + exec_output_tail: Some(tail.clone()), + })); + + let detail = err.to_failure_detail(); + assert_eq!(detail.message, "ACP turn failed"); + assert_eq!(detail.exec_output_tail, Some(tail)); + assert!( + detail + .causes + .iter() + .any(|cause| cause.contains("exit_code=2")), + "cause chain should retain process exit context: {:?}", + detail.causes + ); + assert!( + !detail + .causes + .iter() + .any(|cause| cause.contains("early boom")), + "raw stderr belongs in exec_output_tail, not causes: {:?}", + detail.causes + ); + } + #[expect( clippy::disallowed_methods, reason = "unit test initializes an isolated git repository with the system git binary" diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index 6546d7947..29a67b756 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -139,7 +139,7 @@ impl Handler for PromptHandler { }) .await; match result { - Ok(CodergenResult::Full(outcome)) => return Ok(outcome), + Ok(CodergenResult::Full(outcome)) => return Ok(*outcome), Ok(CodergenResult::Text { text, usage, diff --git a/lib/crates/fabro-workflow/src/node_handler.rs b/lib/crates/fabro-workflow/src/node_handler.rs index dc64ff0ad..6c1c40b85 100644 --- a/lib/crates/fabro-workflow/src/node_handler.rs +++ b/lib/crates/fabro-workflow/src/node_handler.rs @@ -17,7 +17,7 @@ use crate::context::Context; use crate::error::Error; use crate::graph::{WorkflowGraph, WorkflowNode}; use crate::handler::{EngineServices, dispatch_handler, format_panic_message}; -use crate::outcome::{Outcome, StageOutcome}; +use crate::outcome::{FailureDetail, Outcome, StageOutcome}; use crate::retry::build_retry_policy; /// Production node handler that bridges fabro-core's NodeHandler to the @@ -51,11 +51,8 @@ impl NodeHandler for WorkflowNodeHandler { .await .map_err(|err| { CoreError::handler(HandlerErrorDetail { - message: err.to_string(), - retryable: true, - category: Some(FailureCategory::TransientInfra), - system_actor: None, - signature: None, + retryable: true, + failure: err.to_failure_detail(), }) })?; let execution_snapshot = wf_context.snapshot(); @@ -79,12 +76,14 @@ impl NodeHandler for WorkflowNodeHandler { match timeout(duration, panic_safe).await { Ok(inner) => inner, Err(_elapsed) => { + let mut failure = FailureDetail::new( + format!("handler timed out after {}ms", duration.as_millis()), + FailureCategory::TransientInfra, + ); + failure.system_actor = Some(SystemActorKind::Timeout); return Err(CoreError::handler(HandlerErrorDetail { - message: format!("handler timed out after {}ms", duration.as_millis()), - retryable: true, - category: Some(FailureCategory::TransientInfra), - system_actor: Some(SystemActorKind::Timeout), - signature: None, + retryable: true, + failure, })); } } @@ -108,21 +107,15 @@ impl NodeHandler for WorkflowNodeHandler { Ok(Err(fabro_err)) => { let retryable = handler.should_retry(&fabro_err); Err(CoreError::handler(HandlerErrorDetail { - message: fabro_err.to_string(), retryable, - category: Some(fabro_err.failure_category()), - system_actor: None, - signature: fabro_err.failure_signature_hint(), + failure: fabro_err.to_failure_detail(), })) } Err(panic_payload) => { let msg = format_panic_message(&panic_payload); Err(CoreError::handler(HandlerErrorDetail { - message: msg, - retryable: false, - category: Some(FailureCategory::Deterministic), - system_actor: None, - signature: None, + retryable: false, + failure: FailureDetail::new(msg, FailureCategory::Deterministic), })) } } @@ -137,11 +130,8 @@ impl NodeHandler for WorkflowNodeHandler { .await .map_err(|err| { CoreError::handler(HandlerErrorDetail { - message: err.to_string(), - retryable: true, - category: Some(FailureCategory::TransientInfra), - system_actor: None, - signature: None, + retryable: true, + failure: err.to_failure_detail(), }) }) } diff --git a/lib/crates/fabro-workflow/src/outcome.rs b/lib/crates/fabro-workflow/src/outcome.rs index 3d106678f..0d8d0aa49 100644 --- a/lib/crates/fabro-workflow/src/outcome.rs +++ b/lib/crates/fabro-workflow/src/outcome.rs @@ -7,7 +7,7 @@ use fabro_model::{ }; pub use fabro_types::BilledModelUsage; -use crate::error::{Error, classify_failure_reason}; +use crate::error::{Error, FailureSignature, classify_failure_reason}; pub type Outcome = fabro_core::Outcome>; @@ -109,7 +109,7 @@ impl OutcomeExt for Outcome { fn with_signature(mut self, sig: Option>) -> Self { if let Some(ref mut failure) = self.failure { - failure.signature = sig.map(Into::into); + failure.signature = sig.map(|sig| FailureSignature(sig.into())); } self } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 281863d55..4d30c7580 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -100,6 +100,7 @@ models/execute-query-request.ts models/execute-query-response-rows-inner-inner.ts models/execute-query-response.ts models/failure-category.ts +models/failure-detail.ts models/failure-reason.ts models/features-namespace.ts models/file-checkpoint.ts diff --git a/lib/packages/fabro-api-client/src/models/failure-detail.ts b/lib/packages/fabro-api-client/src/models/failure-detail.ts new file mode 100644 index 000000000..e3bf19436 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/failure-detail.ts @@ -0,0 +1,42 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { ExecOutputTail } from './exec-output-tail'; +// May contain unused imports in some cases +// @ts-ignore +import type { FailureCategory } from './failure-category'; +// May contain unused imports in some cases +// @ts-ignore +import type { SystemActorKind } from './system-actor-kind'; + +/** + * Rich diagnostic detail for a failed stage or terminal run. + */ +export interface FailureDetail { + 'message': string; + 'causes'?: Array; + 'category': FailureCategory; + 'system_actor'?: SystemActorKind | null; + /** + * Stable normalized signature for grouping related failures. + */ + 'signature'?: string | null; + 'exec_output_tail'?: ExecOutputTail | null; +} + + + diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 9b74efb79..a5fa311a4 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -78,6 +78,7 @@ export * from './execute-query-request'; export * from './execute-query-response'; export * from './execute-query-response-rows-inner-inner'; export * from './failure-category'; +export * from './failure-detail'; export * from './failure-reason'; export * from './features-namespace'; export * from './file-checkpoint'; diff --git a/lib/packages/fabro-api-client/src/models/run-failure.ts b/lib/packages/fabro-api-client/src/models/run-failure.ts index dc70023d0..ccda167a7 100644 --- a/lib/packages/fabro-api-client/src/models/run-failure.ts +++ b/lib/packages/fabro-api-client/src/models/run-failure.ts @@ -15,31 +15,17 @@ // May contain unused imports in some cases // @ts-ignore -import type { ExecOutputTail } from './exec-output-tail'; -// May contain unused imports in some cases -// @ts-ignore -import type { FailureCategory } from './failure-category'; +import type { FailureDetail } from './failure-detail'; // May contain unused imports in some cases // @ts-ignore import type { FailureReason } from './failure-reason'; -// May contain unused imports in some cases -// @ts-ignore -import type { SystemActorKind } from './system-actor-kind'; /** - * Rich terminal run failure diagnostics. + * Terminal run failure reason and rich diagnostics. */ export interface RunFailure { - 'message': string; - 'causes'?: Array; 'reason': FailureReason; - 'category': FailureCategory; - 'system_actor'?: SystemActorKind | null; - /** - * Stable normalized signature for grouping related failures. - */ - 'signature'?: string | null; - 'exec_output_tail'?: ExecOutputTail | null; + 'detail': FailureDetail; } From 64fd4de393afd8e23e43c6db3eeaee488e405845 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 16 May 2026 13:40:23 -0400 Subject: [PATCH 11/25] docs: offer issue-based contribution path alongside PRs Add a third option for contributors who'd rather not write the code themselves: file an issue and a maintainer will implement it with co-author credit on the landing commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ac7fcd02..5a5c64bb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,7 @@ Outside contributions are welcome! Whether it's a bug fix, a new feature, docume - **Bug fixes and small improvements** -- Send a pull request directly. No need to open an issue first. - **Larger features or changes** -- Please open a [GitHub Issue](https://github.com/fabro-sh/fabro/issues) or start a [Discussion](https://github.com/fabro-sh/fabro/discussions) first so we can align on the approach before you invest significant time. +- **Prefer not to write the code yourself?** -- As an alternative to opening a PR, you can file a [GitHub Issue](https://github.com/fabro-sh/fabro/issues) describing the bug or feature. A Fabro maintainer will implement it (supervising AI coding agents and workflows) and include you as a co-author on the commit that lands the change. - **Questions** -- Open a Discussion or email [bryan@qlty.sh](mailto:bryan@qlty.sh). ## Development setup From ae55bded81dd35b7f1529208c50af2edfa65f5ed Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 10:47:54 -0700 Subject: [PATCH 12/25] fix(sandbox): clone Daytona repos under /home/daytona/repos (#285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Daytona's default snapshot runs as the `daytona` user (uid 1001), which lacks write permission on `/`. With `run.clone.enabled = true`, sandbox init failed at `fs.create_folder("/repos", ...)` with HTTP 400, before the first workflow stage could run: ``` sandbox.git.failed error="Failed to create Daytona repos root" causes=["HTTP 400"] run.failed ``` Root cause: the Daytona provider was using Docker's root-level `/repos` layout. Docker works because its containers run as root; Daytona's default sandbox user does not. **Fix:** move `REPOS_ROOT` for Daytona to `/home/daytona/repos`, alongside the existing `/home/daytona/workspace`. The path is writable by the default sandbox user, the symlink layout is unchanged (`/home/daytona/workspace/` → `/home/daytona/repos//`), and Docker keeps its existing `/repos` path. **Bonus — better error diagnostics.** A new `wrap_fs_error(operation, path, error)` helper in the Daytona provider: - includes the attempted path in the message (was just "Failed to create Daytona repos root" with no indication of which path); - classifies HTTP 400 as a likely permission issue and points at snapshot configuration; - classifies HTTP 401/403 as an API key permissions issue; - preserves the underlying `DaytonaError` in the source chain (per `docs/internal/error-handling-strategy.md` — verified by walking `Error::source()` in the regression test). So if this class of failure recurs (custom snapshot, future path changes, ...) the user gets: > Failed to create Daytona repos root '/home/daytona/repos' failed > (HTTP 400). This usually means the sandbox user lacks write permission > on the parent directory. If you're using a custom Daytona snapshot, > ensure the sandbox user can write to '/home/daytona/repos', or use a > path under the user's home directory (e.g. /home/daytona/...). instead of: > Failed to create Daytona repos root > HTTP 400 ## Test plan - [x] `cargo build --workspace` - [x] `cargo nextest run -p fabro-sandbox --features daytona` — 142/142 pass - [x] `cargo nextest run -p fabro-types -p fabro-workflow` — 1365/1365 pass - [x] New unit test `wrap_fs_error_classifies_http_400_and_403` — asserts top-level message contains path + hint AND walks the source chain to prove `DaytonaError::Api { status_code: 400, .. }` is preserved - [x] `cargo +nightly-2026-04-14 fmt --check --all` - [x] `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` - [x] **Live regression**: `daytona_clone_layout_live_smoke` against the default `daytona-medium` snapshot — failed with `Failed to create Daytona repos root / HTTP 400` before the change; passes end-to-end after (provisions sandbox → clones repo → verifies symlink + HEAD match in 2.5s) ## Related - Closes #284 (thanks @jessmartin for the report, diagnosis, and proposed fix) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Jess Martin <27258+jessmartin@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- lib/crates/fabro-sandbox/src/daytona/mod.rs | 92 +++++++++++++++++-- .../tests/daytona_streaming_live.rs | 6 +- .../fabro-types/tests/sandbox_model_serde.rs | 7 +- .../tests/it/daytona_integration.rs | 2 +- 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 1b794c413..cd6f37bfb 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -33,7 +33,7 @@ use crate::{ }; pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; -pub(crate) const REPOS_ROOT: &str = "/repos"; +pub(crate) const REPOS_ROOT: &str = "/home/daytona/repos"; const DEFAULT_SNAPSHOT: &str = "daytona-medium"; pub const DEFAULT_DAYTONA_API_URL: &str = "https://app.daytona.io/api"; pub(crate) const DAYTONA_DASHBOARD_SANDBOXES_URL: &str = @@ -814,8 +814,11 @@ impl Sandbox for DaytonaSandbox { .create_folder(WORKING_DIRECTORY, None) .await .map_err(|e| { - let err = - crate::Error::context("Failed to create Daytona workspace root", e); + let err = wrap_fs_error( + "Failed to create Daytona workspace root", + WORKING_DIRECTORY, + e, + ); self.emit(SandboxEvent::GitCloneFailed { url: origin_url.clone(), error: err.to_string(), @@ -824,7 +827,7 @@ impl Sandbox for DaytonaSandbox { self.fail_init(init_start, err) })?; fs_svc.create_folder(REPOS_ROOT, None).await.map_err(|e| { - let err = crate::Error::context("Failed to create Daytona repos root", e); + let err = wrap_fs_error("Failed to create Daytona repos root", REPOS_ROOT, e); self.emit(SandboxEvent::GitCloneFailed { url: origin_url.clone(), error: err.to_string(), @@ -836,8 +839,9 @@ impl Sandbox for DaytonaSandbox { .create_folder(&layout.repos_owner_path, None) .await .map_err(|e| { - let err = crate::Error::context( + let err = wrap_fs_error( "Failed to create Daytona repos owner directory", + &layout.repos_owner_path, e, ); self.emit(SandboxEvent::GitCloneFailed { @@ -1790,6 +1794,27 @@ fn daytona_callback_error(err: &crate::Error) -> DaytonaError { DaytonaError::general(format!("output callback failed: {err}")) } +/// Wrap a Daytona filesystem error with a richer message that includes the +/// attempted path and a hint when the status code suggests a configuration +/// issue. Preserves the underlying `DaytonaError` in the source chain so +/// callers can still inspect status/headers via downcasting. +fn wrap_fs_error(operation: &str, path: &str, error: DaytonaError) -> crate::Error { + let message = match error.status_code() { + Some(400) => format!( + "{operation} '{path}' failed (HTTP 400). This usually means the sandbox user \ + lacks write permission on the parent directory. If you're using a custom \ + Daytona snapshot, ensure the sandbox user can write to '{path}', or use a \ + path under the user's home directory (e.g. /home/daytona/...)." + ), + Some(status @ (401 | 403)) => format!( + "{operation} '{path}' rejected by Daytona (HTTP {status}) — check that your \ + DAYTONA_API_KEY has the required permissions." + ), + _ => format!("{operation} '{path}' failed"), + }; + crate::Error::context(message, error) +} + async fn finish_daytona_log_stream( stream_task: &mut JoinHandle>, ) -> crate::Result { @@ -2127,6 +2152,7 @@ fn wrap_bash_command(command: &str) -> String { #[cfg(test)] mod tests { use daytona_api_client::models::api_key_list::Permissions; + use fabro_util::error::collect_chain; use httpmock::Method::GET; use httpmock::MockServer; @@ -2274,10 +2300,64 @@ subpath = "agents" assert_eq!( daytona_symlink_command(&layout), - "ln -s /repos/fabro-sh/fabro /home/daytona/workspace/fabro" + "ln -s /home/daytona/repos/fabro-sh/fabro /home/daytona/workspace/fabro" ); } + #[test] + fn wrap_fs_error_classifies_http_400_and_403() { + let err_400 = wrap_fs_error( + "Failed to create Daytona repos root", + "/home/daytona/repos", + DaytonaError::api(400, ""), + ); + let top_400 = err_400.to_string(); + assert!( + top_400.contains("/home/daytona/repos"), + "400 top-level message should include the attempted path, got: {top_400}" + ); + assert!( + top_400.contains("HTTP 400") && top_400.contains("write permission"), + "400 top-level message should classify as a permission issue, got: {top_400}" + ); + + let chain_400 = collect_chain(&err_400); + assert!( + chain_400 + .iter() + .skip(1) + .any(|cause| cause.contains("HTTP 400") || cause.is_empty()), + "400 source chain should preserve the underlying DaytonaError, got: {chain_400:?}" + ); + let source_400 = std::error::Error::source(&err_400) + .and_then(|s| s.downcast_ref::()) + .expect("source should be a DaytonaError"); + assert_eq!( + source_400.status_code(), + Some(400), + "downcast source should preserve the original status code" + ); + + let err_403 = wrap_fs_error( + "Failed to create Daytona repos root", + "/home/daytona/repos", + DaytonaError::api(403, ""), + ); + let top_403 = err_403.to_string(); + assert!( + top_403.contains("/home/daytona/repos") && top_403.contains("HTTP 403"), + "403 top-level message should include the path and status, got: {top_403}" + ); + assert!( + top_403.contains("DAYTONA_API_KEY"), + "403 top-level message should hint at API key permissions, got: {top_403}" + ); + let source_403 = std::error::Error::source(&err_403) + .and_then(|s| s.downcast_ref::()) + .expect("source should be a DaytonaError"); + assert_eq!(source_403.status_code(), Some(403)); + } + #[test] fn missing_display_uses_daytona_wire_scope_names() { let check = DaytonaKeyCheck { diff --git a/lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs b/lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs index 467f5e457..51e982ccd 100644 --- a/lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs +++ b/lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs @@ -82,10 +82,10 @@ mod daytona_streaming_live { let result = sandbox .exec_command( - "test -d /repos/brynary/rack-test/.git && \ + "test -d /home/daytona/repos/brynary/rack-test/.git && \ test -L /home/daytona/workspace/rack-test && \ - test \"$(readlink /home/daytona/workspace/rack-test)\" = /repos/brynary/rack-test && \ - test \"$(git -C /repos/brynary/rack-test rev-parse HEAD)\" = \ + test \"$(readlink /home/daytona/workspace/rack-test)\" = /home/daytona/repos/brynary/rack-test && \ + test \"$(git -C /home/daytona/repos/brynary/rack-test rev-parse HEAD)\" = \ \"$(git -C /home/daytona/workspace/rack-test rev-parse HEAD)\" && \ git rev-parse --is-inside-work-tree", 30_000, diff --git a/lib/crates/fabro-types/tests/sandbox_model_serde.rs b/lib/crates/fabro-types/tests/sandbox_model_serde.rs index a6609aa5a..0260a8132 100644 --- a/lib/crates/fabro-types/tests/sandbox_model_serde.rs +++ b/lib/crates/fabro-types/tests/sandbox_model_serde.rs @@ -62,7 +62,7 @@ fn sandbox_details_requires_canonical_id_and_working_directory() { clone_origin_url: None, clone_branch: None, workspace_root: Some("/home/daytona/workspace".to_string()), - repos_root: Some("/repos".to_string()), + repos_root: Some("/home/daytona/repos".to_string()), primary_repo_path: None, primary_repo_link: None, }), @@ -99,7 +99,10 @@ fn sandbox_details_requires_canonical_id_and_working_directory() { value["sandbox"]["runtime"]["workspace_root"], "/home/daytona/workspace" ); - assert_eq!(value["sandbox"]["runtime"]["repos_root"], "/repos"); + assert_eq!( + value["sandbox"]["runtime"]["repos_root"], + "/home/daytona/repos" + ); assert_eq!( value["web_url"], "https://app.daytona.io/dashboard/sandboxes?sandboxId=ad65029a-2d01-421e-8936-49451653fcd9" diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 57e4348f1..e2c1e0ad6 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -1868,7 +1868,7 @@ async fn daytona_cp_upload_download_round_trip() { clone_origin_url: None, clone_branch: None, workspace_root: Some("/home/daytona/workspace".to_string()), - repos_root: Some("/repos".to_string()), + repos_root: Some("/home/daytona/repos".to_string()), primary_repo_path: None, primary_repo_link: None, }), From 2296ba6ea833a37c413fbdfb27411788ffa62cf8 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 10:57:33 -0700 Subject: [PATCH 13/25] feat(runs): add parent run links (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds orchestration-only parent links between runs without merging them into fork or rewind lineage. Runs can now be created under a parent, linked to a different parent, or unlinked through event-sourced mutations that rebuild summaries and projections from the run event stream. ## Changes - Adds optional `parent_id` to run manifests, public run summaries, run projections, `run.created`, OpenAPI, and the generated TypeScript API client. - Adds `PUT /api/v1/runs/{id}/parent` and `DELETE /api/v1/runs/{id}/parent` for mutable parent links across any run state, including terminal or archived runs. - Records `run.parent.linked` and `run.parent.unlinked` events with actor metadata and previous/current parent IDs. - Validates parent changes in the API path: parent must exist for new links, self-parenting is rejected, cycles are rejected, and same-parent or already-root operations are idempotent no-ops. - Adds `parent_id` filtering to run listing while preserving dangling historical parent references after parent deletion. ## Validation - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo build -p fabro-api` - `cargo check -p fabro-workflow -p fabro-store -p fabro-server` - `cargo nextest run -p fabro-types -p fabro-store` - `cargo nextest run -p fabro-server --features test-support create_run_can_set_parent_and_list_children link_relink_and_unlink_parent_are_idempotent parent_link_validation_rejects_missing_self_and_cycles deleting_parent_leaves_child_parent_id_as_historical_reference` - `cargo nextest run -p fabro-api run_summary_json_matches_openapi_shape` - `cd lib/packages/fabro-api-client && bun run typecheck` Known unrelated broad-suite blocker: `cargo nextest run -p fabro-server --features test-support get_graph_returns_svg` currently returns 500 because the render subprocess emits test-harness output instead of SVG. --- [![Compound Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 (unknown context, medium reasoning) via [Codex](https://openai.com/codex) --- docs/public/api-reference/fabro-api.yaml | 92 ++++++ .../fabro-api/tests/run_summary_round_trip.rs | 1 + lib/crates/fabro-manifest/src/lib.rs | 1 + .../fabro-mcp-server/src/run_tools/search.rs | 1 + lib/crates/fabro-server/src/demo/mod.rs | 1 + lib/crates/fabro-server/src/run_manifest.rs | 9 + lib/crates/fabro-server/src/server.rs | 2 + .../fabro-server/src/server/handler/events.rs | 1 + .../fabro-server/src/server/handler/runs.rs | 164 ++++++++++- lib/crates/fabro-server/src/server/tests.rs | 5 + .../fabro-server/tests/it/api/run_files.rs | 1 + lib/crates/fabro-server/tests/it/api/runs.rs | 267 +++++++++++++++++- lib/crates/fabro-store/src/lib.rs | 5 +- lib/crates/fabro-store/src/run_state.rs | 8 + lib/crates/fabro-store/src/slate/mod.rs | 168 ++++++++++- .../fabro-store/src/slate/projection_cache.rs | 103 +++++-- .../src/slate/run_catalog_index.rs | 5 +- lib/crates/fabro-types/src/run_event/mod.rs | 8 + lib/crates/fabro-types/src/run_event/run.rs | 14 + lib/crates/fabro-types/src/run_projection.rs | 3 + lib/crates/fabro-types/src/run_summary.rs | 2 + .../fabro-types/tests/run_event_serde.rs | 45 ++- .../fabro-workflow/src/event/convert.rs | 16 ++ lib/crates/fabro-workflow/src/event/events.rs | 27 ++ lib/crates/fabro-workflow/src/event/names.rs | 2 + lib/crates/fabro-workflow/src/event/sink.rs | 1 + .../fabro-workflow/src/event/stored_fields.rs | 2 + lib/crates/fabro-workflow/src/git.rs | 1 + .../fabro-workflow/src/handler/agent.rs | 1 + .../fabro-workflow/src/handler/command.rs | 1 + .../fabro-workflow/src/handler/parallel.rs | 1 + .../fabro-workflow/src/handler/prompt.rs | 1 + .../fabro-workflow/src/lifecycle/git.rs | 1 + .../fabro-workflow/src/operations/archive.rs | 1 + .../fabro-workflow/src/operations/create.rs | 12 + .../fabro-workflow/src/operations/fork.rs | 2 + .../fabro-workflow/src/operations/start.rs | 2 + .../src/pipeline/execute/tests.rs | 1 + .../fabro-workflow/src/pipeline/finalize.rs | 1 + .../fabro-workflow/src/pipeline/persist.rs | 1 + .../src/pipeline/pull_request.rs | 5 + lib/crates/fabro-workflow/src/run_lookup.rs | 1 + .../fabro-workflow/src/runtime_store.rs | 1 + lib/crates/fabro-workflow/src/test_support.rs | 1 + .../src/.openapi-generator/FILES | 1 + .../fabro-api-client/src/api/runs-api.ts | 180 +++++++++++- .../fabro-api-client/src/models/index.ts | 1 + .../src/models/run-manifest.ts | 4 + .../src/models/run-projection.ts | 4 + .../fabro-api-client/src/models/run.ts | 4 + .../src/models/update-run-parent-request.ts | 23 ++ 51 files changed, 1161 insertions(+), 44 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/models/update-run-parent-request.ts diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 72c89cb6b..3a0a27a88 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -823,6 +823,7 @@ paths: - $ref: "#/components/parameters/PageLimit" - $ref: "#/components/parameters/PageOffset" - $ref: "#/components/parameters/IncludeArchived" + - $ref: "#/components/parameters/ParentRunId" responses: "200": description: Paginated durable run summaries @@ -1078,6 +1079,69 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/parent: + put: + operationId: linkRunParent + tags: [Runs] + summary: Link Run Parent + description: Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs. + parameters: + - $ref: "#/components/parameters/RunId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateRunParentRequest" + responses: + "200": + description: Updated durable run summary + content: + application/json: + schema: + $ref: "#/components/schemas/Run" + "400": + description: Self-parent or cycle rejected + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "404": + description: Child or parent run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + delete: + operationId: unlinkRunParent + tags: [Runs] + summary: Unlink Run Parent + description: Removes a run's orchestration parent. Already-root runs are returned unchanged. + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Updated durable run summary + content: + application/json: + schema: + $ref: "#/components/schemas/Run" + "404": + description: Run not found + headers: + x-request-id: + $ref: "#/components/headers/XRequestId" + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/cancel: post: operationId: cancelRun @@ -3771,6 +3835,15 @@ components: default: false example: false + ParentRunId: + name: parent_id + in: query + required: false + description: Return only runs currently linked to this orchestration parent. + schema: + type: string + example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z + ForceRunDelete: name: force in: query @@ -5062,6 +5135,10 @@ components: type: ["string", "null"] description: Optional pre-generated run ID to use instead of allocating a new ULID. example: "01HV6D7S5YF4Z4B2M7K4N0Q6T9" + parent_id: + type: ["string", "null"] + description: Optional orchestration parent run ID. Fork and rewind lineage use separate fields and should not set this value. + example: "01HV6D7S5YF4Z4B2M7K4N0Q6T8" title: type: ["string", "null"] maxLength: 100 @@ -6484,6 +6561,15 @@ components: - $ref: "#/components/schemas/ForkSourceRef" - type: "null" + UpdateRunParentRequest: + type: object + required: + - parent_id + properties: + parent_id: + type: string + description: Existing orchestration parent run ID. + StartRecord: description: Metadata captured when execution starts. type: object @@ -6595,6 +6681,9 @@ components: title: type: string description: Resolved run title from the event log. + parent_id: + type: ["string", "null"] + description: Current orchestration parent run ID, if linked. spec: $ref: "#/components/schemas/RunSpec" web_url: @@ -6675,6 +6764,9 @@ components: properties: id: type: string + parent_id: + type: ["string", "null"] + description: Current orchestration parent run ID, if linked. title: type: string goal: diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs index 153bec293..8144d7d06 100644 --- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs @@ -24,6 +24,7 @@ fn run_summary_json_matches_openapi_shape() { let archived_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 1, 0).unwrap(); let summary = RunSummary { id: run_id, + parent_id: None, title: "API title".to_string(), goal: String::new(), workflow: WorkflowRef { diff --git a/lib/crates/fabro-manifest/src/lib.rs b/lib/crates/fabro-manifest/src/lib.rs index 7455c61c6..95429bf50 100644 --- a/lib/crates/fabro-manifest/src/lib.rs +++ b/lib/crates/fabro-manifest/src/lib.rs @@ -245,6 +245,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { cwd: input.cwd.display().to_string(), git, goal, + parent_id: None, run_id: input.run_id.map(|run_id| run_id.to_string()), title: None, target: types::ManifestTarget { diff --git a/lib/crates/fabro-mcp-server/src/run_tools/search.rs b/lib/crates/fabro-mcp-server/src/run_tools/search.rs index 0a5b2bb15..bc6ce7d37 100644 --- a/lib/crates/fabro-mcp-server/src/run_tools/search.rs +++ b/lib/crates/fabro-mcp-server/src/run_tools/search.rs @@ -337,6 +337,7 @@ mod tests { let created_at = Utc.with_ymd_and_hms(2026, 5, 11, 12, 0, seconds).unwrap(); Run { id: id.parse().expect("test run id should parse"), + parent_id: None, title: "test".to_string(), goal: "test".to_string(), workflow: WorkflowRef { diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index dc91f0855..712f28882 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1056,6 +1056,7 @@ mod runs { let duration_ms = elapsed_secs.and_then(duration_ms_from_secs); RunSummary { id: run_id, + parent_id: None, title: fabro_types::infer_run_title(goal), goal: goal.into(), workflow: WorkflowRef { diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 590a54cee..a92211ecd 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -52,6 +52,7 @@ pub(crate) struct PreparedManifest { pub git: Option, pub root_source: String, pub run_id: Option, + pub parent_id: Option, pub title: Option, pub settings: WorkflowSettings, pub target_path: ManifestPath, @@ -155,6 +156,12 @@ pub(crate) fn prepare_manifest( .map(str::parse::) .transpose() .context("invalid run ID")?, + parent_id: manifest + .parent_id + .as_deref() + .map(str::parse::) + .transpose() + .context("invalid parent run ID")?, title, settings: settings.clone(), target_path, @@ -196,6 +203,7 @@ pub(crate) fn create_run_input( title: prepared.title, git: prepared.git, fork_source_ref: None, + parent_id: prepared.parent_id, provenance: None, configured_providers, web_url, @@ -1325,6 +1333,7 @@ mod tests { cwd: "/tmp/project".to_string(), git: None, goal: None, + parent_id: None, run_id: None, title: None, target: types::ManifestTarget { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 7c64d1a16..ef3682953 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -525,6 +525,7 @@ pub struct AppState { /// proceed in parallel. See `crate::run_files` for semantics. pub(crate) files_in_flight: FilesInFlight, pull_request_create_locks: PullRequestCreateLocks, + parent_link_lock: AsyncMutex<()>, pub(crate) vault: Arc>, pub(super) server_secrets: ServerSecrets, @@ -1590,6 +1591,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result Router> { "/runs/{id}", get(get_run_status).patch(update_run).delete(delete_run), ) + .route( + "/runs/{id}/parent", + put(link_run_parent).delete(unlink_run_parent), + ) .route("/runs/{id}/questions", get(get_questions)) .route("/runs/{id}/questions/{qid}/answer", post(submit_answer)) .route("/runs/{id}/state", get(get_run_state)) @@ -78,6 +84,8 @@ struct ListRunsParams { offset: u32, #[serde(default)] include_archived: bool, + #[serde(default)] + parent_id: Option, } impl ListRunsParams { @@ -156,7 +164,10 @@ async fn list_board_runs( ) -> Response { let entries = match state .store - .list_cached_runs(&fabro_store::ListRunsQuery::default()) + .list_cached_runs(&fabro_store::ListRunsQuery { + parent_id: params.parent_id, + ..fabro_store::ListRunsQuery::default() + }) .await { Ok(runs) => runs, @@ -195,6 +206,140 @@ async fn list_board_runs( .into_response() } +async fn link_run_parent( + subject: RequiredUser, + State(state): State>, + Path(id): Path, + Json(req): Json, +) -> Response { + let child_id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + let parent_id = match req.parent_id.parse::() { + Ok(parent_id) => parent_id, + Err(err) => { + return ApiError::bad_request(format!("invalid parent run ID: {err}")).into_response(); + } + }; + let _parent_link_guard = state.parent_link_lock.lock().await; + let child = match state.store.get_cached_summary(&child_id).await { + Ok(Some(summary)) => summary, + Ok(None) => return ApiError::not_found("Run not found.").into_response(), + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + if parent_id == child_id { + return ApiError::bad_request("A run cannot be its own parent.").into_response(); + } + if let Err(err) = validate_parent_link(&state, child_id, parent_id).await { + return err.into_response(); + } + if child.parent_id == Some(parent_id) { + return (StatusCode::OK, Json(child)).into_response(); + } + + let Ok(run_store) = state.store.open_run(&child_id).await else { + return ApiError::not_found("Run not found.").into_response(); + }; + if let Err(err) = workflow_event::append_event( + &run_store, + &child_id, + &workflow_event::Event::RunParentLinked { + previous_parent_id: child.parent_id, + parent_id, + actor: Some(Principal::User(subject.0)), + }, + ) + .await + { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } + updated_run_response(&state, &child_id).await +} + +async fn unlink_run_parent( + subject: RequiredUser, + State(state): State>, + Path(id): Path, +) -> Response { + let child_id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(err) => return err.into_response(), + }; + let _parent_link_guard = state.parent_link_lock.lock().await; + let child = match state.store.get_cached_summary(&child_id).await { + Ok(Some(summary)) => summary, + Ok(None) => return ApiError::not_found("Run not found.").into_response(), + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + let Some(previous_parent_id) = child.parent_id else { + return (StatusCode::OK, Json(child)).into_response(); + }; + + let Ok(run_store) = state.store.open_run(&child_id).await else { + return ApiError::not_found("Run not found.").into_response(); + }; + if let Err(err) = workflow_event::append_event( + &run_store, + &child_id, + &workflow_event::Event::RunParentUnlinked { + previous_parent_id, + actor: Some(Principal::User(subject.0)), + }, + ) + .await + { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } + updated_run_response(&state, &child_id).await +} + +async fn validate_parent_link( + state: &AppState, + child_id: RunId, + parent_id: RunId, +) -> Result<(), ApiError> { + let mut cursor = Some(parent_id); + let mut visited = HashSet::new(); + while let Some(current_id) = cursor { + if current_id == child_id { + return Err(ApiError::bad_request("Parent link would create a cycle.")); + } + if !visited.insert(current_id) { + return Err(ApiError::bad_request("Parent link would create a cycle.")); + } + let summary = state + .store + .get_cached_summary(¤t_id) + .await + .map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; + let Some(summary) = summary else { + if current_id == parent_id { + return Err(ApiError::not_found("Parent run not found.")); + } + return Ok(()); + }; + cursor = summary.parent_id; + } + Ok(()) +} + +async fn updated_run_response(state: &AppState, run_id: &RunId) -> Response { + match state.store.get_cached_summary(run_id).await { + Ok(Some(summary)) => (StatusCode::OK, Json(summary)).into_response(), + Ok(None) => ApiError::not_found("Run not found.").into_response(), + Err(err) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + } +} + async fn list_runs( _auth: RequiredUser, State(state): State>, @@ -202,7 +347,10 @@ async fn list_runs( ) -> Response { match state .store - .list_cached_runs(&fabro_store::ListRunsQuery::default()) + .list_cached_runs(&fabro_store::ListRunsQuery { + parent_id: params.parent_id, + ..fabro_store::ListRunsQuery::default() + }) .await { Ok(entries) => { @@ -399,6 +547,14 @@ async fn create_run( Err(err) => return ApiError::bad_request(err.to_string()).into_response(), }; let run_id = prepared.run_id.unwrap_or_else(RunId::new); + if let Some(parent_id) = prepared.parent_id { + if parent_id == run_id { + return ApiError::bad_request("A run cannot be its own parent.").into_response(); + } + if let Err(err) = validate_parent_link(&state, run_id, parent_id).await { + return err.into_response(); + } + } info!(run_id = %run_id, "Run created"); let web_url = state.run_web_url(&run_id); diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 142b88b50..740375ec0 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -2454,6 +2454,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await @@ -2801,6 +2802,7 @@ async fn list_run_stages_distinguishes_visits() { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, workflow_event::Event::RunStarting, @@ -3781,6 +3783,7 @@ async fn create_completed_run_ready_for_pull_request( manifest_blob: None, git, fork_source_ref: None, + parent_id: None, web_url: None, }, workflow_event::Event::WorkflowRunStarted { @@ -8262,6 +8265,7 @@ async fn delete_run_with_preserved_sandbox_returns_handoff() { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, workflow_event::Event::RunSubmitted { @@ -8328,6 +8332,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, workflow_event::Event::RunSubmitted { diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs index df7307e82..b70688252 100644 --- a/lib/crates/fabro-server/tests/it/api/run_files.rs +++ b/lib/crates/fabro-server/tests/it/api/run_files.rs @@ -72,6 +72,7 @@ async fn append_completed_run_with_final_patch( manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/crates/fabro-server/tests/it/api/runs.rs index c8fb2f609..9682a8c6e 100644 --- a/lib/crates/fabro-server/tests/it/api/runs.rs +++ b/lib/crates/fabro-server/tests/it/api/runs.rs @@ -3,10 +3,55 @@ use axum::http::{Request, StatusCode}; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, settings_from_toml, - test_app_state_with_options, + MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, response_status, + settings_from_toml, test_app_state_with_options, }; +async fn create_run(app: &axum::Router, mut manifest: serde_json::Value) -> serde_json::Value { + manifest + .as_object_mut() + .expect("manifest should be an object") + .entry("configs") + .or_insert_with(|| serde_json::json!([])); + let request = Request::builder() + .method("POST") + .uri(api("/runs")) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&manifest).expect("manifest should serialize"), + )) + .expect("create run request should build"); + response_json( + app.clone().oneshot(request).await.unwrap(), + StatusCode::CREATED, + "POST /api/v1/runs", + ) + .await +} + +async fn request_json( + app: &axum::Router, + method: &str, + path: String, + body: serde_json::Value, + expected: StatusCode, +) -> serde_json::Value { + let request = Request::builder() + .method(method) + .uri(api(&path)) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_string(&body).expect("request body should serialize"), + )) + .expect("JSON request should build"); + response_json( + app.clone().oneshot(request).await.unwrap(), + expected, + format!("{method} /api/v1{path}"), + ) + .await +} + #[tokio::test] async fn retrieve_run_settings_returns_dense_snapshot() { let storage_dir = tempfile::tempdir().unwrap(); @@ -94,3 +139,221 @@ session_sandboxes = true assert!(body.pointer("/features").is_none()); assert!(body.pointer("/server").is_none()); } + +#[tokio::test] +async fn create_run_can_set_parent_and_list_children() { + let app = fabro_server::test_support::build_test_router(crate::helpers::test_app_state()); + let parent = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await; + let parent_id = parent["id"].as_str().unwrap(); + let mut child_manifest = minimal_manifest_json(MINIMAL_DOT); + child_manifest["parent_id"] = serde_json::json!(parent_id); + + let child = create_run(&app, child_manifest).await; + let child_id = child["id"].as_str().unwrap(); + + assert_eq!(child["parent_id"], parent_id); + let list_request = Request::builder() + .method("GET") + .uri(api(&format!("/runs?parent_id={parent_id}"))) + .body(Body::empty()) + .unwrap(); + let list = response_json( + app.clone().oneshot(list_request).await.unwrap(), + StatusCode::OK, + "GET /api/v1/runs?parent_id", + ) + .await; + assert_eq!(list["data"].as_array().unwrap().len(), 1); + assert_eq!(list["data"][0]["id"], child_id); +} + +#[tokio::test] +async fn link_relink_and_unlink_parent_are_idempotent() { + let app = fabro_server::test_support::build_test_router(crate::helpers::test_app_state()); + let parent_1 = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await; + let parent_2 = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await; + let child = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await; + let parent_1_id = parent_1["id"].as_str().unwrap(); + let parent_2_id = parent_2["id"].as_str().unwrap(); + let child_id = child["id"].as_str().unwrap(); + + let linked = request_json( + &app, + "PUT", + format!("/runs/{child_id}/parent"), + serde_json::json!({ "parent_id": parent_1_id }), + StatusCode::OK, + ) + .await; + assert_eq!(linked["parent_id"], parent_1_id); + + let linked_again = request_json( + &app, + "PUT", + format!("/runs/{child_id}/parent"), + serde_json::json!({ "parent_id": parent_1_id }), + StatusCode::OK, + ) + .await; + assert_eq!(linked_again["parent_id"], parent_1_id); + + let relinked = request_json( + &app, + "PUT", + format!("/runs/{child_id}/parent"), + serde_json::json!({ "parent_id": parent_2_id }), + StatusCode::OK, + ) + .await; + assert_eq!(relinked["parent_id"], parent_2_id); + + let events_request = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{child_id}/events"))) + .body(Body::empty()) + .unwrap(); + let events = response_json( + app.clone().oneshot(events_request).await.unwrap(), + StatusCode::OK, + format!("GET /api/v1/runs/{child_id}/events"), + ) + .await; + let event_names = events["data"] + .as_array() + .unwrap() + .iter() + .map(|event| event["event"].as_str().unwrap()) + .collect::>(); + assert_eq!(event_names, vec![ + "run.created", + "run.submitted", + "run.parent.linked", + "run.parent.linked" + ]); + + let unlink_request = Request::builder() + .method("DELETE") + .uri(api(&format!("/runs/{child_id}/parent"))) + .body(Body::empty()) + .unwrap(); + let unlinked = response_json( + app.clone().oneshot(unlink_request).await.unwrap(), + StatusCode::OK, + format!("DELETE /api/v1/runs/{child_id}/parent"), + ) + .await; + assert!(unlinked["parent_id"].is_null()); + + let unlink_again = Request::builder() + .method("DELETE") + .uri(api(&format!("/runs/{child_id}/parent"))) + .body(Body::empty()) + .unwrap(); + let unlinked_again = response_json( + app.clone().oneshot(unlink_again).await.unwrap(), + StatusCode::OK, + format!("DELETE /api/v1/runs/{child_id}/parent"), + ) + .await; + assert!(unlinked_again["parent_id"].is_null()); +} + +#[tokio::test] +async fn deleting_parent_leaves_child_parent_id_as_historical_reference() { + let app = fabro_server::test_support::build_test_router(crate::helpers::test_app_state()); + let parent = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await; + let parent_id = parent["id"].as_str().unwrap(); + let mut child_manifest = minimal_manifest_json(MINIMAL_DOT); + child_manifest["parent_id"] = serde_json::json!(parent_id); + let child = create_run(&app, child_manifest).await; + let child_id = child["id"].as_str().unwrap(); + + let delete_request = Request::builder() + .method("DELETE") + .uri(api(&format!("/runs/{parent_id}?force=true"))) + .body(Body::empty()) + .unwrap(); + crate::helpers::checked_response_in( + app.clone().oneshot(delete_request).await.unwrap(), + &[StatusCode::OK, StatusCode::NO_CONTENT], + format!("DELETE /api/v1/runs/{parent_id}?force=true"), + ) + .await; + + let get_child = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{child_id}"))) + .body(Body::empty()) + .unwrap(); + let child_after_delete = response_json( + app.clone().oneshot(get_child).await.unwrap(), + StatusCode::OK, + format!("GET /api/v1/runs/{child_id}"), + ) + .await; + assert_eq!(child_after_delete["parent_id"], parent_id); + + request_json( + &app, + "PUT", + format!("/runs/{child_id}/parent"), + serde_json::json!({ "parent_id": parent_id }), + StatusCode::NOT_FOUND, + ) + .await; +} + +#[tokio::test] +async fn parent_link_validation_rejects_missing_self_and_cycles() { + let app = fabro_server::test_support::build_test_router(crate::helpers::test_app_state()); + let parent = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await; + let child = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await; + let parent_id = parent["id"].as_str().unwrap(); + let child_id = child["id"].as_str().unwrap(); + + request_json( + &app, + "PUT", + format!("/runs/{child_id}/parent"), + serde_json::json!({ "parent_id": parent_id }), + StatusCode::OK, + ) + .await; + + request_json( + &app, + "PUT", + format!("/runs/{parent_id}/parent"), + serde_json::json!({ "parent_id": child_id }), + StatusCode::BAD_REQUEST, + ) + .await; + request_json( + &app, + "PUT", + format!("/runs/{child_id}/parent"), + serde_json::json!({ "parent_id": child_id }), + StatusCode::BAD_REQUEST, + ) + .await; + request_json( + &app, + "PUT", + format!("/runs/{child_id}/parent"), + serde_json::json!({ "parent_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV" }), + StatusCode::NOT_FOUND, + ) + .await; + + let missing_child = Request::builder() + .method("DELETE") + .uri(api("/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/parent")) + .body(Body::empty()) + .unwrap(); + response_status( + app.oneshot(missing_child).await.unwrap(), + StatusCode::NOT_FOUND, + "DELETE /api/v1/runs/{missing}/parent", + ) + .await; +} diff --git a/lib/crates/fabro-store/src/lib.rs b/lib/crates/fabro-store/src/lib.rs index 604893c91..51a0d4fbc 100644 --- a/lib/crates/fabro-store/src/lib.rs +++ b/lib/crates/fabro-store/src/lib.rs @@ -30,6 +30,7 @@ pub use types::EventPayload; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ListRunsQuery { - pub start: Option>, - pub end: Option>, + pub start: Option>, + pub end: Option>, + pub parent_id: Option, } diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index da3def19e..45c1647ad 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -160,6 +160,12 @@ impl RunProjectionReducer for RunProjection { EventBody::RunSupersededBy(props) => { self.superseded_by = Some(props.new_run_id); } + EventBody::RunParentLinked(props) => { + self.parent_id = Some(props.parent_id); + } + EventBody::RunParentUnlinked(_props) => { + self.parent_id = None; + } EventBody::RunArchived(_props) => { if self.archived_at.is_some() { return Ok(()); @@ -524,6 +530,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result { }; let mut projection = RunProjection::new(title, spec, stored.ts); + projection.parent_id = props.parent_id; projection.web_url.clone_from(&props.web_url); projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.sandbox)); Ok(projection) @@ -671,6 +678,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary RunSummary { id: *run_id, + parent_id: state.parent_id, title: state.title().into_owned(), goal, workflow: WorkflowRef { diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index e338384f5..18350ec6e 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -533,6 +533,32 @@ mod tests { .unwrap(); } + async fn append_created_with_parent( + run: &RunDatabase, + label: &str, + created_at: DateTime, + parent_id: RunId, + ) { + let run_spec = sample_run_spec(label); + run.append_event(&event_payload( + label, + &created_at.to_rfc3339(), + "run.created", + &serde_json::json!({ + "settings": run_spec.settings, + "graph": run_spec.graph, + "workflow_slug": run_spec.workflow_slug, + "source_directory": run_spec.source_directory, + "run_dir": format!("/tmp/{label}"), + "git": run_spec.git, + "labels": run_spec.labels, + "parent_id": parent_id, + }), + )) + .await + .unwrap(); + } + async fn append_completed(run: &RunDatabase, label: &str, created_at: DateTime) { append_running(run, label, created_at).await; run.append_event(&event_payload( @@ -673,6 +699,143 @@ mod tests { ); } + #[tokio::test] + async fn parent_id_is_projected_from_created_and_parent_events() { + let (_object_store, store) = make_store(); + let parent_1 = store.create_run(&test_run_id("run-1")).await.unwrap(); + let parent_2 = store.create_run(&test_run_id("run-2")).await.unwrap(); + let child = store.create_run(&test_run_id("run-3")).await.unwrap(); + append_created(&parent_1, "run-1", dt("2026-03-27T12:00:00Z")).await; + append_created(&parent_2, "run-2", dt("2026-03-27T12:00:10Z")).await; + append_created_with_parent( + &child, + "run-3", + dt("2026-03-27T12:00:20Z"), + test_run_id("run-1"), + ) + .await; + + let initial = store.open_run(&test_run_id("run-3")).await.unwrap(); + assert_eq!( + initial.state().await.unwrap().parent_id, + Some(test_run_id("run-1")) + ); + assert_eq!( + store + .get_cached_summary(&test_run_id("run-3")) + .await + .unwrap() + .unwrap() + .parent_id, + Some(test_run_id("run-1")) + ); + + child + .append_event(&event_payload( + "run-3", + "2026-03-27T12:00:21Z", + "run.parent.linked", + &serde_json::json!({ + "previous_parent_id": test_run_id("run-1"), + "parent_id": test_run_id("run-2"), + }), + )) + .await + .unwrap(); + assert_eq!( + store + .get_cached_summary(&test_run_id("run-3")) + .await + .unwrap() + .unwrap() + .parent_id, + Some(test_run_id("run-2")) + ); + assert!( + store + .list_runs(&ListRunsQuery { + parent_id: Some(test_run_id("run-1")), + ..ListRunsQuery::default() + }) + .await + .unwrap() + .is_empty() + ); + assert_eq!( + store + .list_runs(&ListRunsQuery { + parent_id: Some(test_run_id("run-2")), + ..ListRunsQuery::default() + }) + .await + .unwrap() + .into_iter() + .map(|summary| summary.id) + .collect::>(), + vec![test_run_id("run-3")] + ); + + child + .append_event(&event_payload( + "run-3", + "2026-03-27T12:00:22Z", + "run.parent.unlinked", + &serde_json::json!({ + "previous_parent_id": test_run_id("run-2"), + }), + )) + .await + .unwrap(); + assert_eq!( + store + .get_cached_summary(&test_run_id("run-3")) + .await + .unwrap() + .unwrap() + .parent_id, + None + ); + assert!( + store + .list_runs(&ListRunsQuery { + parent_id: Some(test_run_id("run-2")), + ..ListRunsQuery::default() + }) + .await + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn list_runs_filters_by_parent_id() { + let (_object_store, store) = make_store(); + let parent = store.create_run(&test_run_id("run-1")).await.unwrap(); + let child = store.create_run(&test_run_id("run-2")).await.unwrap(); + let unrelated = store.create_run(&test_run_id("run-3")).await.unwrap(); + append_created(&parent, "run-1", dt("2026-03-27T12:00:00Z")).await; + append_created_with_parent( + &child, + "run-2", + dt("2026-03-27T12:00:10Z"), + test_run_id("run-1"), + ) + .await; + append_created(&unrelated, "run-3", dt("2026-03-27T12:00:20Z")).await; + + let summaries = store + .list_runs(&ListRunsQuery { + parent_id: Some(test_run_id("run-1")), + ..ListRunsQuery::default() + }) + .await + .unwrap(); + + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, test_run_id("run-2")); + assert_eq!(summaries[0].parent_id, Some(test_run_id("run-1"))); + } + #[tokio::test] async fn control_effect_events_clear_pending_control_and_update_status() { let (_object_store, store) = make_store(); @@ -831,8 +994,9 @@ mod tests { let filtered = reopened .list_cached_runs(&ListRunsQuery { - start: Some(test_run_id("run-2").created_at()), - end: Some(test_run_id("run-2").created_at() + chrono::Duration::seconds(1)), + start: Some(test_run_id("run-2").created_at()), + end: Some(test_run_id("run-2").created_at() + chrono::Duration::seconds(1)), + parent_id: None, }) .await .unwrap(); diff --git a/lib/crates/fabro-store/src/slate/projection_cache.rs b/lib/crates/fabro-store/src/slate/projection_cache.rs index e8d8486aa..9ee673488 100644 --- a/lib/crates/fabro-store/src/slate/projection_cache.rs +++ b/lib/crates/fabro-store/src/slate/projection_cache.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; use fabro_types::{RunId, RunProjection, RunSummary}; @@ -29,24 +29,83 @@ impl CachedRunProjection { #[derive(Debug, Default)] pub(crate) struct RunProjectionCache { - entries: Mutex>, + state: Mutex, +} + +#[derive(Debug, Default)] +struct RunProjectionCacheState { + entries: HashMap, + children_by_parent: HashMap>, +} + +impl RunProjectionCacheState { + fn replace_all(&mut self, entries: Vec) { + self.entries.clear(); + self.children_by_parent.clear(); + for entry in entries { + self.insert(entry); + } + } + + fn insert(&mut self, entry: CachedRunProjection) { + let run_id = entry.run_id; + let parent_id = entry.summary.parent_id; + if let Some(previous) = self.entries.insert(run_id, entry) { + self.remove_parent_index(&previous); + } + if let Some(parent_id) = parent_id { + self.children_by_parent + .entry(parent_id) + .or_default() + .insert(run_id); + } + } + + fn remove(&mut self, run_id: &RunId) { + if let Some(entry) = self.entries.remove(run_id) { + self.remove_parent_index(&entry); + } + } + + fn remove_parent_index(&mut self, entry: &CachedRunProjection) { + let Some(parent_id) = entry.summary.parent_id else { + return; + }; + let Some(children) = self.children_by_parent.get_mut(&parent_id) else { + return; + }; + children.remove(&entry.run_id); + if children.is_empty() { + self.children_by_parent.remove(&parent_id); + } + } } impl RunProjectionCache { pub(crate) async fn replace_all(&self, entries: Vec) { - let mut cache = self.entries.lock().await; - cache.clear(); - cache.extend(entries.into_iter().map(|entry| (entry.run_id, entry))); + self.state.lock().await.replace_all(entries); } pub(crate) async fn replace(&self, entry: CachedRunProjection) { - self.entries.lock().await.insert(entry.run_id, entry); + self.state.lock().await.insert(entry); } pub(crate) async fn list(&self, query: &ListRunsQuery) -> Vec { - let cache = self.entries.lock().await; - let mut entries = cache - .values() + let entries = { + let state = self.state.lock().await; + match query.parent_id { + Some(parent_id) => state + .children_by_parent + .get(&parent_id) + .into_iter() + .flat_map(|children| children.iter()) + .filter_map(|run_id| state.entries.get(run_id).cloned()) + .collect::>(), + None => state.entries.values().cloned().collect::>(), + } + }; + let mut entries = entries + .into_iter() .filter(|entry| { let created_at = entry.run_id.created_at(); if query.start.is_some_and(|start| created_at < start) { @@ -57,7 +116,6 @@ impl RunProjectionCache { } true }) - .cloned() .collect::>(); entries.sort_by(|left, right| { right @@ -70,26 +128,26 @@ impl RunProjectionCache { } pub(crate) async fn get(&self, run_id: &RunId) -> Option { - self.entries.lock().await.get(run_id).cloned() + self.state.lock().await.entries.get(run_id).cloned() } pub(crate) async fn get_summary(&self, run_id: &RunId) -> Option { - self.entries + self.state .lock() .await + .entries .get(run_id) .map(|entry| entry.summary.clone()) } pub(crate) async fn apply_event(&self, run_id: &RunId, event: &EventEnvelope) -> Result<()> { - let mut cache = self.entries.lock().await; - let Some(entry) = cache.get(run_id) else { + let mut state = self.state.lock().await; + let Some(entry) = state.entries.get(run_id).cloned() else { if event.seq == 1 { let projection = RunProjection::apply_events(std::slice::from_ref(event))?; - cache.insert( - *run_id, - CachedRunProjection::from_projection(*run_id, projection, event.seq), - ); + state.insert(CachedRunProjection::from_projection( + *run_id, projection, event.seq, + )); } else { return Err(Error::InvalidEvent(format!( "projection cache cannot initialize run {run_id} from event seq {}", @@ -111,14 +169,13 @@ impl RunProjectionCache { let mut projection = (*entry.projection).clone(); projection.apply_event(event)?; - cache.insert( - *run_id, - CachedRunProjection::from_projection(*run_id, projection, event.seq), - ); + state.insert(CachedRunProjection::from_projection( + *run_id, projection, event.seq, + )); Ok(()) } pub(crate) async fn remove(&self, run_id: &RunId) { - self.entries.lock().await.remove(run_id); + self.state.lock().await.remove(run_id); } } diff --git a/lib/crates/fabro-store/src/slate/run_catalog_index.rs b/lib/crates/fabro-store/src/slate/run_catalog_index.rs index 332c09496..33efce158 100644 --- a/lib/crates/fabro-store/src/slate/run_catalog_index.rs +++ b/lib/crates/fabro-store/src/slate/run_catalog_index.rs @@ -139,8 +139,9 @@ mod tests { assert_eq!( index .list(&ListRunsQuery { - start: Some(second.created_at()), - end: Some(second.created_at() + ChronoDuration::seconds(1)), + start: Some(second.created_at()), + end: Some(second.created_at() + ChronoDuration::seconds(1)), + parent_id: None, }) .await .unwrap(), diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index a4509f005..8ee972961 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -90,6 +90,10 @@ pub enum EventBody { RunUnarchived(RunUnarchivedProps), #[serde(rename = "run.title.updated")] RunTitleUpdated(RunTitleUpdatedProps), + #[serde(rename = "run.parent.linked")] + RunParentLinked(RunParentLinkedProps), + #[serde(rename = "run.parent.unlinked")] + RunParentUnlinked(RunParentUnlinkedProps), #[serde(rename = "run.completed")] RunCompleted(RunCompletedProps), #[serde(rename = "run.failed")] @@ -396,6 +400,8 @@ impl EventBody { Self::RunArchived(_) => "run.archived", Self::RunUnarchived(_) => "run.unarchived", Self::RunTitleUpdated(_) => "run.title.updated", + Self::RunParentLinked(_) => "run.parent.linked", + Self::RunParentUnlinked(_) => "run.parent.unlinked", Self::RunCompleted(_) => "run.completed", Self::RunFailed(_) => "run.failed", Self::RunNotice(_) => "run.notice", @@ -549,6 +555,8 @@ fn is_known_event_name(event: &str) -> bool { | "run.superseded_by" | "run.archived" | "run.unarchived" + | "run.parent.linked" + | "run.parent.unlinked" | "run.completed" | "run.failed" | "run.notice" diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index 123d89c39..ae724224c 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -37,9 +37,23 @@ pub struct RunCreatedProps { #[serde(default, skip_serializing_if = "Option::is_none")] pub fork_source_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub web_url: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunParentLinkedProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_parent_id: Option, + pub parent_id: RunId, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunParentUnlinkedProps { + pub previous_parent_id: RunId, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunStartedProps { pub name: String, diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index 9cba67103..27be839b4 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -14,6 +14,8 @@ use crate::{ pub struct RunProjection { #[serde(default, skip_serializing_if = "String::is_empty")] pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, pub spec: RunSpec, #[serde(default, skip_serializing_if = "Option::is_none")] pub web_url: Option, @@ -156,6 +158,7 @@ impl RunProjection { pub fn new(title: String, spec: RunSpec, created_at: DateTime) -> Self { Self { title, + parent_id: None, spec, web_url: None, start: None, diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs index e4e64f30c..68c7ccb23 100644 --- a/lib/crates/fabro-types/src/run_summary.rs +++ b/lib/crates/fabro-types/src/run_summary.rs @@ -11,6 +11,8 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Run { pub id: RunId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, pub title: String, pub goal: String, pub workflow: WorkflowRef, diff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/crates/fabro-types/tests/run_event_serde.rs index fccb59eae..8f96a0fc3 100644 --- a/lib/crates/fabro-types/tests/run_event_serde.rs +++ b/lib/crates/fabro-types/tests/run_event_serde.rs @@ -2,10 +2,10 @@ use std::collections::BTreeMap; use fabro_types::graph::Graph; use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome}; -use fabro_types::run_event::run::RunCreatedProps; +use fabro_types::run_event::run::{RunCreatedProps, RunParentLinkedProps, RunParentUnlinkedProps}; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; -use fabro_types::{WorkflowSettings, fixtures}; +use fabro_types::{EventBody, WorkflowSettings, fixtures}; fn templated_settings() -> WorkflowSettings { let mut settings = WorkflowSettings::default(); @@ -39,6 +39,7 @@ fn run_created_props_round_trip_templated_settings() { source_run_id: fixtures::RUN_2, checkpoint_sha: "def456".to_string(), }), + parent_id: Some(fixtures::RUN_2), web_url: Some("http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string()), }; @@ -57,6 +58,7 @@ fn run_created_props_round_trip_templated_settings() { json["web_url"], "http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z" ); + assert_eq!(json["parent_id"], fixtures::RUN_2.to_string()); let round_trip: RunCreatedProps = serde_json::from_value(json.clone()).expect("props should deserialize"); @@ -88,6 +90,7 @@ fn run_created_props_omits_web_url_when_absent() { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }; @@ -96,8 +99,46 @@ fn run_created_props_omits_web_url_when_absent() { json.get("web_url").is_none(), "web_url must be omitted when None, got {json}" ); + assert!( + json.get("parent_id").is_none(), + "parent_id must be omitted when None, got {json}" + ); let round_trip: RunCreatedProps = serde_json::from_value(json.clone()).expect("props should deserialize"); assert_eq!(round_trip.web_url, None); + assert_eq!(round_trip.parent_id, None); +} + +#[test] +fn run_parent_events_round_trip_parent_ids() { + let linked = EventBody::RunParentLinked(RunParentLinkedProps { + previous_parent_id: None, + parent_id: fixtures::RUN_2, + }); + let linked_json = serde_json::to_value(&linked).expect("linked event should serialize"); + assert_eq!(linked_json["event"], "run.parent.linked"); + assert_eq!( + linked_json["properties"]["parent_id"], + fixtures::RUN_2.to_string() + ); + + let linked_round_trip: EventBody = + serde_json::from_value(linked_json).expect("linked event should deserialize"); + assert_eq!(linked_round_trip.event_name(), "run.parent.linked"); + + let unlinked = EventBody::RunParentUnlinked(RunParentUnlinkedProps { + previous_parent_id: fixtures::RUN_2, + }); + let unlinked_json = serde_json::to_value(&unlinked).expect("unlinked event should serialize"); + assert_eq!(unlinked_json["event"], "run.parent.unlinked"); + assert_eq!( + unlinked_json["properties"]["previous_parent_id"], + fixtures::RUN_2.to_string() + ); + assert!(unlinked_json["properties"].get("parent_id").is_none()); + + let unlinked_round_trip: EventBody = + serde_json::from_value(unlinked_json).expect("unlinked event should deserialize"); + assert_eq!(unlinked_round_trip.event_name(), "run.parent.unlinked"); } diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 5538c01f7..0ce439079 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -39,6 +39,7 @@ fn event_body_from_event(event: &Event) -> EventBody { manifest_blob, git, fork_source_ref, + parent_id, web_url, .. } => EventBody::RunCreated(fabro_types::RunCreatedProps { @@ -57,6 +58,7 @@ fn event_body_from_event(event: &Event) -> EventBody { manifest_blob: *manifest_blob, git: git.clone(), fork_source_ref: fork_source_ref.clone(), + parent_id: *parent_id, web_url: web_url.clone(), }), Event::WorkflowRunStarted { @@ -143,6 +145,19 @@ fn event_body_from_event(event: &Event) -> EventBody { title: title.clone(), }) } + Event::RunParentLinked { + previous_parent_id, + parent_id, + .. + } => EventBody::RunParentLinked(fabro_types::RunParentLinkedProps { + previous_parent_id: *previous_parent_id, + parent_id: *parent_id, + }), + Event::RunParentUnlinked { + previous_parent_id, .. + } => EventBody::RunParentUnlinked(fabro_types::RunParentUnlinkedProps { + previous_parent_id: *previous_parent_id, + }), Event::WorkflowRunCompleted { duration_ms, artifact_count, @@ -2251,6 +2266,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }); let actor = stored.actor.as_ref().expect("actor set"); diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index 88731cf9e..b9bc4d815 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -45,6 +45,8 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] fork_source_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + parent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] web_url: Option, }, WorkflowRunStarted { @@ -115,6 +117,18 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] actor: Option, }, + RunParentLinked { + #[serde(default, skip_serializing_if = "Option::is_none")] + previous_parent_id: Option, + parent_id: RunId, + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + RunParentUnlinked { + previous_parent_id: RunId, + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, WorkflowRunCompleted { duration_ms: u64, artifact_count: usize, @@ -810,6 +824,19 @@ impl Event { Self::RunTitleUpdated { title, actor } => { info!(title, ?actor, "Run title updated"); } + Self::RunParentLinked { + previous_parent_id, + parent_id, + actor, + } => { + info!(?previous_parent_id, %parent_id, ?actor, "Run parent linked"); + } + Self::RunParentUnlinked { + previous_parent_id, + actor, + } => { + info!(%previous_parent_id, ?actor, "Run parent unlinked"); + } Self::WorkflowRunCompleted { duration_ms, artifact_count, diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs index 22d8b05cd..c5abfbc7c 100644 --- a/lib/crates/fabro-workflow/src/event/names.rs +++ b/lib/crates/fabro-workflow/src/event/names.rs @@ -25,6 +25,8 @@ pub fn event_name(event: &Event) -> &'static str { Event::RunArchived { .. } => "run.archived", Event::RunUnarchived { .. } => "run.unarchived", Event::RunTitleUpdated { .. } => "run.title.updated", + Event::RunParentLinked { .. } => "run.parent.linked", + Event::RunParentUnlinked { .. } => "run.parent.unlinked", Event::WorkflowRunCompleted { .. } => "run.completed", Event::WorkflowRunFailed { .. } => "run.failed", Event::RunNotice { .. } => "run.notice", diff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs index ba7902fcb..bf9ff0714 100644 --- a/lib/crates/fabro-workflow/src/event/sink.rs +++ b/lib/crates/fabro-workflow/src/event/sink.rs @@ -247,6 +247,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/event/stored_fields.rs b/lib/crates/fabro-workflow/src/event/stored_fields.rs index 20abd7a96..c640f0d39 100644 --- a/lib/crates/fabro-workflow/src/event/stored_fields.rs +++ b/lib/crates/fabro-workflow/src/event/stored_fields.rs @@ -68,6 +68,8 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { | Event::RunArchived { actor } | Event::RunUnarchived { actor, .. } | Event::RunTitleUpdated { actor, .. } + | Event::RunParentLinked { actor, .. } + | Event::RunParentUnlinked { actor, .. } | Event::InterviewCompleted { actor, .. } | Event::AgentSteerBuffered { actor, .. } => StoredEventFields { actor: actor.clone(), diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index 6734ebe1c..80220c4fa 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -472,6 +472,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 35d24d421..f0ac0ad07 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -482,6 +482,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, ) diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs index c830bb8b6..ff0ba907d 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/crates/fabro-workflow/src/handler/command.rs @@ -353,6 +353,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, ) diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index f67a8d37d..66e12dc7e 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -706,6 +706,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, ) diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index 29a67b756..67bc37a79 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -274,6 +274,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, ) diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index 7e8e6dca9..d34714631 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -729,6 +729,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/operations/archive.rs b/lib/crates/fabro-workflow/src/operations/archive.rs index 8a6044f16..a0fea188c 100644 --- a/lib/crates/fabro-workflow/src/operations/archive.rs +++ b/lib/crates/fabro-workflow/src/operations/archive.rs @@ -226,6 +226,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 23624c5bd..59c5f1e15 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -47,6 +47,7 @@ pub struct CreateRunInput { pub title: Option, pub git: Option, pub fork_source_ref: Option, + pub parent_id: Option, pub provenance: Option, pub configured_providers: Vec, /// Public URL where this run can be viewed in the web UI, when the server @@ -109,6 +110,7 @@ pub async fn create( title, git, fork_source_ref, + parent_id, provenance, configured_providers, web_url, @@ -169,6 +171,7 @@ pub async fn create( submitted_manifest_bytes.as_deref(), accepted_definition.as_ref(), title, + parent_id, web_url, ) .await?; @@ -189,6 +192,7 @@ async fn persist_created_run( submitted_manifest_bytes: Option<&[u8]>, accepted_definition: Option<&RunDefinition>, explicit_title: Option, + parent_id: Option, web_url: Option, ) -> Result<(), Error> { let record = persisted.run_spec(); @@ -242,6 +246,7 @@ async fn persist_created_run( manifest_blob, git: record.git.clone(), fork_source_ref: record.fork_source_ref.clone(), + parent_id, web_url, }, record.run_id.created_at(), @@ -871,6 +876,7 @@ mod tests { title: None, git: None, fork_source_ref: None, + parent_id: None, provenance: None, configured_providers: Vec::new(), web_url: None, @@ -916,6 +922,7 @@ mod tests { title: None, git: None, fork_source_ref: None, + parent_id: None, provenance: None, configured_providers: Vec::new(), web_url: None, @@ -983,6 +990,7 @@ mod tests { push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, }), fork_source_ref: None, + parent_id: None, provenance: None, configured_providers: Vec::new(), web_url: None, @@ -1093,6 +1101,7 @@ mod tests { title: None, git: None, fork_source_ref: None, + parent_id: None, provenance: None, configured_providers: Vec::new(), web_url: None, @@ -1137,6 +1146,7 @@ mod tests { push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, }), fork_source_ref: None, + parent_id: None, provenance: None, configured_providers: Vec::new(), web_url: None, @@ -1203,6 +1213,7 @@ mod tests { title: None, git: None, fork_source_ref: None, + parent_id: None, provenance: None, configured_providers: Vec::new(), web_url: None, @@ -1248,6 +1259,7 @@ mod tests { title: None, git: None, fork_source_ref: None, + parent_id: None, provenance: Some(fabro_types::RunProvenance { server: Some(fabro_types::RunServerProvenance { version: "0.9.0".to_string(), diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index 2341ddd5b..a42bcf5bb 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -166,6 +166,7 @@ async fn persist_forked_run( manifest_blob: spec.manifest_blob, git: spec.git.clone(), fork_source_ref: spec.fork_source_ref.clone(), + parent_id: None, web_url: None, }) .await @@ -384,6 +385,7 @@ mod tests { push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, }), fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 970c1a875..5517bf28c 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -1306,6 +1306,7 @@ mod tests { title: None, git: None, fork_source_ref: None, + parent_id: None, provenance: None, configured_providers: Vec::new(), web_url: None, @@ -1497,6 +1498,7 @@ mod tests { title: None, git: None, fork_source_ref: None, + parent_id: None, provenance: None, configured_providers: Vec::new(), web_url: None, diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index c65c9e36f..dbb9dcadd 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -211,6 +211,7 @@ async fn seed_created_and_starting( manifest_blob: None, git: run_options.pre_run_git.clone(), fork_source_ref: run_options.fork_source_ref.clone(), + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index 1d573207c..3a5f6ebb5 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -741,6 +741,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index 5bebb4afe..b760bc571 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -173,6 +173,7 @@ mod tests { manifest_blob: None, git: record.git.clone(), fork_source_ref: record.fork_source_ref.clone(), + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 2335d9250..1df783161 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -1164,6 +1164,7 @@ mod tests { manifest_blob: None, git: run_spec.git.clone(), fork_source_ref: None, + parent_id: None, web_url: None, }) .await @@ -1231,6 +1232,7 @@ mod tests { manifest_blob: None, git: run_spec.git.clone(), fork_source_ref: None, + parent_id: None, web_url: None, }) .await @@ -1587,6 +1589,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await @@ -1706,6 +1709,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await @@ -1876,6 +1880,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index 39ff91909..ccb2e2339 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -485,6 +485,7 @@ mod tests { manifest_blob: None, git: run_spec.git.clone(), fork_source_ref: run_spec.fork_source_ref.clone(), + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index acf4f95cb..b32ef1c03 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -172,6 +172,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index db4307139..5e184edc8 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -127,6 +127,7 @@ async fn initialized( manifest_blob: None, git: run_options.pre_run_git.clone(), fork_source_ref: run_options.fork_source_ref.clone(), + parent_id: None, web_url: None, }) .await diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 4d30c7580..db947cc87 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -370,6 +370,7 @@ models/system-repair-runs-response.ts models/system-run-counts.ts models/timeline-entry-response.ts models/tls-mode.ts +models/update-run-parent-request.ts models/update-run-request.ts models/user-response.ts models/validate-response.ts diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index 32677953b..e3150d58e 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -64,6 +64,8 @@ import type { StartRunRequest } from '../models'; // @ts-ignore import type { TimelineEntryResponse } from '../models'; // @ts-ignore +import type { UpdateRunParentRequest } from '../models'; +// @ts-ignore import type { UpdateRunRequest } from '../models'; // @ts-ignore import type { ValidateResponse } from '../models'; @@ -446,6 +448,51 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs. + * @summary Link Run Parent + * @param {string} id Unique run identifier (ULID). + * @param {UpdateRunParentRequest} updateRunParentRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + linkRunParent: async (id: string, updateRunParentRequest: UpdateRunParentRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('linkRunParent', 'id', id) + // verify required parameter 'updateRunParentRequest' is not null or undefined + assertParamExists('linkRunParent', 'updateRunParentRequest', updateRunParentRequest) + const localVarPath = `/api/v1/runs/{id}/parent` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateRunParentRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Links or replaces the GitHub pull request association for a run without modifying the remote pull request. * @summary Link Run Pull Request @@ -548,10 +595,11 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) * @param {number} [pageLimit] Maximum number of items to return per page. * @param {number} [pageOffset] Number of items to skip before returning results. * @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to `false`. + * @param {string} [parentId] Return only runs currently linked to this orchestration parent. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRuns: async (pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options: RawAxiosRequestConfig = {}): Promise => { + listRuns: async (pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options: RawAxiosRequestConfig = {}): Promise => { const localVarPath = `/api/v1/runs`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -582,6 +630,10 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) localVarQueryParameter['include_archived'] = includeArchived; } + if (parentId !== undefined) { + localVarQueryParameter['parent_id'] = parentId; + } + localVarHeaderParameter['Accept'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -1054,6 +1106,46 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration) options: localVarRequestOptions, }; }, + /** + * Removes a run\'s orchestration parent. Already-root runs are returned unchanged. + * @summary Unlink Run Parent + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + unlinkRunParent: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('unlinkRunParent', 'id', id) + const localVarPath = `/api/v1/runs/{id}/parent` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication SessionCookie required + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Removes Fabro\'s stored pull request association for a run without modifying the remote pull request. * @summary Unlink Run Pull Request @@ -1349,6 +1441,20 @@ export const RunsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunsApi.getRunTimeline']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs. + * @summary Link Run Parent + * @param {string} id Unique run identifier (ULID). + * @param {UpdateRunParentRequest} updateRunParentRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async linkRunParent(id: string, updateRunParentRequest: UpdateRunParentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.linkRunParent(id, updateRunParentRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunsApi.linkRunParent']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Links or replaces the GitHub pull request association for a run without modifying the remote pull request. * @summary Link Run Pull Request @@ -1384,11 +1490,12 @@ export const RunsApiFp = function(configuration?: Configuration) { * @param {number} [pageLimit] Maximum number of items to return per page. * @param {number} [pageOffset] Number of items to skip before returning results. * @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to `false`. + * @param {string} [parentId] Return only runs currently linked to this orchestration parent. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, includeArchived, options); + async listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, includeArchived, parentId, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RunsApi.listRuns']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -1540,6 +1647,19 @@ export const RunsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunsApi.unarchiveRun']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Removes a run\'s orchestration parent. Already-root runs are returned unchanged. + * @summary Unlink Run Parent + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async unlinkRunParent(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.unlinkRunParent(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunsApi.unlinkRunParent']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Removes Fabro\'s stored pull request association for a run without modifying the remote pull request. * @summary Unlink Run Pull Request @@ -1695,6 +1815,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? getRunTimeline(id: string, options?: RawAxiosRequestConfig): AxiosPromise> { return localVarFp.getRunTimeline(id, options).then((request) => request(axios, basePath)); }, + /** + * Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs. + * @summary Link Run Parent + * @param {string} id Unique run identifier (ULID). + * @param {UpdateRunParentRequest} updateRunParentRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + linkRunParent(id: string, updateRunParentRequest: UpdateRunParentRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.linkRunParent(id, updateRunParentRequest, options).then((request) => request(axios, basePath)); + }, /** * Links or replaces the GitHub pull request association for a run without modifying the remote pull request. * @summary Link Run Pull Request @@ -1724,11 +1855,12 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? * @param {number} [pageLimit] Maximum number of items to return per page. * @param {number} [pageOffset] Number of items to skip before returning results. * @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to `false`. + * @param {string} [parentId] Return only runs currently linked to this orchestration parent. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.listRuns(pageLimit, pageOffset, includeArchived, options).then((request) => request(axios, basePath)); + listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listRuns(pageLimit, pageOffset, includeArchived, parentId, options).then((request) => request(axios, basePath)); }, /** * Merges the stored pull request for a run on GitHub. @@ -1844,6 +1976,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath? unarchiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.unarchiveRun(id, options).then((request) => request(axios, basePath)); }, + /** + * Removes a run\'s orchestration parent. Already-root runs are returned unchanged. + * @summary Unlink Run Parent + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + unlinkRunParent(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.unlinkRunParent(id, options).then((request) => request(axios, basePath)); + }, /** * Removes Fabro\'s stored pull request association for a run without modifying the remote pull request. * @summary Unlink Run Pull Request @@ -1994,6 +2136,18 @@ export class RunsApi extends BaseAPI { return RunsApiFp(this.configuration).getRunTimeline(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs. + * @summary Link Run Parent + * @param {string} id Unique run identifier (ULID). + * @param {UpdateRunParentRequest} updateRunParentRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public linkRunParent(id: string, updateRunParentRequest: UpdateRunParentRequest, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).linkRunParent(id, updateRunParentRequest, options).then((request) => request(this.axios, this.basePath)); + } + /** * Links or replaces the GitHub pull request association for a run without modifying the remote pull request. * @summary Link Run Pull Request @@ -2025,11 +2179,12 @@ export class RunsApi extends BaseAPI { * @param {number} [pageLimit] Maximum number of items to return per page. * @param {number} [pageOffset] Number of items to skip before returning results. * @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to `false`. + * @param {string} [parentId] Return only runs currently linked to this orchestration parent. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options?: RawAxiosRequestConfig) { - return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, includeArchived, options).then((request) => request(this.axios, this.basePath)); + public listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, includeArchived, parentId, options).then((request) => request(this.axios, this.basePath)); } /** @@ -2157,6 +2312,17 @@ export class RunsApi extends BaseAPI { return RunsApiFp(this.configuration).unarchiveRun(id, options).then((request) => request(this.axios, this.basePath)); } + /** + * Removes a run\'s orchestration parent. Already-root runs are returned unchanged. + * @summary Unlink Run Parent + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public unlinkRunParent(id: string, options?: RawAxiosRequestConfig) { + return RunsApiFp(this.configuration).unlinkRunParent(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * Removes Fabro\'s stored pull request association for a run without modifying the remote pull request. * @summary Unlink Run Pull Request diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index a5fa311a4..345fe00a8 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -347,6 +347,7 @@ export * from './system-repair-runs-response'; export * from './system-run-counts'; export * from './timeline-entry-response'; export * from './tls-mode'; +export * from './update-run-parent-request'; export * from './update-run-request'; export * from './user-response'; export * from './validate-response'; diff --git a/lib/packages/fabro-api-client/src/models/run-manifest.ts b/lib/packages/fabro-api-client/src/models/run-manifest.ts index 7b78402bf..7d1d86c0e 100644 --- a/lib/packages/fabro-api-client/src/models/run-manifest.ts +++ b/lib/packages/fabro-api-client/src/models/run-manifest.ts @@ -44,6 +44,10 @@ export interface RunManifest { * Optional pre-generated run ID to use instead of allocating a new ULID. */ 'run_id'?: string | null; + /** + * Optional orchestration parent run ID. Fork and rewind lineage use separate fields and should not set this value. + */ + 'parent_id'?: string | null; /** * Optional explicit run title. The server trims leading/trailing whitespace, rejects blank values, rejects control characters and newline characters, and requires at most 100 characters. */ diff --git a/lib/packages/fabro-api-client/src/models/run-projection.ts b/lib/packages/fabro-api-client/src/models/run-projection.ts index 2c93eb48e..e49984ebb 100644 --- a/lib/packages/fabro-api-client/src/models/run-projection.ts +++ b/lib/packages/fabro-api-client/src/models/run-projection.ts @@ -52,6 +52,10 @@ export interface RunProjection { * Resolved run title from the event log. */ 'title'?: string; + /** + * Current orchestration parent run ID, if linked. + */ + 'parent_id'?: string | null; 'spec': RunSpec; /** * Absolute web UI URL for this run when server web settings are configured. diff --git a/lib/packages/fabro-api-client/src/models/run.ts b/lib/packages/fabro-api-client/src/models/run.ts index 0849c7118..67d4aa233 100644 --- a/lib/packages/fabro-api-client/src/models/run.ts +++ b/lib/packages/fabro-api-client/src/models/run.ts @@ -61,6 +61,10 @@ import type { WorkflowRef } from './workflow-ref'; */ export interface Run { 'id': string; + /** + * Current orchestration parent run ID, if linked. + */ + 'parent_id'?: string | null; 'title': string; 'goal': string; 'workflow': WorkflowRef; diff --git a/lib/packages/fabro-api-client/src/models/update-run-parent-request.ts b/lib/packages/fabro-api-client/src/models/update-run-parent-request.ts new file mode 100644 index 000000000..5f3ae55e5 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/update-run-parent-request.ts @@ -0,0 +1,23 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface UpdateRunParentRequest { + /** + * Existing orchestration parent run ID. + */ + 'parent_id': string; +} + From 0f1cf4da5cef3382eea30255b65153ea1226dffb Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 12:15:28 -0700 Subject: [PATCH 14/25] feat(cli): wire run parent commands (#288) ## Summary Add CLI support for run parent relationships now that the server API can store them. This lets users create child runs, filter children, inspect parent metadata, and link or unlink parents without dropping to raw API calls. ## What Changed - Added top-level `fabro parent link` and `fabro parent unlink` commands with selector resolution, text output, and JSON summaries. - Added `--parent` to `fabro run`, `fabro create`, and `fabro ps`; create/run send `parent_id` in manifests and `ps` uses server-side parent filtering. - Surfaced `parent_id` in `ps --json` and `inspect`, with a conditional `PARENT` column for unfiltered tables. - Extended `fabro-client` parent-link APIs and `list_store_runs(parent_id)`. ## Test Plan - `cargo nextest run -p fabro-cli` - `cargo nextest run -p fabro-client` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy -p fabro-cli -p fabro-client --all-targets -- -D warnings` - `cargo insta pending-snapshots` - `git diff --check` --- [![Compound Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) Generated with GPT-5 via [Codex](https://openai.com/codex) --- docs/public/reference/cli.mdx | 59 ++++++ lib/crates/fabro-cli/src/args.rs | 48 +++++ lib/crates/fabro-cli/src/commands/mod.rs | 28 +++ .../fabro-cli/src/commands/parent/link.rs | 31 +++ .../fabro-cli/src/commands/parent/mod.rs | 15 ++ .../fabro-cli/src/commands/parent/unlink.rs | 25 +++ lib/crates/fabro-cli/src/commands/pr/close.rs | 2 +- .../fabro-cli/src/commands/pr/create.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/link.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/merge.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/mod.rs | 21 +- .../fabro-cli/src/commands/pr/unlink.rs | 2 +- lib/crates/fabro-cli/src/commands/pr/view.rs | 2 +- .../fabro-cli/src/commands/run/create.rs | 18 +- .../fabro-cli/src/commands/runs/inspect.rs | 2 + .../fabro-cli/src/commands/runs/list.rs | 42 +++- lib/crates/fabro-cli/src/main.rs | 80 +++++++ lib/crates/fabro-cli/src/server_runs.rs | 18 +- lib/crates/fabro-cli/tests/it/cmd/create.rs | 41 ++++ lib/crates/fabro-cli/tests/it/cmd/fabro.rs | 1 + lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 66 +++++- lib/crates/fabro-cli/tests/it/cmd/mod.rs | 1 + lib/crates/fabro-cli/tests/it/cmd/parent.rs | 198 ++++++++++++++++++ lib/crates/fabro-cli/tests/it/cmd/ps.rs | 111 ++++++++++ lib/crates/fabro-cli/tests/it/cmd/run.rs | 55 +++++ lib/crates/fabro-client/src/client.rs | 72 ++++++- 26 files changed, 901 insertions(+), 43 deletions(-) create mode 100644 lib/crates/fabro-cli/src/commands/parent/link.rs create mode 100644 lib/crates/fabro-cli/src/commands/parent/mod.rs create mode 100644 lib/crates/fabro-cli/src/commands/parent/unlink.rs create mode 100644 lib/crates/fabro-cli/tests/it/cmd/parent.rs diff --git a/docs/public/reference/cli.mdx b/docs/public/reference/cli.mdx index 434293abb..568d964c0 100644 --- a/docs/public/reference/cli.mdx +++ b/docs/public/reference/cli.mdx @@ -81,6 +81,7 @@ fabro [OPTIONS] [COMMAND] | `fabro logs` | View the raw worker tracing log of a workflow run | | `fabro mcp` | Model Context Protocol server | | `fabro model` | List and test LLM models | +| `fabro parent` | Manage run parent links | | `fabro pr` | Pull request operations | | `fabro preflight` | Validate run configuration without executing | | `fabro provider` | Provider operations | @@ -306,6 +307,7 @@ fabro create [OPTIONS] | `--goal-file ` | Read the workflow goal from a file | | `--label ` | Attach a label to this run (repeatable, format: KEY=VALUE) | | `--model ` | Override default LLM model | +| `--parent ` | Link this run to an existing orchestration parent run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) | | `--provider ` | Override default LLM provider | | `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | @@ -627,6 +629,62 @@ fabro model test [OPTIONS] | `-p, --provider ` | Filter by provider | | `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | +### `fabro parent` + +Manage run parent links + +```bash +fabro parent [OPTIONS] +``` + +#### Subcommands + +| Command | Description | +| --- | --- | +| `fabro parent link` | Link or replace a run's orchestration parent | +| `fabro parent unlink` | Unlink a run from its orchestration parent | + +#### `fabro parent link` + +Link or replace a run's orchestration parent + +```bash +fabro parent link [OPTIONS] +``` + +#### Arguments + +| Name | Description | +| --- | --- | +| `CHILD_RUN` | Child run selector | +| `PARENT_RUN` | Parent run selector | + +#### Options + +| Option | Description | +| --- | --- | +| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | + +#### `fabro parent unlink` + +Unlink a run from its orchestration parent + +```bash +fabro parent unlink [OPTIONS] +``` + +#### Arguments + +| Name | Description | +| --- | --- | +| `CHILD_RUN` | Child run selector | + +#### Options + +| Option | Description | +| --- | --- | +| `--server ` | Fabro server target: http(s) URL or absolute Unix socket path | + ### `fabro pr` Pull request operations @@ -953,6 +1011,7 @@ fabro run [OPTIONS] | `--goal-file ` | Read the workflow goal from a file | | `--label ` | Attach a label to this run (repeatable, format: KEY=VALUE) | | `--model ` | Override default LLM model | +| `--parent ` | Link this run to an existing orchestration parent run | | `--preserve-sandbox` | Keep the sandbox alive after the run finishes (for debugging) | | `--provider ` | Override default LLM provider | | `--sandbox ` | Sandbox for agent tools
Values: `local`, `docker`, `daytona` | diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index ccdeb1773..4bcd20f8b 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -293,6 +293,10 @@ pub(crate) struct RunArgs { #[arg(long = "label", value_name = "KEY=VALUE")] pub(crate) label: Vec, + /// Link this run to an existing orchestration parent run + #[arg(long, value_name = "RUN")] + pub(crate) parent: Option, + /// Keep the sandbox alive after the run finishes (for debugging) #[arg(long)] pub(crate) preserve_sandbox: bool, @@ -376,6 +380,10 @@ pub(crate) struct RunsListArgs { /// Only display run IDs #[arg(short = 'q', long)] pub(crate) quiet: bool, + + /// Only display runs linked to this orchestration parent + #[arg(long, value_name = "RUN")] + pub(crate) parent: Option, } #[derive(Args)] @@ -908,6 +916,26 @@ pub(crate) struct PrUnlinkArgs { pub(crate) run_id: String, } +#[derive(Args)] +pub(crate) struct ParentLinkArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Child run selector + pub(crate) child_run: String, + /// Parent run selector + pub(crate) parent_run: String, +} + +#[derive(Args)] +pub(crate) struct ParentUnlinkArgs { + #[command(flatten)] + pub(crate) server: ServerTargetArgs, + + /// Child run selector + pub(crate) child_run: String, +} + #[derive(Args)] pub(crate) struct PrMergeArgs { #[command(flatten)] @@ -1202,6 +1230,8 @@ pub(crate) enum Commands { Auth(AuthNamespace), /// Pull request operations Pr(PrNamespace), + /// Manage run parent links + Parent(ParentNamespace), /// Manage server-owned secrets Secret(SecretNamespace), /// Inspect effective settings @@ -1310,6 +1340,10 @@ impl Commands { PrCommand::Merge(_) => "pr merge", PrCommand::Close(_) => "pr close", }, + Self::Parent(ns) => match &ns.command { + ParentCommand::Link(_) => "parent link", + ParentCommand::Unlink(_) => "parent unlink", + }, Self::Secret(ns) => match &ns.command { SecretCommand::List(_) => "secret list", SecretCommand::Rm(_) => "secret rm", @@ -1369,6 +1403,20 @@ pub(crate) enum PrCommand { Close(PrCloseArgs), } +#[derive(Args)] +pub(crate) struct ParentNamespace { + #[command(subcommand)] + pub(crate) command: ParentCommand, +} + +#[derive(Subcommand)] +pub(crate) enum ParentCommand { + /// Link or replace a run's orchestration parent + Link(ParentLinkArgs), + /// Unlink a run from its orchestration parent + Unlink(ParentUnlinkArgs), +} + #[derive(Args)] pub(crate) struct ArtifactNamespace { #[command(subcommand)] diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index 7e5c3ee69..71d66bd08 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod graph; pub(crate) mod install; pub(crate) mod mcp; pub(crate) mod model; +pub(crate) mod parent; pub(crate) mod parse; pub(crate) mod pr; pub(crate) mod preflight; @@ -26,3 +27,30 @@ pub(crate) mod upgrade; pub(crate) mod validate; pub(crate) mod version; pub(crate) mod workflow; + +use std::sync::Arc; + +use anyhow::Result; +use fabro_client::Client; +use fabro_types::RunId; + +use crate::args::ServerTargetArgs; +use crate::command_context::CommandContext; + +pub(crate) async fn resolve_run_id(client: &Client, selector: &str) -> Result { + match selector.parse::() { + Ok(run_id) => Ok(run_id), + Err(_) => Ok(client.resolve_run(selector).await?.id), + } +} + +pub(crate) async fn resolve_run_selector( + base_ctx: &CommandContext, + server: &ServerTargetArgs, + selector: &str, +) -> Result<(CommandContext, Arc, RunId)> { + let ctx = base_ctx.with_target(server)?; + let client = ctx.server().await?; + let run_id = resolve_run_id(client.as_ref(), selector).await?; + Ok((ctx, client, run_id)) +} diff --git a/lib/crates/fabro-cli/src/commands/parent/link.rs b/lib/crates/fabro-cli/src/commands/parent/link.rs new file mode 100644 index 000000000..1289ef9f4 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/parent/link.rs @@ -0,0 +1,31 @@ +use anyhow::Result; +use tracing::info; + +use crate::args::ParentLinkArgs; +use crate::command_context::CommandContext; +use crate::shared::print_json_pretty; + +pub(super) async fn link_command(args: ParentLinkArgs, base_ctx: &CommandContext) -> Result<()> { + let ctx = base_ctx.with_target(&args.server)?; + let client = ctx.server().await?; + let (child_id, parent_id) = tokio::try_join!( + super::resolve_run_id(client.as_ref(), &args.child_run), + super::resolve_run_id(client.as_ref(), &args.parent_run), + )?; + let summary = client.link_run_parent(&child_id, &parent_id).await?; + + info!(%child_id, %parent_id, "Linked run parent"); + + if ctx.json_output() { + print_json_pretty(&summary)?; + } else { + fabro_util::printout!( + ctx.printer(), + "Linked parent: {} -> {}", + child_id, + parent_id + ); + } + + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/parent/mod.rs b/lib/crates/fabro-cli/src/commands/parent/mod.rs new file mode 100644 index 000000000..aae5a326e --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/parent/mod.rs @@ -0,0 +1,15 @@ +mod link; +mod unlink; + +use anyhow::Result; + +use super::{resolve_run_id, resolve_run_selector}; +use crate::args::{ParentCommand, ParentNamespace}; +use crate::command_context::CommandContext; + +pub(crate) async fn dispatch(ns: ParentNamespace, base_ctx: &CommandContext) -> Result<()> { + match ns.command { + ParentCommand::Link(args) => link::link_command(args, base_ctx).await, + ParentCommand::Unlink(args) => unlink::unlink_command(args, base_ctx).await, + } +} diff --git a/lib/crates/fabro-cli/src/commands/parent/unlink.rs b/lib/crates/fabro-cli/src/commands/parent/unlink.rs new file mode 100644 index 000000000..fc8ce68d1 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/parent/unlink.rs @@ -0,0 +1,25 @@ +use anyhow::Result; +use tracing::info; + +use crate::args::ParentUnlinkArgs; +use crate::command_context::CommandContext; +use crate::shared::print_json_pretty; + +pub(super) async fn unlink_command( + args: ParentUnlinkArgs, + base_ctx: &CommandContext, +) -> Result<()> { + let (ctx, client, child_id) = + super::resolve_run_selector(base_ctx, &args.server, &args.child_run).await?; + let summary = client.unlink_run_parent(&child_id).await?; + + info!(%child_id, "Unlinked run parent"); + + if ctx.json_output() { + print_json_pretty(&summary)?; + } else { + fabro_util::printout!(ctx.printer(), "Unlinked parent: {}", child_id); + } + + Ok(()) +} diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index 2aaf1b335..96c76b201 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn close_command(args: PrCloseArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let response = client.close_run_pull_request(&run_id).await?; info!(number = response.number, "Closed pull request"); diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index 0d846539c..2706c0991 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn create_command(args: PrCreateArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let record = client .create_run_pull_request(&run_id, args.force, args.model) .await?; diff --git a/lib/crates/fabro-cli/src/commands/pr/link.rs b/lib/crates/fabro-cli/src/commands/pr/link.rs index 1e0db9eed..ad95b7d20 100644 --- a/lib/crates/fabro-cli/src/commands/pr/link.rs +++ b/lib/crates/fabro-cli/src/commands/pr/link.rs @@ -8,7 +8,7 @@ use crate::shared::print_json_pretty; pub(super) async fn link_command(args: PrLinkArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let record = client.link_run_pull_request(&run_id, args.url).await?; info!( diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 820d34be6..297899001 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn merge_command(args: PrMergeArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let response = client.merge_run_pull_request(&run_id, args.method).await?; info!( diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index a88976663..042ff90b8 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -5,13 +5,10 @@ mod merge; mod unlink; mod view; -use std::sync::Arc; - use anyhow::Result; -use fabro_client::Client; -use fabro_types::RunId; -use crate::args::{PrCommand, PrNamespace, ServerTargetArgs}; +use super::resolve_run_selector; +use crate::args::{PrCommand, PrNamespace}; use crate::command_context::CommandContext; pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Result<()> { @@ -24,17 +21,3 @@ pub(crate) async fn dispatch(ns: PrNamespace, base_ctx: &CommandContext) -> Resu PrCommand::Close(args) => close::close_command(args, base_ctx).await, } } - -async fn resolve_run_for_pr( - base_ctx: &CommandContext, - server: &ServerTargetArgs, - selector: &str, -) -> Result<(CommandContext, Arc, RunId)> { - let ctx = base_ctx.with_target(server)?; - let client = ctx.server().await?; - let run_id = match selector.parse::() { - Ok(run_id) => run_id, - Err(_) => client.resolve_run(selector).await?.id, - }; - Ok((ctx, client, run_id)) -} diff --git a/lib/crates/fabro-cli/src/commands/pr/unlink.rs b/lib/crates/fabro-cli/src/commands/pr/unlink.rs index a6cc363e9..eea63a825 100644 --- a/lib/crates/fabro-cli/src/commands/pr/unlink.rs +++ b/lib/crates/fabro-cli/src/commands/pr/unlink.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn unlink_command(args: PrUnlinkArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let record = client.unlink_run_pull_request(&run_id).await?; info!( diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index 933dccb95..1326e42de 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -7,7 +7,7 @@ use crate::shared::print_json_pretty; pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) -> Result<()> { let (ctx, client, run_id) = - super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?; + super::resolve_run_selector(base_ctx, &args.server, &args.run_id).await?; let detail = client.get_run_pull_request(&run_id).await?; let pull_request = &detail.data.link; let github_details = detail.data.details.as_ref(); diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 2357b5758..23c3df685 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -11,6 +11,7 @@ use super::output::{api_diagnostics_to_local, print_workflow_summary}; use super::overrides::run_args_overrides; use crate::args::RunArgs; use crate::command_context::CommandContext; +use crate::commands::resolve_run_id; use crate::manifest_args::run_manifest_args; pub(crate) struct CreatedRun { @@ -40,7 +41,7 @@ pub(crate) async fn create_run( .transpose() .context("invalid run ID")?; - let built = build_run_manifest(ManifestBuildInput { + let mut built = build_run_manifest(ManifestBuildInput { workflow: workflow_path.clone(), cwd, run_overrides: cli_args_config.run, @@ -50,6 +51,16 @@ pub(crate) async fn create_run( run_id, user_settings_path: Some(active_settings_path(None)), })?; + + let client = if let Some(parent_selector) = args.parent.as_deref() { + let client = ctx.server().await?; + let parent_id = resolve_run_id(client.as_ref(), parent_selector).await?; + built.manifest.parent_id = Some(parent_id.to_string()); + Some(client) + } else { + None + }; + let validation = manifest_validation::validate_manifest( &RunLayer::default(), &built.manifest, @@ -72,7 +83,10 @@ pub(crate) async fn create_run( bail!("Validation failed"); } - let client = ctx.server().await?; + let client = match client { + Some(client) => client, + None => ctx.server().await?, + }; let created_run_id = client .create_run_from_manifest(built.manifest) .await diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index 9b5ff0532..4ee188a00 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -10,6 +10,7 @@ use crate::server_runs::ServerRunSummaryInfo; #[derive(Debug, Serialize)] pub(crate) struct InspectOutput { pub run_id: String, + pub parent_id: Option, pub status: RunStatus, pub run_spec: Option, pub start_record: Option, @@ -37,6 +38,7 @@ fn inspect_run_state(run: &ServerRunSummaryInfo, state: RunProjection) -> Inspec .and_then(|record| serde_json::to_value(record).ok()); InspectOutput { run_id: run.run_id().to_string(), + parent_id: state.parent_id.map(|parent_id| parent_id.to_string()), status: state.status, run_spec: serde_json::to_value(state.spec).ok(), start_record: state diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 92e2db313..7adddc3b2 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -11,6 +11,7 @@ use fabro_workflow::run_status::RunStatus; use super::short_run_id; use crate::args::RunsListArgs; use crate::command_context::CommandContext; +use crate::commands::resolve_run_id; use crate::server_runs::{ServerSummaryLookup, filter_server_runs}; use crate::shared::{color_if, format_duration_ms, run_status_kind, tilde_path}; @@ -21,7 +22,16 @@ pub(crate) async fn list_command( ) -> Result<()> { let ctx = base_ctx.with_target(&args.server)?; let printer = ctx.printer(); - let lookup = ServerSummaryLookup::from_client(ctx.server().await?).await?; + let client = ctx.server().await?; + let parent_id = match args.parent.as_deref() { + Some(selector) => Some(resolve_run_id(client.as_ref(), selector).await?), + None => None, + }; + let filtered_by_parent = parent_id.is_some(); + let lookup = match parent_id { + Some(parent_id) => ServerSummaryLookup::from_client_by_parent(client, parent_id).await?, + None => ServerSummaryLookup::from_client(client).await?, + }; let label_filters = parse_label_filters(&args.filter.label); let filtered = filter_server_runs( lookup.runs(), @@ -37,6 +47,7 @@ pub(crate) async fn list_command( .map(|run| { serde_json::json!({ "run_id": run.run_id(), + "parent_id": run.parent_id(), "workflow_name": run.workflow_name(), "workflow_slug": run.workflow_slug(), "status": run.status(), @@ -75,17 +86,22 @@ pub(crate) async fn list_command( let mut display_runs = filtered; display_runs.reverse(); + let show_parent_column = + !filtered_by_parent && display_runs.iter().any(|run| run.parent_id().is_some()); let use_color = styles.use_color; let now = Utc::now(); - let title = vec![ - "RUN ID".cell().bold(use_color), + let mut title = vec!["RUN ID".cell().bold(use_color)]; + if show_parent_column { + title.push("PARENT".cell().bold(use_color)); + } + title.extend([ "WORKFLOW".cell().bold(use_color), "STATUS".cell().bold(use_color), "DIRECTORY".cell().bold(use_color), "DURATION".cell().bold(use_color), "GOAL".cell().bold(use_color), - ]; + ]); let rows: Vec> = display_runs .iter() @@ -105,10 +121,23 @@ pub(crate) async fn list_command( .map_or_else(|| "-".to_string(), |p| tilde_path(Path::new(p))); let run_id = run.run_id().to_string(); - vec![ + let mut row = vec![ short_run_id(&run_id) .cell() .foreground_color(color_if(use_color, Color::Ansi256(8))), + ]; + if show_parent_column { + let parent_display = run.parent_id().map_or_else( + || "-".to_string(), + |parent_id| short_run_id(&parent_id.to_string()).to_string(), + ); + row.push( + parent_display + .cell() + .foreground_color(color_if(use_color, Color::Ansi256(8))), + ); + } + row.extend([ run.workflow_name().cell(), status_cell(run.status(), use_color), dir_display.cell(), @@ -116,7 +145,8 @@ pub(crate) async fn list_command( truncate_goal(&run.goal(), 50) .cell() .foreground_color(color_if(use_color, Color::Ansi256(8))), - ] + ]); + row }) .collect(); diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index bb3686da6..8d7033829 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -329,6 +329,9 @@ async fn main_inner(worker_token: Option) -> (String, Result<()>) { Commands::Pr(ns) => { Box::pin(commands::pr::dispatch(ns, &base_ctx)).await?; } + Commands::Parent(ns) => { + commands::parent::dispatch(ns, &base_ctx).await?; + } Commands::Secret(ns) => { commands::secret::dispatch(ns, &base_ctx).await?; } @@ -1185,6 +1188,24 @@ destination = "{destination}" } } + #[test] + fn parse_create_parent_flag() { + let cli = Cli::try_parse_from([ + "fabro", + "create", + "--parent", + "nightly-parent", + "workflow.toml", + ]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::RunCmd(RunCommands::Create(args)) => { + assert_eq!(args.parent.as_deref(), Some("nightly-parent")); + } + _ => panic!("unexpected command variant"), + } + } + #[test] fn parse_run_input_short_flag() { let cli = Cli::try_parse_from(["fabro", "run", "workflow.toml", "-I", "foo=bar"]) @@ -1197,6 +1218,65 @@ destination = "{destination}" } } + #[test] + fn parse_run_parent_flag() { + let cli = Cli::try_parse_from([ + "fabro", + "run", + "--parent", + "nightly-parent", + "workflow.toml", + ]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::RunCmd(RunCommands::Run(args)) => { + assert_eq!(args.parent.as_deref(), Some("nightly-parent")); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_ps_parent_flag() { + let cli = Cli::try_parse_from(["fabro", "ps", "--parent", "nightly-parent"]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::RunsCmd(args::RunsCommands::Ps(args)) => { + assert_eq!(args.parent.as_deref(), Some("nightly-parent")); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_parent_link_command() { + let cli = Cli::try_parse_from(["fabro", "parent", "link", "child-run", "parent-run"]) + .expect("should parse"); + match *cli.command.unwrap() { + Commands::Parent(args::ParentNamespace { + command: args::ParentCommand::Link(args), + }) => { + assert_eq!(args.child_run, "child-run"); + assert_eq!(args.parent_run, "parent-run"); + } + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn parse_parent_unlink_command() { + let cli = + Cli::try_parse_from(["fabro", "parent", "unlink", "child-run"]).expect("should parse"); + match *cli.command.unwrap() { + Commands::Parent(args::ParentNamespace { + command: args::ParentCommand::Unlink(args), + }) => { + assert_eq!(args.child_run, "child-run"); + } + _ => panic!("unexpected command variant"), + } + } + #[test] fn run_manifest_args_preserves_input_only_manifest_args() { let cli = Cli::try_parse_from(["fabro", "run", "workflow.toml", "-I", "foo=bar"]) diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs index 0b7517566..113694b64 100644 --- a/lib/crates/fabro-cli/src/server_runs.rs +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -21,6 +21,10 @@ impl ServerRunSummaryInfo { self.summary.id } + pub(crate) fn parent_id(&self) -> Option { + self.summary.parent_id + } + pub(crate) fn workflow_name(&self) -> String { self.summary.workflow.name.clone() } @@ -84,6 +88,18 @@ pub(crate) struct ServerSummaryLookup { impl ServerSummaryLookup { pub(crate) async fn from_client(client: Arc) -> Result { let summaries = client.list_store_runs().await?; + Ok(Self::from_summaries(summaries)) + } + + pub(crate) async fn from_client_by_parent( + client: Arc, + parent_id: RunId, + ) -> Result { + let summaries = client.list_store_runs_by_parent(parent_id).await?; + Ok(Self::from_summaries(summaries)) + } + + fn from_summaries(summaries: Vec) -> Self { let mut runs = summaries .into_iter() .map(ServerRunSummaryInfo::from_summary) @@ -93,7 +109,7 @@ impl ServerSummaryLookup { .cmp(&a.start_time_dt()) .then_with(|| b.run_id().cmp(&a.run_id())) }); - Ok(Self { runs }) + Self { runs } } pub(crate) fn runs(&self) -> &[ServerRunSummaryInfo] { diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index b69c6ae76..2a0424aae 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -60,6 +60,7 @@ fn help() { -v, --verbose Enable verbose output --sandbox Sandbox for agent tools [possible values: local, docker, daytona] --label Attach a label to this run (repeatable, format: KEY=VALUE) + --parent Link this run to an existing orchestration parent run --preserve-sandbox Keep the sandbox alive after the run finishes (for debugging) -d, --detach Run the workflow in the background and print the run ID -h, --help Print help @@ -129,6 +130,46 @@ fn create_uses_configured_server_target_without_server_flag() { assert_eq!(output_stdout(&output).trim(), run_id.as_str()); } +#[test] +fn create_parent_resolves_parent_and_sends_parent_id_in_manifest() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + let parent_id = unique_run_id(); + let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id); + let create_mock = server.mock(|when, then| { + when.method("POST") + .path("/api/v1/runs") + .json_body_includes(format!(r#"{{"parent_id":"{parent_id}"}}"#)); + then.status(201) + .header("Content-Type", "application/json") + .body(run_status_response(run_id.as_str(), "submitted").to_string()); + }); + + let output = context + .create_cmd() + .args([ + "--server", + &format!("{}/api/v1", server.base_url()), + "--dry-run", + "--parent", + "nightly-parent", + fixture("simple.fabro").to_str().unwrap(), + ]) + .output() + .expect("command should execute"); + + assert!( + output.status.success(), + "command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + resolve_mock.assert(); + create_mock.assert(); + assert_eq!(output_stdout(&output).trim(), run_id.as_str()); +} + #[test] fn create_rejects_storage_dir_flag() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs index 6fc7aaff8..fe29cbfee 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fabro.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fabro.rs @@ -41,6 +41,7 @@ fn help() { uninstall Uninstall Fabro from this machine auth Manage CLI authentication state pr Pull request operations + parent Manage run parent links secret Manage server-owned secrets settings Inspect effective settings workflow Workflow operations diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 844104f09..8d212c4b1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -1,7 +1,7 @@ use fabro_test::{fabro_snapshot, test_context}; use httpmock::MockServer; use insta::assert_snapshot; -use serde_json::json; +use serde_json::{Value, json}; use super::support::{ compact_git_inspect, compact_inspect, remote_run_summary_json, run_success, @@ -102,6 +102,7 @@ fn inspect_resolves_selector_via_server_endpoint() { [ { "run_id": "[ULID]", + "parent_id": null, "status": { "kind": "succeeded", "reason": "completed" @@ -217,6 +218,69 @@ fn inspect_resolves_selector_via_server_endpoint() { run_state.assert(); } +#[test] +fn inspect_includes_parent_id_from_run_projection() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + let parent_id = unique_run_id(); + let summary = remote_run_summary( + run_id.as_str(), + &json!({ + "kind": "succeeded", + "reason": "completed" + }), + ); + + let resolve_run = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/runs/resolve") + .query_param("selector", "nightly-build"); + then.status(200) + .header("content-type", "application/json") + .body(summary.to_string()); + }); + let run_state = server.mock(|when, then| { + when.method("GET") + .path(format!("/api/v1/runs/{}/state", run_id.as_str())); + let mut state = run_projection_json( + run_id.as_str(), + &json!({ + "kind": "succeeded", + "reason": "completed" + }), + ); + state["parent_id"] = json!(parent_id); + then.status(200) + .header("content-type", "application/json") + .body(state.to_string()); + }); + + let output = context + .command() + .args([ + "inspect", + "--server", + &format!("{}/api/v1", server.base_url()), + "nightly-build", + ]) + .output() + .expect("inspect should execute"); + + assert!( + output.status.success(), + "inspect failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let items: Value = serde_json::from_slice(&output.stdout).expect("inspect JSON should parse"); + assert_eq!(items[0]["run_id"], run_id); + assert_eq!(items[0]["parent_id"], parent_id); + + resolve_run.assert(); + run_state.assert(); +} + #[test] fn inspect_created_run_shows_run_spec_without_start_or_conclusion() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 68b8e4da7..79adb52e8 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -24,6 +24,7 @@ mod mcp; mod model; mod model_list; mod model_test; +mod parent; mod parse; mod pr; mod pr_close; diff --git a/lib/crates/fabro-cli/tests/it/cmd/parent.rs b/lib/crates/fabro-cli/tests/it/cmd/parent.rs new file mode 100644 index 000000000..173ad9f57 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/parent.rs @@ -0,0 +1,198 @@ +use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; +use serde_json::Value; + +use super::support::{mock_resolved_run, remote_run_summary_json}; +use crate::support::unique_run_id; + +#[test] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["parent", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Manage run parent links + + Usage: fabro parent [OPTIONS] + + Commands: + link Link or replace a run's orchestration parent + unlink Unlink a run from its orchestration parent + help Print this message or the help of the given subcommand(s) + + Options: + --json Output as JSON [env: FABRO_JSON=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help + ----- stderr ----- + "); +} + +#[test] +fn parent_link_resolves_selectors_calls_endpoint_and_prints_link() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + let parent_id = unique_run_id(); + + let child_resolve = mock_resolved_run(&server, "child-build", &child_id); + let parent_resolve = mock_resolved_run(&server, "parent-build", &parent_id); + let link_mock = server.mock(|when, then| { + when.method("PUT") + .path(format!("/api/v1/runs/{child_id}/parent")) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "parent_id": parent_id + })); + let mut summary = remote_run_summary_json( + &child_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-05T12:00:00Z", + ); + summary["parent_id"] = serde_json::json!(parent_id); + then.status(200) + .header("Content-Type", "application/json") + .json_body(summary); + }); + + let mut cmd = context.command(); + cmd.args([ + "parent", + "link", + "--server", + &server.base_url(), + "child-build", + "parent-build", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Linked parent: [ULID] -> [ULID] + ----- stderr ----- + "); + + child_resolve.assert(); + parent_resolve.assert(); + link_mock.assert(); +} + +#[test] +fn parent_link_json_prints_updated_run_summary() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + let parent_id = unique_run_id(); + + let child_resolve = mock_resolved_run(&server, "child-build", &child_id); + let parent_resolve = mock_resolved_run(&server, "parent-build", &parent_id); + let link_mock = server.mock(|when, then| { + when.method("PUT") + .path(format!("/api/v1/runs/{child_id}/parent")) + .json_body(serde_json::json!({ + "parent_id": parent_id + })); + let mut summary = remote_run_summary_json( + &child_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-05T12:00:00Z", + ); + summary["parent_id"] = serde_json::json!(parent_id); + then.status(200) + .header("Content-Type", "application/json") + .json_body(summary); + }); + + let output = context + .command() + .args([ + "--json", + "parent", + "link", + "--server", + &server.base_url(), + "child-build", + "parent-build", + ]) + .output() + .expect("parent link should execute"); + + assert!( + output.status.success(), + "parent link failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let summary: Value = serde_json::from_slice(&output.stdout).expect("JSON should parse"); + assert_eq!(summary["id"], child_id); + assert_eq!(summary["parent_id"], parent_id); + + child_resolve.assert(); + parent_resolve.assert(); + link_mock.assert(); +} + +#[test] +fn parent_unlink_resolves_selector_calls_endpoint_and_prints_unlinked_child() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + + let child_resolve = mock_resolved_run(&server, "child-build", &child_id); + let unlink_mock = server.mock(|when, then| { + when.method("DELETE") + .path(format!("/api/v1/runs/{child_id}/parent")); + then.status(200) + .header("Content-Type", "application/json") + .json_body(remote_run_summary_json( + &child_id, + "Nightly Build", + "nightly-build", + "Nightly run", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-05T12:00:00Z", + )); + }); + + let mut cmd = context.command(); + cmd.args([ + "parent", + "unlink", + "--server", + &server.base_url(), + "child-build", + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Unlinked parent: [ULID] + ----- stderr ----- + "); + + child_resolve.assert(); + unlink_mock.assert(); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index 31dc0e63c..0f82641d7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -54,6 +54,7 @@ fn help() { --verbose Enable verbose output [env: FABRO_VERBOSE=] -a, --all Show all runs, not just running (like docker ps -a) -q, --quiet Only display run IDs + --parent Only display runs linked to this orchestration parent -h, --help Print help ----- stderr ----- "); @@ -372,6 +373,116 @@ fn ps_uses_configured_server_target_without_server_flag() { assert_eq!(runs[0]["source_directory"], "/srv/repo"); } +#[test] +fn ps_parent_resolves_parent_and_filters_on_server() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + let parent_id = unique_run_id(); + let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id); + let mut summary = remote_run_summary_json( + &child_id, + "Child Workflow", + "child-workflow", + "Child goal", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-20T12:00:00Z", + ); + summary["parent_id"] = serde_json::json!(parent_id); + let list_mock = server.mock(|when, then| { + when.method("GET") + .path("/api/v1/runs") + .query_param("parent_id", parent_id.as_str()); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [summary], + "meta": { "has_more": false } + }) + .to_string(), + ); + }); + + let output = context + .ps() + .args([ + "-a", + "--json", + "--server", + &format!("{}/api/v1", server.base_url()), + "--parent", + "nightly-parent", + ]) + .output() + .expect("ps should execute"); + + assert!(output.status.success(), "ps should succeed"); + let runs: Vec = serde_json::from_slice(&output.stdout).expect("ps JSON should parse"); + resolve_mock.assert(); + list_mock.assert(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0]["run_id"], child_id); + assert_eq!(runs[0]["parent_id"], parent_id); +} + +#[test] +fn ps_table_adds_parent_column_for_unfiltered_child_runs() { + let context = test_context!(); + let server = MockServer::start(); + let child_id = unique_run_id(); + let parent_id = unique_run_id(); + let mut summary = remote_run_summary_json( + &child_id, + "Child Workflow", + "child-workflow", + "Child goal", + &serde_json::json!({ + "kind": "succeeded", + "reason": "completed" + }), + "2026-04-20T12:00:00Z", + ); + summary["parent_id"] = serde_json::json!(parent_id); + let list_mock = server.mock(|when, then| { + when.method("GET").path("/api/v1/runs"); + then.status(200) + .header("Content-Type", "application/json") + .body( + serde_json::json!({ + "data": [summary], + "meta": { "has_more": false } + }) + .to_string(), + ); + }); + + let output = context + .ps() + .args(["-a", "--server", &format!("{}/api/v1", server.base_url())]) + .output() + .expect("ps should execute"); + + assert!(output.status.success(), "ps should succeed"); + let stdout = String::from_utf8_lossy(&output.stdout); + list_mock.assert(); + assert!( + stdout.contains("PARENT"), + "table should include parent column:\n{stdout}" + ); + assert!( + stdout.contains(&child_id[..12]), + "table should include child run id:\n{stdout}" + ); + assert!( + stdout.contains(&parent_id[..12]), + "table should include parent run id:\n{stdout}" + ); +} + #[test] fn ps_explicit_remote_target_ignores_broken_local_storage_settings() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 8fead8139..29e76ef01 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -154,6 +154,7 @@ fn help() { -v, --verbose Enable verbose output --sandbox Sandbox for agent tools [possible values: local, docker, daytona] --label Attach a label to this run (repeatable, format: KEY=VALUE) + --parent Link this run to an existing orchestration parent run --preserve-sandbox Keep the sandbox alive after the run finishes (for debugging) -d, --detach Run the workflow in the background and print the run ID -h, --help Print help @@ -209,6 +210,60 @@ fn detach_uses_explicit_server_target_and_prints_remote_run_id() { ); } +#[test] +fn run_parent_resolves_parent_and_sends_parent_id_in_manifest() { + let context = test_context!(); + let server = MockServer::start(); + let run_id = unique_run_id(); + let parent_id = unique_run_id(); + let resolve_mock = super::support::mock_resolved_run(&server, "nightly-parent", &parent_id); + let create_mock = server.mock(|when, then| { + when.method("POST") + .path("/api/v1/runs") + .json_body_includes(format!(r#"{{"parent_id":"{parent_id}"}}"#)); + then.status(201) + .header("Content-Type", "application/json") + .body(run_status_response(run_id.as_str(), "submitted").to_string()); + }); + let start_mock = server.mock(|when, then| { + when.method("POST") + .path(format!("/api/v1/runs/{run_id}/start")); + then.status(200) + .header("Content-Type", "application/json") + .body(run_status_response(run_id.as_str(), "queued").to_string()); + }); + + let workflow = context.install_fixture("simple.fabro"); + let output = context + .run_cmd() + .args([ + "--server", + &format!("{}/api/v1", server.base_url()), + "--detach", + "--dry-run", + "--auto-approve", + "--parent", + "nightly-parent", + workflow.to_str().unwrap(), + ]) + .output() + .expect("command should execute"); + + assert!( + output.status.success(), + "command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + resolve_mock.assert(); + create_mock.assert(); + start_mock.assert(); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + run_id.as_str() + ); +} + #[test] fn detach_uses_configured_server_target_without_server_flag() { let context = test_context!(); diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index 557893c20..d584a8783 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -49,6 +49,11 @@ pub struct RewindRunResult { pub response: types::RewindResponse, } +#[derive(Default)] +struct ListStoreRunsOptions { + parent_id: Option, +} + #[derive(Clone)] struct ClientState { client: fabro_api::ApiClient, @@ -913,20 +918,41 @@ impl Client { } pub async fn list_store_runs(&self) -> Result> { + self.list_store_runs_with_options(ListStoreRunsOptions::default()) + .await + } + + pub async fn list_store_runs_by_parent(&self, parent_id: RunId) -> Result> { + self.list_store_runs_with_options(ListStoreRunsOptions { + parent_id: Some(parent_id), + }) + .await + } + + async fn list_store_runs_with_options( + &self, + options: ListStoreRunsOptions, + ) -> Result> { let mut all_runs = Vec::new(); let mut offset = 0_u64; let limit = 100_u64; + let parent_id = options.parent_id.map(|run_id| run_id.to_string()); loop { let response = self - .send_api(|client| async move { - client - .list_runs() - .page_limit(limit) - .page_offset(offset) - .include_archived(true) - .send() - .await + .send_api(|client| { + let parent_id = parent_id.clone(); + async move { + let mut request = client + .list_runs() + .page_limit(limit) + .page_offset(offset) + .include_archived(true); + if let Some(parent_id) = parent_id { + request = request.parent_id(parent_id); + } + request.send().await + } }) .await?; let parsed = response.into_inner(); @@ -947,6 +973,36 @@ impl Client { Ok(all_runs) } + pub async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> Result { + let body = types::UpdateRunParentRequest { + parent_id: parent_id.to_string(), + }; + let response = self + .send_api(|client| async move { + client + .link_run_parent() + .id(child_id.to_string()) + .body(body.clone()) + .send() + .await + }) + .await?; + convert_type(response.into_inner()) + } + + pub async fn unlink_run_parent(&self, child_id: &RunId) -> Result { + let response = self + .send_api(|client| async move { + client + .unlink_run_parent() + .id(child_id.to_string()) + .send() + .await + }) + .await?; + convert_type(response.into_inner()) + } + pub async fn retrieve_run(&self, run_id: &RunId) -> Result { let response = self .send_api( From 4f5e3b78f8e4250fc338013ff834f127d84b0bae Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 12:29:25 -0700 Subject: [PATCH 15/25] refactor: remove compatibility shims (#281) ## Summary Simplifies the greenfield PR/run schema surface by collapsing alias-only type shims and removing legacy compatibility paths that kept old wire shapes and workflow names alive. ## Changes - Use canonical `Run`, `PullRequestLink`, `PullRequestResponse`, `BoardColumn`, `WorkflowSettings`, SWR `Key`, and `SteerRunRequest` names directly across Rust and web code. - Remove legacy PR/event deserialization compatibility for old PR records and command output fields, with tests updated to reject stale wire shapes. - Drop obsolete workflow aliases for `agent_loop`, `one_shot`, `codergen_mode`, and `stack.child_dotfile`, then update docs and tests to the current names. ## Verification - `git diff --check` - `cargo +nightly-2026-04-14 fmt --check --all` - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` - `cargo nextest run -p fabro-types -p fabro-api -p fabro-client -p fabro-store -p fabro-server -p fabro-workflow -p fabro-cli` - `cd apps/fabro-web && bun run typecheck` - `cd apps/fabro-web && bun test` --- [![Compound Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) Generated with GPT-5 via [Codex](https://openai.com/codex) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- apps/fabro-web/app/data/runs.test.ts | 8 +- apps/fabro-web/app/data/runs.ts | 20 ++-- apps/fabro-web/app/lib/board-events.test.tsx | 11 ++- apps/fabro-web/app/lib/cross-tab-sse.ts | 11 ++- apps/fabro-web/app/lib/live-events.ts | 5 +- apps/fabro-web/app/lib/mutations.ts | 4 +- apps/fabro-web/app/lib/run-events.test.tsx | 23 ++--- apps/fabro-web/app/lib/run-events.ts | 9 +- .../app/lib/run-settings-snapshot.ts | 4 - apps/fabro-web/app/lib/sse.ts | 13 ++- apps/fabro-web/app/routes/automation-runs.tsx | 14 +-- apps/fabro-web/app/routes/run-detail.tsx | 10 +- apps/fabro-web/app/routes/run-settings.tsx | 12 +-- apps/fabro-web/app/routes/runs.tsx | 12 +-- docs/public/execution/run-configuration.mdx | 2 +- docs/public/tutorials/sub-workflow.mdx | 5 +- lib/crates/fabro-api/src/lib.rs | 2 - .../fabro-api/tests/run_summary_round_trip.rs | 14 +-- lib/crates/fabro-cli/src/commands/pr/link.rs | 4 +- .../fabro-cli/src/commands/run/output.rs | 4 +- .../fabro-cli/src/commands/runs/inspect.rs | 6 +- .../fabro-cli/src/commands/runs/list.rs | 6 +- lib/crates/fabro-cli/src/server_runs.rs | 64 ++++++------- lib/crates/fabro-client/src/client.rs | 32 +++---- lib/crates/fabro-graphviz/src/parser/mod.rs | 6 +- .../fabro-graphviz/src/parser/semantic.rs | 74 --------------- lib/crates/fabro-manifest/src/lib.rs | 1 - lib/crates/fabro-server/src/demo/mod.rs | 6 +- lib/crates/fabro-server/src/server.rs | 6 +- .../src/server/handler/pull_requests.rs | 24 ++--- lib/crates/fabro-server/src/server/tests.rs | 6 +- lib/crates/fabro-store/src/lib.rs | 3 +- lib/crates/fabro-store/src/run_state.rs | 20 ++-- lib/crates/fabro-store/src/slate/mod.rs | 12 +-- .../fabro-store/src/slate/projection_cache.rs | 6 +- lib/crates/fabro-types/src/graph.rs | 14 +-- lib/crates/fabro-types/src/lib.rs | 4 - lib/crates/fabro-types/src/pull_request.rs | 52 +++-------- lib/crates/fabro-types/src/run_event/misc.rs | 92 +------------------ lib/crates/fabro-types/src/run_projection.rs | 4 +- lib/crates/fabro-types/src/stage_handler.rs | 2 +- lib/crates/fabro-types/tests/stage_handler.rs | 6 +- lib/crates/fabro-workflow/src/event/events.rs | 10 +- .../fabro-workflow/src/handler/llm/routing.rs | 4 +- .../src/handler/manager_loop.rs | 9 +- lib/crates/fabro-workflow/src/handler/mod.rs | 13 +-- lib/crates/fabro-workflow/src/pipeline/mod.rs | 1 - .../src/pipeline/pull_request.rs | 6 +- lib/crates/fabro-workflow/src/pull_request.rs | 4 +- lib/crates/fabro-workflow/src/run_lookup.rs | 12 +-- .../fabro-workflow/tests/it/integration.rs | 6 +- 51 files changed, 233 insertions(+), 465 deletions(-) diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 92db7c8a8..391cb8c4d 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -5,7 +5,7 @@ import { columnStatusDisplay, isRunStatus, mapRunListItem, - mapRunSummaryToRunItem, + mapRunToRunItem, runStatusDisplay, } from "./runs"; @@ -94,7 +94,7 @@ describe("mapRunListItem", () => { }); }); -describe("mapRunSummaryToRunItem", () => { +describe("mapRunToRunItem", () => { test("maps canonical run summary to RunItem", () => { const summary = makeRun({ pull_request: { @@ -104,7 +104,7 @@ describe("mapRunSummaryToRunItem", () => { html_url: "https://github.com/fabro-sh/fabro/pull/456", }, }); - const item = mapRunSummaryToRunItem(summary); + const item = mapRunToRunItem(summary); expect(item.id).toBe("01ABC"); expect(item.title).toBe("Fix the build"); expect(item.workflow).toBe("fix_build"); @@ -135,7 +135,7 @@ describe("mapRunSummaryToRunItem", () => { }, billing: null, }); - const item = mapRunSummaryToRunItem(summary); + const item = mapRunToRunItem(summary); expect(item.id).toBe("01DEF"); expect(item.title).toBe("Untitled run"); expect(item.workflow).toBe("unknown"); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index df3e2c289..9ff09e868 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -1,7 +1,6 @@ import { formatElapsedSecs, formatDurationSecs } from "../lib/format"; import { BoardColumn, - type BoardColumn as ApiBoardColumn, type Run, type RunStatus as ApiRunStatus, } from "@qltysh/fabro-api-client"; @@ -21,7 +20,7 @@ export interface RunItem { repo: string; title: string; workflow: string; - column?: ColumnStatus; + column?: BoardColumn; lifecycleStatus?: RunStatus | null; lifecycleStatusLabel?: string; number?: number; @@ -41,8 +40,6 @@ export interface RunItem { lastEventAt?: string; } -export type ColumnStatus = ApiBoardColumn; - export const columnStatuses = [ BoardColumn.QUEUED, BoardColumn.INITIALIZING, @@ -51,9 +48,9 @@ export const columnStatuses = [ BoardColumn.SUCCEEDED, BoardColumn.FAILED, BoardColumn.ARCHIVED, -] as const satisfies readonly ColumnStatus[]; +] as const satisfies readonly BoardColumn[]; -export const columnStatusDisplay: Record = { +export const columnStatusDisplay: Record = { queued: { label: "Queued", dot: "bg-fg-muted", text: "text-fg-muted" }, initializing: { label: "Initializing", dot: "bg-amber", text: "text-amber" }, running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" }, @@ -64,7 +61,7 @@ export const columnStatusDisplay: Record void) | null; @@ -47,12 +48,12 @@ describe("subscribeToBoardEvents", () => { test("coordinated mode shares one global source and invalidates the board runs key", async () => { const source = new FakeEventSource(); const created: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator((url) => { created.push(url); return source; }); - const mutate = (key: SseKey) => { + const mutate = (key: Key) => { keys.push(key); return Promise.resolve(); }; @@ -82,9 +83,9 @@ describe("subscribeToBoardEvents", () => { test("fallback mode preserves the existing shared board EventSource", () => { const source = new FakeEventSource(); const created: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); - const mutate = (key: SseKey) => { + const mutate = (key: Key) => { keys.push(key); return Promise.resolve(); }; diff --git a/apps/fabro-web/app/lib/cross-tab-sse.ts b/apps/fabro-web/app/lib/cross-tab-sse.ts index 3b00d3905..83c1b266f 100644 --- a/apps/fabro-web/app/lib/cross-tab-sse.ts +++ b/apps/fabro-web/app/lib/cross-tab-sse.ts @@ -1,3 +1,5 @@ +import type { Key } from "swr"; + import { queryKeys } from "./query-keys"; import { createBrowserEventSource, @@ -5,7 +7,6 @@ import { type EventPayload, type EventSourceLike, type MutateFn, - type SseKey, sseKeyDedupeId, } from "./sse"; import { getNumber, getString, isRecord, type UnknownRecord } from "./unknown"; @@ -113,7 +114,7 @@ interface SubscribeOptions { subscriptionKey: string; mutate: MutateFn; resolveInvalidation: (payload: TPayload) => EventInvalidation; - resyncKeys: () => SseKey[]; + resyncKeys: () => Key[]; fallbackSubscribe: () => () => void; debounceMs?: number; } @@ -133,11 +134,11 @@ interface LocalSubscription { refcount: number; mutators: Map; fallbacks: Map; - pendingKeys: Map; + pendingKeys: Map; debounceTimer: ReturnType | null; debounceMs: number; resolveInvalidation: (payload: EventPayload) => EventInvalidation; - resyncKeys: () => SseKey[]; + resyncKeys: () => Key[]; } interface LeaderState { @@ -689,7 +690,7 @@ export class CrossTabSseCoordinator { private queueInvalidations( subscription: LocalSubscription, - keys: SseKey[], + keys: Key[], { immediate = false }: { immediate?: boolean } = {}, ) { if (keys.length === 0) return; diff --git a/apps/fabro-web/app/lib/live-events.ts b/apps/fabro-web/app/lib/live-events.ts index 1de529bf5..72120e43a 100644 --- a/apps/fabro-web/app/lib/live-events.ts +++ b/apps/fabro-web/app/lib/live-events.ts @@ -1,3 +1,5 @@ +import type { Key } from "swr"; + import { subscribeToCrossTabSse, type CrossTabSseCoordinator, @@ -9,7 +11,6 @@ import { type EventPayload, type EventSourceLike, type SharedEventSubscription, - type SseKey, } from "./sse"; export interface LiveEventPayload extends EventPayload { @@ -29,7 +30,7 @@ interface LiveEventOptions { const subscriptions = new Map(); const SUBSCRIPTION_KEY = "live-events"; -const NO_KEYS: SseKey[] = []; +const NO_KEYS: Key[] = []; const NOOP_MUTATE = () => Promise.resolve(); export function subscribeToLiveEvents( diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts index 8702cced5..5852a7544 100644 --- a/apps/fabro-web/app/lib/mutations.ts +++ b/apps/fabro-web/app/lib/mutations.ts @@ -148,8 +148,6 @@ export function useSubmitInterviewAnswer(runId: string | undefined) { ); } -export type SteerRunArg = SteerRunRequest; - export function useInterruptRun(runId: string | undefined) { const { mutate } = useSWRConfig(); return useSWRMutation( @@ -171,7 +169,7 @@ export function useSteerRun(runId: string | undefined) { const { mutate } = useSWRConfig(); return useSWRMutation( runId ? `steer-run:${runId}` : null, - async (_key: string, { arg }: { arg: SteerRunArg }) => { + async (_key: string, { arg }: { arg: SteerRunRequest }) => { if (!runId) throw new Error("runId is required"); await apiData(() => humanInTheLoopApi.steerRun(runId, arg)); }, diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx index 75a2349d9..e6f7f48d7 100644 --- a/apps/fabro-web/app/lib/run-events.test.tsx +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import type { Key } from "swr"; import { queryKeysForRunEvent, @@ -9,7 +10,7 @@ import { type BroadcastChannelLike, } from "./cross-tab-sse"; import { queryKeys } from "./query-keys"; -import type { EventSourceLike, SseKey } from "./sse"; +import type { EventSourceLike } from "./sse"; type MessageHandler = ((event: { data: string }) => void) | null; @@ -82,7 +83,7 @@ describe("subscribeToRunEvents", () => { test("coordinated mode uses the global attach stream and filters by run_id", async () => { const source = new FakeEventSource(); const created: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator((url) => { created.push(url); return source; @@ -118,7 +119,7 @@ describe("subscribeToRunEvents", () => { test("coordinated terminal events invalidate without closing the global stream", async () => { const source = new FakeEventSource(); - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator(() => source); const cleanup = subscribeToRunEvents( "run-terminal", @@ -150,9 +151,9 @@ describe("subscribeToRunEvents", () => { test("fallback refcounts run-scoped sources and keeps mutators active until final unsubscribe", () => { const source = new FakeEventSource(); const created: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); - const mutate = (key: SseKey) => { + const mutate = (key: Key) => { keys.push(key); return Promise.resolve(); }; @@ -184,9 +185,9 @@ describe("subscribeToRunEvents", () => { test("fallback runs payload callbacks for later subscribers on a shared source", () => { const source = new FakeEventSource(); const seen: string[] = []; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); - const mutate = (key: SseKey) => { + const mutate = (key: Key) => { keys.push(key); return Promise.resolve(); }; @@ -218,7 +219,7 @@ describe("subscribeToRunEvents", () => { test("fallback terminal events close the source after invalidating keys", () => { const source = new FakeEventSource(); - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); const cleanup = subscribeToRunEvents( "run-terminal", @@ -242,7 +243,7 @@ describe("subscribeToRunEvents", () => { test("envelope with suffixed stage_id invalidates stageEvents(runId, stageId)", async () => { const source = new FakeEventSource(); - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator(() => source); const cleanup = subscribeToRunEvents( "run-stage", @@ -275,7 +276,7 @@ describe("subscribeToRunEvents", () => { test("falls back to node_id when an event has no stage_id", async () => { const source = new FakeEventSource(); - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createCoordinator(() => source); const cleanup = subscribeToRunEvents( "run-stage-node", @@ -301,7 +302,7 @@ describe("subscribeToRunEvents", () => { const firstSource = new FakeEventSource(); const secondSource = new FakeEventSource(); const sources = [firstSource, secondSource]; - const keys: SseKey[] = []; + const keys: Key[] = []; const coordinator = createFallbackCoordinator(); const firstCleanup = subscribeToRunEvents( diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index 665cf4189..6ac518451 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { useSWRConfig } from "swr"; +import { useSWRConfig, type Key } from "swr"; import { subscribeToCrossTabSse, @@ -12,7 +12,6 @@ import { type EventPayload, type EventSourceLike, type MutateFn, - type SseKey, type SharedEventSubscription, } from "./sse"; @@ -100,7 +99,7 @@ export function queryKeysForRunEvent( runId: string, event: string, stageId?: string, -): SseKey[] { +): Key[] { if (event === "checkpoint.completed") { return [ ...queryKeys.runs.filesAllScopes(runId), @@ -132,7 +131,7 @@ export function queryKeysForRunEvent( } if (STAGE_EVENTS.has(event)) { - const keys: SseKey[] = [ + const keys: Key[] = [ queryKeys.runs.stages(runId), queryKeys.runs.billing(runId), queryKeys.runs.events(runId, 1000), @@ -147,7 +146,7 @@ export function queryKeysForRunEvent( } if (STEERING_EVENTS.has(event)) { - const keys: SseKey[] = [queryKeys.runs.events(runId, 1000)]; + const keys: Key[] = [queryKeys.runs.events(runId, 1000)]; if (stageId) { keys.push(queryKeys.runs.stageEvents(runId, stageId)); } diff --git a/apps/fabro-web/app/lib/run-settings-snapshot.ts b/apps/fabro-web/app/lib/run-settings-snapshot.ts index e1a0490e0..35f9f50a6 100644 --- a/apps/fabro-web/app/lib/run-settings-snapshot.ts +++ b/apps/fabro-web/app/lib/run-settings-snapshot.ts @@ -1,9 +1,5 @@ -import type { WorkflowSettings } from "@qltysh/fabro-api-client"; - import { getObject } from "./unknown"; -export type Snapshot = WorkflowSettings; - export { getArray, getBool, diff --git a/apps/fabro-web/app/lib/sse.ts b/apps/fabro-web/app/lib/sse.ts index 6e98ec67c..caf02e5f5 100644 --- a/apps/fabro-web/app/lib/sse.ts +++ b/apps/fabro-web/app/lib/sse.ts @@ -1,7 +1,6 @@ import type { Key, MutatorCallback } from "swr"; -export type SseKey = Key; -export type MutateFn = (key: SseKey) => ReturnType; +export type MutateFn = (key: Key) => ReturnType; export interface EventPayload { event?: string; @@ -14,7 +13,7 @@ export interface EventSourceLike { } export interface EventInvalidation { - keys: SseKey[]; + keys: Key[]; close?: boolean; immediate?: boolean; } @@ -26,11 +25,11 @@ export interface SharedEventSubscription { refcount: number; mutators: Map; resolvers: Map; - pendingKeys: Map; + pendingKeys: Map; debounceTimer: ReturnType | null; } -export function sseKeyDedupeId(key: SseKey): string { +export function sseKeyDedupeId(key: Key): string { return stringifyKeyValue(key); } @@ -79,7 +78,7 @@ export function subscribeToSharedEventSource({ return; } - const keys = new Map(); + const keys = new Map(); let close = false; let immediate = false; for (const resolver of current.resolvers.values()) { @@ -129,7 +128,7 @@ export function subscribeToSharedEventSource({ function queueInvalidations( subscription: SharedEventSubscription, - keys: SseKey[], + keys: Key[], { debounceMs, immediate, diff --git a/apps/fabro-web/app/routes/automation-runs.tsx b/apps/fabro-web/app/routes/automation-runs.tsx index 0ad5dc291..60ffea6bc 100644 --- a/apps/fabro-web/app/routes/automation-runs.tsx +++ b/apps/fabro-web/app/routes/automation-runs.tsx @@ -3,10 +3,10 @@ import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outlin import { Link, useParams } from "react-router"; import { InlineMarkdown } from "../components/inline-markdown"; import { PullRequestChip } from "../components/pull-request-chip"; -import { ciConfig, columnForRun, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs"; -import type { ColumnStatus, RunWithStatus } from "../data/runs"; +import { ciConfig, columnForRun, columnStatusDisplay, deriveCiStatus, mapRunToRunItem } from "../data/runs"; +import type { RunWithStatus } from "../data/runs"; import { useWorkflowRuns } from "../lib/queries"; -import type { PaginatedRunList } from "@qltysh/fabro-api-client"; +import type { BoardColumn, PaginatedRunList } from "@qltysh/fabro-api-client"; function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithStatus[] { const apiRuns = result?.data ?? []; @@ -15,7 +15,7 @@ function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithSt const column = columnForRun(r); if (column == null) return null; return { - ...mapRunSummaryToRunItem(r), + ...mapRunToRunItem(r), status: column, statusLabel: columnStatusDisplay[column].label, }; @@ -71,7 +71,7 @@ export default function AutomationRuns() { const runsQuery = useWorkflowRuns(name); const runs = mapWorkflowRuns(runsQuery.data); const [query, setQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); const filtered = runs.filter( (r) => (statusFilter === "all" || r.status === statusFilter) && @@ -96,11 +96,11 @@ export default function AutomationRuns() {
diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 3b409a66a..c12c46b28 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -25,9 +25,9 @@ import { useToast } from "../components/toast"; import { ConfirmDialog, SECONDARY_BUTTON_CLASS, Tooltip } from "../components/ui"; import { isRunStatus, - mapRunSummaryToRunItem, + mapRunToRunItem, runStatusDisplay, - type RunSummary, + type Run, } from "../data/runs"; import { useDemoMode } from "../lib/demo-mode"; import { useSWRConfig } from "swr"; @@ -107,7 +107,7 @@ function useTickingNow(intervalMs: number): number { return now; } -type RunDetailRun = ReturnType & { +type RunDetailRun = ReturnType & { statusLabel: string; statusDot: string; statusText: string; @@ -145,8 +145,8 @@ function runHasSandbox(runState: unknown): boolean { ); } -function buildRunDetailRun(summary: RunSummary): RunDetailRun { - const item = mapRunSummaryToRunItem(summary); +function buildRunDetailRun(summary: Run): RunDetailRun { + const item = mapRunToRunItem(summary); const rawStatus = summary.lifecycle.status; const statusKind = rawStatus.kind; const display = isRunStatus(statusKind) diff --git a/apps/fabro-web/app/routes/run-settings.tsx b/apps/fabro-web/app/routes/run-settings.tsx index b641c0547..d1d2d9139 100644 --- a/apps/fabro-web/app/routes/run-settings.tsx +++ b/apps/fabro-web/app/routes/run-settings.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { useParams } from "react-router"; +import type { WorkflowSettings } from "@qltysh/fabro-api-client"; import { CollapsibleFile } from "../components/collapsible-file"; import { StageSidebar } from "../components/stage-sidebar"; import { @@ -17,7 +18,6 @@ import { import { useRunSettings, useRunStages } from "../lib/queries"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; import { - type Snapshot, type UnknownRecord, getArray, getBool, @@ -31,7 +31,7 @@ export const handle = { wide: true }; export default function RunSettingsPage() { const { id } = useParams(); const stagesQuery = useRunStages(id); - const settingsQuery = useRunSettings(id); + const settingsQuery = useRunSettings(id); const stages = useMemo( () => mapRunStagesToSidebarStages(stagesQuery.data), [stagesQuery.data], @@ -94,7 +94,7 @@ function PageIntro({ ); } -function WorkflowPanel({ snapshot }: { snapshot: Snapshot }) { +function WorkflowPanel({ snapshot }: { snapshot: WorkflowSettings }) { const workflow = getObject(snapshot, "workflow"); const run = getObject(snapshot, "run"); const name = getString(workflow, "name"); @@ -135,7 +135,7 @@ function WorkflowPanel({ snapshot }: { snapshot: Snapshot }) { ); } -function SandboxPanel({ snapshot }: { snapshot: Snapshot }) { +function SandboxPanel({ snapshot }: { snapshot: WorkflowSettings }) { const sandbox = getObject(getObject(snapshot, "run"), "sandbox"); const provider = getString(sandbox, "provider"); const docker = getObject(sandbox, "docker"); @@ -166,7 +166,7 @@ function SandboxPanel({ snapshot }: { snapshot: Snapshot }) { ); } -function GitPanel({ snapshot }: { snapshot: Snapshot }) { +function GitPanel({ snapshot }: { snapshot: WorkflowSettings }) { const run = getObject(snapshot, "run"); const author = getObject(getObject(run, "git"), "author"); const authorName = getString(author, "name"); @@ -199,7 +199,7 @@ function GitPanel({ snapshot }: { snapshot: Snapshot }) { ); } -function ArtifactsPanel({ snapshot }: { snapshot: Snapshot }) { +function ArtifactsPanel({ snapshot }: { snapshot: WorkflowSettings }) { const artifacts = getObject(getObject(snapshot, "run"), "artifacts"); const include = getArray(artifacts, "include"); return ( diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index aaf21ae41..b01d3af24 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -22,7 +22,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { ciConfig, columnForRun, columnStatusDisplay, columnStatuses, deriveCiStatus, mapRunListItem } from "../data/runs"; -import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs"; +import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus } from "../data/runs"; import { formatRelativeTime } from "../lib/format"; import { EmptyState } from "../components/state"; import { InlineMarkdown } from "../components/inline-markdown"; @@ -32,7 +32,7 @@ import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events" import { useAuthConfig, useBoardsRuns, useSystemInfo } from "../lib/queries"; import { queryKeys } from "../lib/query-keys"; import { archiveRun, canArchive } from "../lib/run-actions"; -import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client"; +import type { BoardColumn, PaginatedBoardRunList } from "@qltysh/fabro-api-client"; export { shouldRefreshBoardForEvent }; @@ -44,7 +44,7 @@ interface ColumnStyle { actions: string[]; } -const columnStyles: Record = { +const columnStyles: Record = { queued: { actions: [] }, initializing: { actions: [] }, running: { actions: [] }, @@ -64,7 +64,7 @@ interface BoardRunsResponse { } type Column = { - id: ColumnStatus; + id: BoardColumn; name: string; dot: string; text: string; @@ -499,7 +499,7 @@ function ColumnActionsMenu({ column }: { column: Column }) { ); } -function BoardColumn({ column }: { column: Column }) { +function BoardColumnView({ column }: { column: Column }) { const actions = column.actions; return (
@@ -974,7 +974,7 @@ export default function Runs() {
{visibleColumns.map((col) => (
- +
))}
diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index b2f6240db..85362857f 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -457,7 +457,7 @@ id = "pre-check" name = "Pre-check script" event = "stage_start" script = "./scripts/pre-check.sh" -matcher = "agent_loop" +matcher = "agent" blocking = true timeout = "30s" sandbox = false diff --git a/docs/public/tutorials/sub-workflow.mdx b/docs/public/tutorials/sub-workflow.mdx index a6d4be0b0..1022d5896 100644 --- a/docs/public/tutorials/sub-workflow.mdx +++ b/docs/public/tutorials/sub-workflow.mdx @@ -79,8 +79,7 @@ The child workflow runs through its own start → implement → validate → gat | Attribute | Description | |---|---| -| `stack.child_workflow` | Path to the child workflow file (resolved relative to the parent). Preferred. | -| `stack.child_dotfile` | Backward-compatible alias for `stack.child_workflow`. | +| `stack.child_workflow` | Path to the child workflow file (resolved relative to the parent). | | `stack.child_dot_source` | Inline child Graphviz source (alternative to `child_workflow`) | | `manager.max_cycles` | Safety limit on poll cycles before the child is cancelled (default: 1000) | | `manager.poll_interval` | How often to check for completion or stop conditions (default: `45s`) | @@ -125,7 +124,7 @@ For simpler cases, just add more nodes to a single workflow. Sub-workflows add a ## What you've learned - **Sub-workflow nodes** (`shape=house`) run a child workflow inside a parent -- **`stack.child_workflow`** references an external workflow file for reuse (`stack.child_dotfile` is also supported for backward compatibility) +- **`stack.child_workflow`** references an external workflow file for reuse - **Context flows** from parent to child and back via diff merging - **`manager.max_cycles`** prevents runaway child workflows - **`manager.stop_condition`** cancels the child when an external signal arrives diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index a2df546bd..bd23b4fa9 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -47,7 +47,5 @@ pub mod types { }; pub use crate::generated::types::*; - - pub type RunSummary = fabro_types::Run; } pub use generated::Client as ApiClient; diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs index 8144d7d06..2a9099dc8 100644 --- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs @@ -2,17 +2,17 @@ use std::any::{TypeId, type_name}; use std::collections::HashMap; use chrono::{TimeZone, Utc}; -use fabro_api::types::{RepositoryRef as ApiRepositoryRef, RunSummary as ApiRunSummary}; +use fabro_api::types::{RepositoryRef as ApiRepositoryRef, Run as ApiRun}; use fabro_types::status::{RunStatus, SuccessReason}; use fabro_types::{ - DiffSummary, PullRequestLink, RepositoryProvider, RepositoryRef, RunBillingSummary, RunId, - RunLifecycle, RunLinks, RunOrigin, RunSummary, RunTimestamps, WorkflowRef, + DiffSummary, PullRequestLink, RepositoryProvider, RepositoryRef, Run, RunBillingSummary, RunId, + RunLifecycle, RunLinks, RunOrigin, RunTimestamps, WorkflowRef, }; use serde_json::json; #[test] fn run_summary_reuses_domain_types() { - assert_same_type::(); + assert_same_type::(); assert_same_type::(); } @@ -22,7 +22,7 @@ fn run_summary_json_matches_openapi_shape() { let run_id = RunId::with_timestamp(created_at, 7); let last_event_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 42).unwrap(); let archived_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 1, 0).unwrap(); - let summary = RunSummary { + let summary = Run { id: run_id, parent_id: None, title: "API title".to_string(), @@ -151,7 +151,7 @@ fn run_summary_json_matches_openapi_shape() { fn run_summary_deserializes_when_optional_fields_are_absent() { let created_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap(); let run_id = RunId::with_timestamp(created_at, 7); - let summary: RunSummary = serde_json::from_value(json!({ + let summary: Run = serde_json::from_value(json!({ "id": run_id.to_string(), "goal": "ship it", "title": "ship it", @@ -220,7 +220,7 @@ fn run_summary_rejects_legacy_flat_json() { let created_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap(); let run_id = RunId::with_timestamp(created_at, 7); - let result = serde_json::from_value::(json!({ + let result = serde_json::from_value::(json!({ "run_id": run_id.to_string(), "workflow_name": "legacy", "status": { diff --git a/lib/crates/fabro-cli/src/commands/pr/link.rs b/lib/crates/fabro-cli/src/commands/pr/link.rs index ad95b7d20..993fbbc64 100644 --- a/lib/crates/fabro-cli/src/commands/pr/link.rs +++ b/lib/crates/fabro-cli/src/commands/pr/link.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use fabro_types::PullRequestRecord; +use fabro_types::PullRequestLink; use tracing::info; use crate::args::PrLinkArgs; @@ -31,6 +31,6 @@ pub(super) async fn link_command(args: PrLinkArgs, base_ctx: &CommandContext) -> Ok(()) } -fn record_label(record: &PullRequestRecord) -> String { +fn record_label(record: &PullRequestLink) -> String { format!("github #{}", record.number) } diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index a4de2c462..ced8f83db 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -5,7 +5,7 @@ use anyhow::{Context as _, Result}; use cli_table::format::{Border, Justify, Separator}; use cli_table::{Cell, CellStruct, Style, Table}; use fabro_api::types; -use fabro_types::{PullRequestRecord, RunBlobId, RunId, parse_blob_ref}; +use fabro_types::{PullRequestLink, RunBlobId, RunId, parse_blob_ref}; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_util::error::render_with_causes; use fabro_util::printer::Printer; @@ -139,7 +139,7 @@ pub(crate) async fn print_run_summary_with_client( let pr_url = run_state .pull_request .as_ref() - .map(PullRequestRecord::html_url); + .map(PullRequestLink::html_url); let Some(conclusion) = conclusion else { return Ok(()); }; diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index 4ee188a00..71f55cab2 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -5,7 +5,7 @@ use serde::Serialize; use crate::args::InspectArgs; use crate::command_context::CommandContext; use crate::server_client::RunProjection; -use crate::server_runs::ServerRunSummaryInfo; +use crate::server_runs::ServerRunInfo; #[derive(Debug, Serialize)] pub(crate) struct InspectOutput { @@ -23,7 +23,7 @@ pub(crate) async fn run(args: &InspectArgs, base_ctx: &CommandContext) -> Result let ctx = base_ctx.with_target(&args.server)?; let printer = ctx.printer(); let client = ctx.server().await?; - let run = ServerRunSummaryInfo::from_summary(client.resolve_run(&args.run).await?); + let run = ServerRunInfo::from_run(client.resolve_run(&args.run).await?); let run_id = run.run_id(); let state = client.get_run_state(&run_id).await?; let output = inspect_run_state(&run, state); @@ -32,7 +32,7 @@ pub(crate) async fn run(args: &InspectArgs, base_ctx: &CommandContext) -> Result Ok(()) } -fn inspect_run_state(run: &ServerRunSummaryInfo, state: RunProjection) -> InspectOutput { +fn inspect_run_state(run: &ServerRunInfo, state: RunProjection) -> InspectOutput { let checkpoint = state .current_checkpoint() .and_then(|record| serde_json::to_value(record).ok()); diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 7adddc3b2..120539a74 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -12,7 +12,7 @@ use super::short_run_id; use crate::args::RunsListArgs; use crate::command_context::CommandContext; use crate::commands::resolve_run_id; -use crate::server_runs::{ServerSummaryLookup, filter_server_runs}; +use crate::server_runs::{ServerRunLookup, filter_server_runs}; use crate::shared::{color_if, format_duration_ms, run_status_kind, tilde_path}; pub(crate) async fn list_command( @@ -29,8 +29,8 @@ pub(crate) async fn list_command( }; let filtered_by_parent = parent_id.is_some(); let lookup = match parent_id { - Some(parent_id) => ServerSummaryLookup::from_client_by_parent(client, parent_id).await?, - None => ServerSummaryLookup::from_client(client).await?, + Some(parent_id) => ServerRunLookup::from_client_by_parent(client, parent_id).await?, + None => ServerRunLookup::from_client(client).await?, }; let label_filters = parse_label_filters(&args.filter.label); let filtered = filter_server_runs( diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs index 113694b64..4f90bd91e 100644 --- a/lib/crates/fabro-cli/src/server_runs.rs +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -3,38 +3,38 @@ use std::sync::Arc; use anyhow::Result; use chrono::{DateTime, Utc}; -use fabro_types::{RunId, RunStatus, RunSummary}; +use fabro_types::{Run, RunId, RunStatus}; use crate::server_client::Client; #[derive(Debug, Clone)] -pub(crate) struct ServerRunSummaryInfo { - summary: RunSummary, +pub(crate) struct ServerRunInfo { + run: Run, } -impl ServerRunSummaryInfo { - pub(crate) fn from_summary(summary: RunSummary) -> Self { - Self { summary } +impl ServerRunInfo { + pub(crate) fn from_run(run: Run) -> Self { + Self { run } } pub(crate) fn run_id(&self) -> RunId { - self.summary.id + self.run.id } pub(crate) fn parent_id(&self) -> Option { - self.summary.parent_id + self.run.parent_id } pub(crate) fn workflow_name(&self) -> String { - self.summary.workflow.name.clone() + self.run.workflow.name.clone() } pub(crate) fn workflow_slug(&self) -> Option<&str> { - self.summary.workflow.slug.as_deref() + self.run.workflow.slug.as_deref() } pub(crate) fn status(&self) -> RunStatus { - self.summary.lifecycle.status + self.run.lifecycle.status } pub(crate) fn start_time(&self) -> String { @@ -44,65 +44,65 @@ impl ServerRunSummaryInfo { } pub(crate) fn start_time_dt(&self) -> Option> { - self.summary + self.run .timestamps .started_at - .or(Some(self.summary.id.created_at())) + .or(Some(self.run.id.created_at())) } pub(crate) fn labels(&self) -> &HashMap { - &self.summary.labels + &self.run.labels } pub(crate) fn duration_ms(&self) -> Option { - self.summary.timestamps.duration_ms + self.run.timestamps.duration_ms } pub(crate) fn total_usd_micros(&self) -> Option { - self.summary + self.run .billing .as_ref() .and_then(|billing| billing.total_usd_micros) } pub(crate) fn source_directory(&self) -> Option<&str> { - self.summary.source_directory.as_deref() + self.run.source_directory.as_deref() } pub(crate) fn repo_origin_url(&self) -> Option<&str> { - self.summary + self.run .repository .as_ref() .and_then(|repository| repository.origin_url.as_deref()) } pub(crate) fn goal(&self) -> String { - self.summary.goal.clone() + self.run.goal.clone() } } -pub(crate) struct ServerSummaryLookup { - runs: Vec, +pub(crate) struct ServerRunLookup { + runs: Vec, } -impl ServerSummaryLookup { +impl ServerRunLookup { pub(crate) async fn from_client(client: Arc) -> Result { - let summaries = client.list_store_runs().await?; - Ok(Self::from_summaries(summaries)) + let runs = client.list_store_runs().await?; + Ok(Self::from_runs(runs)) } pub(crate) async fn from_client_by_parent( client: Arc, parent_id: RunId, ) -> Result { - let summaries = client.list_store_runs_by_parent(parent_id).await?; - Ok(Self::from_summaries(summaries)) + let runs = client.list_store_runs_by_parent(parent_id).await?; + Ok(Self::from_runs(runs)) } - fn from_summaries(summaries: Vec) -> Self { - let mut runs = summaries + fn from_runs(runs: Vec) -> Self { + let mut runs = runs .into_iter() - .map(ServerRunSummaryInfo::from_summary) + .map(ServerRunInfo::from_run) .collect::>(); runs.sort_by(|a, b| { b.start_time_dt() @@ -112,18 +112,18 @@ impl ServerSummaryLookup { Self { runs } } - pub(crate) fn runs(&self) -> &[ServerRunSummaryInfo] { + pub(crate) fn runs(&self) -> &[ServerRunInfo] { &self.runs } } pub(crate) fn filter_server_runs( - runs: &[ServerRunSummaryInfo], + runs: &[ServerRunInfo], before: Option<&str>, workflow: Option<&str>, labels: &[(String, String)], running_only: bool, -) -> Vec { +) -> Vec { runs.iter() .filter(|run| !running_only || run.status().is_active()) .filter(|run| { diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index d584a8783..4916aac7c 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -12,7 +12,7 @@ use fabro_http::multipart::{Form, Part}; use fabro_model::{Model, ModelTestMode, ProviderId}; use fabro_types::settings::run::MergeStrategy; use fabro_types::{ - ArtifactUpload, EventEnvelope, RunBlobId, RunEvent, RunId, RunProjection, RunSummary, StageId, + ArtifactUpload, EventEnvelope, Run, RunBlobId, RunEvent, RunId, RunProjection, StageId, }; use fabro_util::exit::{ErrorExt, ExitClass}; use futures::StreamExt; @@ -794,7 +794,7 @@ impl Client { Ok(bytes) } - pub async fn start_run(&self, run_id: &RunId, resume: bool) -> Result { + pub async fn start_run(&self, run_id: &RunId, resume: bool) -> Result { let response = self .send_api(|client| async move { client @@ -808,7 +808,7 @@ impl Client { convert_type(response.into_inner()) } - pub async fn cancel_run(&self, run_id: &RunId) -> Result { + pub async fn cancel_run(&self, run_id: &RunId) -> Result { let response = self .send_api( |client| async move { client.cancel_run().id(run_id.to_string()).send().await }, @@ -846,7 +846,7 @@ impl Client { Ok(()) } - pub async fn archive_run(&self, run_id: &RunId) -> Result { + pub async fn archive_run(&self, run_id: &RunId) -> Result { let response = self .send_api( |client| async move { client.archive_run().id(run_id.to_string()).send().await }, @@ -855,7 +855,7 @@ impl Client { convert_type(response.into_inner()) } - pub async fn unarchive_run(&self, run_id: &RunId) -> Result { + pub async fn unarchive_run(&self, run_id: &RunId) -> Result { let response = self .send_api( |client| async move { client.unarchive_run().id(run_id.to_string()).send().await }, @@ -917,12 +917,12 @@ impl Client { Ok(response.into_inner()) } - pub async fn list_store_runs(&self) -> Result> { + pub async fn list_store_runs(&self) -> Result> { self.list_store_runs_with_options(ListStoreRunsOptions::default()) .await } - pub async fn list_store_runs_by_parent(&self, parent_id: RunId) -> Result> { + pub async fn list_store_runs_by_parent(&self, parent_id: RunId) -> Result> { self.list_store_runs_with_options(ListStoreRunsOptions { parent_id: Some(parent_id), }) @@ -932,7 +932,7 @@ impl Client { async fn list_store_runs_with_options( &self, options: ListStoreRunsOptions, - ) -> Result> { + ) -> Result> { let mut all_runs = Vec::new(); let mut offset = 0_u64; let limit = 100_u64; @@ -973,7 +973,7 @@ impl Client { Ok(all_runs) } - pub async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> Result { + pub async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> Result { let body = types::UpdateRunParentRequest { parent_id: parent_id.to_string(), }; @@ -990,7 +990,7 @@ impl Client { convert_type(response.into_inner()) } - pub async fn unlink_run_parent(&self, child_id: &RunId) -> Result { + pub async fn unlink_run_parent(&self, child_id: &RunId) -> Result { let response = self .send_api(|client| async move { client @@ -1003,7 +1003,7 @@ impl Client { convert_type(response.into_inner()) } - pub async fn retrieve_run(&self, run_id: &RunId) -> Result { + pub async fn retrieve_run(&self, run_id: &RunId) -> Result { let response = self .send_api( |client| async move { client.retrieve_run().id(run_id.to_string()).send().await }, @@ -1012,7 +1012,7 @@ impl Client { convert_type(response.into_inner()) } - pub async fn resolve_run(&self, selector: &str) -> Result { + pub async fn resolve_run(&self, selector: &str) -> Result { let response = self .send_api(|client| async move { client @@ -1068,7 +1068,7 @@ impl Client { run_id: &RunId, force: bool, model: Option, - ) -> Result { + ) -> Result { let body = types::CreateRunPullRequestRequest { force, model }; let response = self .send_api(|client| async move { @@ -1087,7 +1087,7 @@ impl Client { pub async fn get_run_pull_request( &self, run_id: &RunId, - ) -> Result { + ) -> Result { let response = self .send_api(|client| async move { client @@ -1105,7 +1105,7 @@ impl Client { &self, run_id: &RunId, html_url: String, - ) -> Result { + ) -> Result { let body = types::LinkRunPullRequestRequest { html_url }; let response = self .send_api(|client| async move { @@ -1124,7 +1124,7 @@ impl Client { pub async fn unlink_run_pull_request( &self, run_id: &RunId, - ) -> Result { + ) -> Result { let response = self .send_api(|client| async move { client diff --git a/lib/crates/fabro-graphviz/src/parser/mod.rs b/lib/crates/fabro-graphviz/src/parser/mod.rs index 8b76834cb..fd924786f 100644 --- a/lib/crates/fabro-graphviz/src/parser/mod.rs +++ b/lib/crates/fabro-graphviz/src/parser/mod.rs @@ -186,11 +186,11 @@ mod tests { } #[test] - fn parse_legacy_codergen_mode_attribute() { - let input = r#"digraph Legacy { + fn parse_prompt_handler_type_attribute() { + let input = r#"digraph Prompt { start [shape=Mdiamond] exit [shape=Msquare] - classify [codergen_mode="one_shot", prompt="Classify this"] + classify [type="prompt", prompt="Classify this"] start -> classify -> exit }"#; let graph = parse(input).unwrap(); diff --git a/lib/crates/fabro-graphviz/src/parser/semantic.rs b/lib/crates/fabro-graphviz/src/parser/semantic.rs index 6c52fc691..90b10e5d0 100644 --- a/lib/crates/fabro-graphviz/src/parser/semantic.rs +++ b/lib/crates/fabro-graphviz/src/parser/semantic.rs @@ -104,18 +104,6 @@ impl SemanticState { if let Some(cls) = subgraph_class { Self::add_class_to_node(node, cls); } - // Legacy: translate codergen_mode to type if type is not explicitly set - if !node.attrs.contains_key("type") { - if let Some(mode) = node.attrs.get("codergen_mode").and_then(AttrValue::as_str) { - let mapped = match mode { - "one_shot" => "prompt", - "agent_loop" => "agent", - other => other, - }; - node.attrs - .insert("type".to_string(), AttrValue::String(mapped.to_string())); - } - } // Parse explicit class attr into classes vec let class_str = node .attrs @@ -537,66 +525,4 @@ mod tests { assert!(graph.nodes.contains_key("a")); assert!(graph.nodes.contains_key("b")); } - - #[test] - fn codergen_mode_legacy_translates_to_type() { - let dot = DotGraph { - name: "Legacy".into(), - statements: vec![ - Statement::Node(NodeStmt { - id: "classify".into(), - attrs: Some(vec![( - "codergen_mode".into(), - AstValue::Str("one_shot".into()), - )]), - }), - Statement::Node(NodeStmt { - id: "work".into(), - attrs: Some(vec![( - "codergen_mode".into(), - AstValue::Str("agent_loop".into()), - )]), - }), - ], - }; - - let graph = ast_to_graph(&dot).unwrap(); - assert_eq!( - graph.nodes["classify"] - .attrs - .get("type") - .and_then(AttrValue::as_str), - Some("prompt") - ); - assert_eq!( - graph.nodes["work"] - .attrs - .get("type") - .and_then(AttrValue::as_str), - Some("agent") - ); - } - - #[test] - fn codergen_mode_does_not_override_explicit_type() { - let dot = DotGraph { - name: "ExplicitType".into(), - statements: vec![Statement::Node(NodeStmt { - id: "gate".into(), - attrs: Some(vec![ - ("type".into(), AstValue::Str("human".into())), - ("codergen_mode".into(), AstValue::Str("one_shot".into())), - ]), - })], - }; - - let graph = ast_to_graph(&dot).unwrap(); - assert_eq!( - graph.nodes["gate"] - .attrs - .get("type") - .and_then(AttrValue::as_str), - Some("human") - ); - } } diff --git a/lib/crates/fabro-manifest/src/lib.rs b/lib/crates/fabro-manifest/src/lib.rs index 95429bf50..9b7cd94bc 100644 --- a/lib/crates/fabro-manifest/src/lib.rs +++ b/lib/crates/fabro-manifest/src/lib.rs @@ -397,7 +397,6 @@ fn collect_workflow_files( if let Some(child_ref) = node .attrs .get("stack.child_workflow") - .or_else(|| node.attrs.get("stack.child_dotfile")) .and_then(AttrValue::as_str) { collect_workflow_entry( diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 712f28882..c0f4f5515 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1048,13 +1048,13 @@ mod runs { pending_control: Option, total_usd_micros: Option, entries: &[(&str, &str)], - ) -> RunSummary { + ) -> Run { let created_at = ts(created_at); let run_id = RunId::with_timestamp(created_at, sequence); let source_directory = Some(format!("/demo/{repo_name}")); let repo_origin_url = Some(format!("https://github.com/demo/{repo_name}.git")); let duration_ms = elapsed_secs.and_then(duration_ms_from_secs); - RunSummary { + Run { id: run_id, parent_id: None, title: fabro_types::infer_run_title(goal), @@ -1184,7 +1184,7 @@ mod runs { ] } - pub(super) fn summaries() -> Vec { + pub(super) fn summaries() -> Vec { vec![ summary( 1, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index ef3682953..ad97b85da 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -82,7 +82,7 @@ use fabro_types::settings::server::{ }; use fabro_types::settings::{InterpString, RunNamespace}; use fabro_types::{ - EventBody, InterviewQuestionRecord, Principal, PullRequestRecord, QuestionType, RunBlobId, + EventBody, InterviewQuestionRecord, Principal, PullRequestLink, QuestionType, RunBlobId, RunControlAction, RunEvent, RunId, ServerSettings, SessionCapability, }; use fabro_util::error::{ @@ -1319,7 +1319,7 @@ struct PrunePlan { reason = "sync helper invoked from async handler via spawn_blocking (see callers at :1301 / :1341)" )] fn build_disk_usage_response( - summaries: &[fabro_types::RunSummary], + summaries: &[fabro_types::Run], storage_dir: &std::path::Path, verbose: bool, ) -> anyhow::Result { @@ -1392,7 +1392,7 @@ fn build_disk_usage_response( fn build_prune_plan( request: &PruneRunsRequest, - summaries: &[fabro_types::RunSummary], + summaries: &[fabro_types::Run], storage_dir: &std::path::Path, ) -> anyhow::Result { let scratch_base_dir = scratch_base(storage_dir); diff --git a/lib/crates/fabro-server/src/server/handler/pull_requests.rs b/lib/crates/fabro-server/src/server/handler/pull_requests.rs index b996fa760..cc81baeaf 100644 --- a/lib/crates/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/crates/fabro-server/src/server/handler/pull_requests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use super::super::{ ApiError, AppState, CloseRunPullRequestResponse, CreateRunPullRequestRequest, IntoResponse, Json, LinkRunPullRequestRequest, MergeRunPullRequestRequest, MergeRunPullRequestResponse, - PullRequestRecord, RequireRunScoped, Response, Router, RunId, State, StatusCode, get, + PullRequestLink, RequireRunScoped, Response, Router, RunId, State, StatusCode, get, lock_pull_request_create, post, pull_request, warn, workflow_event, }; @@ -54,8 +54,8 @@ fn parse_github_owner_repo_from_url(url: &str, kind: &str) -> Result<(String, St fn pull_request_record_from_link_request( body: &LinkRunPullRequestRequest, -) -> Result { - PullRequestRecord::from_github_url(body.html_url.trim()).map_err(|err| { +) -> Result { + PullRequestLink::from_github_url(body.html_url.trim()).map_err(|err| { let code = if err.contains("GitHub pull request URL") { "unsupported_pull_request_provider" } else { @@ -117,7 +117,7 @@ fn github_pull_request_not_found_error(number: u64) -> ApiError { } struct PullRequestGithubContext { - record: PullRequestRecord, + record: PullRequestLink, owner: String, repo: String, number: u64, @@ -127,7 +127,7 @@ struct PullRequestGithubContext { async fn load_pull_request_record( state: &Arc, id: &RunId, -) -> Result { +) -> Result { let run_store = state .store .open_run_reader(id) @@ -146,7 +146,7 @@ async fn load_pull_request_record( }) } -fn github_coordinates_for_record(record: &PullRequestRecord) -> (String, String, u64) { +fn github_coordinates_for_record(record: &PullRequestLink) -> (String, String, u64) { (record.owner.clone(), record.repo.clone(), record.number) } @@ -253,10 +253,10 @@ impl<'a> RunPrInputs<'a> { } fn unavailable_pull_request_response( - record: PullRequestRecord, + record: PullRequestLink, reason: fabro_types::PullRequestDetailsUnavailableReason, -) -> fabro_types::PullRequestDetail { - fabro_types::PullRequestDetail { +) -> fabro_types::PullRequestResponse { + fabro_types::PullRequestResponse { data: fabro_types::PullRequest { link: record, details: None, @@ -269,10 +269,10 @@ fn unavailable_pull_request_response( } fn available_pull_request_response( - record: PullRequestRecord, + record: PullRequestLink, details: fabro_types::PullRequestDetails, -) -> fabro_types::PullRequestDetail { - fabro_types::PullRequestDetail { +) -> fabro_types::PullRequestResponse { + fabro_types::PullRequestResponse { data: fabro_types::PullRequest { link: record, details: Some(details), diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 740375ec0..b8742ddab 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -3720,7 +3720,7 @@ async fn create_run_with_pull_request_record( async fn create_run_with_linked_pull_request_record( state: &Arc, run_id: RunId, - pull_request: PullRequestRecord, + pull_request: PullRequestLink, ) { create_durable_run_with_events(state, run_id, &[workflow_event::Event::PullRequestLinked { pull_request, @@ -5613,7 +5613,7 @@ async fn merge_run_pull_request_uses_stored_link_coordinates() { }); let (state, app, run_id) = pr_test_app(Some("ghu_test"), Some(github.base_url())); - create_run_with_linked_pull_request_record(&state, run_id, PullRequestRecord { + create_run_with_linked_pull_request_record(&state, run_id, PullRequestLink { owner: "acme".to_string(), repo: "widgets".to_string(), number: 42, @@ -9685,7 +9685,7 @@ async fn demo_get_run_returns_run_summary_shape() { .unwrap(); let response = app.oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::OK).await; - // Should have RunSummary fields, not RunStatusResponse fields + // Should have Run fields, not RunStatusResponse fields assert!(body["id"].is_string(), "should have id field"); assert!(body["goal"].is_string(), "should have goal field"); assert!( diff --git a/lib/crates/fabro-store/src/lib.rs b/lib/crates/fabro-store/src/lib.rs index 51a0d4fbc..4809f67d2 100644 --- a/lib/crates/fabro-store/src/lib.rs +++ b/lib/crates/fabro-store/src/lib.rs @@ -16,8 +16,7 @@ pub use artifact_store::{ }; pub use error::{Error, Result}; pub use fabro_types::{ - EventEnvelope, PendingInterviewRecord, RunBlobId, RunProjection, RunSummary, StageId, - StageProjection, + EventEnvelope, PendingInterviewRecord, Run, RunBlobId, RunProjection, StageId, StageProjection, }; pub(crate) use keyed_mutex::KeyedMutex; pub use run_state::RunProjectionReducer; diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 45c1647ad..67dd98001 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -10,10 +10,10 @@ use fabro_types::run_event::{ use fabro_types::settings::run::RunSandboxSettings; use fabro_types::{ BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination, Conclusion, EventBody, - FailureSignature, InterviewQuestionRecord, Outcome, PendingInterviewRecord, PullRequestRecord, - RepositoryRef, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, - RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox, RunSandboxRuntime, RunSpec, - RunStatus, RunSummary, RunTimestamps, SandboxProvider, StageCompletion, StageHandler, StageId, + FailureSignature, InterviewQuestionRecord, Outcome, PendingInterviewRecord, PullRequestLink, + RepositoryRef, Run, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunId, + RunLifecycle, RunLinks, RunModel, RunOrigin, RunProjection, RunSandbox, RunSandboxRuntime, + RunSpec, RunStatus, RunTimestamps, SandboxProvider, StageCompletion, StageHandler, StageId, StageOutcome, StageProjection, StageState, StartRecord, WorkflowRef, first_event_seq, }; use fabro_util::error::render_compact_with_causes; @@ -244,7 +244,7 @@ impl RunProjectionReducer for RunProjection { }); } EventBody::PullRequestCreated(props) => { - self.pull_request = Some(PullRequestRecord { + self.pull_request = Some(PullRequestLink { owner: props.owner.clone(), repo: props.repo.clone(), number: props.pr_number, @@ -625,7 +625,7 @@ fn stage_at_completed_visit<'a>( Some(state.stage_entry(node_id, visit, first_event_seq(seq))) } -pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary { +pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run { let workflow_name = if state.spec.graph.name.is_empty() { "unnamed".to_string() } else { @@ -676,7 +676,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary .and_then(|conclusion| conclusion.billing.as_ref()) .and_then(|billing| billing.total_usd_micros); - RunSummary { + Run { id: *run_id, parent_id: state.parent_id, title: state.title().into_owned(), @@ -961,7 +961,7 @@ mod tests { use fabro_types::{ BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint, CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph, - Outcome, PullRequestRecord, QuestionType, RunBlobId, RunControlAction, RunDiff, RunEvent, + Outcome, PullRequestLink, QuestionType, RunBlobId, RunControlAction, RunDiff, RunEvent, RunSpec, RunStatus, StageOutcome, StageState, SuccessReason, WorkflowSettings, first_event_seq, fixtures, }; @@ -2518,12 +2518,12 @@ mod tests { }; let mut state = running_projection(); - let github_pull_request = PullRequestRecord { + let github_pull_request = PullRequestLink { owner: "fabro-sh".to_string(), repo: "fabro".to_string(), number: 123, }; - let replacement_pull_request = PullRequestRecord { + let replacement_pull_request = PullRequestLink { owner: "acme".to_string(), repo: "widgets".to_string(), number: 42, diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 18350ec6e..969bd9b43 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -14,7 +14,7 @@ pub use auth_codes::{AuthCode, AuthCodeStore}; pub use auth_tokens::{ConsumeOutcome, RefreshToken, RefreshTokenStore}; pub use blob_store::{Blob, BlobStore}; use chrono::{DateTime, Utc}; -use fabro_types::{RunId, RunSummary}; +use fabro_types::{Run, RunId}; use object_store::ObjectStore; pub use projection_cache::CachedRunProjection; use projection_cache::RunProjectionCache; @@ -191,7 +191,7 @@ impl Database { RunDatabase::open_reader(*run_id, db, Arc::clone(&self.projection_cache)).await } - pub async fn list_runs(&self, query: &ListRunsQuery) -> Result> { + pub async fn list_runs(&self, query: &ListRunsQuery) -> Result> { Ok(self .list_cached_runs(query) .await? @@ -203,7 +203,7 @@ impl Database { pub async fn list_runs_with_projection( &self, query: &ListRunsQuery, - ) -> Result> { + ) -> Result> { Ok(self .list_cached_runs(query) .await? @@ -287,7 +287,7 @@ impl Database { Ok(self.projection_cache.get(run_id).await) } - pub async fn get_cached_summary(&self, run_id: &RunId) -> Result> { + pub async fn get_cached_summary(&self, run_id: &RunId) -> Result> { self.warm_projection_cache().await?; Ok(self.projection_cache.get_summary(run_id).await) } @@ -380,11 +380,11 @@ impl Runs { self.db.open_run(run_id).await } - pub async fn find(&self, run_id: &RunId) -> Result> { + pub async fn find(&self, run_id: &RunId) -> Result> { self.db.get_cached_summary(run_id).await } - pub async fn list(&self, query: &ListRunsQuery) -> Result> { + pub async fn list(&self, query: &ListRunsQuery) -> Result> { self.db.list_runs(query).await } } diff --git a/lib/crates/fabro-store/src/slate/projection_cache.rs b/lib/crates/fabro-store/src/slate/projection_cache.rs index 9ee673488..0b821aec6 100644 --- a/lib/crates/fabro-store/src/slate/projection_cache.rs +++ b/lib/crates/fabro-store/src/slate/projection_cache.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; -use fabro_types::{RunId, RunProjection, RunSummary}; +use fabro_types::{Run, RunId, RunProjection}; use tokio::sync::Mutex; use crate::run_state::{RunProjectionReducer, build_summary}; @@ -10,7 +10,7 @@ use crate::{Error, EventEnvelope, ListRunsQuery, Result}; #[derive(Debug, Clone)] pub struct CachedRunProjection { pub run_id: RunId, - pub summary: RunSummary, + pub summary: Run, pub projection: Arc, pub last_seq: u32, } @@ -131,7 +131,7 @@ impl RunProjectionCache { self.state.lock().await.entries.get(run_id).cloned() } - pub(crate) async fn get_summary(&self, run_id: &RunId) -> Option { + pub(crate) async fn get_summary(&self, run_id: &RunId) -> Option { self.state .lock() .await diff --git a/lib/crates/fabro-types/src/graph.rs b/lib/crates/fabro-types/src/graph.rs index e4d766c62..48ef9a2d7 100644 --- a/lib/crates/fabro-types/src/graph.rs +++ b/lib/crates/fabro-types/src/graph.rs @@ -69,23 +69,17 @@ impl AttrValue { } } -/// Returns true if the handler type is an LLM-based handler (agent or prompt, -/// including legacy aliases). +/// Returns true if the handler type is an LLM-based handler (agent or prompt). #[must_use] pub fn is_llm_handler_type(handler_type: Option<&str>) -> bool { - matches!( - handler_type, - Some("agent" | "agent_loop" | "prompt" | "one_shot") - ) + matches!(handler_type, Some("agent" | "prompt")) } pub const KNOWN_HANDLER_TYPES: &[&str] = &[ "start", "exit", "agent", - "agent_loop", "prompt", - "one_shot", "human", "conditional", "parallel", @@ -97,7 +91,7 @@ pub const KNOWN_HANDLER_TYPES: &[&str] = &[ ]; /// Returns true if the handler type is part of Fabro's built-in handler -/// vocabulary, including legacy aliases. +/// vocabulary. #[must_use] pub fn is_known_handler_type(handler_type: &str) -> bool { KNOWN_HANDLER_TYPES.contains(&handler_type) @@ -558,9 +552,7 @@ mod tests { #[test] fn is_llm_handler_type_checks() { assert!(is_llm_handler_type(Some("agent"))); - assert!(is_llm_handler_type(Some("agent_loop"))); assert!(is_llm_handler_type(Some("prompt"))); - assert!(is_llm_handler_type(Some("one_shot"))); assert!(!is_llm_handler_type(Some("command"))); assert!(!is_llm_handler_type(Some("human"))); assert!(!is_llm_handler_type(None)); diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 8e6bdf055..afcedbd1d 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -70,7 +70,6 @@ pub use pull_request::{ PullRequestRef, PullRequestResponse, PullRequestTimestamps, PullRequestUser, }; pub use repository::{RepositoryProvider, RepositoryRef}; -pub type RepositoryReference = RepositoryRef; pub use run::{ DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome, RunClientProvenance, RunProvenance, RunServerProvenance, RunSpec, @@ -90,9 +89,6 @@ pub use run_summary::{ AutomationRef, Run, RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind, RunTimestamps, WorkflowRef, }; -pub type RunSummary = Run; -pub type PullRequestRecord = PullRequestLink; -pub type PullRequestDetail = PullRequestResponse; pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title}; pub use sandbox_details::{ SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, diff --git a/lib/crates/fabro-types/src/pull_request.rs b/lib/crates/fabro-types/src/pull_request.rs index 0bafd9baa..d2b91e8fe 100644 --- a/lib/crates/fabro-types/src/pull_request.rs +++ b/lib/crates/fabro-types/src/pull_request.rs @@ -44,9 +44,8 @@ impl<'de> Deserialize<'de> for PullRequestLink { D: Deserializer<'de>, { #[derive(Deserialize)] + #[serde(deny_unknown_fields)] struct Wire { - #[serde(default)] - provider: Option, #[serde(default)] html_url: Option, #[serde(default)] @@ -58,30 +57,14 @@ impl<'de> Deserialize<'de> for PullRequestLink { } let wire = Wire::deserialize(deserializer)?; - if wire - .provider - .as_deref() - .is_some_and(|provider| provider != "github") - { - return Err(D::Error::custom( - "pull request links must reference github.com pull requests", - )); - } - - let link = - if let (Some(owner), Some(repo), Some(number)) = (wire.owner, wire.repo, wire.number) { - Self { - owner, - repo, - number, - } - } else { - let html_url = wire - .html_url - .as_deref() - .ok_or_else(|| D::Error::custom("missing pull request owner/repo/number"))?; - github_pull_request_link_from_url(html_url).map_err(D::Error::custom)? - }; + let (Some(owner), Some(repo), Some(number)) = (wire.owner, wire.repo, wire.number) else { + return Err(D::Error::custom("missing pull request owner/repo/number")); + }; + let link = Self { + owner, + repo, + number, + }; if let Some(html_url) = wire.html_url { let url_link = @@ -306,27 +289,14 @@ mod tests { } #[test] - fn pull_request_link_accepts_legacy_github_record() { - let link: PullRequestLink = serde_json::from_value(json!({ + fn pull_request_link_rejects_extra_legacy_record_fields() { + let result = serde_json::from_value::(json!({ "provider": "github", "html_url": "https://github.com/fabro-sh/fabro/pull/270", "number": 270, "owner": "fabro-sh", "repo": "fabro", "title": "ignored live metadata" - })) - .unwrap(); - - assert_eq!(link.owner, "fabro-sh"); - assert_eq!(link.repo, "fabro"); - assert_eq!(link.number, 270); - } - - #[test] - fn pull_request_link_rejects_legacy_external_record() { - let result = serde_json::from_value::(json!({ - "provider": "external", - "html_url": "https://gitlab.com/acme/widgets/-/merge_requests/42" })); assert!(result.is_err()); diff --git a/lib/crates/fabro-types/src/run_event/misc.rs b/lib/crates/fabro-types/src/run_event/misc.rs index 0ce664db0..6345cd298 100644 --- a/lib/crates/fabro-types/src/run_event/misc.rs +++ b/lib/crates/fabro-types/src/run_event/misc.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize, de}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use super::ExecOutputTail; @@ -204,7 +204,8 @@ pub struct CommandStartedProps { pub timeout_ms: Option, } -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CommandCompletedProps { pub output: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -217,93 +218,6 @@ pub struct CommandCompletedProps { pub live_streaming: bool, } -impl<'de> Deserialize<'de> for CommandCompletedProps { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - struct Wire { - #[serde(default)] - output: Option, - #[serde(default)] - stdout: Option, - #[serde(default)] - stderr: Option, - #[serde(default)] - exit_code: Option, - duration_ms: u64, - termination: CommandTermination, - #[serde(default)] - output_bytes: Option, - #[serde(default)] - stdout_bytes: Option, - #[serde(default)] - stderr_bytes: Option, - #[serde(default)] - live_streaming: bool, - } - - let wire = Wire::deserialize(deserializer)?; - let (output, output_bytes) = if let Some(output) = wire.output { - (output, wire.output_bytes.unwrap_or(0)) - } else { - let stdout_bytes = wire.stdout_bytes.unwrap_or(0); - let stderr_bytes = wire.stderr_bytes.unwrap_or(0); - let legacy_output = if stdout_bytes == 0 && stderr_bytes > 0 && wire.stderr.is_some() { - wire.stderr - } else { - wire.stdout.or(wire.stderr) - } - .ok_or_else(|| de::Error::missing_field("output"))?; - let legacy_bytes = if stdout_bytes == 0 && stderr_bytes > 0 { - stderr_bytes - } else { - stdout_bytes - }; - (legacy_output, legacy_bytes) - }; - - Ok(Self { - output, - exit_code: wire.exit_code, - duration_ms: wire.duration_ms, - termination: wire.termination, - output_bytes, - live_streaming: wire.live_streaming, - }) - } -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn command_completed_deserializes_legacy_stdout_stderr_shape() { - let props: CommandCompletedProps = serde_json::from_value(json!({ - "stdout": "blob://sha256/stdout", - "stderr": "blob://sha256/stderr", - "exit_code": 1, - "duration_ms": 42, - "termination": "exited", - "stdout_bytes": 0, - "stderr_bytes": 12, - "streams_separated": true, - "live_streaming": true - })) - .unwrap(); - - assert_eq!(props.output, "blob://sha256/stderr"); - assert_eq!(props.output_bytes, 12); - assert_eq!(props.exit_code, Some(1)); - assert_eq!(props.termination, CommandTermination::Exited); - assert!(props.live_streaming); - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentCliStartedProps { pub visit: u32, diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index 27be839b4..ef2186ffc 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -6,7 +6,7 @@ use chrono::{DateTime, Utc}; use crate::{ BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition, - ModelRef, PullRequestRecord, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus, + ModelRef, PullRequestLink, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus, StageCompletion, StageHandler, StageId, StageState, StartRecord, }; @@ -29,7 +29,7 @@ pub struct RunProjection { pub checkpoints: Vec, pub conclusion: Option, pub sandbox: Option, - pub pull_request: Option, + pub pull_request: Option, pub superseded_by: Option, pub pending_interviews: BTreeMap, stages: HashMap, diff --git a/lib/crates/fabro-types/src/stage_handler.rs b/lib/crates/fabro-types/src/stage_handler.rs index 52a6ab989..eb75eb0b9 100644 --- a/lib/crates/fabro-types/src/stage_handler.rs +++ b/lib/crates/fabro-types/src/stage_handler.rs @@ -40,7 +40,7 @@ impl StageHandler { match raw.unwrap_or("agent") { "start" => Self::Start, "exit" => Self::Exit, - "prompt" | "one_shot" => Self::Prompt, + "prompt" => Self::Prompt, "command" | "tool" => Self::Command, "human" => Self::Human, "conditional" => Self::Conditional, diff --git a/lib/crates/fabro-types/tests/stage_handler.rs b/lib/crates/fabro-types/tests/stage_handler.rs index a63ba8c94..9abc208cd 100644 --- a/lib/crates/fabro-types/tests/stage_handler.rs +++ b/lib/crates/fabro-types/tests/stage_handler.rs @@ -27,13 +27,13 @@ fn stage_handler_serializes_canonical_wire_values() { } #[test] -fn stage_handler_collapses_legacy_aliases_and_defaults_to_agent() { +fn stage_handler_maps_current_handler_types_and_defaults_to_agent() { assert_eq!( - StageHandler::from_handler_type(Some("agent_loop")), + StageHandler::from_handler_type(Some("agent")), StageHandler::Agent ); assert_eq!( - StageHandler::from_handler_type(Some("one_shot")), + StageHandler::from_handler_type(Some("prompt")), StageHandler::Prompt ); assert_eq!( diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index b9bc4d815..fc26008e1 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -2,8 +2,8 @@ use std::collections::BTreeMap; use ::fabro_types::{ BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason, - ForkSourceRef, GitContext, ParallelBranchId, Principal, PullRequestRecord, RunBlobId, - RunFailure, RunId, RunNoticeLevel, RunProvenance, SandboxProvider, StageId, SuccessReason, + ForkSourceRef, GitContext, ParallelBranchId, Principal, PullRequestLink, RunBlobId, RunFailure, + RunId, RunNoticeLevel, RunProvenance, SandboxProvider, StageId, SuccessReason, run_event as fabro_types, }; use fabro_agent::{AgentEvent, SandboxEvent}; @@ -663,10 +663,10 @@ pub enum Event { draft: bool, }, PullRequestLinked { - pull_request: PullRequestRecord, + pull_request: PullRequestLink, }, PullRequestUnlinked { - pull_request: PullRequestRecord, + pull_request: PullRequestLink, }, PullRequestFailed { error: String, @@ -730,7 +730,7 @@ impl Event { } pub fn pull_request_created( - record: &PullRequestRecord, + record: &PullRequestLink, base_branch: &str, head_branch: &str, title: &str, diff --git a/lib/crates/fabro-workflow/src/handler/llm/routing.rs b/lib/crates/fabro-workflow/src/handler/llm/routing.rs index a6838b46c..c0011efac 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/routing.rs @@ -37,9 +37,7 @@ pub(crate) fn node_needs_api_backend(node: &Node) -> bool { } match node.handler_type() { - Some("prompt" | "one_shot") => { - !matches!(select_one_shot_backend(node), Ok(LlmBackend::Acp)) - } + Some("prompt") => !matches!(select_one_shot_backend(node), Ok(LlmBackend::Acp)), _ => matches!(select_run_backend(node), Ok(LlmBackend::Api)), } } diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index d22331ad0..d07b4edb0 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -56,9 +56,7 @@ fn parse_duration_str(s: &str) -> Duration { /// Parse a child workflow graph from node attributes: inline /// `stack.child_dot_source` (no file inlining), or file path -/// `stack.child_workflow` / `stack.child_dotfile` (with file inlining). -/// `stack.child_workflow` is preferred; `stack.child_dotfile` is kept for -/// backward compatibility. +/// `stack.child_workflow` (with file inlining). fn parse_child_graph(node: &Node, services: &EngineServices) -> Result { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); @@ -88,7 +86,6 @@ fn parse_child_graph(node: &Node, services: &EngineServices) -> Result, @@ -156,20 +155,10 @@ pub fn default_registry( registry.register("start", Box::new(start::StartHandler)); registry.register("exit", Box::new(exit::ExitHandler)); registry.register("agent", Box::new(agent::AgentHandler::new(make_backend()))); - // Legacy alias - registry.register( - "agent_loop", - Box::new(agent::AgentHandler::new(make_backend())), - ); registry.register( "prompt", Box::new(prompt::PromptHandler::new(make_backend())), ); - // Legacy alias - registry.register( - "one_shot", - Box::new(prompt::PromptHandler::new(make_backend())), - ); registry.register("conditional", Box::new(conditional::ConditionalHandler)); registry.register("human", Box::new(human::HumanHandler::new(interviewer))); registry.register("command", Box::new(command::CommandHandler)); diff --git a/lib/crates/fabro-workflow/src/pipeline/mod.rs b/lib/crates/fabro-workflow/src/pipeline/mod.rs index faa876788..d8a3fb2c2 100644 --- a/lib/crates/fabro-workflow/src/pipeline/mod.rs +++ b/lib/crates/fabro-workflow/src/pipeline/mod.rs @@ -9,7 +9,6 @@ pub(crate) mod types; mod validate; pub use execute::execute; -pub use fabro_types::PullRequestRecord; pub(crate) use finalize::{ billing_from_projection, build_conclusion_from_store, build_terminal_event, }; diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 1df783161..8ac528e36 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -7,7 +7,7 @@ use fabro_llm::client::Client; use fabro_llm::generate::{GenerateParams, generate_object}; use fabro_model::Catalog; use fabro_store::RunProjection; -use fabro_types::PullRequestRecord; +use fabro_types::PullRequestLink; use fabro_types::settings::run::MergeStrategy; use fabro_util::text::strip_goal_decoration; use tracing::{debug, info, warn}; @@ -472,7 +472,7 @@ pub struct OpenPullRequestRequest<'a> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreatedPullRequest { - pub link: PullRequestRecord, + pub link: PullRequestLink, pub title: String, pub base_branch: String, pub head_branch: String, @@ -547,7 +547,7 @@ pub async fn maybe_open_pull_request( } } - let link = PullRequestRecord { + let link = PullRequestLink { owner, repo, number: created.number, diff --git a/lib/crates/fabro-workflow/src/pull_request.rs b/lib/crates/fabro-workflow/src/pull_request.rs index 66aab1d82..bb9a74744 100644 --- a/lib/crates/fabro-workflow/src/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pull_request.rs @@ -1,4 +1,4 @@ pub use crate::pipeline::{ - AutoMergeOptions, CreatedPullRequest, OpenPullRequestRequest, PrContent, PullRequestRecord, - build_pr_content, maybe_open_pull_request, + AutoMergeOptions, CreatedPullRequest, OpenPullRequestRequest, PrContent, build_pr_content, + maybe_open_pull_request, }; diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index ccb2e2339..eb5736cbb 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -12,7 +12,7 @@ use chrono::{DateTime, Utc}; use fabro_config::Storage; use fabro_config::user::default_storage_dir; use fabro_store::Database; -use fabro_types::{RunId, RunSummary}; +use fabro_types::{Run, RunId}; use serde::Serialize; use crate::operations::make_run_dir; @@ -30,7 +30,7 @@ struct RunLocalState { #[derive(Debug, Clone, Serialize)] pub struct RunInfo { #[serde(skip)] - summary: Option, + summary: Option, pub dir_name: String, #[serde(skip)] pub start_time_dt: Option>, @@ -43,7 +43,7 @@ pub struct RunInfo { } impl RunInfo { - fn new(summary: Option, local: RunLocalState) -> Self { + fn new(summary: Option, local: RunLocalState) -> Self { Self { summary, dir_name: local.dir_name, @@ -217,7 +217,7 @@ pub async fn scan_runs_combined(store: &Database, base: &Path) -> Result Result> { +pub fn scan_runs_with_summaries(summaries: &[Run], base: &Path) -> Result> { let mut runs_by_id: HashMap = HashMap::new(); for summary in summaries { @@ -247,7 +247,7 @@ pub fn scan_runs_with_summaries(summaries: &[RunSummary], base: &Path) -> Result Ok(runs) } -fn run_info_from_summary(summary: &RunSummary, scratch_base: &Path) -> Option { +fn run_info_from_summary(summary: &Run, scratch_base: &Path) -> Option { let path = make_run_dir(scratch_base, &summary.id); if !path.exists() { return None; @@ -328,7 +328,7 @@ pub async fn resolve_run_combined( } pub fn resolve_run_from_summaries( - summaries: &[RunSummary], + summaries: &[Run], base: &Path, identifier: &str, ) -> Result { diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 2f9eec59e..f60f1a1eb 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -4200,11 +4200,11 @@ async fn manager_loop_context_flows_e2e() { } // =========================================================================== -// 19b-3. Manager loop with child_dotfile E2E +// 19b-3. Manager loop with child_workflow E2E // =========================================================================== #[tokio::test] -async fn manager_loop_child_dotfile_e2e() { +async fn manager_loop_child_workflow_e2e() { let dir = tempfile::tempdir().unwrap(); let dot_path = dir.path().join("child.dot"); std::fs::write( @@ -4220,7 +4220,7 @@ async fn manager_loop_child_dotfile_e2e() { AttrValue::String("stack.manager_loop".to_string()), ); supervisor.attrs.insert( - "stack.child_dotfile".to_string(), + "stack.child_workflow".to_string(), AttrValue::String(dot_path.to_string_lossy().to_string()), ); supervisor.attrs.insert( From 7ac15b28f08a272d20fb50c2c90d5c90f416df09 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sat, 16 May 2026 14:10:16 -0700 Subject: [PATCH 16/25] feat(fabro-web): port /chats/new + /chats/:id from prototype (#289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Ports the validated `/chats/new` and `/chats/:id` chat surface from `docs/superpowers/prototypes/2026-05-16-chats-new/` into `apps/fabro-web`. Client-side scripted prototype mounted inside the existing `AppShell`; replaces `/start` as the planned new "kick off agent work" entry point (but does not delete `/start` in this phase). - New routes: `/chats/new` (empty-state composer) and `/chats/:chatId` (active conversation with assistant-ui's ``, scripted streaming replies, markdown + tool-call rendering). - Drives `@assistant-ui/react` + `@assistant-ui/react-ui` via `useLocalRuntime` and a custom `ChatModelAdapter` that cycles a 6-entry scripted reply bank. - Tailwind v4 cascade fix: assistant-ui CSS is now imported via `@layer assistant-ui` so v4 utilities cascade above the package's unlayered scoped preflight. Includes a discovered Bun-specific tweak — see Notable Deviations below. - StrictMode-safe first-message handoff: store seeds the user message into `seedMessages` with a `pendingResponse: true` flag, and `chats-detail` triggers a single `runtime.thread.startRun({ parentId: null })` then consumes the flag. Avoids the prototype's autorespond-lost-stream race under React 19 StrictMode. The Ask-Fabro right sidebar (also in the prototype) is **out of scope** for this PR. Companion spec: [`docs/superpowers/specs/2026-05-16-chats-new-prototype-design.md`](../tree/chats-new-port/docs/superpowers/specs/2026-05-16-chats-new-prototype-design.md) Implementation plan: [`docs/superpowers/plans/2026-05-16-chats-new-fabro-web-port.md`](../tree/chats-new-port/docs/superpowers/plans/2026-05-16-chats-new-fabro-web-port.md) ## Screenshots Captured from a local debug `fabro server` running this branch's binary, signed in via GitHub. ### `/chats/new` (empty state) ![chats-new empty state](https://github.com/fabro-sh/fabro/raw/chats-new-port/docs/superpowers/prototypes/2026-05-16-chats-new/screenshots/chats-new-v4.png) ### `/chats/:chatId` (active conversation) ![chats-detail active chat](https://github.com/fabro-sh/fabro/raw/chats-new-port/docs/superpowers/prototypes/2026-05-16-chats-new/screenshots/chats-detail-v4.png) ## Files **New** (under `apps/fabro-web/`): - `app/lib/chats-types.ts` — `Chat` wrapper + `ChatContentPart` discriminated union over the API client's `CompletionContentPart` - `app/lib/chats-script.ts` — 6-entry scripted reply bank (`CompletionMessage[]`) - `app/lib/chats-store.tsx` — Context + `useReducer` for chat metadata, `pendingResponse` flag, scriptIndex - `app/lib/chats-runtime.ts` — `createScriptedAdapter` + `toThreadMessages` boundary converter - `app/lib/test-utils.tsx` — minimal `renderHook` shim (lifts the duplicated `IS_REACT_ACT_ENVIRONMENT` + dep-warning silencing pattern out of `install-app.test.tsx`) - `app/components/chats/{tool-fallback,composer-chips,custom-composer}.tsx` - `app/routes/{chats-layout,chats-new,chats-detail}.tsx` - Tests: `chats-store.test.tsx` (5), `chats-runtime.test.ts` (4), `chats-router.test.tsx` (3) **Modified:** - `package.json` — adds `@assistant-ui/{react,react-ui,react-markdown}` (pinned exactly to versions verified in the prototype) - `app/app.css` — `@layer` declaration + assistant-ui CSS imports into `layer(assistant-ui)` + `.fabro-chat` `--aui-*` variable overrides mapping to the Fabro palette - `app/root.tsx` — removed `import "./app.css"` (see Notable Deviations) - `app/router.tsx` — wires the chats routes under the AppShell tree ## Notable deviations from the plan Two intentional deviations, both explained in their commit bodies: 1. **`apps/fabro-web/app/root.tsx` no longer imports `./app.css`.** Bun's CSS bundler (used by `Bun.build` on `entry.tsx`) rejects spec-valid `@layer name, name;` ordering between `@import` rules, even though Tailwind's CLI accepts it. The CSS is built standalone by the Tailwind CLI step in `scripts/build.ts` and linked from `index.template.html`, so dropping the JS-side import bypasses Bun's parser without any runtime change. A safety-net comment at the top of `app.css` warns future engineers against re-adding the import. Commit: `c37690be9`. 2. **`!` non-null assertions removed** in two places where the verbatim prototype copy violated the global CLAUDE.md rule banning `!` in production code: `chats-script.ts` now uses a typed `FALLBACK_REPLY` and `??` coalescing; `composer-chips.tsx` lifts the first option of each chip into a `DEFAULT_*` constant. `chats-runtime.test.ts`'s `for await` drain loops were also replaced with `Array.fromAsync(...)` per the no-loops-in-tests rule. Commits: `ace6ac6d4`, `652ad97af`. ## Test plan - [x] `cd apps/fabro-web && bun run typecheck` — clean - [x] `bun test` — 383 pass / 0 fail (12 new tests for chats) - [x] `cd apps/fabro-web && bun run build` — succeeds; assistant-ui CSS bundled into `dist/assets/app.css` - [x] **Manual browser smoke test** — debug `fabro` binary running this branch served `/chats/new` and `/chats/seed_email` correctly inside the real AppShell with GitHub-OAuth auth (screenshots above). --------- Co-authored-by: Claude Opus 4.7 (1M context) --- apps/fabro-web/app/app.css | 58 + .../app/components/chats/composer-chips.tsx | 98 ++ .../app/components/chats/custom-composer.tsx | 43 + .../app/components/chats/tool-fallback.tsx | 48 + apps/fabro-web/app/layouts/app-shell.test.tsx | 4 +- apps/fabro-web/app/layouts/app-shell.tsx | 20 +- apps/fabro-web/app/lib/chats-runtime.test.ts | 131 ++ apps/fabro-web/app/lib/chats-runtime.ts | 126 ++ apps/fabro-web/app/lib/chats-script.ts | 189 ++ apps/fabro-web/app/lib/chats-store.test.tsx | 95 + apps/fabro-web/app/lib/chats-store.tsx | 201 +++ apps/fabro-web/app/lib/chats-types.ts | 59 + apps/fabro-web/app/lib/test-utils.tsx | 57 + apps/fabro-web/app/root.tsx | 1 - apps/fabro-web/app/router.tsx | 9 + apps/fabro-web/app/routes/chats-detail.tsx | 103 ++ apps/fabro-web/app/routes/chats-layout.tsx | 64 + apps/fabro-web/app/routes/chats-new.tsx | 69 + .../app/routes/chats-router.test.tsx | 53 + apps/fabro-web/package.json | 3 + bun.lock | 449 ++++- .../2026-05-16-chats-new-fabro-web-port.md | 1566 +++++++++++++++++ .../2026-05-16-chats-new/.gitignore | 5 + .../prototypes/2026-05-16-chats-new/README.md | 68 + .../prototypes/2026-05-16-chats-new/bun.lock | 819 +++++++++ .../2026-05-16-chats-new/index.html | 12 + .../2026-05-16-chats-new/package.json | 29 + .../screenshots/chats-detail-v4.png | Bin 0 -> 733443 bytes .../screenshots/chats-new-v4.png | Bin 0 -> 1109719 bytes .../2026-05-16-chats-new/src/app.tsx | 81 + .../src/components/ask-fabro-sidebar.tsx | 80 + .../src/components/composer-chips.tsx | 95 + .../src/components/custom-composer.tsx | 43 + .../src/components/sidebar-composer.tsx | 43 + .../src/components/tool-fallback.tsx | 48 + .../2026-05-16-chats-new/src/index.css | 271 +++ .../src/lib/ask-fabro-context.tsx | 40 + .../src/lib/chats-runtime.ts | 146 ++ .../src/lib/chats-script.ts | 182 ++ .../src/lib/chats-store.tsx | 172 ++ .../2026-05-16-chats-new/src/lib/types.ts | 58 + .../2026-05-16-chats-new/src/main.tsx | 33 + .../src/routes/chats-detail.tsx | 94 + .../src/routes/chats-layout.tsx | 58 + .../src/routes/chats-new.tsx | 65 + .../src/routes/sample.tsx | 119 ++ .../2026-05-16-chats-new/tsconfig.json | 20 + .../2026-05-16-chats-new/vite.config.ts | 11 + .../2026-05-16-chats-new-prototype-design.md | 319 ++++ 49 files changed, 6350 insertions(+), 7 deletions(-) create mode 100644 apps/fabro-web/app/components/chats/composer-chips.tsx create mode 100644 apps/fabro-web/app/components/chats/custom-composer.tsx create mode 100644 apps/fabro-web/app/components/chats/tool-fallback.tsx create mode 100644 apps/fabro-web/app/lib/chats-runtime.test.ts create mode 100644 apps/fabro-web/app/lib/chats-runtime.ts create mode 100644 apps/fabro-web/app/lib/chats-script.ts create mode 100644 apps/fabro-web/app/lib/chats-store.test.tsx create mode 100644 apps/fabro-web/app/lib/chats-store.tsx create mode 100644 apps/fabro-web/app/lib/chats-types.ts create mode 100644 apps/fabro-web/app/lib/test-utils.tsx create mode 100644 apps/fabro-web/app/routes/chats-detail.tsx create mode 100644 apps/fabro-web/app/routes/chats-layout.tsx create mode 100644 apps/fabro-web/app/routes/chats-new.tsx create mode 100644 apps/fabro-web/app/routes/chats-router.test.tsx create mode 100644 docs/superpowers/plans/2026-05-16-chats-new-fabro-web-port.md create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/.gitignore create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/README.md create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/bun.lock create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/index.html create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/package.json create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/screenshots/chats-detail-v4.png create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/screenshots/chats-new-v4.png create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/app.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/components/ask-fabro-sidebar.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/components/composer-chips.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/components/custom-composer.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/components/sidebar-composer.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/components/tool-fallback.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/index.css create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/lib/ask-fabro-context.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/lib/chats-runtime.ts create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/lib/chats-script.ts create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/lib/chats-store.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/lib/types.ts create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/main.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/routes/chats-detail.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/routes/chats-layout.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/routes/chats-new.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/src/routes/sample.tsx create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/tsconfig.json create mode 100644 docs/superpowers/prototypes/2026-05-16-chats-new/vite.config.ts create mode 100644 docs/superpowers/specs/2026-05-16-chats-new-prototype-design.md diff --git a/apps/fabro-web/app/app.css b/apps/fabro-web/app/app.css index da290d54d..d269e21b7 100644 --- a/apps/fabro-web/app/app.css +++ b/apps/fabro-web/app/app.css @@ -1,7 +1,27 @@ @import "@xterm/xterm/css/xterm.css"; + +/* + * Do NOT `import "./app.css"` from JS — Bun's CSS bundler rejects the + * `@layer name, name;` ordering rule between `@import` rules below. This file + * is built standalone by the Tailwind CLI step in `scripts/build.ts`; the + * generated `assets/app.css` is linked from `index.template.html`. + */ + +/* + * Cascade layer order. @assistant-ui/react-ui ships unlayered CSS authored + * against Tailwind v3; in Tailwind v4 utilities live in @layer utilities, and + * any unlayered CSS wins over layered CSS regardless of selector specificity. + * Putting assistant-ui in a named layer that we declare BEFORE utilities makes + * Tailwind v4 utility classes cascade above assistant-ui's scoped preflight. + */ +@layer theme, base, assistant-ui, components, utilities; + @import "tailwindcss"; @plugin "@tailwindcss/typography"; +@import "@assistant-ui/react-ui/styles/index.css" layer(assistant-ui); +@import "@assistant-ui/react-ui/styles/markdown.css" layer(assistant-ui); + @layer base { a[href], button, @@ -127,3 +147,41 @@ .graph-svg ellipse[stroke="#357f9e"], .graph-svg polygon[stroke="#357f9e"] { stroke: #67B2D7; } .graph-svg [fill="#1a1a1a"] { fill: #c6d4e0; } + +/* --------------------------------------------------------------------------- + * assistant-ui theme overrides (--aui-* variables) — mapped to Fabro tokens. + * Values are HSL component triples so assistant-ui's hsl(var(...)) wrapper + * works. Scoped to .fabro-chat so the shadcn theme cannot leak out. + * + * Source colors come from the @theme block above: + * navy-950 #0F1729 = 220 47% 11% + * panel #252C3D = 222 24% 19% + * panel-alt #1a2133 = 223 33% 15% + * teal-500 #67B2D7 = 200 60% 62% (Fabro's "teal" is a sky blue) + * mint #5AC8A8 = 163 49% 57% + * ice-100 #E8EDF3 = 213 27% 93% + * ice-300 #A8B5C5 = 213 22% 72% + * ------------------------------------------------------------------------- */ +.fabro-chat { + --aui-background: 220 47% 11%; + --aui-foreground: 0 0% 100%; + --aui-card: 222 24% 19%; + --aui-card-foreground: 0 0% 100%; + --aui-popover: 222 24% 19%; + --aui-popover-foreground: 0 0% 100%; + --aui-primary: 200 60% 62%; + --aui-primary-foreground: 220 47% 11%; + --aui-secondary: 223 33% 15%; + --aui-secondary-foreground: 0 0% 100%; + --aui-muted: 223 33% 15%; + --aui-muted-foreground: 213 22% 72%; + --aui-accent: 163 49% 57%; + --aui-accent-foreground: 220 47% 11%; + --aui-destructive: 0 76% 66%; + --aui-destructive-foreground: 0 0% 100%; + --aui-border: 218 28% 17%; + --aui-input: 218 28% 17%; + --aui-ring: 200 60% 62%; + --aui-radius: 0.5rem; + --aui-thread-max-width: 44rem; +} diff --git a/apps/fabro-web/app/components/chats/composer-chips.tsx b/apps/fabro-web/app/components/chats/composer-chips.tsx new file mode 100644 index 000000000..fc07d7838 --- /dev/null +++ b/apps/fabro-web/app/components/chats/composer-chips.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, +} from "@headlessui/react"; +import { + ChevronUpDownIcon, + CheckIcon, + FolderIcon, + CpuChipIcon, +} from "@heroicons/react/16/solid"; + +type Choice = { id: string; label: string }; + +const DEFAULT_PROJECT: Choice = { id: "fabro-web", label: "fabro-web" }; +const PROJECTS: Choice[] = [ + DEFAULT_PROJECT, + { id: "fabro-workflows", label: "fabro-workflows" }, + { id: "fabro-cli", label: "fabro-cli" }, +]; + +const DEFAULT_BRANCH: Choice = { id: "main", label: "main" }; +const BRANCHES: Choice[] = [ + DEFAULT_BRANCH, + { id: "develop", label: "develop" }, + { id: "feature/start-page", label: "feature/start-page" }, +]; + +const DEFAULT_MODEL: Choice = { id: "claude-opus-4-7", label: "Claude Opus 4.7" }; +const MODELS: Choice[] = [ + DEFAULT_MODEL, + { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { id: "gpt-5", label: "GPT-5" }, +]; + +function BranchIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +type IconComponent = React.ComponentType<{ className?: string }>; + +function Chip({ + options, + value, + onChange, + Icon, +}: { + options: Choice[]; + value: Choice; + onChange: (c: Choice) => void; + Icon: IconComponent; +}) { + return ( + +
+ + + {value.label} + + + + {options.map((opt) => ( + + + {opt.label} + + ))} + +
+
+ ); +} + +export default function ComposerChips() { + const [project, setProject] = useState(DEFAULT_PROJECT); + const [branch, setBranch] = useState(DEFAULT_BRANCH); + const [model, setModel] = useState(DEFAULT_MODEL); + return ( +
+ + + +
+ ); +} diff --git a/apps/fabro-web/app/components/chats/custom-composer.tsx b/apps/fabro-web/app/components/chats/custom-composer.tsx new file mode 100644 index 000000000..adbb56d97 --- /dev/null +++ b/apps/fabro-web/app/components/chats/custom-composer.tsx @@ -0,0 +1,43 @@ +import { ComposerPrimitive, ThreadPrimitive } from "@assistant-ui/react"; +import { ArrowUpIcon } from "@heroicons/react/24/solid"; +import { StopIcon } from "@heroicons/react/24/outline"; + +import ComposerChips from "./composer-chips"; + +export default function CustomComposer() { + return ( + + +
+ + + + + + + + + + + +
+
+ ); +} diff --git a/apps/fabro-web/app/components/chats/tool-fallback.tsx b/apps/fabro-web/app/components/chats/tool-fallback.tsx new file mode 100644 index 000000000..32fa82062 --- /dev/null +++ b/apps/fabro-web/app/components/chats/tool-fallback.tsx @@ -0,0 +1,48 @@ +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { WrenchScrewdriverIcon } from "@heroicons/react/24/outline"; + +export default function ToolFallback(props: ToolCallMessagePartProps) { + const { toolName, args, result } = props; + return ( +
+
+ + tool + + {toolName} + +
+
+
+          {formatJson(args)}
+        
+
+ {result !== undefined && ( +
+
+            {formatJson(result)}
+          
+
+ )} +
+ ); +} + +function Section({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +function formatJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} diff --git a/apps/fabro-web/app/layouts/app-shell.test.tsx b/apps/fabro-web/app/layouts/app-shell.test.tsx index ee7832aca..41f0e3e4f 100644 --- a/apps/fabro-web/app/layouts/app-shell.test.tsx +++ b/apps/fabro-web/app/layouts/app-shell.test.tsx @@ -6,15 +6,17 @@ describe("getVisibleNavigation", () => { const items = getVisibleNavigation(true); const names = items.map((i) => i.name); expect(names[0]).toBe("Automations"); + expect(names).toContain("Chats"); expect(names).toContain("Runs"); expect(names).toContain("Insights"); expect(names).toContain("Settings"); }); - test("hides Automations and Insights in production mode", () => { + test("hides Automations, Chats, and Insights in production mode", () => { const items = getVisibleNavigation(false); const names = items.map((i) => i.name); expect(names).not.toContain("Automations"); + expect(names).not.toContain("Chats"); expect(names).not.toContain("Insights"); expect(names).toContain("Runs"); expect(names).toContain("Settings"); diff --git a/apps/fabro-web/app/layouts/app-shell.tsx b/apps/fabro-web/app/layouts/app-shell.tsx index 15e058817..7c2a86a7f 100644 --- a/apps/fabro-web/app/layouts/app-shell.tsx +++ b/apps/fabro-web/app/layouts/app-shell.tsx @@ -11,6 +11,7 @@ import { Bars3Icon, BeakerIcon, ChartBarIcon, + ChatBubbleLeftRightIcon, Cog6ToothIcon, PlayIcon, SparklesIcon, @@ -25,11 +26,24 @@ import { useAuthMe } from "../lib/queries"; const allNavigation = [ { name: "Automations", href: "/automations", icon: SparklesIcon, demoOnly: true }, + { + name: "Chats", + href: "/chats/new", + icon: ChatBubbleLeftRightIcon, + demoOnly: true, + // /chats/new is the click target, but the entry stays active on any + // /chats/:id child route as well. + activePathPrefix: "/chats", + }, { name: "Runs", href: "/runs", icon: PlayIcon, demoOnly: false }, { name: "Insights", href: "/insights", icon: ChartBarIcon, demoOnly: true }, { name: "Settings", href: "/settings", icon: Cog6ToothIcon, demoOnly: false }, ]; +function activeFor(item: (typeof allNavigation)[number], pathname: string): boolean { + return pathname.startsWith(item.activePathPrefix ?? item.href); +} + export function getVisibleNavigation(demoMode: boolean) { return allNavigation.filter((item) => !item.demoOnly || demoMode); } @@ -61,7 +75,7 @@ export default function AppShell() { const { user, provider, demoMode } = auth; const navigation = getVisibleNavigation(demoMode); - const currentNav = navigation.find((item) => pathname.startsWith(item.href)); + const currentNav = navigation.find((item) => activeFor(item, pathname)); const title = currentNav?.name ?? ""; const lastMatch = matches[matches.length - 1]; const handle = lastMatch?.handle as { headerExtra?: React.ReactNode } | undefined; @@ -101,7 +115,7 @@ export default function AppShell() {
{navigation.map((item) => { - const current = pathname.startsWith(item.href); + const current = activeFor(item, pathname); return (
{navigation.map((item) => { - const current = pathname.startsWith(item.href); + const current = activeFor(item, pathname); return ( [0]; + +// The scripted adapter only reads `abortSignal` from RunArgs; the other fields +// belong to assistant-ui's full ModelContext surface and have no test value. +// One centralized factory keeps the unavoidable casts off the call sites. +function fakeRunArgs(abortSignal: AbortSignal): RunArgs { + return { + messages: [], + abortSignal, + runConfig: {}, + context: { tools: [] } as unknown as RunArgs["context"], + unstable_getMessage: () => ({}) as never, + }; +} + +async function runAll( + adapter: ChatModelAdapter, + abortSignal: AbortSignal, +): Promise> { + const result = adapter.run(fakeRunArgs(abortSignal)); + if (Symbol.asyncIterator in result) { + return await Array.fromAsync(result); + } + return [await result]; +} + +describe("createScriptedAdapter", () => { + test("yields chunks ending in the full scripted reply content", async () => { + let onCompleteCalled = false; + let completedReply: ChatMessage | null = null; + const adapter = createScriptedAdapter({ + getChat: () => ({ ...emptyChat, scriptIndex: 0 }), + onReplyComplete: (reply) => { + onCompleteCalled = true; + completedReply = reply; + }, + }); + + const controller = new AbortController(); + const runResults = await runAll(adapter, controller.signal); + + expect(onCompleteCalled).toBe(true); + expect(completedReply).toBe(SCRIPTED_REPLIES[0]); + // Final result must contain at least one text part with the full text from + // the first scripted reply. + const finalContent = runResults[runResults.length - 1]?.content; + expect(finalContent).toBeDefined(); + const finalText = finalContent + ?.filter((p) => p.type === "text") + .map((p) => p.text ?? "") + .join(""); + const expectedText = SCRIPTED_REPLIES[0]!.content + .filter((p) => p.kind === "text") + .map((p) => p.data.text) + .join(""); + expect(finalText).toBe(expectedText); + }); + + test("picks reply based on getChat().scriptIndex (wraps modulo bank length)", async () => { + let completed: ChatMessage | null = null; + const adapter = createScriptedAdapter({ + getChat: () => ({ ...emptyChat, scriptIndex: SCRIPTED_REPLIES.length + 2 }), + onReplyComplete: (reply) => { + completed = reply; + }, + }); + const controller = new AbortController(); + await runAll(adapter, controller.signal); + expect(completed).toBe(SCRIPTED_REPLIES[2]); + }); +}); + +describe("toThreadMessages", () => { + test("converts a user text message", () => { + const out = toThreadMessages([ + { role: "user", content: [{ kind: "text", data: { text: "hi" } }] }, + ]); + expect(out).toEqual([ + { role: "user", content: [{ type: "text", text: "hi" }] }, + ]); + }); + + test("converts an assistant message with paired tool_call + tool_result", () => { + const out = toThreadMessages([ + { + role: "assistant", + content: [ + { + kind: "tool_call", + data: { + tool_call_id: "t1", + name: "search", + arguments: { q: "hello" }, + }, + }, + { + kind: "tool_result", + data: { tool_call_id: "t1", content: { ok: true } }, + }, + ], + }, + ]); + expect(out).toHaveLength(1); + expect(out[0]?.role).toBe("assistant"); + const parts = out[0]?.content; + expect(Array.isArray(parts)).toBe(true); + if (!Array.isArray(parts)) throw new Error("expected array content"); + expect(parts).toHaveLength(1); + const first = parts[0]; + 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 }); + }); +}); diff --git a/apps/fabro-web/app/lib/chats-runtime.ts b/apps/fabro-web/app/lib/chats-runtime.ts new file mode 100644 index 000000000..cc43f6f1a --- /dev/null +++ b/apps/fabro-web/app/lib/chats-runtime.ts @@ -0,0 +1,126 @@ +import type { + ChatModelAdapter, + ChatModelRunResult, + ThreadAssistantMessagePart, + ThreadMessageLike, +} from "@assistant-ui/react"; + +import type { Chat, ChatContentPart, ChatMessage } from "./chats-types"; +import { pickReply } from "./chats-script"; + +const STREAM_CHUNK_CHARS = 28; +const STREAM_CHUNK_INTERVAL_MS = 55; + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const handle = setTimeout(resolve, ms); + signal.addEventListener( + "abort", + () => { + clearTimeout(handle); + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); +} + +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") { + 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), + }); + } else if (part.kind === "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 + ) { + out[i] = { ...candidate, result: part.data.content }; + break; + } + } + } + } + return out; +} + +export function createScriptedAdapter(args: { + getChat: () => Chat | undefined; + onReplyComplete: (reply: ChatMessage) => void; +}): ChatModelAdapter { + return { + async *run({ abortSignal }) { + const chat = args.getChat(); + const reply = pickReply(chat?.scriptIndex ?? 0); + const accumulated: ChatContentPart[] = []; + + for (const part of reply.content) { + if (part.kind === "text") { + const text = part.data.text; + let cursor = 0; + accumulated.push({ kind: "text", data: { 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) }, + }; + yield buildUpdate(accumulated); + if (cursor < text.length) { + await sleep(STREAM_CHUNK_INTERVAL_MS, abortSignal); + } + } + } else { + accumulated.push(part); + yield buildUpdate(accumulated); + await sleep(STREAM_CHUNK_INTERVAL_MS * 3, abortSignal); + } + } + + args.onReplyComplete(reply); + }, + }; +} + +function buildUpdate(parts: ChatContentPart[]): ChatModelRunResult { + return { content: toAssistantParts(parts) }; +} + +export function toThreadMessages( + messages: readonly ChatMessage[], +): ThreadMessageLike[] { + return messages.map((msg) => { + if (msg.role === "user") { + return { + role: "user", + content: msg.content + .filter((p) => p.kind === "text") + .map((p) => ({ type: "text", text: p.data.text }) as const), + }; + } + if (msg.role === "assistant") { + return { + role: "assistant", + content: toAssistantParts(msg.content), + }; + } + return { role: "system", content: [] }; + }); +} diff --git a/apps/fabro-web/app/lib/chats-script.ts b/apps/fabro-web/app/lib/chats-script.ts new file mode 100644 index 000000000..9eb39661e --- /dev/null +++ b/apps/fabro-web/app/lib/chats-script.ts @@ -0,0 +1,189 @@ +import type { ChatMessage } from "./chats-types"; + +/** + * Scripted assistant replies cycled through per chat. Generic content, + * intentionally not Fabro-specific. Each entry is a single assistant + * ChatMessage; tool calls and their results are siblings in the content + * array so the renderer can pair them. + */ +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.", + }, + }, + ], + }, + { + 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.", + }, + }, + ], + }, + { + role: "assistant", + content: [ + { + kind: "text", + data: { + text: "Let me search for that real quick.", + }, + }, + { + kind: "tool_call", + data: { + tool_call_id: "call_search_1", + name: "search_web", + 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.", + }, + ], + }, + }, + }, + { + 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.", + }, + }, + ], + }, + { + 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.", + }, + }, + ], + }, + { + role: "assistant", + content: [ + { + kind: "text", + data: { + text: "I'll compute that for you.", + }, + }, + { + kind: "tool_call", + data: { + tool_call_id: "call_calc_1", + name: "run_calculation", + 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.", + }, + }, + ], + }, + { + 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.", + }, + }, + ], + }, +]; + +const FALLBACK_REPLY: ChatMessage = { + role: "assistant", + content: [{ kind: "text", data: { text: "(No reply available.)" } }], +}; + +export function pickReply(scriptIndex: number): ChatMessage { + return ( + SCRIPTED_REPLIES[scriptIndex % SCRIPTED_REPLIES.length] ?? FALLBACK_REPLY + ); +} diff --git a/apps/fabro-web/app/lib/chats-store.test.tsx b/apps/fabro-web/app/lib/chats-store.test.tsx new file mode 100644 index 000000000..1b7082b3a --- /dev/null +++ b/apps/fabro-web/app/lib/chats-store.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act } from "react-test-renderer"; + +import { renderHook, setupReactTestEnv } from "./test-utils"; +import { + ChatsProvider, + useChatsActions, + useChatsState, +} from "./chats-store"; + +function useStore() { + return { ...useChatsActions(), state: useChatsState() }; +} + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe("chats-store reducer", () => { + let teardown: () => void = () => {}; + beforeEach(() => { + teardown = setupReactTestEnv(); + }); + afterEach(() => { + teardown(); + }); + + + test("createChatWithFirstMessage seeds title and user message", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + let id = ""; + act(() => { + id = result.current.createChatWithFirstMessage("Help me with React"); + }); + const chat = result.current.state.chats[id]; + expect(chat?.title).toBe("Help me with React"); + expect(chat?.pendingResponse).toBe(true); + 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" }, + }); + }); + + test("title is truncated to 40 chars at word boundary", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + let id = ""; + act(() => { + id = result.current.createChatWithFirstMessage( + "Help me draft a launch announcement email for our new analytics dashboard", + ); + }); + expect(result.current.state.chats[id]?.title).toBe( + "Help me draft a launch announcement…", + ); + }); + + test("consumePendingResponse clears the flag", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + let id = ""; + act(() => { + id = result.current.createChatWithFirstMessage("hi"); + }); + expect(result.current.state.chats[id]?.pendingResponse).toBe(true); + act(() => { + result.current.consumePendingResponse(id); + }); + expect(result.current.state.chats[id]?.pendingResponse).toBe(false); + }); + + test("advanceScriptIndex increments by one", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + let id = ""; + act(() => { + id = result.current.createChatWithFirstMessage("hi"); + }); + expect(result.current.state.chats[id]?.scriptIndex).toBe(0); + act(() => { + result.current.advanceScriptIndex(id); + }); + expect(result.current.state.chats[id]?.scriptIndex).toBe(1); + }); + + test("seed chats appear in order on mount", () => { + const { result } = renderHook(() => useStore(), { wrapper }); + expect(result.current.state.order.length).toBeGreaterThanOrEqual(3); + const titles = result.current.state.order.map( + (id) => result.current.state.chats[id]?.title, + ); + expect(titles).toContain("Draft a launch email"); + expect(titles).toContain("Refactor a React hook"); + expect(titles).toContain("Compare Postgres vs SQLite"); + }); +}); diff --git a/apps/fabro-web/app/lib/chats-store.tsx b/apps/fabro-web/app/lib/chats-store.tsx new file mode 100644 index 000000000..94d251f01 --- /dev/null +++ b/apps/fabro-web/app/lib/chats-store.tsx @@ -0,0 +1,201 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useReducer, + type ReactNode, +} from "react"; + +import type { Chat, ChatMessage } from "./chats-types"; +import { pickReply } from "./chats-script"; + +type State = { + chats: Record; + order: string[]; // newest first +}; + +type Action = + | { + type: "create"; + id: string; + title: string; + createdAt: number; + userMessage: ChatMessage; + } + | { type: "consume_pending"; chatId: string } + | { type: "advance_script"; chatId: string }; + +function deriveTitle(text: string): string { + const trimmed = text.trim().replace(/\s+/g, " "); + if (trimmed.length <= 40) return trimmed || "New chat"; + const cut = trimmed.slice(0, 40); + const lastSpace = cut.lastIndexOf(" "); + const base = lastSpace > 20 ? cut.slice(0, lastSpace) : cut; + return `${base}…`; +} + +function userMessage(text: string): ChatMessage { + return { + role: "user", + content: [{ kind: "text", data: { text } }], + }; +} + +function seedChat(args: { + id: string; + title: string; + ageDays: number; + scriptIndex: number; + userText: string; +}): Chat { + return { + id: args.id, + title: args.title, + createdAt: Date.now() - args.ageDays * 86_400_000, + scriptIndex: args.scriptIndex + 1, // seeded reply already "consumed" + pendingResponse: false, + seedMessages: [userMessage(args.userText), pickReply(args.scriptIndex)], + }; +} + +const initialState: State = (() => { + const seeds: Chat[] = [ + seedChat({ + id: "seed_email", + title: "Draft a launch email", + ageDays: 0.5, + scriptIndex: 0, + userText: + "Help me draft a launch announcement email for our new analytics dashboard.", + }), + seedChat({ + id: "seed_hook", + title: "Refactor a React hook", + ageDays: 2, + scriptIndex: 3, + userText: + "My useChat hook has grown to 200 lines and I keep tangling concerns. How should I think about refactoring it?", + }), + seedChat({ + id: "seed_db", + title: "Compare Postgres vs SQLite", + ageDays: 6, + scriptIndex: 5, + userText: + "For a side project with ~50 daily users, should I reach for Postgres or stick with SQLite?", + }), + ]; + const chats: Record = {}; + for (const s of seeds) chats[s.id] = s; + return { chats, order: seeds.map((s) => s.id) }; +})(); + +function reducer(state: State, action: Action): State { + switch (action.type) { + case "create": { + const chat: Chat = { + id: action.id, + title: action.title, + createdAt: action.createdAt, + scriptIndex: 0, + pendingResponse: true, + seedMessages: [action.userMessage], + }; + return { + chats: { ...state.chats, [action.id]: chat }, + order: [action.id, ...state.order], + }; + } + case "consume_pending": { + const existing = state.chats[action.chatId]; + if (!existing || !existing.pendingResponse) return state; + return { + ...state, + chats: { + ...state.chats, + [action.chatId]: { ...existing, pendingResponse: false }, + }, + }; + } + case "advance_script": { + const existing = state.chats[action.chatId]; + if (!existing) return state; + return { + ...state, + chats: { + ...state.chats, + [action.chatId]: { + ...existing, + scriptIndex: existing.scriptIndex + 1, + }, + }, + }; + } + } +} + +export type ChatsActions = { + createChatWithFirstMessage: (text: string) => string; + consumePendingResponse: (chatId: string) => void; + advanceScriptIndex: (chatId: string) => void; +}; + +const ChatsStateContext = createContext(null); +const ChatsActionsContext = createContext(null); + +function shortId(): string { + return `c_${Math.random().toString(36).slice(2, 8)}`; +} + +export function ChatsProvider({ children }: { children: ReactNode }) { + const [state, dispatch] = useReducer(reducer, initialState); + + const actions = useMemo( + () => ({ + createChatWithFirstMessage(text: string) { + const id = shortId(); + dispatch({ + type: "create", + id, + title: deriveTitle(text), + createdAt: Date.now(), + userMessage: userMessage(text), + }); + return id; + }, + consumePendingResponse(chatId: string) { + dispatch({ type: "consume_pending", chatId }); + }, + advanceScriptIndex(chatId: string) { + dispatch({ type: "advance_script", chatId }); + }, + }), + [], + ); + + return ( + + + {children} + + + ); +} + +export function useChatsState(): State { + const value = useContext(ChatsStateContext); + if (!value) throw new Error("useChatsState must be used inside "); + return value; +} + +export function useChatsActions(): ChatsActions { + const value = useContext(ChatsActionsContext); + if (!value) throw new Error("useChatsActions must be used inside "); + return value; +} + +export function useChat(chatId: string | undefined): Chat | undefined { + const state = useChatsState(); + return chatId ? state.chats[chatId] : undefined; +} diff --git a/apps/fabro-web/app/lib/chats-types.ts b/apps/fabro-web/app/lib/chats-types.ts new file mode 100644 index 000000000..0272ead32 --- /dev/null +++ b/apps/fabro-web/app/lib/chats-types.ts @@ -0,0 +1,59 @@ +/** + * 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. + */ +export type ChatContentPart = + | { kind: "text"; data: { text: string } } + | { + kind: "tool_call"; + data: { + tool_call_id: string; + name: string; + arguments: { [key: string]: JsonValue }; + }; + } + | { + kind: "tool_result"; + data: { + tool_call_id: string; + content: JsonValue; + is_error?: boolean; + }; + }; + +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +export type ChatRole = "user" | "assistant" | "system"; + +/** Strict in-app message shape; widens to the API's CompletionMessage at the + * wire boundary. Keeping this strict inside the chat code lets every + * `switch (part.kind)` be exhaustive. */ +export type ChatMessage = { + role: ChatRole; + content: ChatContentPart[]; +}; + +/** + * Sidebar/store wrapper around a single chat. Messages and the in-flight + * stream live inside assistant-ui's runtime; the store holds the metadata + * needed to render the sidebar, derive titles, and drive the scripted + * reply bank. `seedMessages` is the initial history fed to the runtime via + * `initialMessages` on mount. `pendingResponse` flags a chat where the user + * sent the first message but the assistant has not yet replied. + */ +export type Chat = { + id: string; + title: string; + createdAt: number; + scriptIndex: number; + seedMessages: ChatMessage[]; + pendingResponse: boolean; +}; diff --git a/apps/fabro-web/app/lib/test-utils.tsx b/apps/fabro-web/app/lib/test-utils.tsx new file mode 100644 index 000000000..f881952de --- /dev/null +++ b/apps/fabro-web/app/lib/test-utils.tsx @@ -0,0 +1,57 @@ +import { createElement, type ReactNode } from "react"; +import TestRenderer, { act } from "react-test-renderer"; + +const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const; + +/** + * Per-test setup for code that uses react-test-renderer: + * - Sets IS_REACT_ACT_ENVIRONMENT (required by act()). + * - Silences react-test-renderer's deprecation warning. + * + * Returns a teardown function; pair with beforeEach/afterEach so the global + * state is scoped to the test rather than leaking process-wide. + */ +export function setupReactTestEnv(): () => void { + type Globals = { [IS_REACT_ACT_ENV]?: boolean }; + const globals = globalThis as Globals; + const hadEnv = IS_REACT_ACT_ENV in globals; + const previousEnv = globals[IS_REACT_ACT_ENV]; + globals[IS_REACT_ACT_ENV] = true; + + const originalConsoleError = console.error; + console.error = ((...args: unknown[]) => { + if ( + typeof args[0] === "string" && + args[0].startsWith("react-test-renderer is deprecated") + ) { + return; + } + originalConsoleError(...args); + }) as typeof console.error; + + return () => { + console.error = originalConsoleError; + if (hadEnv) { + globals[IS_REACT_ACT_ENV] = previousEnv; + } else { + delete globals[IS_REACT_ACT_ENV]; + } + }; +} + +export function renderHook( + hook: () => T, + options: { wrapper: React.ComponentType<{ children: ReactNode }> }, +): { result: { current: T } } { + const result = { current: undefined as unknown as T }; + function HookHost() { + result.current = hook(); + return null; + } + act(() => { + TestRenderer.create( + createElement(options.wrapper, null, createElement(HookHost)), + ); + }); + return { result }; +} diff --git a/apps/fabro-web/app/root.tsx b/apps/fabro-web/app/root.tsx index a1cfa3506..063d3579d 100644 --- a/apps/fabro-web/app/root.tsx +++ b/apps/fabro-web/app/root.tsx @@ -1,5 +1,4 @@ import { isRouteErrorResponse, Outlet } from "react-router"; -import "./app.css"; export default function Root() { return ; diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index e29d7b432..6e1d085bc 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -6,6 +6,9 @@ import * as RedirectHome from "./routes/redirect-home"; import * as Setup from "./routes/setup"; import * as AuthLogin from "./routes/auth-login"; import * as Start from "./routes/start"; +import * as ChatsLayout from "./routes/chats-layout"; +import * as ChatsNew from "./routes/chats-new"; +import * as ChatsDetail from "./routes/chats-detail"; import * as Automations from "./routes/automations"; import * as AutomationDetail from "./routes/automation-detail"; import * as AutomationDefinition from "./routes/automation-definition"; @@ -87,6 +90,12 @@ export const routes: RouteObject[] = [ }), children: [ route("start", Start), + route("chats", ChatsLayout, { + children: [ + route("new", ChatsNew), + route(":chatId", ChatsDetail), + ], + }), route("automations", Automations), route("automations/:name", AutomationDetail, { children: [ diff --git a/apps/fabro-web/app/routes/chats-detail.tsx b/apps/fabro-web/app/routes/chats-detail.tsx new file mode 100644 index 000000000..def71da9c --- /dev/null +++ b/apps/fabro-web/app/routes/chats-detail.tsx @@ -0,0 +1,103 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useNavigate, useParams } from "react-router"; +import { + AssistantRuntimeProvider, + useLocalRuntime, +} from "@assistant-ui/react"; +import { Thread, makeMarkdownText } from "@assistant-ui/react-ui"; + +import { useChat, useChatsActions } from "../lib/chats-store"; +import { + createScriptedAdapter, + toThreadMessages, +} from "../lib/chats-runtime"; +import CustomComposer from "../components/chats/custom-composer"; +import ToolFallback from "../components/chats/tool-fallback"; +import { EmptyState } from "../components/state"; +import type { Chat, ChatMessage } from "../lib/chats-types"; + +// AppShell handle lives on the parent chats-layout route; do not redeclare it +// here. + +const MarkdownText = makeMarkdownText(); + +export default function ChatsDetail() { + const { chatId } = useParams<{ chatId: string }>(); + const navigate = useNavigate(); + const chat = useChat(chatId); + + if (!chatId || !chat) { + return ( +
+ navigate("/chats/new")} + className="text-sm font-medium text-teal-300 hover:text-teal-500" + > + Start a new chat + + } + /> +
+ ); + } + + return ; +} + +function ChatRuntime({ chatId, chat }: { chatId: string; chat: Chat }) { + const { advanceScriptIndex, consumePendingResponse } = useChatsActions(); + + // Keep latest `chat` accessible to the stable adapter closure below without + // recreating the adapter (and the assistant-ui runtime) on every store dispatch. + const chatRef = useRef(chat); + useEffect(() => { + chatRef.current = chat; + }); + + const initialMessages = useMemo( + () => toThreadMessages(chat.seedMessages), + [chat.seedMessages], + ); + + const adapter = useMemo( + () => + createScriptedAdapter({ + getChat: () => chatRef.current, + onReplyComplete: (_reply: ChatMessage) => advanceScriptIndex(chatId), + }), + [chatId, advanceScriptIndex], + ); + + const runtime = useLocalRuntime(adapter, { initialMessages }); + + // Autorespond: chats arriving here from /chats/new carry the user's first + // message in seedMessages with pendingResponse=true. Trigger one startRun + // once per mount; the ref dedupes within a StrictMode mount cycle (state + // updates from consumePendingResponse aren't visible to the re-fired effect + // closure), and the store flag dedupes across mounts (e.g. navigating away + // and back to the same chat). + const didStartRef = useRef(false); + useEffect(() => { + if (!chat.pendingResponse || didStartRef.current) return; + didStartRef.current = true; + consumePendingResponse(chatId); + runtime.thread.startRun({ parentId: null }); + }, [chat.pendingResponse, chatId, consumePendingResponse, runtime]); + + return ( + +
+ +
+
+ ); +} diff --git a/apps/fabro-web/app/routes/chats-layout.tsx b/apps/fabro-web/app/routes/chats-layout.tsx new file mode 100644 index 000000000..e4d17df47 --- /dev/null +++ b/apps/fabro-web/app/routes/chats-layout.tsx @@ -0,0 +1,64 @@ +import { NavLink, Outlet, useNavigate } from "react-router"; +import { PencilSquareIcon } from "@heroicons/react/24/outline"; + +import { ChatsProvider, useChatsState } from "../lib/chats-store"; + +export const handle = { hideHeader: true, fullHeight: true, wide: true }; + +export default function ChatsLayout() { + return ( + + {/* Cancel AppShell's content padding (px-4 py-6 / sm:px-6 / lg:px-8) so the + * chat surface bleeds edge-to-edge below the top nav. The height grows + * by the vertical padding amount to recover the full viewport area. */} +
+ +
+ +
+
+
+ ); +} + +function Sidebar() { + const state = useChatsState(); + const navigate = useNavigate(); + return ( + + ); +} diff --git a/apps/fabro-web/app/routes/chats-new.tsx b/apps/fabro-web/app/routes/chats-new.tsx new file mode 100644 index 000000000..f696c243d --- /dev/null +++ b/apps/fabro-web/app/routes/chats-new.tsx @@ -0,0 +1,69 @@ +import { useRef, useState, type FormEvent } from "react"; +import { useNavigate } from "react-router"; +import { ArrowUpIcon } from "@heroicons/react/24/solid"; + +import { useChatsActions } from "../lib/chats-store"; +import ComposerChips from "../components/chats/composer-chips"; + +export function meta() { + return [{ title: "New chat — Fabro" }]; +} + +export default function ChatsNew() { + const navigate = useNavigate(); + const { createChatWithFirstMessage } = useChatsActions(); + const [text, setText] = useState(""); + const textareaRef = useRef(null); + + function submit(value: string) { + const trimmed = value.trim(); + if (!trimmed) return; + const id = createChatWithFirstMessage(trimmed); + navigate(`/chats/${id}`); + } + + function onSubmit(e: FormEvent) { + e.preventDefault(); + submit(text); + } + + return ( +
+
+
+